feat(dashboard): make keycloak-js login work over Tor onion

Chromium never treats plaintext .onion origins as a secure context, so
keycloak-js (PKCE via crypto.subtle) could not run. A custom Playwright
browser fixture launches Firefox with the Tor-Browser prefs
(dom.securecontext.allowlist_onions, origin/mixed-content handling) for
onion targets and keeps Chromium for clearnet; all dashboard specs share
it via the register pattern.

keycloak-js is now vendored from the deployment's own CDN as an ES
module (jsdelivr is unreachable over Tor) with CORS opened on the CDN
proxy. Personas and the oidc-login spec wait for adapter readiness and
navbar settle before driving login/logout, which the async code->token
exchange needs over Tor latency.

Verified: full deploy matrix green (onion 14/14 twice, clearnet pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-07-10 01:55:16 +02:00
1 parent 84b22b37a8
commit edb96e9ae3
25 files changed
+101 -36

No files matched your search

@@ -168,8 +168,8 @@ async function runAdminFlow(page, opts = {}) {
}
if (adminReachedAuthenticated) {
const loginStillVisible = await page
.getByRole("link", { name: /^\s*(log\s*in|sign\s*in|login|sso|admin)\s*$/i })
.or(page.getByRole("button", { name: /^\s*(log\s*in|sign\s*in|login|sso|admin)\s*$/i }))
.getByRole("link", { name: /log\s*in|sign\s*in|sso/i })
.or(page.getByRole("button", { name: /log\s*in|sign\s*in|sso/i }))
.first()
.isVisible({ timeout: resolveTimeout(2_000) })
.catch(() => false);
@@ -137,8 +137,8 @@ async function runBiberFlow(page, opts = {}) {
}
if (reachedAuthenticated) {
const loginStillVisible = await page
.getByRole("link", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i })
.or(page.getByRole("button", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i }))
.getByRole("link", { name: /log\s*in|sign\s*in|sso/i })
.or(page.getByRole("button", { name: /log\s*in|sign\s*in|sso/i }))
.first()
.isVisible({ timeout: resolveTimeout(2_000) })
.catch(() => false);
@@ -97,6 +97,8 @@ async function waitForAnyLogoutCandidate(page, timeoutMs = resolveTimeout(30_000
async function inAppLogout(page) {
await page.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) }).catch(() => {});
await waitForAnyLogoutCandidate(page);
await page.waitForTimeout(resolveTimeout(3_000));
await waitForAnyLogoutCandidate(page);
if (await tryLogoutFrom(page)) {
@@ -1,4 +1,4 @@
const { expect } = require("@playwright/test");
const { expect } = require("./fixtures/onion-test");
const { resolveTimeout } = require("./timeouts");
const { normalizeBaseUrl } = require("./personas");
@@ -0,0 +1,43 @@
/**
* Custom Playwright `test` whose worker-scoped `browser` is launched directly so
* Firefox user-prefs actually apply (the test runner ignores
* `use.launchOptions.firefoxUserPrefs`). On Firefox — the browser the real Tor
* Browser is based on — set the prefs that make `.onion` a secure context
* (RFC 7686) so the Web Crypto API used by keycloak-js is available, and route
* `.onion` through the SOCKS proxy. Chromium/clearnet launches unchanged.
*/
const base = require("@playwright/test");
const isOnion = Object.values(process.env).some(
(value) => typeof value === "string" && /\bhttps?:\/\/[^/,\s"']+\.onion\b/i.test(value)
);
const test = base.test.extend({
browser: async ({ playwright }, use) => {
const proxy = process.env.PLAYWRIGHT_PROXY
? { server: process.env.PLAYWRIGHT_PROXY }
: undefined;
let browser;
if (isOnion) {
browser = await playwright.firefox.launch({
proxy,
firefoxUserPrefs: {
"dom.securecontext.allowlist_onions": true,
"network.proxy.socks_remote_dns": true,
"network.dns.blockDotOnion": false,
"network.http.sendOriginHeader": 2,
"network.http.referer.hideOnionSource": false,
"security.mixed_content.upgrade_display_content": false,
"security.mixed_content.block_display_content": false,
"security.mixed_content.block_active_content": false,
},
});
} else {
browser = await playwright.chromium.launch({ proxy });
}
await use(browser);
await browser.close();
},
});
module.exports = { ...base, test };
@@ -3,7 +3,7 @@
// each scenario is registered from its own `test-*.js` companion module
// so each test stays atomar and individually inspectable.
const { test } = require("@playwright/test");
const { test } = require("./fixtures/onion-test");
const shared = require("./_shared");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { resolveTimeout } = require("./timeouts");
const { runAdminFlow } = require("./personas");
@@ -1,4 +1,4 @@
const { test } = require("@playwright/test");
const { test } = require("./fixtures/onion-test");
const { runBiberFlow } = require("./personas");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { normalizeBaseUrl, gotoOnion } = require("./personas");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const {
assertCspMetaParity,
@@ -1,4 +1,4 @@
const { test } = require("@playwright/test");
const { test } = require("./fixtures/onion-test");
const { runGuestFlow } = require("./personas");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, gotoOnion } = require("./personas");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { resolveTimeout } = require("./timeouts");
const { gotoOnion } = require("./personas");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { resolveTimeout } = require("./timeouts");
const { gotoOnion } = require("./personas");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { normalizeBaseUrl, gotoOnion } = require("./personas");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { gotoOnion } = require("./personas");
exports.register = function (shared) {
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { gotoOnion } = require("./personas");
exports.register = function (shared) {
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, isVisible, normalizeBaseUrl, gotoOnion } = require("./personas");
@@ -177,6 +177,11 @@ exports.register = function (shared) {
await shared.waitForResourceResponse(diagnostics.responses, `${shared.env.dashboardJsBaseUrl}/oidc.js`, "dashboard oidc script");
const loggedOutControls = await expectLoggedOutHeaderAuthState(page);
await page
.waitForFunction(() => window.__oidcLoginReady === true, null, {
timeout: resolveTimeout(30_000),
})
.catch(() => {});
await loggedOutControls.loginTrigger.click();
const usernameField = page.locator("input[name='username'], input#username").first();
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { normalizeBaseUrl, gotoOnion } = require("./personas");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { resolveTimeout } = require("./timeouts");
const { escapeRegex, gotoOnion } = require("./personas");
@@ -6,7 +6,7 @@
// personas no longer drive the dashboard click; they go straight to the
// app URL.
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./fixtures/onion-test");
const { resolveTimeout } = require("./timeouts");
const { gotoOnion } = require("./personas");
@@ -10,3 +10,10 @@
loop: "{{ DASHBOARD_JS_FILES }}"
loop_control:
loop_var: js_file_name
- name: "Vendor keycloak-js adapter next to oidc.js"
get_url_retry:
url: "{{ DASHBOARD_KEYCLOAK_JS_SRC_URL }}"
dest: "{{ [ DASHBOARD_JS_SERVER_DIR, DASHBOARD_KEYCLOAK_JS_FILE_NAME ] | path_join }}"
mode: "0644"
when: DASHBOARD_OIDC_ENABLED | bool
@@ -15,6 +15,13 @@ const KC_CONFIG = {
silentCheckSsoRedirectUri: "{{ (DASHBOARD_APP_BASE_URL | regex_replace('/+$', '')) ~ DASHBOARD_LOCATION_SILENT_CHECK }}"
};
// Resolve the vendored keycloak-js adapter relative to THIS script's own URL so a
// .onion page loads it from the same (onion) CDN origin instead of clearnet/jsdelivr.
const KC_ADAPTER_URL = new URL(
"./{{ DASHBOARD_KEYCLOAK_JS_FILE_NAME }}",
(document.currentScript && document.currentScript.src) || "{{ DASHBOARD_JS_BASE_URL | regex_replace('/+$', '') }}/"
).href;
const DEBUG = {{ MODE_DEBUG | string | lower }};
// Keycloak's hidden session-status iframe is noisy on newer Chromium builds and
// collides with dashboard-embedded OIDC flows. We keep explicit init/redirect
@@ -61,16 +68,11 @@ function setItemVisible(el, visible) {
}
/* ==============================================
3) Dynamically load the Keycloak.js adapter
3) Load the Keycloak.js ESM adapter (same-origin)
============================================== */
function loadKeycloakAdapter(src) {
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error("Failed to load keycloak.js from " + src));
document.head.appendChild(s);
});
async function loadKeycloakAdapter(src) {
const mod = await import(src);
return mod.default;
}
/* ==============================================
@@ -137,22 +139,22 @@ async function initAuthUI() {
syncAuthMenuVisibility(false);
// Load keycloak.js
const kcJsUrl = "https://cdn.jsdelivr.net/npm/keycloak-js@latest/dist/keycloak.min.js";
let KeycloakAdapter;
try {
await loadKeycloakAdapter(kcJsUrl);
KeycloakAdapter = await loadKeycloakAdapter(KC_ADAPTER_URL);
} catch (e) {
console.error("[oidc] Failed to load adapter:", e);
return; // nothing else to do
}
if (typeof window.Keycloak !== "function") {
console.error("[oidc] window.Keycloak is not available after loading:", kcJsUrl);
if (typeof KeycloakAdapter !== "function") {
console.error("[oidc] Keycloak adapter is not available after loading:", KC_ADAPTER_URL);
return;
}
// Initialize the Keycloak instance
try {
keycloak = new Keycloak({ url: KC_CONFIG.url, realm: KC_CONFIG.realm, clientId: KC_CONFIG.clientId });
keycloak = new KeycloakAdapter({ url: KC_CONFIG.url, realm: KC_CONFIG.realm, clientId: KC_CONFIG.clientId });
keycloak.onReady = (nextAuthenticated) => syncAuthMenuVisibility(nextAuthenticated);
keycloak.onAuthSuccess = () => syncAuthMenuVisibility(true);
keycloak.onAuthLogout = () => syncAuthMenuVisibility(false);
@@ -212,7 +214,7 @@ async function initAuthUI() {
if (DEBUG) {
console.log("[oidc] init done", {
authenticated,
kcJsUrl,
kcAdapterUrl: KC_ADAPTER_URL,
checkLoginIframe: KEYCLOAK_IFRAME_POLLING_ENABLED,
silentCheckSsoRedirectUri: KC_CONFIG.silentCheckSsoRedirectUri
});
+5
View File
@@ -24,6 +24,11 @@ DASHBOARD_JS_FILES: >-
DASHBOARD_JS_BASE_PATH: "{{ DASHBOARD_APPLICATION_ID | get_entity_name }}/js"
DASHBOARD_JS_SERVER_DIR: "{{ [ lookup('nginx','directories.data.cdn'), DASHBOARD_JS_BASE_PATH ] | path_join }}"
DASHBOARD_JS_BASE_URL: "{{ (DASHBOARD_JS_CDN_URL | trim('/')) ~ '/' ~ (DASHBOARD_JS_BASE_PATH | trim('/')) }}"
# keycloak-js adapter, vendored next to oidc.js so the browser loads it same-origin
# (the public jsdelivr CDN is unreachable from a .onion browser context).
DASHBOARD_KEYCLOAK_JS_VERSION: "26.2.4"
DASHBOARD_KEYCLOAK_JS_FILE_NAME: "keycloak.js"
DASHBOARD_KEYCLOAK_JS_SRC_URL: "https://cdn.jsdelivr.net/npm/keycloak-js@{{ DASHBOARD_KEYCLOAK_JS_VERSION }}/lib/keycloak.js"
## Webserver
DASHBOARD_LOCATION_SILENT_CHECK: "/silent-check-sso.html"
@@ -8,6 +8,7 @@ server
{% include 'roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2' %}
{% set aca_origin = "* always" %}
{% include 'roles/sys-svc-proxy/templates/headers/access_control_allow.conf.j2' %}
charset utf-8;