fix(abs-guard): audiobooks.franksdank.cloud completes OIDC (forwarded scheme + provider OAuth defaults) #131

Merged
dank merged 4 commits from fix/abs-guard-oauth-defaults into main 2026-11-03 01:26:32 -08:00
Owner

The guarded Audiobookshelf door, renamed and working

The vhost is audiobooks.franksdank.cloud (renamed here at Frank's request on
2026-11-03, before the abs. name was ever deployed — so no alias is left behind: repo,
LAN record, public record, Caddy matcher and the IdP rows all move together, and the old
name appears nowhere).

The abs-era vhost, its DNS, TLS and the Anubis/Authentik guard went live in #130. Signing
in on the guarded door did not: ABS's login page rendered, Sign in with Authentik
produced an authentik Redirect URI Error page, and after the redirect URIs were filled
by hand the same button produced a bodiless HTTP 400. The LAN door
(http://192.168.0.235:8000/audiobookshelf, what the phone app uses) kept working. Two
independent walls, both fixed here.

Wall 1 — the hand-built proxy provider had no OAuth defaults

audiobooks-proxy (then abs-proxy) was created with ProxyProvider.objects.create(...),
which is not the API: the REST serializer calls ProxyProvider.set_oauth_defaults(), the raw
ORM path does not. The row looked fully configured — external_host, internal_host,
application bound, embedded-outpost claim present — while redirect_uris was empty (those
are derived from external_host by _get_callback_url()), grant_types was [], and
authorization_flow was NULL. Each breaks the login in a different, unrelated-looking way,
all before any credential is requested.

scripts/authentik-proxy-provider-defaults.sh fills them from the vendor's own
set_oauth_defaults() plus default-provider-authorization-implicit-consent, idempotently,
for every proxy provider. --check is a census (exit 1 on drift, writes nothing).

Applied to the live IdP on 2026-11-03; the census afterwards:

$ scripts/authentik-proxy-provider-defaults.sh --check
ok   embedded-outpost (ext https://www.franksdank.cloud)
ok   search-proxy (ext https://search.franksdank.cloud)
ok   prometheus-proxy (ext https://prometheus.franksdank.cloud)
ok   romm-proxy (ext https://romm.franksdank.cloud)
ok   files-proxy (ext https://files.franksdank.cloud)
ok   loki-proxy (ext https://loki.franksdank.cloud/)
ok   grafana-proxy (ext https://grafana.franksdank.cloud/)
ok   seerr-proxy (ext https://seerr.franksdank.cloud/)
ok   marreta-proxy (ext https://marreta.franksdank.cloud)
ok   notebook-proxy (ext https://notebook.franksdank.cloud/)
ok   abs-proxy (ext https://abs.franksdank.cloud/)
providers checked, drift: 0

Nothing else drifted, which is the point of the census: grafana-sso and the ten providers
created through the REST API already matched and were left alone. This is authentik DB
state — no deployable file carries it, so a blueprint re-import or an ak shell re-creation
reverts it and the symptom returns verbatim.

Wall 2 — Caddy lied to ABS about its own scheme

Audiobookshelf validates every OIDC callback against the origin it believes it is served
from: getRequestOrigin() is x-forwarded-proto://Host (dist-server/server/utils/requestUtils.js,
read inside the pinned image digest). The post-auth http://:8017 listener is fed over plain
http, so Caddy's own X-Forwarded-Proto is http, ABS computes http://<name> and rejects
its own https callback. Probed at the app, holding the callback fixed:

Request to 192.168.0.235:8000/audiobookshelf/auth/openid/callback?… Answer
Host: <vhost>, no forwarded scheme 400 {"message":"Invalid callback URL - must be same-origin"}
same + X-Forwarded-Proto: https 302 → …/application/o/authorize/?client_id=audiobookshelf&redirect_uri=https%3A%2F%2F<vhost>%2Faudiobookshelf%2Fauth%2Fopenid%2Fcallback…

So the fix is in the @audiobooks route on :8017: header_up X-Forwarded-Proto https. The
edge for a guarded name is TLS-only, so https is the truthful value, not a spoof. Same class
as the grafana-sso incident — the guard's view of the request is part of the app's contract.

The rename: one commit, because a test enforces the lockstep

audiobooks.franksdank.cloud replaces abs.franksdank.cloud in every place that names it,
in a single commit — the pihole seam fails the suite if a Caddyfile vhost has no
addn-hosts record, so splitting it would leave a red commit:

  • dankclaw/Caddyfile — site block, matcher @abs → @audiobooks, guard-state comment
  • dankclaw/pihole/addn-hosts — the LAN record
  • scripts/authentik-audiobookshelf-sso.sh — ABS_EXTRA_BASES default, so the RP's
    registered callback list is regenerated for the new origin (the script replaces the
    list, so the old URI leaves the provider)
  • shared/homepage/services.json — the admin tile link
  • tests/e2e/specs/14-guard.spec.ts — GUARDED + the leak marker
  • the three vault pages that name the door

Verification

Check Result
./tests/run.sh green (tail in the comment thread)
docs seams after the last docs commit 6/6
specs/11-admin.spec.ts + specs/14-guard.spec.ts 18 passed (pre-rename: abs in the admin SSO walk and the guest census)
LAN door untouched /audiobookshelf/healthcheck → OK, /ping → {"success":true}, login page serves
Guard answers anonymously https://<vhost>/audiobookshelf/login → Anubis challenge, zero app bytes
23-audiobookshelf-sso.spec.ts (LAN origin) passes
Porkbun rename, provider rename + defaults re-run, RP URI re-registration run in the deploy window — [[LAN-DNS]] Porkbun zone ops order: zone edit first, then deploy, because the deploy's own e2e gate resolves the new name
In-app Sign in with Authentik on the guarded door pending that window — the 302 above is the wall removed

Two earlier e2e failures on those spec files were ERR_NAME_NOT_RESOLVED — the workstation's
systemd-resolved negative cache still held the pre-Porkbun NXDOMAIN. It expired on its own;
the same specs pass unmodified, so no test was changed to make the suite green.

Docs

One owner per fact: the provider-defaults law on Authentik Outpost Reference
(provisioning rule 3), the origin-check law on Audiobookshelf (Laws), and both as steps
in Guard a new vhost in Caddy, so the next guarded app doesn't re-hit either wall. The
rename procedure itself is already owned by LAN-DNS Porkbun zone ops — this PR follows
it, it does not restate it.


cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb

Built in the worktree /tmp/wt-absfix (branch fix/abs-guard-oauth-defaults, off origin/main).

# The guarded Audiobookshelf door, renamed and working The vhost is **`audiobooks.franksdank.cloud`** (renamed here at Frank's request on 2026-11-03, before the `abs.` name was ever deployed — so no alias is left behind: repo, LAN record, public record, Caddy matcher and the IdP rows all move together, and the old name appears nowhere). The `abs`-era vhost, its DNS, TLS and the Anubis/Authentik guard went live in #130. Signing in **on the guarded door** did not: ABS's login page rendered, `Sign in with Authentik` produced an authentik **`Redirect URI Error`** page, and after the redirect URIs were filled by hand the same button produced a bodiless **HTTP 400**. The LAN door (`http://192.168.0.235:8000/audiobookshelf`, what the phone app uses) kept working. Two independent walls, both fixed here. ## Wall 1 — the hand-built proxy provider had no OAuth defaults `audiobooks-proxy` (then `abs-proxy`) was created with `ProxyProvider.objects.create(...)`, which is not the API: the REST serializer calls `ProxyProvider.set_oauth_defaults()`, the raw ORM path does not. The row looked fully configured — `external_host`, `internal_host`, application bound, embedded-outpost claim present — while `redirect_uris` was empty (those are *derived* from `external_host` by `_get_callback_url()`), `grant_types` was `[]`, and `authorization_flow` was NULL. Each breaks the login in a different, unrelated-looking way, all before any credential is requested. `scripts/authentik-proxy-provider-defaults.sh` fills them from the vendor's own `set_oauth_defaults()` plus `default-provider-authorization-implicit-consent`, idempotently, for **every** proxy provider. `--check` is a census (exit 1 on drift, writes nothing). Applied to the live IdP on 2026-11-03; the census afterwards: $ scripts/authentik-proxy-provider-defaults.sh --check ok embedded-outpost (ext https://www.franksdank.cloud) ok search-proxy (ext https://search.franksdank.cloud) ok prometheus-proxy (ext https://prometheus.franksdank.cloud) ok romm-proxy (ext https://romm.franksdank.cloud) ok files-proxy (ext https://files.franksdank.cloud) ok loki-proxy (ext https://loki.franksdank.cloud/) ok grafana-proxy (ext https://grafana.franksdank.cloud/) ok seerr-proxy (ext https://seerr.franksdank.cloud/) ok marreta-proxy (ext https://marreta.franksdank.cloud) ok notebook-proxy (ext https://notebook.franksdank.cloud/) ok abs-proxy (ext https://abs.franksdank.cloud/) providers checked, drift: 0 Nothing else drifted, which is the point of the census: `grafana-sso` and the ten providers created through the REST API already matched and were left alone. This is authentik **DB** state — no deployable file carries it, so a blueprint re-import or an `ak shell` re-creation reverts it and the symptom returns verbatim. ## Wall 2 — Caddy lied to ABS about its own scheme Audiobookshelf validates every OIDC `callback` against the origin it believes it is served from: `getRequestOrigin()` is `x-forwarded-proto://Host` (`dist-server/server/utils/requestUtils.js`, read inside the pinned image digest). The post-auth `http://:8017` listener is fed over plain http, so Caddy's own `X-Forwarded-Proto` is `http`, ABS computes `http://<name>` and rejects its own `https` callback. Probed at the app, holding the callback fixed: |Request to `192.168.0.235:8000/audiobookshelf/auth/openid/callback?…`|Answer| |---|---| |`Host: <vhost>`, no forwarded scheme|`400 {"message":"Invalid callback URL - must be same-origin"}`| |same + `X-Forwarded-Proto: https`|`302` → `…/application/o/authorize/?client_id=audiobookshelf&redirect_uri=https%3A%2F%2F<vhost>%2Faudiobookshelf%2Fauth%2Fopenid%2Fcallback…`| So the fix is in the `@audiobooks` route on `:8017`: `header_up X-Forwarded-Proto https`. The edge for a guarded name is TLS-only, so `https` is the truthful value, not a spoof. Same class as the grafana-sso incident — the guard's view of the request is part of the app's contract. ## The rename: one commit, because a test enforces the lockstep `audiobooks.franksdank.cloud` replaces `abs.franksdank.cloud` in every place that names it, in a single commit — the `pihole` seam fails the suite if a Caddyfile vhost has no `addn-hosts` record, so splitting it would leave a red commit: - `dankclaw/Caddyfile` — site block, matcher `@abs` → `@audiobooks`, guard-state comment - `dankclaw/pihole/addn-hosts` — the LAN record - `scripts/authentik-audiobookshelf-sso.sh` — `ABS_EXTRA_BASES` default, so the RP's registered callback list is regenerated for the new origin (the script **replaces** the list, so the old URI leaves the provider) - `shared/homepage/services.json` — the admin tile link - `tests/e2e/specs/14-guard.spec.ts` — `GUARDED` + the leak marker - the three vault pages that name the door ## Verification |Check|Result| |---|---| |`./tests/run.sh`|green (tail in the comment thread)| |docs seams after the last docs commit|6/6| |`specs/11-admin.spec.ts` + `specs/14-guard.spec.ts`|18 passed (pre-rename: `abs` in the admin SSO walk and the guest census)| |LAN door untouched|`/audiobookshelf/healthcheck` → `OK`, `/ping` → `{"success":true}`, login page serves| |Guard answers anonymously|`https://<vhost>/audiobookshelf/login` → Anubis challenge, zero app bytes| |`23-audiobookshelf-sso.spec.ts` (LAN origin)|passes| |Porkbun rename, provider rename + defaults re-run, RP URI re-registration|**run in the deploy window** — `[[LAN-DNS]]` *Porkbun zone ops* order: zone edit first, then deploy, because the deploy's own e2e gate resolves the new name| |In-app `Sign in with Authentik` on the guarded door|**pending that window** — the `302` above is the wall removed| Two earlier e2e failures on those spec files were `ERR_NAME_NOT_RESOLVED` — the workstation's `systemd-resolved` negative cache still held the pre-Porkbun NXDOMAIN. It expired on its own; the same specs pass unmodified, so no test was changed to make the suite green. ## Docs One owner per fact: the provider-defaults law on [[Authentik Outpost Reference]] (provisioning rule 3), the origin-check law on [[Audiobookshelf]] (Laws), and both as steps in *Guard a new vhost* in [[Caddy]], so the next guarded app doesn't re-hit either wall. The rename procedure itself is already owned by [[LAN-DNS]] *Porkbun zone ops* — this PR follows it, it does not restate it. --- cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb Built in the worktree `/tmp/wt-absfix` (branch `fix/abs-guard-oauth-defaults`, off `origin/main`).
dank added 3 commits 2026-11-03 01:17:36 -08:00
A proxy provider built by hand in `ak shell` never runs
ProxyProvider.set_oauth_defaults() — the REST serializer does, the ORM does not —
so the row looks fully configured while redirect_uris is empty (they are derived
from external_host by _get_callback_url()), grant_types is [], and
authorization_flow is NULL. Each one kills a guarded login in a different,
unrelated-looking way, all before any credential is requested: authentik's own
"Redirect URI Error" page, then a bodiless HTTP 400 from /application/o/authorize/
once the URIs are filled by hand, and a 500 NoneType.slug in the flow planner.
abs-proxy (abs.franksdank.cloud) hit the first two on 2026-11-03.

The script calls the vendor's own set_oauth_defaults() and points the
authorization flow at default-provider-authorization-implicit-consent for every
proxy provider, idempotently; a provider that already matches is untouched, so a
deliberate deviation is never flattened. `--check` is the census (exit 1 on
drift, writes nothing) for gate use. Applied live 2026-11-03: abs-proxy fixed,
the other ten (REST-created) already compliant, census now reports drift: 0.

This is authentik DB state and no deployable file carries it — a blueprint
re-import or an ak-shell re-creation reverts it and the symptom returns, so the
law and the failure signatures are recorded on the outpost page as provisioning
rule 3.
ABS checks every OIDC callback against the origin it believes it is served from:
getRequestOrigin() is x-forwarded-proto://Host (dist-server/server/utils/requestUtils.js,
read in the pinned image digest 2026-11-03). The post-auth http://:8017 listener is fed
over plain http, so Caddy's own X-Forwarded-Proto is http, ABS derives
http://abs.franksdank.cloud, and answers its own https callback with

    400 {"message":"Invalid callback URL - must be same-origin"}

Measured at the app with the callback held fixed: Host abs.franksdank.cloud alone
reproduces the 400 (as does every other Host, including the bare LAN IP), and adding
`X-Forwarded-Proto: https` turns it into a 302 to /application/o/authorize/ carrying
the registered redirect_uri. The edge for a guarded name is TLS-only, so https is the
truthful value, not a spoof; the LAN door needs nothing because there browser and
server already agree on http, which is why only the guarded path was broken.
Step 1 gains the closing sentence the abs-proxy incident demands: the ak-shell
snippet that creates a ProxyProvider with objects.create() must be followed by
scripts/authentik-proxy-provider-defaults.sh, or the guard fails before any
credential. Step 2 gains the forwarded-scheme clause for any app that derives its
own origin from the request. Each fact keeps one owner — the laws stay on
[[Authentik Outpost Reference]] (provisioning rule 3) and [[Audiobookshelf]]; this
page only says where in the procedure they bite. Outpost page timestamp bumped to
match its substantive edit.
Author
Owner

Test evidence

./tests/run.sh on fix/abs-guard-oauth-defaults (824 tests):

ok 824 every crop in the tracked pack list clears the invariant

==> ALL TESTS PASSED

Docs seams re-run after the last docs commit (bats tests/docs_completeness.bats):

1..6
ok 3 no unresolved links in the docs vault
ok 5 the vault conforms to OKF v0.1
ok 6 vault indices are generated, not hand-carved

Playwright, the two spec files that carry abs (11-admin, 14-guard), against the live fleet:

✓  13 [guest] › specs/14-guard.spec.ts:58:3 › unauthenticated direct navigation to abs.franksdank.cloud is gated by authentik (1.5s)
✓  17 [admin] › specs/11-admin.spec.ts:28:1 › admin open links reach their apps through SSO (8.5s)
18 passed (1.0m)

The earlier failures in those two files were net::ERR_NAME_NOT_RESOLVED — the workstation's
systemd-resolved negative cache still held the NXDOMAIN that predates the Porkbun record.
It expired by itself; both specs pass unmodified, so no test was changed to make the suite green.

Census after the provider fix

scripts/authentik-proxy-provider-defaults.sh --check → 11 providers ok,
providers checked, drift: 0, exit 0 (full output in the description).

## Test evidence `./tests/run.sh` on `fix/abs-guard-oauth-defaults` (824 tests): ``` ok 824 every crop in the tracked pack list clears the invariant ==> ALL TESTS PASSED ``` Docs seams re-run after the last docs commit (`bats tests/docs_completeness.bats`): ``` 1..6 ok 3 no unresolved links in the docs vault ok 5 the vault conforms to OKF v0.1 ok 6 vault indices are generated, not hand-carved ``` Playwright, the two spec files that carry `abs` (`11-admin`, `14-guard`), against the live fleet: ``` ✓ 13 [guest] › specs/14-guard.spec.ts:58:3 › unauthenticated direct navigation to abs.franksdank.cloud is gated by authentik (1.5s) ✓ 17 [admin] › specs/11-admin.spec.ts:28:1 › admin open links reach their apps through SSO (8.5s) 18 passed (1.0m) ``` The earlier failures in those two files were `net::ERR_NAME_NOT_RESOLVED` — the workstation's `systemd-resolved` negative cache still held the NXDOMAIN that predates the Porkbun record. It expired by itself; both specs pass unmodified, so no test was changed to make the suite green. ## Census after the provider fix `scripts/authentik-proxy-provider-defaults.sh --check` → 11 providers `ok`, `providers checked, drift: 0`, exit 0 (full output in the description).
dank changed title from fix(abs-guard): the guarded door completes OIDC (forwarded scheme + provider OAuth defaults) to fix(abs-guard): audiobooks.franksdank.cloud completes OIDC (forwarded scheme + provider OAuth defaults) 2026-11-03 01:24:00 -08:00
dank added 1 commit 2026-11-03 01:24:00 -08:00
`abs.` was a three-letter abbreviation of the container; the library it serves is
/mnt/entropy/Audiobooks, and `audiobooks.` says what the door is. Renamed in one
commit on purpose: the pihole seam fails the suite if a Caddyfile vhost has no
addn-hosts record, so any split would leave a red commit, and the `abs` name was
never deployed at the IdP with this fix, so nothing has to keep answering it —
no alias, no redirect, no stale URI.

Moves together: the Caddyfile site block, its matcher (@abs -> @audiobooks), the
LAN record, ABS_EXTRA_BASES in authentik-audiobookshelf-sso.sh (that script
REPLACES the provider's redirect-URI list, so the old origin's callback leaves the
RP when it is next run), the homepage admin tile, the guard spec's GUARDED list and
leak marker, and the three vault pages that name the door. authentik's provider row
follows in the deploy window (external_host + name), which re-derives its
redirect_uris through scripts/authentik-proxy-provider-defaults.sh; the Application
row keeps its own name, "Audiobookshelf Guard" — it names the app, not the vhost.
dank merged commit df597bb1b1 into main 2026-11-03 01:26:32 -08:00
Author
Owner

Live verification after merge and deploy (merge df597bb1, 2026-11-03)

Gate run: ./scripts/deploy.sh dankclaw --service pihole --service caddy — 38 passed, 1 failed (the failure is explained below and is not this change).

Check Result
LAN DNS (Pi-hole addn-hosts) audiobooks.franksdank.cloud → 192.168.0.143; the abs. record is gone
Public DNS (Porkbun A) audiobooks.franksdank.cloud → 71.227.227.172
TLS Let's Encrypt cert, SAN audiobooks.franksdank.cloud — DNS-01 re-issued for the new name
Guard chain Anubis challenge pre-auth; outpost passes the app's login page through (green in 14-guard)
OIDC round trip Headless admin walk on the new door: "Sign in with Authentik" → app session issued → GET /api/me 200, type=admin
SPA boot 30 _nuxt/*.js fetched over the vhost, every 200 answered application/javascript; library cards render real content
Realtime transport GET /socket.io/?EIO=4&transport=polling and the /audiobookshelf/ mount both answer 0{"sid":…}; the client logs [SOCKET] Connected
LAN door untouched 192.168.0.235:8000/audiobookshelf still serves assets as application/javascript and the socket handshake still answers

The one red spec is a test-side assumption, not a regression

23-audiobookshelf-sso.spec.ts asserts that every .js response on the LAN host carries a JavaScript MIME. The trace shows the entry script fetched twice:

  1. 200 — Content-Type: application/javascript; charset=UTF-8, ETag: W/"1417-…", Cache-Control: public, max-age=0
  2. request with If-None-Match / If-Modified-Since → 304 Not Modified, which carries no Content-Type by HTTP spec

max-age=0 makes that revalidation deterministic once the OIDC bounce re-navigates the page, so the assertion fires on a perfectly healthy bundle: the defect class it was written for is an HTML stub served as a script (200 text/html), and a 304 is not that. The precise assertion is to judge only responses that carried a body (status === 200), which keeps the guard exactly as strong and drops the revalidation noise. Holding the change pending Frank's OK, since it edits a pre-existing test.

## Live verification after merge and deploy (merge `df597bb1`, 2026-11-03) Gate run: `./scripts/deploy.sh dankclaw --service pihole --service caddy` — **38 passed, 1 failed** (the failure is explained below and is not this change). |Check|Result| |---|---| |LAN DNS (Pi-hole `addn-hosts`)|`audiobooks.franksdank.cloud → 192.168.0.143`; the `abs.` record is gone| |Public DNS (Porkbun A)|`audiobooks.franksdank.cloud → 71.227.227.172`| |TLS|Let's Encrypt cert, SAN `audiobooks.franksdank.cloud` — DNS-01 re-issued for the new name| |Guard chain|Anubis challenge pre-auth; outpost passes the app's login page through (green in `14-guard`)| |OIDC round trip|Headless admin walk on the new door: "Sign in with Authentik" → app session issued → `GET /api/me` `200`, `type=admin`| |SPA boot|30 `_nuxt/*.js` fetched over the vhost, every `200` answered `application/javascript`; library cards render real content| |Realtime transport|`GET /socket.io/?EIO=4&transport=polling` and the `/audiobookshelf/` mount both answer `0{"sid":…}`; the client logs `[SOCKET] Connected`| |LAN door untouched|`192.168.0.235:8000/audiobookshelf` still serves assets as `application/javascript` and the socket handshake still answers| ### The one red spec is a test-side assumption, not a regression `23-audiobookshelf-sso.spec.ts` asserts that **every** `.js` response on the LAN host carries a JavaScript MIME. The trace shows the entry script fetched twice: 1. `200` — `Content-Type: application/javascript; charset=UTF-8`, `ETag: W/"1417-…"`, `Cache-Control: public, max-age=0` 2. request with `If-None-Match` / `If-Modified-Since` → `304 Not Modified`, **which carries no `Content-Type` by HTTP spec** `max-age=0` makes that revalidation deterministic once the OIDC bounce re-navigates the page, so the assertion fires on a perfectly healthy bundle: the defect class it was written for is *an HTML stub served as a script* (`200 text/html`), and a `304` is not that. The precise assertion is to judge only responses that carried a body (`status === 200`), which keeps the guard exactly as strong and drops the revalidation noise. Holding the change pending Frank's OK, since it edits a pre-existing test.
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dank/serverAppConfigs#131