Compare commits

..
Author SHA1 Message Date
Frank 66766f23ef docs(audiobookshelf): the IdP is the library gate now
Rewrite the listener onboarding, the role row, and the fail-closed law: the
claim grants every signer, revocation happens on the ABS side (disable or
delete the user), and what actually guards the library is who can reach the
audiobookshelf application in authentik. Name both e2e gates in Verification.
2026-11-03 02:06:20 -08:00
Frank 5cf5416471 test(e2e): pin the Audiobookshelf default listener grant
spec 24- signs in through SSO as qa-e2e — an identity in no Audiobookshelf
group — and must land inside the app with type "user". It is the counterpart
to 23-, which pins the admin escalation: together they catch both directions
of a scope-mapping regression (household locked out, or household made site
admin). Verified red against the pre-change mapping — the identity never
received a token — and green after re-provisioning: 4 passed.

Joined to the standard project, which is the only project whose identity
observes the default.
2026-11-03 02:06:20 -08:00
Frank 30186d8365 feat(authentik): make every Audiobookshelf SSO signer a listener
The abs_groups claim decided the listener role from membership of
audiobookshelf_users, a group nobody was ever added to, while the ABS
application carries no access policy of its own. The result: seventeen
household accounts authenticated at the IdP successfully and were then
refused by the app — a policy decision that reads as a broken login.

The claim now answers ["user"] for every signer and appends "admin" only
for audiobookshelf_admins, so the IdP login is the gate and onboarding a
listener is exactly "create the authentik account". Admins keep the exact
claim they always received, ["admin","user"], in that order: ABS's
group-tag matching has never been measured for order-sensitivity and the
working admin login is not where to find out. audiobookshelf_users is
deleted rather than left empty — a group that decides nothing is a trap
for the next reader, who will assume it gates something.

