diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index 2243ca6..7e24d00 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -268,10 +268,11 @@ files.franksdank.cloud { # Anubis → Authentik → app, like romm/files. The container carries # ROUTER_BASE_PATH=/audiobookshelf, so this vhost serves the app under that # path; the bare root is redirected onto it in the post-auth listener, never -# pre-auth. Guard state in the IdP: proxy provider `abs-proxy` -# (external_host https://abs.franksdank.cloud/, internal_host http://caddy:8017, -# application "Audiobookshelf Guard"), claimed by the embedded outpost. -abs.franksdank.cloud { +# pre-auth. Guard state in the IdP: proxy provider `audiobooks-proxy` +# (external_host https://audiobooks.franksdank.cloud/, internal_host +# http://caddy:8017, application "Audiobookshelf Guard"), claimed by the +# embedded outpost. +audiobooks.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } @@ -423,13 +424,22 @@ http://:8017 { reverse_proxy 192.168.0.235:3923 } - @abs host abs.franksdank.cloud - handle @abs { + @audiobooks host audiobooks.franksdank.cloud + handle @audiobooks { # The app answers only under its ROUTER_BASE_PATH; the bare root is a # convenience hop, taken post-auth so nothing reaches the app # pre-authentication. redir / /audiobookshelf/ permanent - reverse_proxy 192.168.0.235:8000 + # Audiobookshelf validates every OIDC callback against the origin it + # believes it is served from — getRequestOrigin() is + # x-forwarded-proto://Host — and a mismatch is a bodiless 400 + # "Invalid callback URL - must be same-origin" before the IdP is ever + # contacted (measured 2026-11-03). This listener is fed over plain http + # by the outpost, so Caddy's own X-Forwarded-Proto here says http; the + # edge for this name is TLS-only, so https is the truthful value. + reverse_proxy 192.168.0.235:8000 { + header_up X-Forwarded-Proto https + } } @prowlarr host prowlarr.franksdank.cloud diff --git a/dankclaw/pihole/addn-hosts b/dankclaw/pihole/addn-hosts index 51abc29..c2421cd 100644 --- a/dankclaw/pihole/addn-hosts +++ b/dankclaw/pihole/addn-hosts @@ -26,7 +26,7 @@ 192.168.0.143 romm.franksdank.cloud 192.168.0.143 files.franksdank.cloud 192.168.0.143 jelly.franksdank.cloud -192.168.0.143 abs.franksdank.cloud +192.168.0.143 audiobooks.franksdank.cloud 192.168.0.143 radarr.franksdank.cloud 192.168.0.143 lidarr.franksdank.cloud 192.168.0.143 readarr.franksdank.cloud diff --git a/scripts/authentik-audiobookshelf-sso.sh b/scripts/authentik-audiobookshelf-sso.sh index 7dbba89..2f3df52 100755 --- a/scripts/authentik-audiobookshelf-sso.sh +++ b/scripts/authentik-audiobookshelf-sso.sh @@ -79,8 +79,8 @@ # origin, reached directly, which is also what the phone # app is configured with) # ABS_EXTRA_BASES space-separated additional origins, base path included -# (default: https://abs.franksdank.cloud/audiobookshelf — -# the guarded vhost, dankclaw/Caddyfile). +# (default: https://audiobooks.franksdank.cloud/audiobookshelf +# — the guarded vhost, dankclaw/Caddyfile). # ABS builds its redirect_uri from the ORIGIN of the # request, so each origin the app is served at needs its # own registered URI — an unregistered origin is a @@ -100,7 +100,7 @@ SSO_USER="${ABS_OIDC_USERNAME:-akadmin}" MEMBERS="${ABS_OIDC_USERS:-}" E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}" BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}" -EXTRA_BASES="${ABS_EXTRA_BASES:-https://abs.franksdank.cloud/audiobookshelf}" +EXTRA_BASES="${ABS_EXTRA_BASES:-https://audiobooks.franksdank.cloud/audiobookshelf}" HOST="${AUTHENTIK_HOST:-dankclaw}" # The claim expression: translate group membership into ABS role names. It is stored diff --git a/scripts/authentik-proxy-provider-defaults.sh b/scripts/authentik-proxy-provider-defaults.sh new file mode 100755 index 0000000..74883ba --- /dev/null +++ b/scripts/authentik-proxy-provider-defaults.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Census and repair the OAuth2 defaults on every authentik PROXY provider. +# +# A proxy provider built by hand in `ak shell` never runs +# ProxyProvider.set_oauth_defaults() — the REST serializer calls it, the ORM does +# not — so the row looks perfectly configured (external_host, internal_host, +# application bound, embedded-outpost claim present) while three OAuth facts are +# blank. Each one kills the guarded login in a different, unrelated-looking way, +# all BEFORE any credential is asked for: +# +# * redirect_uris empty -> authentik answers its own page: +# "Redirect URI Error — The request fails due to a missing, invalid, or +# mismatching redirection URI (redirect_uri)". Measured on the Audiobookshelf +# guard provider, 2026-11-03. +# * grant_types empty -> the authorize endpoint refuses the outpost's +# authorization-code request with a bodiless HTTP 400 (Chromium renders +# "This page isn't working / HTTP ERROR 400"). Same provider, one step +# later, after the URIs were filled in by hand. +# * authorization_flow NULL -> the authorize view crashes building its flow +# planner: AttributeError 'NoneType' object has no attribute 'slug', HTTP +# 500. That is what authentik-grafana-sso-fix.sh exists for. +# +# So: a guard that shows ANY of those three pages is not an Anubis problem and +# not a Caddy problem — run this script before touching the route. +# +# Repair calls the VENDOR's own set_oauth_defaults() (grant_types = authz code + +# client credentials + password, confidential client, the stock scope mappings, +# redirect_uris derived from external_host by _get_callback_url) and points the +# authorization flow at default-provider-authorization-implicit-consent, the flow +# every working provider here uses. Providers that already match are untouched, +# so a deliberate per-provider deviation is never silently flattened. +# +# This is authentik DB state. It is NOT in any deployable file: a blueprint +# re-import, or an ak-shell re-creation of a provider, reverts these facts and +# the symptom returns verbatim. Re-run after any such rebuild. Idempotent. +# +# Usage: authentik-proxy-provider-defaults.sh [--check] +# no argument repair what drifts, print one line per provider +# --check census only, exit 1 if anything drifts (gate use) +# override host: AUTHENTIK_HOST=… +set -euo pipefail +HOST="${AUTHENTIK_HOST:-dankclaw}" +MODE="${1:-}" +case "$MODE" in + "" | --check) : ;; + *) echo "usage: $(basename "$0") [--check]" >&2; exit 2 ;; +esac + +ssh "$HOST" 'podman exec -i authentik-server ak shell' < grants %s, %d redirect URIs, flow %s" + % ([str(g) for g in after.grant_types], len(after.redirect_uris), + after.authorization_flow and after.authorization_flow.slug)) + +print("providers checked, drift: %d" % drift) +raise SystemExit(1 if (CHECK and drift) else 0) +PYEOF + +echo "Verify: walk the guarded vhost in a browser — Anubis solves, authentik" +echo "renders its login form (no Redirect URI Error, no HTTP 400), and the" +echo "callback lands back on the app." diff --git a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md index 0e397df..6f67bea 100644 --- a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md +++ b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md @@ -17,7 +17,7 @@ server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on array. **Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user manager) — -**Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (direct, what the phone app uses) and guarded `https://abs.franksdank.cloud/audiobookshelf/`. +**Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (direct, what the phone app uses) and guarded `https://audiobooks.franksdank.cloud/audiobookshelf/`. ## Shape @@ -98,7 +98,7 @@ the local form, and the phone app offers SSO on its server-address screen. The l | The ABS half: issuer, the four IdP URLs, claim, auto-register | `scripts/audiobookshelf-init.sh`, armed automatically when that secret exists in the credential file | | Role at first SSO sign-in | the `abs_groups` claim: `audiobookshelf_admins` → `admin`, `audiobookshelf_users` → `user`, in neither → denied (fail closed) | | Admin group members | `akadmin` (the operator's own authentik admin account — sign in as it and the claim grants site admin) and `qa-e2e-admin` (the identity the deploy gate signs in as, so the script re-adds it every run; drop it and the gate fails, not a phone) | -| The guard in front of the vhost | proxy provider `abs-proxy` (external_host `https://abs.franksdank.cloud/`, internal_host `http://caddy:8017`) + application "Audiobookshelf Guard", claimed by the embedded outpost — the ak-shell recipe in [[Caddy]] *Guard a new vhost* creates it; without that row the chain answers authentik's error page, not the login | +| The guard in front of the vhost | proxy provider `audiobooks-proxy` (external_host `https://audiobooks.franksdank.cloud/`, internal_host `http://caddy:8017`) + application "Audiobookshelf Guard" (its own name tracks the app, not the vhost), claimed by the embedded outpost — the ak-shell recipe in [[Caddy]] *Guard a new vhost* creates it; without that row the chain answers authentik's error page, not the login | The IdP saying yes is not the same as being allowed in: with `authOpenIDAutoRegister` on, a first sign-in creates the account, and the claim alone decides the type. A user in @@ -140,6 +140,14 @@ Both halves — authentik's provider row and ABS's settings — are asserted end second wall appears after the fix ships (measured 2026-11-02: the browser reached authentik, got `code`+`state`, and ABS still bounced to `/login?error=undefined`). The script sets `authorization_code` + `refresh_token`; a blueprint re-import reverts the row silently. +- **The guarded door has to tell the truth about its scheme.** ABS validates every OIDC + `callback` against the origin it believes it is served from — `getRequestOrigin()` is + `x-forwarded-proto://Host` (`server/utils/requestUtils.js`, read in the pinned digest + 2026-11-03) — so behind a TLS edge that reaches the container over plain http it computes + `http://audiobooks.franksdank.cloud` and answers its own https callback with a 400 "Invalid callback + URL - must be same-origin" before the IdP is ever contacted. The Caddy `:8017` route pins + `header_up X-Forwarded-Proto https` for exactly that reason ([[Caddy]] *Guard a new vhost*); + the LAN door needs nothing, because there browser and server agree on `http`. - **The claim mapping is not the whole `property_mappings` list.** A provider holding only the custom mapping grants a token scoped shorter than the scopes ABS requests (`openid profile email abs_groups`): the code is issued and exchanged normally, and ABS's @@ -189,7 +197,7 @@ Both halves — authentik's provider row and ABS's settings — are asserted end service across a reboot ([[Quadlet Lifecycle]]). - **Two doors, one app.** `http://192.168.0.235:8000/audiobookshelf` is the raw LAN address — plain HTTP, nothing in front, what the phone app is configured - with; `https://abs.franksdank.cloud/audiobookshelf/` is the guarded vhost + with; `https://audiobooks.franksdank.cloud/audiobookshelf/` is the guarded vhost (Caddy → Anubis → the Authentik outpost → Caddy `:8017` → this container), the house chain for browser traffic — [[Caddy]] *Guard a new vhost*. That chain serves browsers only: the phone app is a non-browser client and solves diff --git a/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md b/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md index 596ac89..6bbb7fa 100644 --- a/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md +++ b/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md @@ -4,7 +4,7 @@ description: "Outpost, proxy-provider, OAuth2, LDAP and flow configuration for t tags: - infrastructure - authentik -timestamp: 2026-10-19 +timestamp: 2026-11-03 --- # Authentik Outpost Reference @@ -56,7 +56,7 @@ correctly — the outpost matches on hostname. ## Provisioning rules for proxy providers -Two provisioning rules, each verified live by an outage: +Three provisioning rules, each verified live by an outage or a dead login: 1. **All three flows are required.** A ProxyProvider created via raw API/ORM with any of `authentication_flow`/`authorization_flow`/`invalidation_flow` @@ -80,6 +80,16 @@ Two provisioning rules, each verified live by an outage: the app. No error is logged anywhere — the SSO "works" and the app never appears. Create the Application with `provider_id` set (the UI does this automatically; raw API/ORM drives must do it explicitly). +3. **A raw ORM create skips the OAuth defaults.** The REST serializer calls + `ProxyProvider.set_oauth_defaults()`; `ProxyProvider.objects.create()` does not. The row + then looks fully configured — external_host, internal_host, application bound, outpost + claimed — while `redirect_uris` is empty (those URIs are *derived* from `external_host` by + `_get_callback_url`) and `grant_types` is `[]`. Both kill the guard before any credential, + in this order: authentik renders its own **"Redirect URI Error"** page, and once the URIs + are filled by hand the next wall is a bodiless **HTTP 400** out of `/application/o/authorize/` + (both measured on the Audiobookshelf guard provider, 2026-11-03). Census and repair in one idempotent pass, all + providers at once: `scripts/authentik-proxy-provider-defaults.sh`; `--check` prints the + census and exits 1 on drift, and a provider that already matches is left alone. ## OAuth2 relying-party quirks (this fork; corrected live 2026-10-20) diff --git a/serverAppConfigs-docs/Infrastructure/Caddy.md b/serverAppConfigs-docs/Infrastructure/Caddy.md index 06ed99b..c116a2e 100644 --- a/serverAppConfigs-docs/Infrastructure/Caddy.md +++ b/serverAppConfigs-docs/Infrastructure/Caddy.md @@ -107,7 +107,7 @@ authentik's error page instead of the app. from authentik.providers.proxy.models import ProxyProvider from authentik.core.models import Application from authentik.outposts.models import Outpost - NAME, EXT = "abs", "https://abs.franksdank.cloud/" + NAME, EXT = "audiobooks", "https://audiobooks.franksdank.cloud/" pp = ProxyProvider.objects.filter(name=f"{NAME}-proxy").first() if pp is None: pp = ProxyProvider.objects.create(name=f"{NAME}-proxy", external_host=EXT, @@ -125,10 +125,24 @@ authentik's error page instead of the app. outpost routes on the ORIGINAL `Host` header alone, so anything else matches zero providers. Never print a provider's fields wholesale — a proxy provider carries a client secret, and it lands in whatever transcript the command runs in. + + `ProxyProvider.objects.create()` is not the API: the serializer's + `set_oauth_defaults()` never runs, so the row lands with `redirect_uris` and + `grant_types` empty, and the guard fails *before* any credential — authentik's + "Redirect URI Error" page, then a bodiless HTTP 400 once the URIs are filled + by hand. Run `scripts/authentik-proxy-provider-defaults.sh` after the snippet + (idempotent, covers every provider; `--check` is the census); law and failure + signatures in [[Authentik Outpost Reference]]. 2. **Then the route.** The public site block proxies the whole request to `anubis:8923` with `header_up X-Real-Ip {remote_host}`, and the post-auth `http://:8017` listener gains a `@ host ` handler pointing at the app — copy `romm`/`files`, which are the same shape including a Dankserver LAN-port upstream. + An app that derives its own origin from the request needs the scheme pinned: + Audiobookshelf checks every OIDC callback against `x-forwarded-proto://Host`; this + listener is fed over plain http so Caddy's own `X-Forwarded-Proto` is `http`, and the + app answers a 400 "Invalid callback URL - must be same-origin" for its own https + callback. The edge for a guarded name is TLS-only, so `header_up X-Forwarded-Proto + https` is the truthful value ([[Audiobookshelf]]). 3. **Then the name and the audit.** A `dankclaw/pihole/addn-hosts` line (the `pihole` seam fails the build if a Caddyfile vhost has no record), a Porkbun A record if the name must resolve off-LAN ([[LAN-DNS]]), and the subdomain added to `GUARDED` in diff --git a/shared/homepage/services.json b/shared/homepage/services.json index 99fd6a2..b7e87d9 100644 --- a/shared/homepage/services.json +++ b/shared/homepage/services.json @@ -516,7 +516,7 @@ "category": "admin", "section": "Media Library Apps", "description": "Audiobook server for the entropy library: its own SQLite library, its own login, per-listener positions.", - "url": "http://abs.franksdank.cloud/audiobookshelf/", + "url": "http://audiobooks.franksdank.cloud/audiobookshelf/", "docs": "", "probe": "192.168.0.235:8000", "method": "http", diff --git a/tests/e2e/specs/14-guard.spec.ts b/tests/e2e/specs/14-guard.spec.ts index 7d1fe57..7e322c6 100644 --- a/tests/e2e/specs/14-guard.spec.ts +++ b/tests/e2e/specs/14-guard.spec.ts @@ -15,11 +15,11 @@ const BASE = "https://www.franksdank.cloud"; // Every vhost that Caddy routes through anubis:8923 (see dankclaw/Caddyfile). // Keep this list in lockstep with the Caddyfile: a new guarded vhost must be // added here or the guard-chain audit is incomplete. -const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook", "abs"]; +const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook", "audiobooks"]; // Markers that prove app content leaked past the guard to an anonymous user. Each // marker must be a string the APP renders that the gate itself could never print — -// for abs that is the Audiobookshelf login's own SSO button label (the app's OIDC +// for audiobooks that is the Audiobookshelf login's own SSO button label (the app's // door), which authentik's gate never renders because it IS the IdP. const LEAK_MARKERS: Record = { "search.franksdank.cloud": ["SearXNG preferences"], @@ -29,7 +29,7 @@ const LEAK_MARKERS: Record = { "grafana.franksdank.cloud": ["Dashboards"], "prometheus.franksdank.cloud": ["Expression Browser", "Graph"], "notebook.franksdank.cloud": ["Open Notebook"], - "abs.franksdank.cloud": ["Sign in with Authentik"], + "audiobooks.franksdank.cloud": ["Sign in with Authentik"], }; test("homepage root (/) is guarded for unauthenticated users", async ({ page }) => {