From f4344ddf3aab38a5990cdd12a9adf279dcb20dcc Mon Sep 17 00:00:00 2001 From: hujincalrin41 Date: Thu, 8 Oct 2026 08:49:14 +0000 Subject: [PATCH 1/3] fix(deploy): let UC_EXTERNAL_URL carry the path prefix the cookie fix depends on deploy-uc.sh rejected any UC_EXTERNAL_URL with a path and exited, while the state-cookie fix from #21 only takes effect when server.external-url has one. The two conditions were mutually exclusive: anyone deploying through this repo's own script could not reach the fix, and the sample file and both guides told them a path was a mistake. A path is required when a gateway serves UC under a prefix and strips it before forwarding -- the redirect_uri registered with the identity provider carries it, and the state cookie must be scoped to it. Accept scheme://host[:port][/path]; keep rejecting what can only be a slip: a trailing slash, an empty segment, a query, a fragment, a missing scheme. The Secure flag reads only the scheme, so a path never affects it, and a host-only value renders byte-identically to before. Say so where operators read: the property's comment, the sample file, and a 'behind a path prefix' section in both deployment guides with the worked example and the redirect URI to register. Closes #22 --- deploy/README.md | 20 +++++++++++++- deploy/README.zh-CN.md | 17 +++++++++++- deploy/deploy-uc.sh | 17 +++++++----- deploy/uc.env.example | 26 ++++++++++++------- .../server/utils/ServerProperties.java | 8 +++--- 5 files changed, 68 insertions(+), 20 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index a0853b0..616d714 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -104,7 +104,7 @@ PORT=3000 HOST=0.0.0.0 node ui/server.js | `UC_TOKEN_URL` | no | provider's OAuth token endpoint, e.g. `https://login.microsoftonline.com//oauth2/v2.0/token` | | `UC_CLIENT_ID` | no | client id of the application registered for the UI login; must also be listed in `UC_AUDIENCES` | | `UC_CLIENT_SECRET` | no | that application's client secret — stays on the server, never sent to the browser | -| `UC_EXTERNAL_URL` | no | browser-facing base URL of UC (`https://uc.example.com`) when a proxy in front does not send `X-Forwarded-Proto`/`X-Forwarded-Host`; blank = derived from the request | +| `UC_EXTERNAL_URL` | no | browser-facing base URL of UC, `scheme://host[:port]` plus the path prefix when a gateway serves UC under one (`https://uc.example.com`, or `https://api.example.com/unitycatalog`). Needed when a proxy in front does not send `X-Forwarded-Proto`/`X-Forwarded-Host`, and whenever a prefix is stripped before the request reaches UC; blank = derived from the request. See [Behind a path prefix](#behind-a-path-prefix) | | `UC_ADMIN_PASSWORD` | no | password of the built-in `admin` for the UI's administrator sign-in at `/login/admin`. Blank = that entry point off. See [UI sign-in](#ui-sign-in) | | `ALIYUN_REGION` | **yes** | e.g. `cn-hangzhou` | | `ALIYUN_ACCESS_KEY` | **yes** | master RAM user AK (used for STS `AssumeRole`) | @@ -372,6 +372,24 @@ Multi-tenant (`common` / `organizations`) endpoints advertise the literal issuer The UI proxy forwards `X-Forwarded-Proto`/`X-Forwarded-Host` so UC can build the callback URL the browser actually uses; set `UC_EXTERNAL_URL` if another proxy in front strips them. +##### Behind a path prefix + +A gateway may serve UC under a prefix and strip it before forwarding: the browser is on +`https://api.example.com/unitycatalog/...` while the server receives `/...`. Nothing in the request +tells the server about the prefix, yet two things it hands the browser must carry it — the +`redirect_uri` sent to the identity provider (the registered one carries the prefix) and the path +the OAuth state cookie is scoped to (a cookie scoped to the unprefixed path is never sent back to +`/unitycatalog/.../callback`, and the sign-in fails with `Missing login state` after Microsoft has +already authenticated the person). Put the prefix in `UC_EXTERNAL_URL`: + +```bash +UC_EXTERNAL_URL=https://api.example.com/unitycatalog +``` + +and register `https://api.example.com/unitycatalog/api/1.0/unity-control/auth/callback` in Entra. +`deploy-uc.sh` accepts the path; it still rejects a trailing slash, a query or a fragment. Deployments +at the root of their host are unaffected by leaving the path out. + #### 3. Provisioning users There is no just-in-time provisioning: every employee who should be able to sign in must exist in UC diff --git a/deploy/README.zh-CN.md b/deploy/README.zh-CN.md index 4683d43..1d83b7b 100644 --- a/deploy/README.zh-CN.md +++ b/deploy/README.zh-CN.md @@ -98,7 +98,7 @@ PORT=3000 HOST=0.0.0.0 node ui/server.js | `UC_TOKEN_URL` | 否 | IdP 的 OAuth 令牌端点,如 `https://login.microsoftonline.com/<租户id>/oauth2/v2.0/token` | | `UC_CLIENT_ID` | 否 | 为 UI 登录注册的应用的 client id;`deploy-uc.sh` 会自动把它追加进 `UC_AUDIENCES` | | `UC_CLIENT_SECRET` | 否 | 该应用的 client secret,**只留在服务端**,不下发浏览器。有有效期,到期要轮换 | -| `UC_EXTERNAL_URL` | 否 | 浏览器访问 UC 的基地址(如 `https://uc.example.com`),当前置代理不传 `X-Forwarded-Proto`/`X-Forwarded-Host` 时需要;留空则按请求推导 | +| `UC_EXTERNAL_URL` | 否 | 浏览器访问 UC 的基地址,`scheme://host[:port]`,网关在前缀下提供 UC 时再加上该前缀(如 `https://uc.example.com`,或 `https://api.example.com/unitycatalog`)。前置代理不传 `X-Forwarded-Proto`/`X-Forwarded-Host` 时需要,前缀在到达 UC 前被剥掉时也需要;留空则按请求推导。见[网关加路径前缀](#网关加路径前缀) | | `UC_ADMIN_PASSWORD` | 否 | 内置 `admin` 在 `/login/admin` 的登录密码。留空 = 关闭该入口,见 [UI 登录](#ui-登录) | | `ALIYUN_REGION` | **是** | 如 `cn-hangzhou` | | `ALIYUN_ACCESS_KEY` | **是** | master RAM 用户 AK(用于 STS `AssumeRole`) | @@ -313,6 +313,21 @@ OIDC discovery。 UI 代理会转发 `X-Forwarded-Proto` / `X-Forwarded-Host`,UC 据此拼出浏览器实际使用的回调地址;若前面还有 一层代理把这两个头去掉了,就填 `UC_EXTERNAL_URL`。 +##### 网关加路径前缀 + +网关可能把 UC 挂在一个前缀下并在转发前剥掉它:浏览器访问的是 `https://api.example.com/unitycatalog/...`, +服务端收到的却是 `/...`。请求里没有任何东西能告诉服务端这个前缀,但服务端交给浏览器的两样东西必须带着 +它——发给身份提供方的 `redirect_uri`(登记的那个带前缀),以及 OAuth state cookie 的作用域路径(按无前缀 +路径设置的 cookie 永远不会被带回 `/unitycatalog/.../callback`,于是在微软已经认证成功之后报 +`Missing login state`)。把前缀写进 `UC_EXTERNAL_URL`: + +```bash +UC_EXTERNAL_URL=https://api.example.com/unitycatalog +``` + +并在 Entra 里登记 `https://api.example.com/unitycatalog/api/1.0/unity-control/auth/callback`。 +`deploy-uc.sh` 接受路径,仍拒绝结尾斜杠、查询串与片段。位于主机根路径的部署不填路径,行为不变。 + #### 3. 建用户 **没有自动建号**:每个要登录的员工必须先在 UC 里存在(`POST /scim2/Users`),邮箱要与令牌里的 `email` diff --git a/deploy/deploy-uc.sh b/deploy/deploy-uc.sh index 1d0ffc9..90955c8 100755 --- a/deploy/deploy-uc.sh +++ b/deploy/deploy-uc.sh @@ -117,12 +117,17 @@ done [ "$UC_AUTHORIZATION" = "enable" ] && [ "$login_set" -eq 0 ] && [ -z "$UC_ADMIN_PASSWORD" ] && \ echo "WARN: authorization is on but neither Microsoft sign-in nor UC_ADMIN_PASSWORD is set: nobody can sign in to the UI" -# UC_EXTERNAL_URL overrides what the server would otherwise derive, and two things depend on it: -# the redirect_uri handed to the identity provider, which must equal the registered one byte for -# byte, and whether the session cookie is marked Secure. A path here silently breaks the first; the -# property's own validator only checks that the value parses as a URL, so check the shape. -if [ -n "$UC_EXTERNAL_URL" ] && ! [[ "$UC_EXTERNAL_URL" =~ ^https?://[^/]+$ ]]; then - echo "ERROR: UC_EXTERNAL_URL must be scheme://host[:port] with no path, e.g. https://uc.example.com" +# UC_EXTERNAL_URL overrides what the server would otherwise derive: the redirect_uri handed to the +# identity provider, which must equal the registered one byte for byte, and the path the OAuth +# state cookie is scoped to. A path is legitimate here -- it is required when a gateway serves UC +# under a prefix and strips it before forwarding, since the registered redirect_uri then carries +# that prefix -- so accept scheme://host[:port][/segment...]. What is still rejected is what can +# only be a slip: a trailing slash, an empty segment, a query or a fragment, a missing scheme. The +# Secure flag on the session cookie reads only the scheme, so a path never affects it. The +# property's own validator only checks that the value parses as a URL, hence this shape check. +if [ -n "$UC_EXTERNAL_URL" ] && ! [[ "$UC_EXTERNAL_URL" =~ ^https?://[^/?#]+(/[^/?#]+)*$ ]]; then + echo "ERROR: UC_EXTERNAL_URL must be scheme://host[:port][/path], with no trailing slash, query or fragment," + echo " e.g. https://uc.example.com or https://api.example.com/unitycatalog" echo " got: $UC_EXTERNAL_URL" exit 1 fi diff --git a/deploy/uc.env.example b/deploy/uc.env.example index a8bb47f..367ae8e 100644 --- a/deploy/uc.env.example +++ b/deploy/uc.env.example @@ -75,17 +75,25 @@ UC_AUTHORIZATION_URL= UC_TOKEN_URL= UC_CLIENT_ID= UC_CLIENT_SECRET= -# Browser-facing base URL of UC (scheme://host[:port], no path). It builds the OAuth redirect_uri -# and decides whether the session cookie is marked Secure. NORMALLY LEAVE BLANK: the server derives -# the browser-facing origin from X-Forwarded-Proto/Host, which the deployment puts there. Set it -# only when those headers do not reach the server -- a proxy that strips them, or a TLS terminator -# that never adds them. Two ways to get it wrong, both silent: +# Browser-facing base URL of UC (scheme://host[:port], plus a path when UC is served under one). It +# builds the OAuth redirect_uri, scopes the OAuth state cookie, and decides whether the session +# cookie is marked Secure. NORMALLY LEAVE BLANK: the server derives the browser-facing origin from +# X-Forwarded-Proto/Host, which the deployment puts there. Set it in two situations: +# - those headers do not reach the server -- a proxy that strips them, or a TLS terminator that +# never adds them: +# UC_EXTERNAL_URL=https://uc.example.com +# - a gateway serves UC under a path prefix and strips it before forwarding, so the browser is on +# https://api.example.com/unitycatalog/... while the server sees /...; the prefix must be here, +# because the redirect_uri registered with the identity provider carries it and the state +# cookie must be scoped to the path the browser actually uses: +# UC_EXTERNAL_URL=https://api.example.com/unitycatalog +# Ways to get it wrong: # - a scheme that does not match reality: https here over plain HTTP makes the browser drop the # session cookie (sign-in appears to succeed and lands back on the login page); http here over -# HTTPS leaves the cookie without Secure -# - anything after the host: the redirect_uri no longer equals the one registered in Entra, which -# answers AADSTS50011 -- deploy-uc.sh rejects a path outright -# UC_EXTERNAL_URL=https://uc.example.com +# HTTPS leaves the cookie without Secure. Both are silent +# - a path the gateway does not actually strip, or a different one: the redirect_uri no longer +# equals the one registered in Entra, which answers AADSTS50011 +# - a trailing slash, query or fragment: deploy-uc.sh rejects these at render time UC_EXTERNAL_URL= # --- Aliyun OSS (master RAM identity for STS AssumeRole) --- diff --git a/server/src/main/java/io/unitycatalog/server/utils/ServerProperties.java b/server/src/main/java/io/unitycatalog/server/utils/ServerProperties.java index e7e2e2a..c63af0e 100644 --- a/server/src/main/java/io/unitycatalog/server/utils/ServerProperties.java +++ b/server/src/main/java/io/unitycatalog/server/utils/ServerProperties.java @@ -196,9 +196,11 @@ public enum Property { CLIENT_ID("server.client-id"), CLIENT_SECRET("server.client-secret"), REDIRECT_PORT("server.redirect-port", POSITIVE_INTEGER_VALIDATOR), - // Base URL at which browsers reach this server (scheme + host [+ port]), used to build the - // OAuth redirect_uri for the server-hosted login flow. Optional: when unset it is derived - // from X-Forwarded-Proto / X-Forwarded-Host or the Host header of the incoming request. + // Base URL at which browsers reach this server, used to build the OAuth redirect_uri for the + // server-hosted login flow; its path, if any, also scopes the OAuth state cookie, which is how + // the flow survives a gateway that serves this server under a prefix and strips it. Optional: + // when unset it is derived from X-Forwarded-Proto / X-Forwarded-Host or the Host header of + // the incoming request. EXTERNAL_URL("server.external-url", URL_VALIDATOR), // Password for signing the built-in "admin" account into the UI (issue #15). Blank = off. ADMIN_PASSWORD("server.admin-password"), From 7ef457862180059c8d7e364650e38cb931668317 Mon Sep 17 00:00:00 2001 From: hujincalrin41 Date: Thu, 8 Oct 2026 10:13:26 +0000 Subject: [PATCH 2/3] docs: record #24 in features.md --- features.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/features.md b/features.md index e98ea6d..020b891 100644 --- a/features.md +++ b/features.md @@ -73,3 +73,10 @@ Feature history of this fork, by PR merge date. - features: — - bugfix: Behind a reverse proxy that serves this server under a path prefix and strips it before forwarding, hosted login failed at the callback with "Missing login state". `/auth/login` scoped the `UC_OAUTH_STATE` cookie to the mount path the server sees (`/api/1.0/unity-control/auth`), while the browser comes back to `/callback` under the prefix, so the cookie was never sent. The state cookie is now set and cleared on the path the browser actually uses -- the path of `server.external-url` followed by the mount path -- which is the directory of the `redirect_uri` the provider is handed. Deployments whose `server.external-url` carries no path are unaffected, and `UC_TOKEN` stays at `Path=/`. Reaching this through the bundled deploy scripts additionally needs #22, which lifts the host-only check `deploy-uc.sh` applies to `UC_EXTERNAL_URL`. + +## 2026-10-08 (deployment follow-up) + +#24 (https://github.com/relytcloud/unitycatalog/pull/24) + +- features: — +- bugfix: `deploy-uc.sh` accepts a path in `UC_EXTERNAL_URL` (`https://api.example.com/unitycatalog`), which the #21 state-cookie fix depends on and which the script rejected outright, so the fix could not be reached through the repo's own deployment path. Trailing slash, empty segment, query, fragment and a missing scheme are still rejected. The sample file and both deployment guides now describe the behind-a-prefix case and the redirect URI to register. From 3116eed29423d5ae2e5c4db0c82e6ddec0125cb5 Mon Sep 17 00:00:00 2001 From: hujincalrin41 Date: Thu, 8 Oct 2026 10:14:04 +0000 Subject: [PATCH 3/3] docs: fold #24 under the existing 2026-10-08 heading --- features.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/features.md b/features.md index 020b891..8153d97 100644 --- a/features.md +++ b/features.md @@ -74,8 +74,6 @@ Feature history of this fork, by PR merge date. - features: — - bugfix: Behind a reverse proxy that serves this server under a path prefix and strips it before forwarding, hosted login failed at the callback with "Missing login state". `/auth/login` scoped the `UC_OAUTH_STATE` cookie to the mount path the server sees (`/api/1.0/unity-control/auth`), while the browser comes back to `/callback` under the prefix, so the cookie was never sent. The state cookie is now set and cleared on the path the browser actually uses -- the path of `server.external-url` followed by the mount path -- which is the directory of the `redirect_uri` the provider is handed. Deployments whose `server.external-url` carries no path are unaffected, and `UC_TOKEN` stays at `Path=/`. Reaching this through the bundled deploy scripts additionally needs #22, which lifts the host-only check `deploy-uc.sh` applies to `UC_EXTERNAL_URL`. -## 2026-10-08 (deployment follow-up) - #24 (https://github.com/relytcloud/unitycatalog/pull/24) - features: —