diff --git a/docs/openbao.md b/docs/openbao.md index 82814fd..99f2021 100644 --- a/docs/openbao.md +++ b/docs/openbao.md @@ -342,7 +342,9 @@ The gateway serves a standalone KeyCape login page at `/ui/vault/auth` so Ember never handles the bare auth route (avoids `?with=token` / `?with=netkingdom/` bounce when OIDC mounts are hidden from the unauthenticated listing). Clicking **Sign in with KeyCape** calls `auth_url` and redirects to KeyCape directly. -OIDC callbacks under `/ui/vault/auth//oidc/` still proxy to the OpenBao UI. +OIDC callbacks under `/ui/vault/auth//oidc/callback` are handled by a +standalone page that exchanges the authorization code, stores the UI session +token, and redirects into the Ember app (no popup/`window.opener` flow). The OpenBao UI redirects the browser to KeyCape at `kc.coulomb.social`, then returns to: diff --git a/helm/openbao-ui-overlay/README.md b/helm/openbao-ui-overlay/README.md index 44e6e19..b8c4dd9 100644 --- a/helm/openbao-ui-overlay/README.md +++ b/helm/openbao-ui-overlay/README.md @@ -33,6 +33,7 @@ behaviour. | `overlay.css` | Hide raw OpenBao login fields | | `overlay.js` | Apply presets, branding on post-login Ember pages | | `login.html` / `login.js` / `login.css` | Standalone KeyCape login at `/ui/vault/auth` | +| `callback.html` / `callback.js` | OIDC code exchange at `/ui/vault/auth/*/oidc/callback` | | `nginx.conf` | Gateway proxy + standalone auth page + HTML injection | | `patches//manifest.sha256` | Upstream UI fingerprints for drift detection | diff --git a/helm/openbao-ui-overlay/callback.html b/helm/openbao-ui-overlay/callback.html new file mode 100644 index 0000000..0d7c75c --- /dev/null +++ b/helm/openbao-ui-overlay/callback.html @@ -0,0 +1,16 @@ + + + + + + Signing in with KeyCape + + + + +
+

Signing in with KeyCape

+

Completing sign-in and opening OpenBao…

