open-webui: SSO via authentik (OIDC authorization-code flow)

- quadlet: OAUTH_*/OPENID_* env + EnvironmentFile for the client secret
  (secret stays off-repo in /home/dank/Containers/open-webui/env, mode 600)
- authentik objects created live: application 'open-webui', OAuth2 provider
  'Open WebUI' (Strict redirect URI), 'Open WebUI roles' scope mapping,
  entitlements Open WebUI Users/Admins bound to openwebui_users/admins
- e2e: 18-openwebui-sso.spec.ts (silent SSO round-trip) + prefix in config
- unit tests: OIDC wiring + secret-hygiene assertions
- docs: SSO section + history entry
This commit is contained in:
Frank
2026-09-24 20:04:17 -07:00
parent 6ffcde0197
commit b48de913b8
5 changed files with 130 additions and 2 deletions
@@ -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
@@ -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]]
+3 -2
View File
@@ -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"],
},
+36
View File
@@ -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
});
+33
View File
@@ -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"
}