// Headless dump of an AUTHENTICATED Authentik page (user-settings SPA, etc.). // // The user-settings interface (/auth/if/user/) redirects to login when // unauthenticated, so a plain dump is useless. This variant logs in first. // // Two auth modes: // 1) Recovery link (no password needed). Generate one on the host with: // docker exec deploy-authentik-server-1 ak create_recovery_key 1 akadmin // then pass the returned "/auth/recovery/use-token//" path. // 2) Identification + password (set AK_USER / AK_PASS env vars). // // Usage: // AK_RECOVERY="/auth/recovery/use-token/XXer/" \ // node scripts/authentik-dom-dump-auth.mjs "" "" "" // # or // AK_USER=akadmin AK_PASS=... \ // node scripts/authentik-dom-dump-auth.mjs "" "" "" import { chromium } from "@playwright/test" import { writeFileSync } from "node:fs" const target = process.argv[2] ?? "http://localhost/auth/if/user/#/settings" const outName = process.argv[3] ?? "user-settings" const theme = process.argv[4] ?? "light" const origin = process.env.AK_ORIGIN ?? "http://localhost" const recovery = process.env.AK_RECOVERY ?? "" const akUser = process.env.AK_USER ?? "akadmin" const akPass = process.env.AK_PASS ?? "" const outDir = "/tmp/authentik-dom" await import("node:fs").then((fs) => fs.mkdirSync(outDir, { recursive: true })) const browser = await chromium.launch() const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, colorScheme: theme === "dark" ? "dark" : "light", ignoreHTTPSErrors: true, }) const page = await ctx.newPage() // Authentik advertises api.base as https://localhost, but nginx is http-only here. await page.addInitScript(() => { Object.defineProperty(window, "authentik", { configurable: true, set(v) { try { if (v && v.api) { v.api.base = location.origin + "/auth/" v.api.relBase = "/auth/" } } catch {} Object.defineProperty(window, "authentik", { value: v, writable: true, configurable: true, }) }, get() { return undefined }, }) }) async function loginWithRecovery() { const url = recovery.startsWith("http") ? recovery : origin + recovery await page.goto(url, { waitUntil: "networkidle", timeout: 30000 }) // Recovery flow may present a "use this token" confirmation or land straight // on the user interface. Give it time and click any primary continue button. await page.waitForTimeout(2500) for (let i = 0; i < 4; i++) { const clicked = await page.evaluate(() => { function findBtn(root) { const btn = root.querySelector?.("button.pf-m-primary, button[type=submit]") if (btn) return btn const all = root.querySelectorAll?.("*") ?? [] for (const el of all) { if (el.shadowRoot) { const f = findBtn(el.shadowRoot) if (f) return f } } return null } const b = findBtn(document) if (b) { b.click() return true } return false }) if (!clicked) break await page.waitForTimeout(1500) } } async function loginWithPassword() { await page.goto(`${origin}/auth/if/flow/default-authentication-flow/`, { waitUntil: "networkidle", timeout: 30000, }) await page.waitForTimeout(2000) // Fill identification, submit, then password, submit — reaching across shadow roots. const typeAndSubmit = async (value) => { await page.evaluate((val) => { function findInput(root) { const i = root.querySelector?.("input[name=uidField], input[name=password], input") if (i) return i for (const el of root.querySelectorAll?.("*") ?? []) { if (el.shadowRoot) { const f = findInput(el.shadowRoot) if (f) return f } } return null } const input = findInput(document) if (input) { input.value = val input.dispatchEvent(new Event("input", { bubbles: true })) } }, value) await page.keyboard.press("Enter") await page.waitForTimeout(2500) } await typeAndSubmit(akUser) await typeAndSubmit(akPass) } if (recovery) { await loginWithRecovery() } else { await loginWithPassword() } // Now navigate to the authenticated target. await page.goto(target, { waitUntil: "networkidle", timeout: 30000 }) await page.waitForTimeout(3500) // Recursively serialize light + shadow DOM into an indented outline. const outline = await page.evaluate(() => { function attrs(el) { return [...el.attributes] .filter((a) => !["style"].includes(a.name)) .map((a) => (a.value ? `${a.name}="${a.value}"` : a.name)) .join(" ") } function walk(node, depth, lines) { const pad = " ".repeat(depth) if (node.nodeType === Node.ELEMENT_NODE) { const tag = node.tagName.toLowerCase() const a = attrs(node) lines.push(`${pad}<${tag}${a ? " " + a : ""}>`) if (node.shadowRoot) { lines.push(`${pad} #shadow-root`) for (const c of node.shadowRoot.children) walk(c, depth + 2, lines) } for (const c of node.children) walk(c, depth + 1, lines) } return lines } const lines = [] walk(document.documentElement, 0, lines) return lines.slice(0, 2000).join("\n") }) writeFileSync(`${outDir}/${outName}-${theme}.dom.txt`, outline) await page.screenshot({ path: `${outDir}/${outName}-${theme}.png`, fullPage: true }) console.log(`wrote ${outDir}/${outName}-${theme}.dom.txt (+ .png) — final url: ${page.url()}`) await browser.close()