+
+ + \ No newline at end of file diff --git a/helm/openbao-ui-overlay/callback.js b/helm/openbao-ui-overlay/callback.js new file mode 100644 index 0000000..af6ed02 --- /dev/null +++ b/helm/openbao-ui-overlay/callback.js @@ -0,0 +1,142 @@ +(function () { + "use strict"; + + const PRESETS_URL = "/ui/platform-overlay/presets.json"; + const TOKEN_PREFIX = "vault-"; + const TOKEN_SEPARATOR = "☃"; + const CLUSTER_ID = "1"; + const DEFAULT_PRESETS = { mount: "netkingdom", role: "platform-admin" }; + const OIDC_BACKEND = { + type: "oidc", + typeDisplay: "OIDC", + description: "Authenticate using JWT or OIDC provider.", + tokenPath: "client_token", + displayNamePath: "display_name", + formAttributes: ["role", "jwt"], + }; + const POST_LOGIN_PATH = "/ui/vault/vault/secrets"; + + function parseCallbackContext() { + const match = window.location.pathname.match( + /\/ui\/vault\/auth\/(.+)\/oidc\/callback\/?$/ + ); + if (!match) { + throw new Error("Unsupported OIDC callback path"); + } + + const mount = decodeURIComponent(match[1]).replace(/\/$/, ""); + const params = new URLSearchParams(window.location.search); + let state = params.get("state") || ""; + let namespace = ""; + + if (state.includes(",ns=")) { + const parts = state.split(",ns="); + state = parts[0]; + namespace = parts[1] || ""; + } + + const code = params.get("code"); + if (!mount || !state || !code) { + throw new Error("OIDC callback missing required parameters"); + } + + return { mount, state, code, namespace }; + } + + async function loadPresets() { + try { + const response = await fetch(PRESETS_URL, { cache: "no-store" }); + if (!response.ok) return { ...DEFAULT_PRESETS }; + return { ...DEFAULT_PRESETS, ...(await response.json()) }; + } catch (_error) { + return { ...DEFAULT_PRESETS }; + } + } + + async function exchangeOidc({ mount, state, code }) { + const query = new URLSearchParams({ state, code }); + const response = await fetch( + `/v1/auth/${encodeURIComponent(mount)}/oidc/callback?${query}`, + { method: "GET", headers: { Accept: "application/json" } } + ); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const detail = + payload?.errors?.[0] || + `OIDC callback exchange failed (${response.status})`; + throw new Error(detail); + } + + const auth = payload.auth || payload.data?.auth || payload.data; + if (!auth?.client_token) { + throw new Error("OIDC callback did not return a client token"); + } + + return auth; + } + + function persistAuthToken(auth, presets) { + const mount = presets.mount || "netkingdom"; + const selectedAuth = mount.endsWith("/") ? mount : `${mount}/`; + const tokenName = `${TOKEN_PREFIX}oidc${TOKEN_SEPARATOR}${CLUSTER_ID}`; + const namespacePath = auth.namespace_path?.replace(/\/$/, "") || ""; + const tokenData = { + userRootNamespace: namespacePath, + displayName: + auth.display_name || + auth.metadata?.name || + auth.metadata?.username || + "KeyCape", + backend: OIDC_BACKEND, + token: auth.client_token, + policies: auth.policies || [], + renewable: Boolean(auth.renewable), + entity_id: auth.entity_id, + }; + + if (tokenData.renewable && auth.lease_duration) { + tokenData.ttl = auth.lease_duration; + tokenData.tokenExpirationEpoch = + Date.now() + auth.lease_duration * 1000; + } + + window.localStorage.setItem("selectedAuth", selectedAuth); + window.localStorage.setItem(tokenName, JSON.stringify(tokenData)); + } + + function showError(message) { + const root = document.getElementById("callback-root"); + if (!root) return; + root.innerHTML = ` +
+

Sign-in failed

+ + +
+ `; + document.getElementById("callback-retry")?.addEventListener("click", () => { + window.location.assign("/ui/vault/auth"); + }); + } + + async function init() { + try { + const presets = await loadPresets(); + const context = parseCallbackContext(); + const auth = await exchangeOidc(context); + persistAuthToken(auth, presets); + window.location.replace(POST_LOGIN_PATH); + } catch (error) { + showError( + error instanceof Error + ? error.message + : "OIDC sign-in failed. Contact your administrator." + ); + } + } + + init(); +})(); \ No newline at end of file diff --git a/helm/openbao-ui-overlay/nginx.conf b/helm/openbao-ui-overlay/nginx.conf index c89a4d8..b26c042 100644 --- a/helm/openbao-ui-overlay/nginx.conf +++ b/helm/openbao-ui-overlay/nginx.conf @@ -33,6 +33,13 @@ http { add_header Cache-Control "no-store"; } + # OIDC callback handler — exchanges code without Ember popup/postMessage flow. + location ~ ^/ui/vault/auth/.+/oidc/callback/?$ { + alias /etc/nginx/overlay/callback.html; + default_type text/html; + add_header Cache-Control "no-store"; + } + # Static UI bundles and API calls bypass HTML injection and stay compressed. location ~ ^/(v1|ui/assets|ui/engines-dist|ui/favicon\.svg) { proxy_pass http://openbao_upstream; diff --git a/helm/openbao-ui-overlay/overlay.js b/helm/openbao-ui-overlay/overlay.js index 8d96ee0..64c59eb 100644 --- a/helm/openbao-ui-overlay/overlay.js +++ b/helm/openbao-ui-overlay/overlay.js @@ -21,6 +21,26 @@ let overlayApplied = false; let signInHandlerInstalled = false; + function hasStoredSession() { + try { + return Object.keys(window.localStorage).some((key) => + key.startsWith("vault-") + ); + } catch (_error) { + return false; + } + } + + function isUiEntryPath() { + const path = window.location.pathname; + return path === "/ui" || path === "/ui/"; + } + + function redirectUnauthenticatedUiEntry() { + if (!isUiEntryPath() || hasStoredSession()) return; + window.location.replace("/ui/vault/auth"); + } + function isAuthPage() { const path = window.location.pathname; return ( @@ -240,6 +260,7 @@ } async function init() { + redirectUnauthenticatedUiEntry(); if (!isAuthPage() || isOidcCallbackPage()) return; await loadPresets(); diff --git a/scripts/openbao-ui-overlay-apply.sh b/scripts/openbao-ui-overlay-apply.sh index d69385a..d71584d 100755 --- a/scripts/openbao-ui-overlay-apply.sh +++ b/scripts/openbao-ui-overlay-apply.sh @@ -27,7 +27,7 @@ if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then exit 0 fi -for required in overlay.css overlay.js login.css login.html login.js presets.json nginx.conf VERSION; do +for required in overlay.css overlay.js callback.html callback.js login.css login.html login.js presets.json nginx.conf VERSION; do if [ ! -f "$OVERLAY_DIR/$required" ]; then echo "missing overlay asset: $OVERLAY_DIR/$required" >&2 exit 1 @@ -47,6 +47,8 @@ $KUBECTL create configmap openbao-ui-overlay \ --namespace "$OPENBAO_NAMESPACE" \ --from-file="$OVERLAY_DIR/overlay.css" \ --from-file="$OVERLAY_DIR/overlay.js" \ + --from-file="$OVERLAY_DIR/callback.html" \ + --from-file="$OVERLAY_DIR/callback.js" \ --from-file="$OVERLAY_DIR/login.css" \ --from-file="$OVERLAY_DIR/login.html" \ --from-file="$OVERLAY_DIR/login.js" \ diff --git a/scripts/openbao-verify-login-overlay.sh b/scripts/openbao-verify-login-overlay.sh index 4ce04b7..94817e8 100755 --- a/scripts/openbao-verify-login-overlay.sh +++ b/scripts/openbao-verify-login-overlay.sh @@ -65,6 +65,18 @@ if grep -Eq 'vault-|engines-dist' <<<"$auth_html"; then fi ok "auth page is standalone login.html (no Ember shell)" +callback_html="$(curl -fsS "$BASE_URL/ui/vault/auth/netkingdom/oidc/callback")" +require_pattern \ + "OIDC callback serves standalone handler" \ + "$callback_html" \ + 'Signing in with KeyCape|callback.js' + +if grep -Eq 'window\.opener\.postMessage|vault-' <<<"$callback_html"; then + err "OIDC callback still serves Ember shell (expected standalone callback.html)" + exit 1 +fi +ok "OIDC callback is standalone callback.html (no Ember postMessage flow)" + step "Overlay asset endpoints" index_html="$(curl -fsS "$BASE_URL/ui/")" overlay_js="$(curl -fsS "$BASE_URL/ui/platform-overlay/overlay.js")"