fix(openbao-ui): handle OIDC callback without Ember popup flow
OpenBao's Ember UI expects OIDC to complete in a popup and postMessage to window.opener. The standalone KeyCape login uses a full-page redirect, so the callback now exchanges the authorization code directly, persists the UI token in localStorage, and redirects into the vault UI. Unauthenticated /ui/ loads also redirect to the standalone login page to avoid ?with= bounce loops.
This commit is contained in:
@@ -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/<mount>/oidc/` still proxy to the OpenBao UI.
|
||||
OIDC callbacks under `/ui/vault/auth/<mount>/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:
|
||||
|
||||
@@ -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/<version>/manifest.sha256` | Upstream UI fingerprints for drift detection |
|
||||
|
||||
|
||||
16
helm/openbao-ui-overlay/callback.html
Normal file
16
helm/openbao-ui-overlay/callback.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Signing in with KeyCape</title>
|
||||
<link rel="stylesheet" href="/ui/platform-overlay/login.css" />
|
||||
<script src="/ui/platform-overlay/callback.js" defer></script>
|
||||
</head>
|
||||
<body id="callback-root">
|
||||
<main class="login-card">
|
||||
<h1>Signing in with KeyCape</h1>
|
||||
<p>Completing sign-in and opening OpenBao…</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
142
helm/openbao-ui-overlay/callback.js
Normal file
142
helm/openbao-ui-overlay/callback.js
Normal file
@@ -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 = `
|
||||
<main class="login-card">
|
||||
<h1>Sign-in failed</h1>
|
||||
<p class="login-error is-visible">${message}</p>
|
||||
<button class="login-button" type="button" id="callback-retry">
|
||||
Back to sign in
|
||||
</button>
|
||||
</main>
|
||||
`;
|
||||
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();
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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")"
|
||||
|
||||
Reference in New Issue
Block a user