461bd63804
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.
88 lines
4.5 KiB
TypeScript
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\//);
|
|
});
|