Merge pull request 'fix(abs-guard): audiobooks.franksdank.cloud completes OIDC (forwarded scheme + provider OAuth defaults)' (#131) from fix/abs-guard-oauth-defaults into main

This commit was merged in pull request #131.
This commit is contained in:
dank committed 2026-11-03 01:26:32 -08:00
commit df597bb1b1
9 files changed
+158 -21

No files matched your search

+17 -7
View File
@@ -268,10 +268,11 @@ files.franksdank.cloud {
# Anubis → Authentik → app, like romm/files. The container carries # Anubis → Authentik → app, like romm/files. The container carries
# ROUTER_BASE_PATH=/audiobookshelf, so this vhost serves the app under that # 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 # 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` # pre-auth. Guard state in the IdP: proxy provider `audiobooks-proxy`
# (external_host https://abs.franksdank.cloud/, internal_host http://caddy:8017, # (external_host https://audiobooks.franksdank.cloud/, internal_host
# application "Audiobookshelf Guard"), claimed by the embedded outpost. # http://caddy:8017, application "Audiobookshelf Guard"), claimed by the
abs.franksdank.cloud { # embedded outpost.
audiobooks.franksdank.cloud {
reverse_proxy anubis:8923 { reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host} header_up X-Real-Ip {remote_host}
} }
@@ -423,13 +424,22 @@ http://:8017 {
reverse_proxy 192.168.0.235:3923 reverse_proxy 192.168.0.235:3923
} }
@abs host abs.franksdank.cloud @audiobooks host audiobooks.franksdank.cloud
handle @abs { handle @audiobooks {
# The app answers only under its ROUTER_BASE_PATH; the bare root is a # The app answers only under its ROUTER_BASE_PATH; the bare root is a
# convenience hop, taken post-auth so nothing reaches the app # convenience hop, taken post-auth so nothing reaches the app
# pre-authentication. # pre-authentication.
redir / /audiobookshelf/ permanent 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 @prowlarr host prowlarr.franksdank.cloud
+1 -1
View File
@@ -26,7 +26,7 @@
192.168.0.143 romm.franksdank.cloud 192.168.0.143 romm.franksdank.cloud
192.168.0.143 files.franksdank.cloud 192.168.0.143 files.franksdank.cloud
192.168.0.143 jelly.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 radarr.franksdank.cloud
192.168.0.143 lidarr.franksdank.cloud 192.168.0.143 lidarr.franksdank.cloud
192.168.0.143 readarr.franksdank.cloud 192.168.0.143 readarr.franksdank.cloud
+3 -3
View File
@@ -79,8 +79,8 @@
# origin, reached directly, which is also what the phone # origin, reached directly, which is also what the phone
# app is configured with) # app is configured with)
# ABS_EXTRA_BASES space-separated additional origins, base path included # ABS_EXTRA_BASES space-separated additional origins, base path included
# (default: https://abs.franksdank.cloud/audiobookshelf — # (default: https://audiobooks.franksdank.cloud/audiobookshelf
# the guarded vhost, dankclaw/Caddyfile). # — the guarded vhost, dankclaw/Caddyfile).
# ABS builds its redirect_uri from the ORIGIN of the # ABS builds its redirect_uri from the ORIGIN of the
# request, so each origin the app is served at needs its # request, so each origin the app is served at needs its
# own registered URI — an unregistered origin is a # own registered URI — an unregistered origin is a
@@ -100,7 +100,7 @@ SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
MEMBERS="${ABS_OIDC_USERS:-}" MEMBERS="${ABS_OIDC_USERS:-}"
E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}" E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}"
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}" 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}" HOST="${AUTHENTIK_HOST:-dankclaw}"
# The claim expression: translate group membership into ABS role names. It is stored # The claim expression: translate group membership into ABS role names. It is stored
+95
View File
@@ -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' <<PYEOF
from authentik.flows.models import Flow
from authentik.providers.proxy.models import ProxyProvider, _get_callback_url
CHECK = $( [ "$MODE" = "--check" ] && echo True || echo False )
FLOW = "default-provider-authorization-implicit-consent"
WANT_GRANTS = ["authorization_code", "client_credentials", "password"]
flow = Flow.objects.filter(slug=FLOW).first()
if not flow and not CHECK:
raise SystemExit("flow %s is missing; nothing was changed" % FLOW)
drift = 0
for p in ProxyProvider.objects.all().order_by("pk"):
want_uris = [u.url for u in _get_callback_url(p.external_host)]
have_uris = [u.url for u in p.redirect_uris]
bad = []
if [str(g) for g in p.grant_types] != WANT_GRANTS:
bad.append("grant_types=%s" % [str(g) for g in p.grant_types])
if have_uris != want_uris:
bad.append("redirect_uris=%s" % have_uris)
if p.authorization_flow_id is None:
bad.append("authorization_flow=None")
if not bad:
print("ok %s (ext %s)" % (p.name, p.external_host))
continue
drift += 1
if CHECK:
print("DRIFT %s: %s" % (p.name, "; ".join(bad)))
continue
print("fix %s: %s" % (p.name, "; ".join(bad)))
p.set_oauth_defaults()
if p.authorization_flow_id is None:
p.authorization_flow = flow
p.save()
after = ProxyProvider.objects.get(pk=p.pk)
print(" -> 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."
@@ -17,7 +17,7 @@ server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on
array. array.
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user manager) — **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 ## 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 | | 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) | | 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) | | 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, 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 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, 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 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. `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 - **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 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 (`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]]). service across a reboot ([[Quadlet Lifecycle]]).
- **Two doors, one app.** `http://192.168.0.235:8000/audiobookshelf` is the raw - **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 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), (Caddy → Anubis → the Authentik outpost → Caddy `:8017` → this container),
the house chain for browser traffic — [[Caddy]] *Guard a new vhost*. That 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 chain serves browsers only: the phone app is a non-browser client and solves
@@ -4,7 +4,7 @@ description: "Outpost, proxy-provider, OAuth2, LDAP and flow configuration for t
tags: tags:
- infrastructure - infrastructure
- authentik - authentik
timestamp: 2026-10-19 timestamp: 2026-11-03
--- ---
# Authentik Outpost Reference # Authentik Outpost Reference
@@ -56,7 +56,7 @@ correctly — the outpost matches on hostname.
## Provisioning rules for proxy providers ## 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 1. **All three flows are required.** A ProxyProvider created via raw API/ORM
with any of `authentication_flow`/`authorization_flow`/`invalidation_flow` 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 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 appears. Create the Application with `provider_id` set (the UI does this
automatically; raw API/ORM drives must do it explicitly). 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) ## OAuth2 relying-party quirks (this fork; corrected live 2026-10-20)
+15 -1
View File
@@ -107,7 +107,7 @@ authentik's error page instead of the app.
from authentik.providers.proxy.models import ProxyProvider from authentik.providers.proxy.models import ProxyProvider
from authentik.core.models import Application from authentik.core.models import Application
from authentik.outposts.models import Outpost 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() pp = ProxyProvider.objects.filter(name=f"{NAME}-proxy").first()
if pp is None: if pp is None:
pp = ProxyProvider.objects.create(name=f"{NAME}-proxy", external_host=EXT, 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 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 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. 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` 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 with `header_up X-Real-Ip {remote_host}`, and the post-auth `http://:8017` listener
gains a `@<name> host <vhost>` handler pointing at the app — copy `romm`/`files`, gains a `@<name> host <vhost>` handler pointing at the app — copy `romm`/`files`,
which are the same shape including a Dankserver LAN-port upstream. 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` 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 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 name must resolve off-LAN ([[LAN-DNS]]), and the subdomain added to `GUARDED` in
+1 -1
View File
@@ -516,7 +516,7 @@
"category": "admin", "category": "admin",
"section": "Media Library Apps", "section": "Media Library Apps",
"description": "Audiobook server for the entropy library: its own SQLite library, its own login, per-listener positions.", "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": "", "docs": "",
"probe": "192.168.0.235:8000", "probe": "192.168.0.235:8000",
"method": "http", "method": "http",
+3 -3
View File
@@ -15,11 +15,11 @@ const BASE = "https://www.franksdank.cloud";
// Every vhost that Caddy routes through anubis:8923 (see dankclaw/Caddyfile). // 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 // Keep this list in lockstep with the Caddyfile: a new guarded vhost must be
// added here or the guard-chain audit is incomplete. // 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 // 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 — // 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. // door), which authentik's gate never renders because it IS the IdP.
const LEAK_MARKERS: Record<string, string[]> = { const LEAK_MARKERS: Record<string, string[]> = {
"search.franksdank.cloud": ["SearXNG preferences"], "search.franksdank.cloud": ["SearXNG preferences"],
@@ -29,7 +29,7 @@ const LEAK_MARKERS: Record<string, string[]> = {
"grafana.franksdank.cloud": ["Dashboards"], "grafana.franksdank.cloud": ["Dashboards"],
"prometheus.franksdank.cloud": ["Expression Browser", "Graph"], "prometheus.franksdank.cloud": ["Expression Browser", "Graph"],
"notebook.franksdank.cloud": ["Open Notebook"], "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 }) => { test("homepage root (/) is guarded for unauthenticated users", async ({ page }) => {