mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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:
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
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user