From 461bd6380415694c27a0f19c1831a3370c722db3 Mon Sep 17 00:00:00 2001 From: jhodgkin Date: Mon, 13 Jul 2026 09:19:49 -0600 Subject: [PATCH] Version-control the Authentik OIDC blueprint, add full-logout + username regression tests The Authentik blueprint that provisions the OAuth2 Provider/Application only lived on CT121's filesystem via ad-hoc scp/pct push -- deploy/authentik/ is now the source of truth, with redeploy steps in docs/oidc-setup.md. Also documents two bugs found and fixed while implementing issue #19: the first RP-Initiated Logout attempt only ended the app-scoped session, and the provider had no property_mappings so the ID token's username claim was missing (fell back to a raw sub hash that looked like a leaked session token). Both are covered by new Playwright regression tests. The deep-check Fingerbank test now skips instead of failing when its target device (192.168.1.106) has since been manually labeled known via the dashboard, rather than assuming it stays unlabeled forever. --- deploy/authentik/homelab-monitor-oidc.yaml | 55 +++++++++++++++++++ docs/oidc-setup.md | 64 ++++++++++++++++++++-- e2e/tests/deep-check.spec.ts | 9 ++- e2e/tests/oidc-login.spec.ts | 40 +++++++++++++- 4 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 deploy/authentik/homelab-monitor-oidc.yaml diff --git a/deploy/authentik/homelab-monitor-oidc.yaml b/deploy/authentik/homelab-monitor-oidc.yaml new file mode 100644 index 0000000..076dd42 --- /dev/null +++ b/deploy/authentik/homelab-monitor-oidc.yaml @@ -0,0 +1,55 @@ +version: 1 +metadata: + name: homelab-monitor-oidc +entries: + - model: authentik_providers_oauth2.oauth2provider + id: homelab-monitor-provider + identifiers: + name: homelab-monitor + attrs: + client_type: confidential + client_id: 91e5b20f5902319144cc1530559f5c8ba27e0027 + # Real secret lives only in CT122's .env / Vaultwarden -- this + # blueprint file as deployed to CT121 has the real value baked in + # (Authentik blueprints don't support secret refs), but the copy + # tracked in this repo uses a placeholder. If re-deploying from + # scratch, pull the real secret from Vaultwarden ("Homelab Monitor - + # Authentik OIDC client") and substitute it before pushing to CT121. + client_secret: __SEE_VAULTWARDEN_HOMELAB_MONITOR_OIDC_CLIENT__ + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + # default-invalidation-flow (full Authentik logout), not + # default-provider-invalidation-flow (app-scoped only). The app-scoped + # one leaves Authentik's own browser session cookie valid, so a + # subsequent "Sign in with Authentik" click silently re-authenticates + # with no prompt -- doesn't satisfy "sign out should sign out of + # Authentik too" (issue #19). Confirmed via a full Playwright + # click-through both ways before settling on this. Per-provider + # setting; doesn't affect any other app's logout behavior. + invalidation_flow: !Find [authentik_flows.flow, [slug, default-invalidation-flow]] + signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]] + sub_mode: hashed_user_id + # Without these, the ID token only ever carries the bare required + # claims (sub/iss/aud/exp/...) -- never preferred_username or email. + # The app's claim fallback chain (preferred_username ?? email ?? sub) + # landed on the raw sub hash, which rendered next to the sign-out + # button looking like a leaked session token. Confirmed root cause by + # decoding a real captured ID token before concluding this was it, + # rather than guessing. + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [name, "authentik default OAuth Mapping: OpenID 'openid'"]] + - !Find [authentik_providers_oauth2.scopemapping, [name, "authentik default OAuth Mapping: OpenID 'profile'"]] + - !Find [authentik_providers_oauth2.scopemapping, [name, "authentik default OAuth Mapping: OpenID 'email'"]] + redirect_uris: + - matching_mode: strict + url: https://monitor.jerodrigged.com/api/auth/oidc/callback + - matching_mode: strict + url: http://192.168.1.103:8090/api/auth/oidc/callback + - model: authentik_core.application + id: homelab-monitor-app + identifiers: + slug: homelab-monitor + attrs: + name: Homelab Monitor + provider: !KeyOf homelab-monitor-provider + open_in_new_tab: false + meta_launch_url: https://monitor.jerodrigged.com diff --git a/docs/oidc-setup.md b/docs/oidc-setup.md index d33b452..e99e2f1 100644 --- a/docs/oidc-setup.md +++ b/docs/oidc-setup.md @@ -8,9 +8,22 @@ button — this is additive, not a replacement, so the existing admin login keep No API token or admin credentials were needed. Authentik supports **blueprints** — declarative YAML files it applies automatically — so the OAuth2 Provider + Application -were created via `/opt/authentik/blueprints-local/homelab-monitor-oidc.yaml` on CT121, -picked up by the `worker` container on startup. This is purely additive: it doesn't -touch any existing user, group, or admin credential. +were created via a blueprint deployed to `/opt/authentik/blueprints-local/homelab-monitor-oidc.yaml` +on CT121, picked up by the `worker` container on startup. This is purely additive: it +doesn't touch any existing user, group, or admin credential. + +**Source of truth is `deploy/authentik/homelab-monitor-oidc.yaml` in this repo** — the +copy on CT121 was hand-edited via `scp`/`pct push` several times before this was +committed here, meaning CT121 was the only record of the current config for a while. If +CT121 is ever rebuilt, redeploy from this file (substituting the real `client_secret` +from Vaultwarden, since Authentik blueprints don't support secret references and the +repo copy uses a placeholder): + +```bash +scp deploy/authentik/homelab-monitor-oidc.yaml pve:/tmp/ +ssh pve "pct push 121 /tmp/homelab-monitor-oidc.yaml /opt/authentik/blueprints-local/homelab-monitor-oidc.yaml" +ssh pve "pct exec 121 -- docker restart authentik-worker-1" +``` - Blueprint volume mount added to `/opt/authentik/docker-compose.yml` (backed up as `docker-compose.yml.bak-homelab-monitor` before editing) for both `server` and `worker`. @@ -21,10 +34,14 @@ touch any existing user, group, or admin credential. set in `.env`) and `http://192.168.1.103:8090/api/auth/oidc/callback` (LAN fallback, works if the tunnel/DNS is down). - Application slug: `homelab-monitor`. +- `property_mappings`: the three default Authentik OpenID scope mappings (openid/profile/email) + — see "Bug: username field showed a raw session-token-looking hash" below for why this matters. +- `invalidation_flow`: `default-invalidation-flow` (full Authentik logout), not the + app-scoped `default-provider-invalidation-flow` — see "Bug: sign out didn't actually + sign out of Authentik" below. -To change anything (redirect URI, flow, scopes), edit the blueprint file on CT121 and -either wait for Authentik's file-watcher or `docker restart authentik-worker-1` — it -re-applies on any change to the file. +To change anything (redirect URI, flow, scopes), edit `deploy/authentik/homelab-monitor-oidc.yaml`, +redeploy via the commands above, and commit the change. ## Bug: LAN-IP issuer sent browsers somewhere they couldn't reach @@ -69,6 +86,41 @@ Authentik has no dedicated `post_logout_redirect_uri` allowlist field (unlike provider's DB schema (`\d authentik_providers_oauth2_oauth2provider`) before implementing, not assumed. +## Bug: sign out didn't actually sign out of Authentik + +Issue #19's premise. First implementation was spec-compliant OIDC RP-Initiated Logout — +redirected through Authentik's `end_session_endpoint` with correct `id_token_hint` + +`post_logout_redirect_uri` — but Authentik's `default-provider-invalidation-flow` +(the initial choice) only invalidates *this application's* session, leaving the +underlying Authentik browser cookie valid. A subsequent "Sign in with Authentik" click +would silently re-authenticate with no prompt at all — technically correct per spec +(RP-Initiated Logout is only supposed to end the requesting client's session), but +doesn't satisfy "sign out should sign out of Authentik too." + +Fix: switched `invalidation_flow` to `default-invalidation-flow` (Authentik's actual +full-logout flow, designation `Logout` vs. the other's `Logged out of application`). +This is a per-provider setting — doesn't change any other app's own logout behavior. +Verified the difference with a full Playwright click-through both ways: app-scoped +logout → "Sign in with Authentik" silently re-authenticates; full logout → a real +Authentik login form ("Welcome to authentik! Login to continue.") is shown. + +## Bug: username field showed a raw session-token-looking hash + +The OAuth2Provider was created without any `property_mappings` (scope-to-claims +mappings) attached. Requesting `scope=openid profile email` in the authorization +request doesn't matter if the provider itself isn't configured to *release* the +corresponding claims — the ID token only ever carried the bare required claims +(`sub`/`iss`/`aud`/`exp`/...), confirmed by decoding a real captured ID token rather +than guessing. The app's claim fallback chain (`preferred_username ?? email ?? sub`, +`apps/api/src/auth/oidc.ts`) landed on the raw `sub` value — with `sub_mode: +hashed_user_id`, a long hash — which rendered next to the sign-out button looking like +a leaked session token. + +Fix: attached Authentik's three default OpenID scope mappings (`authentik default OAuth +Mapping: OpenID 'openid'/'profile'/'email'`) via `property_mappings` in the blueprint. +`preferred_username` now populates correctly. No app-code change was needed — the fix +belonged entirely on the Authentik provisioning side. + ## Credentials `OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` are in CT122's `.env` and saved in Vaultwarden diff --git a/e2e/tests/deep-check.spec.ts b/e2e/tests/deep-check.spec.ts index ce87a97..433ae65 100644 --- a/e2e/tests/deep-check.spec.ts +++ b/e2e/tests/deep-check.spec.ts @@ -44,12 +44,15 @@ test("deep check surfaces a Fingerbank identification with a confidence label", await expect(page.locator(".device-table tbody tr").first()).toBeVisible({ timeout: 15_000 }); // The Nintendo device (192.168.1.106) reliably gets a Fingerbank match, if - // it's still on the network -- skip rather than fail if it's gone, since - // this specific IP isn't something the test suite controls. + // it's still on the network and still unknown -- skip rather than fail if + // it's gone, or if it's since been manually labeled via the dashboard (its + // "known" state isn't something the test suite controls either). const row = page.locator('tr[data-ip="192.168.1.106"]'); + const deepCheckButton = row.getByRole("button", { name: /deep check/i }); test.skip((await row.count()) === 0, "192.168.1.106 not currently on the network"); + test.skip((await deepCheckButton.count()) === 0, "192.168.1.106 has since been labeled known"); - await row.getByRole("button", { name: /deep check/i }).click(); + await deepCheckButton.click(); await expect(page.locator(".deep-check-row").first()).toBeVisible({ timeout: 20_000 }); const resultText = await page.locator(".deep-check-row").first().textContent(); diff --git a/e2e/tests/oidc-login.spec.ts b/e2e/tests/oidc-login.spec.ts index 606f16b..a16b935 100644 --- a/e2e/tests/oidc-login.spec.ts +++ b/e2e/tests/oidc-login.spec.ts @@ -3,9 +3,7 @@ import { test, expect } from "@playwright/test"; const USERNAME = process.env.OIDC_USERNAME ?? "playwright-test"; const PASSWORD = process.env.OIDC_PASSWORD ?? ""; -test("OIDC login via Authentik stays on public domains throughout", async ({ page }) => { - test.skip(!PASSWORD, "OIDC_PASSWORD not set"); - +async function loginViaAuthentik(page: import("@playwright/test").Page) { await page.goto("/"); const oidcButton = page.getByRole("link", { name: "Sign in with Authentik" }); await expect(oidcButton).toBeVisible(); @@ -27,11 +25,47 @@ test("OIDC login via Authentik stays on public domains throughout", async ({ pag await page.waitForURL(/^https:\/\/monitor\.jerodrigged\.com\//, { timeout: 15_000 }); expect(page.url()).not.toMatch(/192\.168\./); +} +test("OIDC login via Authentik stays on public domains throughout", async ({ page }) => { + test.skip(!PASSWORD, "OIDC_PASSWORD not set"); + await loginViaAuthentik(page); await expect(page.locator(".host-card").first()).toBeVisible({ timeout: 15_000 }); + // Regression check: the OAuth2Provider originally had no property_mappings + // attached, so the ID token never carried preferred_username/email -- the + // app's claim fallback landed on the raw `sub` value, which (with + // sub_mode: hashed_user_id) is a long hash that looked like a leaked + // session token sitting next to the sign-out button. + const displayedUsername = await page.locator(".username").textContent(); + expect(displayedUsername).toBe(USERNAME); + expect(displayedUsername!.length).toBeLessThan(40); +}); + +test("sign out ends the Authentik session too, not just the local one", async ({ page }) => { + test.skip(!PASSWORD, "OIDC_PASSWORD not set"); + await loginViaAuthentik(page); + await expect(page.locator(".host-card").first()).toBeVisible({ timeout: 15_000 }); + + // Regression check for issue #19. First implementation redirected through + // Authentik's end_session_endpoint but used the app-scoped invalidation + // flow, which only ends this app's session and leaves Authentik's own + // browser cookie valid -- a subsequent "Sign in with Authentik" click + // would silently re-authenticate with no prompt at all. Full-logout flow + // (see docs/oidc-setup.md) is required to actually satisfy "sign out + // should sign out of Authentik too". await page.getByRole("button", { name: "Sign out" }).click(); + await page.waitForURL(/^https:\/\/auth\.jerodrigged\.com\//, { timeout: 10_000 }); + await page.getByRole("link", { name: "Log back into Homelab Monitor" }).click(); + await page.waitForURL(/^https:\/\/monitor\.jerodrigged\.com\//, { timeout: 10_000 }); await expect(page.getByPlaceholder("Username")).toBeVisible(); + + // The real assertion: a fresh "Sign in with Authentik" click must require + // actual credentials again, not silently re-authenticate. + await page.getByRole("link", { name: "Sign in with Authentik" }).click(); + await page.waitForURL(/^https:\/\/auth\.jerodrigged\.com\//, { timeout: 10_000 }); + await expect(page.getByPlaceholder(/email or username/i)).toBeVisible({ timeout: 10_000 }); + await expect(page.locator(".device-table")).toHaveCount(0); }); test("wrong Authentik password does not reach the dashboard", async ({ page }) => {