Files
homelab-monitor/e2e/tests/oidc-login.spec.ts
T
jhodgkin 461bd63804
CI / web (push) Successful in 17s
CI / api (push) Successful in 24s
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.
2026-07-13 09:19:49 -06:00

88 lines
4.5 KiB
TypeScript

import { test, expect } from "@playwright/test";
const USERNAME = process.env.OIDC_USERNAME ?? "playwright-test";
const PASSWORD = process.env.OIDC_PASSWORD ?? "";
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();
await oidcButton.click();
// Regression check for the actual bug reported: the authorization endpoint
// must be the public auth.jerodrigged.com, never a LAN IP a browser off the
// LAN can't reach.
await page.waitForURL(/^https:\/\/auth\.jerodrigged\.com\//, { timeout: 10_000 });
expect(page.url()).not.toMatch(/192\.168\./);
await page.getByPlaceholder(/email or username/i).fill(USERNAME);
await page.getByRole("button", { name: /log in|continue|next/i }).click();
// Authentik's password field has no <label> association -- it's identified
// by placeholder text ("Please enter your password"), not an a11y label.
await page.getByPlaceholder(/password/i).fill(PASSWORD);
await page.getByRole("button", { name: /log in|continue|sign in/i }).click();
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 }) => {
test.skip(!PASSWORD, "OIDC_PASSWORD not set");
await page.goto("/");
await page.getByRole("link", { name: "Sign in with Authentik" }).click();
await page.waitForURL(/^https:\/\/auth\.jerodrigged\.com\//, { timeout: 10_000 });
await page.getByPlaceholder(/email or username/i).fill(USERNAME);
await page.getByRole("button", { name: /log in|continue|next/i }).click();
await page.getByPlaceholder(/password/i).fill("definitely-not-the-password");
await page.getByRole("button", { name: /log in|continue|sign in/i }).click();
// Authentik re-shows the password stage with an error, never redirects back.
await expect(page.getByText(/failed|invalid|incorrect/i)).toBeVisible({ timeout: 10_000 });
expect(page.url()).toMatch(/^https:\/\/auth\.jerodrigged\.com\//);
});