Two side fixes found by running the script:
 - Two comments inside the unquoted PYEOF heredoc carried backticks, so
   bash command-substituted them locally on EVERY run ("audiobookshelf_users:
   command not found", "user: command not found") and spliced the output
   into the python program. Names in that body are plain now, with a note
   on why. A quoted heredoc is not an option: the body interpolates.
 - The default-grant probe picked authentik's AnonymousUser row, then its
   admin superuser. It now walks real non-superuser accounts, preferring
   the fleet's plain-user marker group, and prints up to three claims.
2026-11-03 02:06:20 -08:00
dank 3a6f7c7280 Merge pull request 'feat(caddy): Audiobookshelf outside the guard chain, so the phone app works from anywhere' (#133) from feat/audiobookshelf-public-app-access into main 2026-11-03 01:56:51 -08:00
dank c7643fb4d0 docs: regenerate the vault index row for the new Audiobookshelf description 2026-11-03 01:55:06 -08:00
dank 882a9f6808 docs: own the un-guard procedure and the browser-only law
AGENTS.md states the three exposure postures and why the guard chain cannot front a
non-browser client. Infrastructure/Caddy.md gains *Unguard a vhost* — route first,
IdP rows last, audit lists after — since deleting the proxy provider while traffic
still enters :8016 hands browsers authentik's error page. The Audiobookshelf page
retires its two-doors law (the guarded vhost as a browser-only door, remote
listening via VPN) for one address every client carries, adds how a listener gets an
account, and says plainly what the exemption costs.
2026-11-03 01:54:08 -08:00
dank aa57c1a894 fix(authentik): register the mobile OIDC redirect on every ABS origin
The mobile-redirect URI was registered on the LAN origin only, because the phone
had to be configured with the LAN address while the vhost was guarded. Now that the
app rides the public name it sends the public mobile-redirect URI, and the RP's
STRICT matching answers an unregistered one with "Redirect URI Error" — a phone
locked out at the last step of a login that looked healthy. Both callback URIs are
now emitted for every entry in BASES.
2026-11-03 01:54:08 -08:00
dank 2a48751283 feat(caddy): serve Audiobookshelf outside the guard chain
The Audiobookshelf phone app could never sign in over the public name. The guard
chain answered its /ping and /socket.io/ handshake with Anubis challenge HTML, so
the app evaluated 'isInit' in e.data on a non-object and reported "Failed to
contact server" while the container's request log recorded nothing at all. Anubis
demands a JavaScript proof-of-work and the authentik outpost works from a session
cookie: the chain is browser-only by construction, and a native client, a podcast
RSS reader or an OPDS client cannot pass it.

Audiobookshelf authenticates itself (local accounts plus authentik OIDC), so it
joins jelly/plex/*arr in the exempt set: the public site block dials the container
directly, keeping the X-Forwarded-Proto pin its OIDC origin check depends on, and
the @audiobooks route leaves the post-auth listener so one host has one owner. The
IdP rows that guarded the name go after this deploys, never before.

tests/e2e/specs/14-guard.spec.ts loses audiobooks as a guarded target and gains the
exemption assertion (app bytes to an anonymous client, the app's own 401, never
challenge HTML); tests/anubis.bats pins the route shape.
2026-11-03 01:54:08 -08:00
dank 6083903ccb Merge pull request 'fix(e2e): judge only body-carrying script responses in the ABS SSO spec' (#132) from fix/e2e-mime-304 into main 2026-11-03 01:42:28 -08:00
11 changed files with 370 additions and 124 deletions

No files matched your search

+16 -3
View File
@@ -227,9 +227,18 @@ See `serverAppConfigs-docs/Architecture/Testing Policy.md`.
## External Exposure Policy (MANDATORY)
Every Caddy-proxied service is either behind the auth chain (Anubis :8923 →
Authentik embedded outpost) or explicitly internal-only — no public DNS name,
no vhost. New services default to internal-only until guarded.
Every Caddy-proxied service is in exactly one of three postures: behind the
auth chain (Anubis :8923 → Authentik embedded outpost), **exempt by
construction** because it authenticates itself (`auth.`, git smart-HTTP, Gitea,
OpenWebUI, Jellyfin, Plex, Audiobookshelf, the *arr stack), or internal-only —
no public DNS name, no vhost. New services default to internal-only until guarded.
The guard chain is a **browser-only device**: Anubis demands a JavaScript
proof-of-work and the outpost works from a session cookie, so any non-browser
client — a native app, a podcast RSS reader, an OPDS client, `git`, a
service-to-service call — is answered with challenge HTML where it expected
JSON or a socket payload. A service that must serve such a client cannot sit
behind the chain; it has to own its authentication.
When guarding a new vhost — provider first, then route:
@@ -248,6 +257,10 @@ When guarding a new vhost — provider first, then route:
Exempt categories (document the reason in the vhost comment): the IdP itself
(`auth.`), git smart-HTTP endpoints, services with their own complete auth.
Unguarding a vhost is the mirror image of the procedure above — the route flips
first and the IdP rows go last — and `serverAppConfigs-docs/Infrastructure/Caddy.md`
*Unguard a vhost* owns the steps.
## Deletion & Trash Policy (MANDATORY)
Never `rm`. Move deletions into trash so they stay recoverable:
+29 -35
View File
@@ -22,13 +22,16 @@
# CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth
# decision; Host headers survive every hop) → authenticated traffic is
# forwarded back to http://:8017 and routed straight to the app.
# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole) and the
# Authentik IdP itself (auth.franksdank.cloud — its login flow must never sit
# behind a challenge) are exempt by construction: their vhosts never route
# through Anubis. ComfyUI left this exempt set on 2026-08-31 and Grafana left
# it on 2026-09-01 (OAuth2 relying-party mode against authentik): both now sit
# behind the guard chain; Grafana authenticates users itself via the OAuth2
# code flow, so no identity header is forwarded to it.
# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, Plex, Audiobookshelf,
# *arr, Pi-hole) and the Authentik IdP itself (auth.franksdank.cloud — its login
# flow must never sit behind a challenge) are exempt by construction: their
# vhosts never route through Anubis. The chain is browser-only by nature — a
# native app client, a podcast RSS reader or a git client solves no JavaScript
# proof, so anything that must serve one has to own its own authentication.
# ComfyUI left this exempt set on 2026-08-31 and Grafana left it on 2026-09-01
# (OAuth2 relying-party mode against authentik): both now sit behind the guard
# chain; Grafana authenticates users itself via the OAuth2 code flow, so no
# identity header is forwarded to it.
# The pihole vhost keeps its basicauth edge; the hash placeholder below is
# rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into
# /home/dank/Containers/caddy/Caddyfile, which the container mounts.
@@ -264,17 +267,26 @@ files.franksdank.cloud {
}
}
# --- Audiobookshelf (guarded) ---
# 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 `audiobooks-proxy`
# (external_host https://audiobooks.franksdank.cloud/, internal_host
# http://caddy:8017, application "Audiobookshelf Guard"), claimed by the
# embedded outpost.
# --- Audiobookshelf (exempt by construction: own auth) --------------------
# Caddy → app: no Anubis, no outpost. The guard chain answers a native client's
# /ping, /api/login and /socket.io/ handshake with challenge HTML, so behind it
# the phone app parsed a non-object where the socket `init` payload belongs and
# podcast RSS/OPDS feeds broke the same way. ABS authenticates itself (local
# accounts plus authentik OIDC), so it joins jelly/plex/*arr outside the chain
# (2026-11-03); the IdP rows that guarded this name — proxy provider
# `audiobooks-proxy` and application "Audiobookshelf Guard" — are retired with
# the change, after the route flips. Accepted exposure: /audiobookshelf/api/login
# is internet-visible with no rate limiter on either side (this Caddy build has
# no rate_limit module; ABS 2.37.2 ships no lockout) — the same class every other
# own-auth vhost here lives with, so shared accounts need long unique passwords.
# ROUTER_BASE_PATH=/audiobookshelf, so the bare root is a convenience hop onto it.
# X-Forwarded-Proto must say https: ABS derives its OIDC origin from
# x-forwarded-proto://Host and this hop is plain http, so without the pin its own
# https callback is a 400 "Invalid callback URL - must be same-origin".
audiobooks.franksdank.cloud {
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
redir / /audiobookshelf/ permanent
reverse_proxy 192.168.0.235:8000 {
header_up X-Forwarded-Proto https
}
}
@@ -424,24 +436,6 @@ http://:8017 {
reverse_proxy 192.168.0.235:3923
}
@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
# 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
handle @prowlarr {
reverse_proxy 192.168.0.112:9696
+4 -2
View File
@@ -268,8 +268,10 @@ else:
# not auto-launch: the login page keeps offering the local form, which is the
# break-glass path when the IdP is unreachable.
"authOpenIDAutoLaunch": False,
# a household user's first SSO sign-in creates their account; the group claim
# decides the role, and a user in neither group is denied (fail closed).
# a household user's first SSO sign-in creates their account. The role comes from
# the abs_groups claim, which grants the listener role to every signer; only the
# authentik admin group escalates. Revoking a listener therefore happens HERE in
# ABS (disable or delete the user), never by removing an IdP group membership.
"authOpenIDAutoRegister": True,
"authOpenIDGroupClaim": "abs_groups",
# The app's own scheme lives HERE, never in the IdP: ABS swaps it for
+68 -36
View File
@@ -11,15 +11,17 @@
# dist-server/server/auth/OidcAuthStrategy.js + objects/settings/ServerSettings.js)
# rather than a forum, because each of these is a dead-login trap:
#
# * The browser callback URI <base>/auth/openid/callback for EVERY origin ABS is
# reached at, plus <base>/auth/openid/mobile-redirect on ABS_BASE only. ABS SWAPS
# the app's own audiobookshelf://oauth for the mobile-redirect URL before the IdP
# ever sees a redirect_uri, so registering the app scheme with authentik — the
# obvious move — is what makes phone logins fail. The app scheme belongs in ABS's
# authOpenIDMobileRedirectURIs list (its built-in default), not here. The mobile
# URI is registered on the LAN origin only because the phone is configured with
# the LAN address: it is a non-browser client and cannot solve the guard
# challenge that sits in front of the vhost.
# * Two URIs per origin ABS is reached at — <base>/auth/openid/callback and
# <base>/auth/openid/mobile-redirect — for EVERY entry in BASES (the LAN address
# and the public vhost). ABS SWAPS the app's own audiobookshelf://oauth for the
# mobile-redirect URL before the IdP ever sees a redirect_uri, so registering the
# app scheme with authentik — the obvious move — is what makes phone logins fail.
# The app scheme belongs in ABS's authOpenIDMobileRedirectURIs list (its built-in
# default), not here. The mobile URI exists on every origin because the app sends
# the one belonging to the door it was configured with: the vhost started serving
# non-browser clients on 2026-11-03 (it left the guard chain), so a phone pointed
# at the public name sends the public mobile-redirect URI, and STRICT matching
# answers an unregistered one with "Redirect URI Error".
# * <base> CARRIES THE BASE PATH. ABS builds redirect_uri as
# <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback, and that subfolder is
# the ROUTER_BASE_PATH the container is mounted at — /audiobookshelf, which is also the only
@@ -68,9 +70,12 @@
# ABS_OIDC_CLIENT_ID provider/client id (audiobookshelf)
# ABS_OIDC_GROUPS_CLAIM claim ABS reads for roles (abs_groups)
# ABS_OIDC_USERNAME authentik user made admin (akadmin)
# ABS_OIDC_USERS space-separated authentik usernames given the USER group
# (default: nobody — an empty list is fail-closed, and the
# household decides who listens to audiobooks)
# There is deliberately NO user list. The claim grants the LISTENER role to every
# authentik account that signs in (2026-11-03), so onboarding a listener is exactly
# "create the authentik account". Before this, a household account authenticated at
# the IdP and was then refused by ABS for being in no group — a policy decision that
# read as a broken login. Revocation lives on the ABS side (disable or delete the
# user there): a group can no longer withhold a role the claim always sends.
# ABS_E2E_ADMIN authentik user kept in the ADMIN group for the Playwright gate
# (qa-e2e-admin — tests/e2e/specs/23-audiobookshelf-sso.spec.ts
# signs in as this identity, so a re-run restores the fixture)
@@ -95,53 +100,67 @@ SECRET="${ABS_OIDC_CLIENT_SECRET:?set ABS_OIDC_CLIENT_SECRET (see the header)}"
CLIENT_ID="${ABS_OIDC_CLIENT_ID:-audiobookshelf}"
CLAIM="${ABS_OIDC_GROUPS_CLAIM:-abs_groups}"
ADMIN_GROUP="audiobookshelf_admins"
USER_GROUP="audiobookshelf_users"
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://audiobooks.franksdank.cloud/audiobookshelf}"
HOST="${AUTHENTIK_HOST:-dankclaw}"
# The claim expression: translate group membership into ABS role names. It is stored
# verbatim so a later read of the provider shows exactly this policy.
# The claim expression: every signer is a listener; only the admin group escalates.
# Stored verbatim so a later read of the provider shows exactly this policy. The default
# lives HERE and nowhere else — ABS reads this list off the token, so an account in no
# Audiobookshelf group still arrives with ["user"] and is auto-registered as a listener
# instead of being refused after a successful authentication.
read -r -d '' EXPR <<PYEXPR || true
groups = {g.name for g in user.groups.all()}
# Order is deliberate: an admin's claim comes out ["admin","user"], byte-identical to what
# this mapping emitted before the default existed, because ABS's group-tag matching has never
# been measured for order-sensitivity and the working admin login is not the place to find out.
roles = []
if "$ADMIN_GROUP" in groups:
roles.append("admin")
if groups & {"$ADMIN_GROUP", "$USER_GROUP"}:
roles.append("user")
roles.append("user")
return {"$CLAIM": roles}
PYEXPR
# NOTE: the heredoc below is UNQUOTED on purpose — its body interpolates the variables
# above. The consequence is that a backtick anywhere in that python body is a LOCAL command
# substitution whose output is spliced into the program before it ever runs, and comments
# are not exempt, because bash does not know what a python comment is. Write names plainly
# in the body: measured 2026-11-03, two comments in this body had the shell run
# audiobookshelf_users and user as commands on every execution of this script.
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
from authentik.core.models import Application, Group, User
from authentik.flows.models import Flow
from authentik.providers.oauth2.models import (
ClientType, OAuth2Provider, RedirectURI, RedirectURIMatchingMode, RedirectURIType, ScopeMapping)
ADMIN_GROUP, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP"
ADMIN_GROUP = "$ADMIN_GROUP"
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
BASES = [BASE] + """$EXTRA_BASES""".split()
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
MEMBERS = """$MEMBERS""".split()
EXPR = '''$EXPR'''
# The expression reads user.groups, NOT user.ak_groups: authentik 2026.8 logs
# "User.ak_groups is deprecated … use User.groups" on every evaluation of the old
# name (measured on this run), and a stored mapping that leans on a deprecated
# accessor is a future upgrade failure sitting inside the login path.
for name in (ADMIN_GROUP, USER_GROUP):
Group.objects.get_or_create(name=name)
admin_g, user_g = Group.objects.get(name=ADMIN_GROUP), Group.objects.get(name=USER_GROUP)
Group.objects.get_or_create(name=ADMIN_GROUP)
admin_g = Group.objects.get(name=ADMIN_GROUP)
# The audiobookshelf_users group is retired. Its membership used to be the only door to the
# listener role; the claim now sends that role to every signer, so the group decides
# nothing — and a group that decides nothing is a trap for the next reader, who will
# assume it gates something and start adding people to it.
for stale in Group.objects.filter(name="audiobookshelf_users"):
stale.delete()
print("group: retired audiobookshelf_users (the claim grants every signer)")
# --- the claim mapping (scope_name IS the claim name ABS requests) ---------------
mapping = ScopeMapping.objects.filter(name="audiobookshelf-groups").first()
if mapping is None:
mapping = ScopeMapping.objects.create(name="audiobookshelf-groups", scope_name=CLAIM,
expression=EXPR,
description="Audiobookshelf role names from group membership")
description="Audiobookshelf roles: every signer is a listener, the admin group escalates")
print("mapping: created (scope_name %s)" % CLAIM)
elif mapping.scope_name != CLAIM or mapping.expression != EXPR:
mapping.scope_name, mapping.expression = CLAIM, EXPR
@@ -151,18 +170,18 @@ else:
print("mapping: already current")
# Prove the expression runs and yields an ABS role BEFORE anyone tries to log in:
# authentik execs the expression inside a function whose context has `user`.
# authentik execs the expression inside a function whose context has the name "user".
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
_ns = {}
exec(_src, _ns)
# --- provider --------------------------------------------------------------------
# One browser callback per origin (LAN address and the guarded vhost), because ABS
# derives redirect_uri from the request origin; the mobile URI stays on the LAN origin.
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
url="%s/auth/openid/callback" % b) for b in BASES]
uris.append(RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
url="%s/auth/openid/mobile-redirect" % BASE))
# One callback pair per origin (LAN address and the public vhost): ABS derives the
# browser callback from the request origin and the mobile callback from the door the
# app was configured with, so each origin needs both or that door cannot sign in.
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url="%s%s" % (b, p))
for b in BASES
for p in ("/auth/openid/callback", "/auth/openid/mobile-redirect")]
authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
# Three things beyond the name, each one a silent dead login: the grant-type list (2026.8
@@ -238,13 +257,26 @@ else:
e2e.groups.add(admin_g)
print("e2e fixture: %s in %s" % (E2E_ADMIN, ADMIN_GROUP))
print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
for name in MEMBERS:
u = User.objects.get(username=name)
u.groups.add(user_g)
print("users: %s in %s" % (", ".join(MEMBERS) or "(none — fail closed)", USER_GROUP))
# The default is printed at provision time, not merely asserted: an expression that regressed
# to returning nothing for a non-member refuses every household login, and this is where a
# re-run notices it. The population matters — authentik ships an AnonymousUser row, a
# superuser, and ak-outpost-* service identities, and proving the default on one of those says
# nothing about the friend who logs in. Prefer the fleet's plain-user marker group (owned by
# serverAppConfigs-docs/Infrastructure/Authentik.md; a preference only, since that group
# belongs to other consumers), which normally lands on qa-e2e — the identity the e2e gate
# signs in as. is_superuser is a python property on authentik's User, not a queryable field.
pool = User.objects.filter(is_active=True).exclude(groups__name__in=[ADMIN_GROUP])
pool = pool.exclude(username__in=[root.username, E2E_ADMIN, "AnonymousUser"])
pool = [u for u in pool.order_by("username")
if not u.is_superuser and not u.username.startswith("ak-outpost-")]
household = [u for u in pool if any(g.name == "Non-Admin Users" for g in u.groups.all())]
for u in (household or pool)[:3]:
print("default probe: %s %s -> %s" % (
u.username, sorted(g.name for g in u.groups.all()),
_ns["_probe"](user=u, request=None)))
# --- verification: the claim the callback will actually receive ---------------------
for probe in [root] + ([e2e] if e2e else []) + [User.objects.get(username=n) for n in MEMBERS]:
for probe in [root] + ([e2e] if e2e else []):
print("claim for %s -> %s" % (probe.username, _ns["_probe"](user=probe, request=None)))
p.refresh_from_db()
print("provider %s | client_id %s | flow %s | grants %s | uris %s" % (
@@ -1,6 +1,6 @@
---
type: reference
description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws."
description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, public vhost, laws."
tags:
- dankserver
- audiobooks
@@ -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://audiobooks.franksdank.cloud/audiobookshelf/`.
**Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (plain HTTP, nothing in front) and public `https://audiobooks.franksdank.cloud/audiobookshelf/` — Caddy straight to the container, **exempt from the guard chain** because the app must answer non-browser clients ([[Caddy]] *Unguard a vhost*). The public name is the address every client carries: Pi-hole answers it with `192.168.0.143` on the LAN, Porkbun answers it with the WAN address outside, and Caddy serves a real certificate either way.
## Shape
@@ -69,14 +69,20 @@ the file, not the bind: the same line count grows while the total climbs.
## Phone app
The official **Audiobookshelf** app connects with Server Address
`http://192.168.0.235:8000/audiobookshelf` — the base path belongs in the address — username
`frank`, and the same password as the web UI, or the SSO button; plain HTTP. Measured from a
second host 2026-11-02 and re-measured on both base paths: `/ping` (`{"success":true}`) and
`/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH` says, so the app's
connectivity probe works before it resolves the base path at all, and `iptables -S INPUT`
carries no rule for 8000 — a phone on `192.168.0.0/24` connects with no further setup, and
`download: true` on the root account is what makes offline listening work. Away
from the LAN, see the two-doors law in *Laws* — the guarded vhost is not the remedy.
`https://audiobooks.franksdank.cloud/audiobookshelf` — the base path belongs in the address —
username `frank`, and the same password as the web UI, or the SSO button. One address works
at home and away: on the LAN Pi-hole answers that name with `192.168.0.143` and Caddy dials
the container; off-LAN the same name resolves to the WAN address and takes the same route
(inbound 443 is forwarded at the router — fetched from a datacenter node 2026-11-03). The raw
LAN address still answers and is the break-glass door whenever Caddy is down. `/ping`
(`{"success":true}`) and `/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH`
says, so the app's connectivity probe works before it resolves the base path at all, and
`download: true` on the account is what makes offline listening work.
**A home device that bypasses Pi-hole cannot reach it.** The G36 does not hairpin: resolving
the name to the WAN address from inside the LAN times out (measured 2026-11-03). Only a
client pinned to an external resolver hits this — everything using the router's DNS gets the
LAN answer.
**The login token is at `user.token`, not top level.** `POST /login` answers
`{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits
@@ -84,6 +90,24 @@ inside the user object. A client that reads `response.token` gets the empty stri
`Authorization: Bearer ` with nothing after it, and every `/api/*` call then answers `401`
as though the password were wrong.
## Listeners (accounts)
The door is public now, so the account is the only gate between a listener and the library.
Two ways in, both per listener:
- **Local account** — **Settings → Users → Add User**: type `user`, tick only the libraries
they may hear, and leave `download` off unless they need offline listening. Hand them the
server address above and their own password.
- **Household SSO** — create the authentik account, and that is the whole of it: the first
**Sign in with Authentik** auto-registers the ABS account (`authOpenIDAutoRegister`) as a
listener, because the claim grants that role to every signer. To shut a listener off,
disable or delete their user in Audiobookshelf — no IdP group membership can withhold it.
Nothing rate-limits `/audiobookshelf/api/login` — this Caddy build carries no `rate_limit`
module and ABS 2.37.2 ships no lockout — so the form is a guessing target exactly like
`jelly.`, `plex.` and the *arr logins already are. The mitigation is credential hygiene:
long unique passwords per listener, never a reuse of the root password.
## Single sign-on (authentik)
Both doors take the household identity: the web UI puts **Sign in with Authentik** beside
@@ -93,19 +117,24 @@ the local form, and the phone app offers SSO on its server-address screen. The l
| Piece | Owner |
|-------|-------|
| Provider `audiobookshelf`, the two groups, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
| Provider `audiobookshelf`, the admin group, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
| Client secret | `ABS_OIDC_CLIENT_SECRET` in `~/.config/secrets/audiobookshelf` on Dankserver (mode 600; never in the repo, a URL or an argv) |
| 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: every signer gets `user`; `audiobookshelf_admins` adds `admin`. Nothing in the IdP can deny the listener role — the login itself is the gate (law since 2026-11-03; the `audiobookshelf_users` group was retired, it had zero members and every household account was being refused) |
| 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 `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 |
| Guard state of the door | none since 2026-11-03: proxy provider `audiobooks-proxy` and application "Audiobookshelf Guard" were deleted and the embedded outpost un-claimed them, because that chain answers a native client with challenge HTML ([[Caddy]] *Unguard a vhost* for the order). The SSO relying party `audiobookshelf` above is a different object and stays |
| Redirect URIs on the RP | `<base>/auth/openid/callback` **and** `<base>/auth/openid/mobile-redirect` for **both** origins (LAN address and public name): the app sends the mobile URI belonging to the door it was configured with, and STRICT matching answers an unregistered one with "Redirect URI Error" |
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
neither group is refused after a successful authentication — that is the mapping working,
not a broken login.
With `authOpenIDAutoRegister` on, a first sign-in creates the account and the claim alone
decides its type — and the claim always carries `user`, so **the IdP is now the library gate**:
any authentik account that can reach this application becomes a listener. That is the intent
(the household signs in with the accounts it already has), which makes two things worth
checking when they change: whether authentik's own invitation/signup flow stays something only
Frank drives, and whether an access policy should be bound to the `audiobookshelf` application
to narrow who may even reach the login. Revoking one person is a disable/delete of their ABS
user; revoking everyone is that policy.
Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*: a sign-in as `qa-e2e-admin` that must land inside the app with site-admin rights.
Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*. `23-` signs in as `qa-e2e-admin` and must land inside the app with site-admin rights; `24-` signs in as the standard identity, which is in no Audiobookshelf group, and must land as a listener. Together they pin the escalation and the default: a mapping that stops granting silently locks the household out, and a mapping that over-grants turns the household into site admins.
## Laws
@@ -195,13 +224,17 @@ Both halves — authentik's provider row and ABS's settings — are asserted end
- **No `systemctl --user enable`.** Generated Quadlet units cannot be enabled on
this host; `WantedBy=default.target` in the unit file is what carries the
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://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
no challenge, so remote listening wants a VPN into the LAN, not the vhost.
- **One door for every client, and it sits outside the guard chain on purpose.**
`https://audiobooks.franksdank.cloud/audiobookshelf` is what browsers and apps use;
`http://192.168.0.235:8000/audiobookshelf` is the raw LAN socket that same Caddy dials,
so it doubles as break-glass when the proxy is down. Behind the chain (until
2026-11-03) the vhost answered the phone app's `/ping` and `/socket.io/` handshake with
Anubis' challenge HTML — Anubis demands a JavaScript proof and the outpost needs a
session cookie, and a native client solves neither — so the app evaluated
`'isInit' in e.data` on a non-object, reported "Failed to contact server", and the
container's request log never recorded the attempt. Podcast RSS and OPDS readers died
the same death. ABS's own auth is the gate, as it is for [[Plex]] and [[Jellyfin]]
([[Caddy]] *Unguard a vhost*).
- **There is no `/setup` route; the creation door is `POST /init`.** `/status`
(no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with
`{"newRoot":{"username","password"}}` creates it, `POST /login` returns the
@@ -287,13 +320,23 @@ bootstrapper against a stub of the API above: create-once, idempotent re-run, no
URL, refusal by URL when the server does not answer, the deployer calling it only after the
health gate, and the SSO arm sending the mounted base path as the redirect subfolder);
`tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view);
and the live Playwright gate `tests/e2e/specs/23-audiobookshelf-sso.spec.ts`, run by
`tests/e2e/run.sh` at the end of every `deploy.sh` — the `admin` project signs in as
`qa-e2e-admin` against `http://192.168.0.235:8000/audiobookshelf` and asserts the two things
no stub can see: every `.js` the app loads arrives as JavaScript (a bundle built for the wrong
base path arrives as `text/html`, which boots nothing), and `/api/me` answers 200 with
`type: "admin"` — true only while grant types, the OpenID scope mappings and the group claim
all hold together.
and the live Playwright gates `tests/e2e/specs/23-audiobookshelf-sso.spec.ts` and
`tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts`, run by `tests/e2e/run.sh` at
the end of every `deploy.sh`. `23-` (the `admin` project) signs in as `qa-e2e-admin` against
`http://192.168.0.235:8000/audiobookshelf` and asserts the two things no stub can see: every
`.js` the app loads arrives as JavaScript (a bundle built for the wrong base path arrives as
`text/html`, which boots nothing), and `/api/me` answers 200 with `type: "admin"` — true only
while grant types, the OpenID scope mappings and the group claim all hold together. `24-` (the
`standard` project) signs in as `qa-e2e`, an account in no Audiobookshelf group, and must come
back `type: "user"`: the default grant that lets the household in, so a scope-mapping
regression surfaces as a failed sign-in in the gate instead of as a friend's complaint.
The public door is pinned by the guard audit `tests/e2e/specs/14-guard.spec.ts`, which now
carries an **exemption** assertion where it used to carry a guarded one: an anonymous raw
client must get the app's own `{"success":true}` on `/ping`, a machine-format socket
handshake, and the app's own `401` on `/audiobookshelf/api/libraries` — never challenge
HTML. `tests/anubis.bats` pins the same shape in the Caddyfile: the vhost never proxies to
`anubis:8923`, and the post-auth listener carries no `@audiobooks` route.
Five book folders hold media no scanner can read, so they stay out of the library:
three 2024 Nebula nominees in the `_unsorted` box (two `.m4b` with no `moov` atom,
+50 -3
View File
@@ -90,7 +90,7 @@ pihole.franksdank.cloud {
After rendering, the line reads `dankuser <hash>` (one complete hash) — `tests/deployment_flow.bats:96-99` pins the `dankuser` name and the single-hash form, and `tests/caddyfile.bats:75` pins the bare `dankuser REPLACE_WITH_HASH` template. Do not put a `$2a$14$` prefix in the template: it would render a double-prefixed, invalid hash (2026-08-22 incident — every basicauth vhost returned 401 for every password; `tests/caddyfile.bats:67-72` forbids the prefix). `tests/deployment_flow.bats` pins the render contract.
Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.`, `notebook.`, `abs.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile`: the header comment and the two internal listeners). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), and the media/*arr vhosts. Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik (`dankclaw/Caddyfile:122-132`). See [[Homepage]] and [[Authentik]].
Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.`, `notebook.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile`: the header comment and the two internal listeners). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), the media/*arr vhosts, and `audiobooks.` — Audiobookshelf enforces its own auth (local accounts plus authentik OIDC) and must answer native app clients, podcast RSS and OPDS, which the chain structurally cannot deliver: it answers a client that solves no JavaScript proof-of-work with challenge HTML wherever that client expects JSON or a socket payload ([[Audiobookshelf]]). Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik. See [[Homepage]] and [[Authentik]].
## Guard a new vhost
@@ -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 = "audiobooks", "https://audiobooks.franksdank.cloud/"
NAME, EXT = "example", "https://example.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,
@@ -141,7 +141,9 @@ authentik's error page instead of the app.
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
callback (measured 2026-11-03, while this vhost was still guarded — it is exempt
now, and the same pin sits on its edge block instead).
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
@@ -149,6 +151,51 @@ authentik's error page instead of the app.
`tests/e2e/specs/14-guard.spec.ts` with an app-specific leak marker — that spec is
the deploy gate's proof that the guard, not the app, answers an anonymous client.
## Unguard a vhost
The same three artifacts, in the opposite order. Getting it wrong takes the app
down for browsers while looking healthy:
1. **Route first.** Move the public site block off `anubis:8923` to a direct
`reverse_proxy` (keeping any origin-pinning `header_up` the app needs), delete
the `@<name>` handler from the `http://:8017` listener, and deploy. Traffic
stops entering the auth listener at `:8016`, so the IdP row goes unused — and
nothing breaks.
2. **Then the IdP rows**, once the route is verified:
```
ssh dankclaw 'podman exec -i authentik-server ak shell' <<'PY'
from authentik.providers.proxy.models import ProxyProvider
from authentik.outposts.models import Outpost
pp = ProxyProvider.objects.filter(name="audiobooks-proxy").first()
if pp:
o = Outpost.objects.get(managed="goauthentik.io/outposts/embedded")
o.providers.remove(pp)
app = pp.application # the "<Name> Guard" application row
if app:
app.delete()
pp.delete()
print("providers now claimed:", Outpost.objects.get(
managed="goauthentik.io/outposts/embedded").providers.count())
PY
```
Deleting the provider **before** the route flips is the wrong order: the
outpost then matches zero providers for a Host that still arrives, and every
browser gets authentik's error page instead of the app.
3. **Then the audit lists.** Drop the subdomain from `GUARDED` in
`tests/e2e/specs/14-guard.spec.ts` (and its leak marker), and replace it
there with an exemption assertion — the deploy gate must keep proving the
exemption is deliberate, the way `plex` does: app bytes to an anonymous
client, zero challenge HTML, and the app's own `401` on an unauthenticated
API call rather than the gate's `200`.
What the exemption costs, said plainly: the app's login endpoint becomes
internet-visible with no rate limiter in front of it unless the edge carries one
(this Caddy build has no `rate_limit` module) or the app ships a lockout (ABS
2.37.2 does not). That is the same exposure `jelly.`, `plex.` and the *arr
vhosts already live with; the mitigation is the credentials.
## Operations
Validate before a reload:
+1 -1
View File
@@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers.
| Page | Owns |
|---|---|
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws. |
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, public vhost, laws. |
| [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. |
| [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. |
| [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. |
+11 -2
View File
@@ -75,8 +75,10 @@ PY
}
@test "exempt vhosts never route through anubis (auth, plex, own-auth apps)" {
# The IdP login flow and git smart-HTTP clients cannot solve challenges;
# these vhosts bypass the chain by construction.
# The IdP login flow, git smart-HTTP clients, and any non-browser client (the
# Audiobookshelf phone app, podcast RSS readers) cannot solve a challenge, so
# services that enforce their own complete auth bypass the chain by
# construction — the gate would answer their API calls with HTML.
local block
block=$(awk '/^auth\.franksdank\.cloud/,/^}/' "$CADDY")
echo "$block" | grep -q 'reverse_proxy authentik-server:9000'
@@ -84,4 +86,11 @@ PY
block=$(awk '/^plex\.franksdank\.cloud/,/^}/' "$CADDY")
echo "$block" | grep -q '192\.168\.0\.235:32400'
! echo "$block" | grep -q anubis
block=$(awk '/^audiobooks\.franksdank\.cloud/,/^}/' "$CADDY")
echo "$block" | grep -q '192\.168\.0\.235:8000'
! echo "$block" | grep -q anubis
# The exemption is total: the post-auth listener must not still carry an
# audiobooks route. A leftover handle there is a second owner for one host,
# and its X-Forwarded-Proto pin would silently disagree with the edge.
! grep -q '@audiobooks host audiobooks.franksdank.cloud' "$CADDY"
}
+5 -2
View File
@@ -35,8 +35,11 @@ export default defineConfig({
// 2026-10-19 - the newest guarded app, in neither GUARDED list nor any
// spec until now: the 17-hermes "uncovered vhost broke silently" class).
// 22- joins Prometheus's app-is-really-there proof (its own API), closing
// the "not a login wall but maybe a 404" hole 20- closed for Grafana.
testMatch: /(10|12|13|16|17|18|20|21|22)-.*\.spec\.ts/,
// the "not a login wall but maybe a 404" hole 20- closed for Grafana. 24-
// joins 2026-11-03 (Audiobookshelf's DEFAULT listener grant) and belongs to
// this project precisely because the standard identity is in no Audiobookshelf
// group — the admin project could not observe the default.
testMatch: /(10|12|13|16|17|18|20|21|22|24)-.*\.spec\.ts/,
use: { storageState: ".auth/qa-e2e.json" },
dependencies: ["setup"],
},
+58 -8
View File
@@ -6,21 +6,21 @@ import { expect, test } from "@playwright/test";
// navigation would skip. These tests drive the browser as an UNAUTHENTICATED
// user (the guest project has no storage state) and navigate directly to each
// guarded vhost's root path: the guard must present authentik's flow UI in
// place and must never leak app content. plex is the documented exemption —
// PMS enforces its own auth — so it must be reachable unauthenticated with
// zero challenge pages, proving the exemption is deliberate, not accidental.
// place and must never leak app content. plex and audiobooks are the documented
// exemptions — PMS and Audiobookshelf enforce their own auth — so they must be
// reachable unauthenticated with zero challenge pages, proving the exemption is
// deliberate, not accidental.
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", "audiobooks"];
const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook"];
// 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 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.
// marker must be a string the APP renders that the gate itself could never print:
// authentik's gate never renders an app's UI text, because it IS the IdP.
const LEAK_MARKERS: Record<string, string[]> = {
"search.franksdank.cloud": ["SearXNG preferences"],
"romm.franksdank.cloud": ["RomM"],
@@ -29,7 +29,6 @@ const LEAK_MARKERS: Record<string, string[]> = {
"grafana.franksdank.cloud": ["Dashboards"],
"prometheus.franksdank.cloud": ["Expression Browser", "Graph"],
"notebook.franksdank.cloud": ["Open Notebook"],
"audiobooks.franksdank.cloud": ["Sign in with Authentik"],
};
test("homepage root (/) is guarded for unauthenticated users", async ({ page }) => {
@@ -102,3 +101,54 @@ test("plex is exempt from the guard chain and serves PMS content unauthenticated
const resp = await page.request.get("https://plex.franksdank.cloud/");
expect(resp.status(), "PMS must answer directly, not error").toBeLessThan(500);
});
// Audiobookshelf is exempt by construction (2026-11-03). The phone app is a
// non-browser client and solves no JS proof-of-work, so behind the guard chain it
// was answered with challenge HTML wherever the socket `init` payload belongs —
// every remote sign-in died as a TypeError on a non-object, and the container's
// request log never saw a byte of it. ABS enforces its own auth (local accounts
// plus authentik OIDC), exactly like PMS above. Asserted from the CLIENT side: the
// endpoints an app must call before it holds any credential are answered by the
// app, and the app's own 401 — not a 200 of HTML — is what an anonymous API call gets.
test("audiobookshelf answers an unauthenticated app client with app bytes, not gate bytes", async ({
request,
}) => {
const ABS = "https://audiobooks.franksdank.cloud";
// The probe every Audiobookshelf client sends first. The app answers it at the
// ROOT whatever ROUTER_BASE_PATH says, so this is the vhost's own behaviour.
const ping = await request.get(`${ABS}/ping`, { timeout: 30_000 });
expect(ping.status(), "/ping must be answered by the app").toBe(200);
expect(await ping.json(), "/ping must carry the app's own payload").toEqual({ success: true });
// The socket.io handshake. Engine.IO answers this in a machine format; the gate
// answered it in HTML, which is the defect this exemption exists to kill.
const sock = await request.get(`${ABS}/audiobookshelf/socket.io/?EIO=4&transport=polling`, {
timeout: 30_000,
});
expect(
await sock.text(),
"the socket handshake was answered by the gate, not Engine.IO",
).not.toContain("Making sure you're not a bot");
expect(
sock.headers()["content-type"] ?? "",
"the socket handshake must not arrive as HTML",
).not.toMatch(/text\/html/);
// An API call with no credential: the app refuses it. A gate would answer 200
// with HTML, so the status code alone distinguishes the two owners.
const api = await request.get(`${ABS}/audiobookshelf/api/libraries`, { timeout: 30_000 });
expect(api.status(), "the app's own auth gate must own unauthenticated API calls").toBe(401);
});
test("audiobookshelf login renders the app's own sign-in to an anonymous browser", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("https://audiobooks.franksdank.cloud/audiobookshelf/login");
expect(new URL(page.url()).hostname).toBe("audiobooks.franksdank.cloud");
const body = await page.locator("body").innerText();
expect(body, "the bot challenge still fronts this vhost").not.toContain("Making sure you're not a bot");
expect(await page.locator('input[name="uidField"]').count(), "the IdP gate form must not front the app").toBe(0);
// The app answers, and its login screen carries SSO beside the local form.
await expect(page.getByText("Sign in with Authentik")).toBeVisible({ timeout: 90_000 });
});
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
// Audiobookshelf grants the LISTENER role to anyone the IdP lets sign in (2026-11-03).
//
// The role used to come from membership of `audiobookshelf_users` — a group nobody was ever
// added to. The household accounts exist in authentik, and every one of them was denied at ABS
// AFTER a successful authentication, which reads as a broken login rather than a policy decision.
// The scope mapping now answers `abs_groups: ["user"]` for every signer and reserves `admin` for
// `audiobookshelf_admins`, so the IdP is the only gate to the library and onboarding a listener
// is exactly "create the account".
//
// This is the counterpart to 23-: that spec proves the admin escalation, this one proves the
// DEFAULT grant, from an identity in neither group. Against the old mapping it is red — ABS
// refuses the sign-in and the SPA never leaves /login.
const HOST = "192.168.0.235:8000";
const ABS = `http://${HOST}/audiobookshelf`;
test("an SSO signer in no Audiobookshelf group lands as a listener", async ({ page }) => {
test.setTimeout(180_000);
await page.goto(`${ABS}/login`, { waitUntil: "domcontentloaded" });
const sso = page.getByText("Sign in with Authentik");
await expect(sso).toBeVisible();
await sso.click();
// The standard project's storageState carries a live authentik session, so the round trip is
// silent: authorize -> code -> callback -> token. Still being on /login means the app refused
// the identity (the denial this change removes) or the exchange failed.
await expect
.poll(() => !new URL(page.url()).pathname.endsWith("/login"), { timeout: 90_000 })
.toBe(true);
const grabToken = () =>
page.evaluate(() => {
for (let i = 0; i < localStorage.length; i++) {
const v = localStorage.getItem(localStorage.key(i) as string) || "";
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(v)) return v;
}
const c = document.cookie.match(/(?:^|;\s*)abs_token=([^;]+)/);
return c ? decodeURIComponent(c[1]) : "";
});
await expect.poll(grabToken, { timeout: 60_000 }).not.toBe("");
const me = await page.request.get(`${ABS}/api/me`, {
headers: { Authorization: `Bearer ${await grabToken()}` },
});
expect(me.status()).toBe(200);
const user = await me.json();
expect(user.username).toBe("qa-e2e");
// The default, not a grant: the listener role for a signer in neither group. Being `user`
// rather than `admin` is also the proof that the default never escalates.
expect(user.type).toBe("user");
});