diff --git a/dankclaw/open-webui/quadlet/open-webui.container b/dankclaw/open-webui/quadlet/open-webui.container index fb05bcb..37c9951 100644 --- a/dankclaw/open-webui/quadlet/open-webui.container +++ b/dankclaw/open-webui/quadlet/open-webui.container @@ -33,6 +33,28 @@ Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770 Environment=ANONYMIZED_TELEMETRY=False Environment=DO_NOT_TRACK=True +# SSO via authentik (OIDC authorization-code flow, 2026-09-24). The OAuth +# client (application slug "open-webui") lives in authentik; the client +# SECRET stays off-repo in the git-ignored host env file (mode 600). The +# container reaches the IdP over the public URL: Caddy publishes 0.0.0.0:443 +# and Pi-hole resolves auth.franksdank.cloud to the host LAN IP, so the +# server-side code exchange works from this bridge. Roles come from the +# "Open WebUI roles" scope mapping (entitlements "Open WebUI Users" / +# "Open WebUI Admins"); OAUTH_MERGE_ACCOUNTS_BY_EMAIL folds SSO logins into +# the existing local account (frank8465@protonmail.com). These vars SEED the +# per-key config DB on first run (fill-missing only); on the live data dir +# the Admin Panel is authoritative. +EnvironmentFile=/home/dank/Containers/open-webui/env +Environment=OAUTH_CLIENT_ID=KmtzKc0bjWjaIFeM0puYudI1CLXR2p0jnPCSf4N0 +Environment=OAUTH_PROVIDER_NAME=authentik +Environment=OPENID_PROVIDER_URL=https://auth.franksdank.cloud/application/o/open-webui/.well-known/openid-configuration +Environment=OPENID_REDIRECT_URI=https://ai.franksdank.cloud/oauth/oidc/callback +Environment=WEBUI_URL=https://ai.franksdank.cloud +Environment=ENABLE_OAUTH_SIGNUP=true +Environment=OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true +Environment=OAUTH_SCOPES=openid email profile roles +Environment=ENABLE_OAUTH_ROLE_MANAGEMENT=true + # Web search -> SearXNG, reached over the host-published port (NOT the # search.franksdank.cloud vhost): the Anubis->Authentik guard chain is a # browser mechanism and a plain HTTP client cannot solve it. The internal diff --git a/serverAppConfigs-docs/Dankclaw/OpenWebUI.md b/serverAppConfigs-docs/Dankclaw/OpenWebUI.md index 0130152..3b86116 100644 --- a/serverAppConfigs-docs/Dankclaw/OpenWebUI.md +++ b/serverAppConfigs-docs/Dankclaw/OpenWebUI.md @@ -84,6 +84,40 @@ Chat web search is served by [[SearXNG]] on this host, wired in the quadlet table on first start (fill-missing only); afterwards the DB is authoritative and Admin-UI edits persist there. +## SSO (authentik) + +Login is authentik via the OIDC authorization-code flow (wired 2026-09-24). +The vhost stays exempt from the Anubis guard chain: the OAuth dance is +browser-driven and only touches the two exempt vhosts (`ai.` and `auth.`). + +- **authentik objects** (created via the admin API, 2026-09-24): application + `open-webui` (slug `open-webui`) with the OAuth2 provider `Open WebUI` + (confidential client, `authorization_code` + `refresh_token`, Strict + redirect URI `https://ai.franksdank.cloud/oauth/oidc/callback`, implicit + consent flow). The client ID is in the quadlet; the client secret lives in + the git-ignored host file `/home/dank/Containers/open-webui/env` (mode 600, + loaded via `EnvironmentFile=`). +- **Roles:** the scope mapping `Open WebUI roles` (scope `roles`) reads the + application entitlements `Open WebUI Users` → `user` and `Open WebUI + Admins` → `admin`. The entitlements are bound to the groups + `openwebui_users` / `openwebui_admins`. `akadmin` (frank8465@protonmail.com) + is in `openwebui_admins`; `qa-e2e-admin` is in `openwebui_users`. +- **Account merge:** `OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true` folds an SSO login + into the existing local account with the same email — the pre-SSO admin + (frank8465@protonmail.com) keeps its data. `ENABLE_OAUTH_SIGNUP=true` lets + other `openwebui_users` create accounts on first login. +- **IdP reachability:** the container is on its own bridge (10.89.3.0/24) and + cannot reach `authentik-server:9000` by name. It reaches the IdP over the + public URL `https://auth.franksdank.cloud` — Caddy publishes `0.0.0.0:443` + (netavark DNATs published ports from other containers) and Pi-hole resolves + the name to the host LAN IP. The server-side code exchange rides that path. +- **Config authority:** the OAuth env vars in the quadlet seed the per-key + config DB on first run (fill-missing only); on the live data dir the Admin + Panel is authoritative. To rotate the client secret: create a new token in + authentik, update the host env file, and set the new value in the Admin + Panel (or the DB), then restart the container. + + ## Boot persistence (Quadlet) The unit lives at `dankclaw/open-webui/quadlet/open-webui.container`. On the host it installs to `~/.config/containers/systemd/open-webui.container`. The generator turns it into `open-webui.service`. Because the unit sets `[Install] WantedBy=default.target` and user linger is on, it starts at boot. `systemctl --user is-enabled open-webui.service` reports `generated` (the quadlet boot-start state). @@ -124,6 +158,8 @@ The quadlet carries a built-in healthcheck (30s interval, 3 retries, 300s start - **2026-09-21 — model picker rebuilt for the 3-model fleet.** The picker carried 39 stale rows (37 dead Ollama models + 2 `frank-default-*`) and was missing `frank-medium`. Rebuilt the `model` table to exactly `frank-large`/`frank-medium`/`frank-small` and set `model_ids` on each connection (llama.cpp's Ollama-shaped `/v1/models` is invisible to OpenWebUI's discovery). Pointed the two on-host endpoints at `host.containers.internal` — the bridge refuses the host LAN IP for non-published ports. All three verified end-to-end via `/openai/chat/completions`. +- **2026-09-24 — SSO via authentik.** OIDC authorization-code flow wired per the [SSO (authentik)](#sso-authentik) section: application/provider/entitlements created in authentik, client secret in the host env file, roles from the `roles` scope claim. E2E coverage in `tests/e2e/specs/18-openwebui-sso.spec.ts`. + ## See Also - [[Dankclaw Stack]] diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index ec0450b..a9e9097 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -28,8 +28,9 @@ export default defineConfig({ // whose prefix is not listed in any project silently never runs — a // green suite with a test that does not exist. 17- joined on 2026-09-16 // (the hermes vhost, whose upstream is a host address rather than a - // container name, so it needs its own pass condition). - testMatch: /(10|12|13|16|17)-.*\.spec\.ts/, + // container name, so it needs its own pass condition). 18- joined on + // 2026-09-24 (OpenWebUI SSO through authentik). + testMatch: /(10|12|13|16|17|18)-.*\.spec\.ts/, use: { storageState: ".auth/qa-e2e.json" }, dependencies: ["setup"], }, diff --git a/tests/e2e/specs/18-openwebui-sso.spec.ts b/tests/e2e/specs/18-openwebui-sso.spec.ts new file mode 100644 index 0000000..0e387a1 --- /dev/null +++ b/tests/e2e/specs/18-openwebui-sso.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +// OpenWebUI (ai.franksdank.cloud) authenticates through authentik via the +// OIDC authorization-code flow. With ONLY the standard authentik session, +// visiting the vhost must show the "Continue with authentik" button, and +// clicking it must complete the round-trip silently (no password prompt on +// the IdP) and land in the logged-in app. + +test("SSO: OpenWebUI login completes through authentik silently", async ({ page }) => { + test.setTimeout(180_000); + await page.goto("https://ai.franksdank.cloud/"); + + // The app is a React SPA; the login page (no local session) renders the + // OAuth button labelled after the provider name. + const ssoButton = page.getByRole("button", { name: /continue with authentik/i }); + await expect(ssoButton).toBeVisible({ timeout: 90_000 }); + + // The round-trip: ai -> auth (silent, the session is already there) -> + // back to ai with the code. No authentik password form may appear. + await ssoButton.click(); + await expect + .poll( + async () => { + const body = (await page.locator("body").innerText()).toLowerCase(); + return !body.includes("continue with authentik"); + }, + { timeout: 90_000 }, + ) + .toBe(true); + await expect + .poll(() => new URL(page.url()).hostname, { timeout: 60_000 }) + .toBe("ai.franksdank.cloud"); + const body = await page.locator("body").innerText(); + expect(body.toLowerCase()).not.toContain("powered by authentik"); // IdP flow shell + expect(body.toLowerCase()).not.toContain("password"); // no credential prompt +}); diff --git a/tests/open_webui_quadlet.bats b/tests/open_webui_quadlet.bats index 3ff4799..f992811 100644 --- a/tests/open_webui_quadlet.bats +++ b/tests/open_webui_quadlet.bats @@ -89,3 +89,36 @@ QDIR="$REPO_ROOT/dankclaw/open-webui/quadlet" fi has_no_leaked_secret "$QDIR/open-webui.container" } + +@test "open-webui OIDC: provider URL points at the open-webui application on auth.franksdank.cloud" { + # The OAuth client lives in authentik (application slug "open-webui"); + # the container reaches the IdP over the public URL (Caddy publishes + # 0.0.0.0:443, Pi-hole resolves the name to the host LAN IP). + grep -qE '^Environment=OPENID_PROVIDER_URL=https://auth\.franksdank\.cloud/application/o/open-webui/\.well-known/openid-configuration$' \ + "$QDIR/open-webui.container" \ + || { echo "open-webui.container: OPENID_PROVIDER_URL must point at the open-webui application on auth.franksdank.cloud"; return 1; } +} + +@test "open-webui OIDC: redirect URI matches the ai.franksdank.cloud vhost" { + # The browser-side callback must land on the published vhost; the + # authentik provider allows exactly this URI (Strict). + grep -qE '^Environment=OPENID_REDIRECT_URI=https://ai\.franksdank\.cloud/oauth/oidc/callback$' \ + "$QDIR/open-webui.container" \ + || { echo "open-webui.container: OPENID_REDIRECT_URI must be the ai.franksdank.cloud callback"; return 1; } +} + +@test "open-webui OIDC: WEBUI_URL, signup, email merge, and role management" { + grep -qE '^Environment=WEBUI_URL=https://ai\.franksdank\.cloud$' "$QDIR/open-webui.container" + grep -qE '^Environment=ENABLE_OAUTH_SIGNUP=true' "$QDIR/open-webui.container" + grep -qE '^Environment=OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true' "$QDIR/open-webui.container" + grep -qE '^Environment=ENABLE_OAUTH_ROLE_MANAGEMENT=true' "$QDIR/open-webui.container" + grep -qE '^Environment=OAUTH_SCOPES=openid email profile roles$' "$QDIR/open-webui.container" +} + +@test "open-webui OIDC: client secret lives in the host env file, not the unit" { + # The client ID is a public identifier and may be tracked; the secret + # must come from the git-ignored host env file (mode 600). + grep -qE '^EnvironmentFile=/home/dank/Containers/open-webui/env$' "$QDIR/open-webui.container" \ + || { echo "open-webui.container: must load the client secret from the host env file"; return 1; } + ! grep -q 'OAUTH_CLIENT_SECRET' "$QDIR/open-webui.container" +}