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.
This commit is contained in:
@@ -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
|
||||||
+58
-6
@@ -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** —
|
No API token or admin credentials were needed. Authentik supports **blueprints** —
|
||||||
declarative YAML files it applies automatically — so the OAuth2 Provider + Application
|
declarative YAML files it applies automatically — so the OAuth2 Provider + Application
|
||||||
were created via `/opt/authentik/blueprints-local/homelab-monitor-oidc.yaml` on CT121,
|
were created via a blueprint deployed to `/opt/authentik/blueprints-local/homelab-monitor-oidc.yaml`
|
||||||
picked up by the `worker` container on startup. This is purely additive: it doesn't
|
on CT121, picked up by the `worker` container on startup. This is purely additive: it
|
||||||
touch any existing user, group, or admin credential.
|
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
|
- 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`.
|
`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
|
set in `.env`) and `http://192.168.1.103:8090/api/auth/oidc/callback` (LAN fallback, works if the
|
||||||
tunnel/DNS is down).
|
tunnel/DNS is down).
|
||||||
- Application slug: `homelab-monitor`.
|
- 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
|
To change anything (redirect URI, flow, scopes), edit `deploy/authentik/homelab-monitor-oidc.yaml`,
|
||||||
either wait for Authentik's file-watcher or `docker restart authentik-worker-1` — it
|
redeploy via the commands above, and commit the change.
|
||||||
re-applies on any change to the file.
|
|
||||||
|
|
||||||
## Bug: LAN-IP issuer sent browsers somewhere they couldn't reach
|
## 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
|
provider's DB schema (`\d authentik_providers_oauth2_oauth2provider`) before
|
||||||
implementing, not assumed.
|
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
|
## Credentials
|
||||||
|
|
||||||
`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` are in CT122's `.env` and saved in Vaultwarden
|
`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` are in CT122's `.env` and saved in Vaultwarden
|
||||||
|
|||||||
@@ -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 });
|
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
|
// 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
|
// it's still on the network and still unknown -- skip rather than fail if
|
||||||
// this specific IP isn't something the test suite controls.
|
// 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 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 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 });
|
await expect(page.locator(".deep-check-row").first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
const resultText = await page.locator(".deep-check-row").first().textContent();
|
const resultText = await page.locator(".deep-check-row").first().textContent();
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import { test, expect } from "@playwright/test";
|
|||||||
const USERNAME = process.env.OIDC_USERNAME ?? "playwright-test";
|
const USERNAME = process.env.OIDC_USERNAME ?? "playwright-test";
|
||||||
const PASSWORD = process.env.OIDC_PASSWORD ?? "";
|
const PASSWORD = process.env.OIDC_PASSWORD ?? "";
|
||||||
|
|
||||||
test("OIDC login via Authentik stays on public domains throughout", async ({ page }) => {
|
async function loginViaAuthentik(page: import("@playwright/test").Page) {
|
||||||
test.skip(!PASSWORD, "OIDC_PASSWORD not set");
|
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
const oidcButton = page.getByRole("link", { name: "Sign in with Authentik" });
|
const oidcButton = page.getByRole("link", { name: "Sign in with Authentik" });
|
||||||
await expect(oidcButton).toBeVisible();
|
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 });
|
await page.waitForURL(/^https:\/\/monitor\.jerodrigged\.com\//, { timeout: 15_000 });
|
||||||
expect(page.url()).not.toMatch(/192\.168\./);
|
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 });
|
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.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();
|
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 }) => {
|
test("wrong Authentik password does not reach the dashboard", async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user