From cfdd3435bccb4290d3428e27fa74bba5abfc1851 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 19:50:44 -0800 Subject: [PATCH 1/5] fix(audiobookshelf): mount the app at the base path its SPA was built for ROUTER_BASE_PATH=/ served a healthy API and a web UI that could never boot. The index.html in the pinned image is built with routerBasePath:/audiobookshelf, so every script request fell through to the SPA fallback and came back as text/html, which Chrome refuses to execute. healthcheck, the API and the phone app all stayed fine, which is exactly how this hid behind an API-only deploy check. The deployer now reads the mounted base path out of the unit and hands it to the bootstrapper as ABS_URL, so the API door, the login route and the OIDC redirect subfolder cannot drift from the unit. healthcheck answers at the root under either base path, so the health gate is unchanged. --- .../audiobookshelf/quadlet/audiobookshelf.container | 13 +++++++++++-- scripts/deploy-remote.sh | 10 ++++++++-- tests/dankserver_quadlet.bats | 7 ++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/dankserver/audiobookshelf/quadlet/audiobookshelf.container b/dankserver/audiobookshelf/quadlet/audiobookshelf.container index e694cbd..872443a 100644 --- a/dankserver/audiobookshelf/quadlet/audiobookshelf.container +++ b/dankserver/audiobookshelf/quadlet/audiobookshelf.container @@ -33,9 +33,16 @@ Network=pasta # README is the npm package's naming and is INERT here: with ABS_PORT=8000 the # server listened on :80 under base path /audiobookshelf, so the unit sat # `active (running)` while every probe to :8000 was refused (measured 2026-11-02, -# first deploy). ROUTER_BASE_PATH=/ is what makes the URL the plain host:port. +# first deploy). +# ROUTER_BASE_PATH MUST be /audiobookshelf: the SPA in /app/client/dist/index.html is +# built with `routerBasePath:"/audiobookshelf"` baked in, so it asks for its assets under +# that prefix no matter what the server is mounted at. With `/` the server answers the +# page but every script request falls through to the SPA fallback and comes back as +# text/html — Chrome refuses to execute it and the UI never leaves "Loading…" +# (measured 2026-11-03; the API and /healthcheck were fine, which is why an API-only +# verification missed it). Environment=PORT=8000 -Environment=ROUTER_BASE_PATH=/ +Environment=ROUTER_BASE_PATH=/audiobookshelf Environment=CONFIG_PATH=/config Environment=METADATA_PATH=/metadata Environment=TZ=America/Vancouver @@ -45,6 +52,8 @@ Volume=/home/dank/Containers/audiobookshelf/metadata:/metadata Volume=/mnt/entropy/Audiobooks:/audiobooks:ro # The image is Alpine: it ships wget and no curl, so the health check must name # the binary that exists or the container is unhealthy while serving perfectly. +# /healthcheck answers `OK` at the root AND under the base path (measured on both +# base paths), so the probe below is correct whatever ROUTER_BASE_PATH says. HealthCmd=wget -q -O /dev/null http://127.0.0.1:8000/healthcheck HealthInterval=30s HealthTimeout=10s diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 5160157..7e90e4b 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -1315,7 +1315,8 @@ deploy_audiobookshelf() { mkdir -p /home/dank/Containers/audiobookshelf/config \ /home/dank/Containers/audiobookshelf/metadata systemctl --user restart audiobookshelf.service - # /healthcheck answers once the HTTP server is bound; the library scan that + # /healthcheck answers once the HTTP server is bound, at the root whatever + # ROUTER_BASE_PATH says (measured on both base paths); the library scan that # follows runs in the background and must not gate the deploy. wait_for_health audiobookshelf http://127.0.0.1:8000/healthcheck 90 # A server with no root user and no library is "healthy" and useless, so the @@ -1323,7 +1324,12 @@ deploy_audiobookshelf() { # it creates the account (credential in the mode-600 host secret file), the # Audiobooks library over the /audiobooks bind, and fires the scan. Every # step is idempotent, so a re-deploy costs one incremental scan. - scripts/audiobookshelf-init.sh + # The bootstrapper has to speak the same base path the unit mounts (the API, the + # login route and the OIDC redirect subfolder all hang off it), so the URL is read + # out of the unit file here instead of being restated in the script's default. + abs_base=$(awk -F= '/^Environment=ROUTER_BASE_PATH=/{print $3}' \ + dankserver/audiobookshelf/quadlet/audiobookshelf.container) + ABS_URL="http://127.0.0.1:8000${abs_base}" scripts/audiobookshelf-init.sh } # The content server's single admin account and the desktop's basic-auth diff --git a/tests/dankserver_quadlet.bats b/tests/dankserver_quadlet.bats index ae4b34e..9b66f9f 100755 --- a/tests/dankserver_quadlet.bats +++ b/tests/dankserver_quadlet.bats @@ -182,8 +182,13 @@ load helpers # under base path /audiobookshelf and the deploy gate timed out beside a unit # that was active (running). PORT and ROUTER_BASE_PATH are what the image's own # ENV block and the server source actually use. + # The base path is not a free choice either: /app/client/dist/index.html inside the + # pinned image carries routerBasePath:"/audiobookshelf", so a server mounted at / hands + # out a page whose every script request falls through to the SPA fallback and comes back + # text/html — the UI never leaves "Loading…" while the API stays healthy (measured + # 2026-11-02, which is exactly how it hid behind an API-only verification). unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container" grep -q '^Environment=PORT=8000$' "$unit" || return 1 - grep -q '^Environment=ROUTER_BASE_PATH=/$' "$unit" || return 1 + grep -q '^Environment=ROUTER_BASE_PATH=/audiobookshelf$' "$unit" || return 1 ! grep -qE '^Environment=ABS_' "$unit" || return 1 } -- 2.54.0 From 393b77dfd245bee34ed0a498bd62d48737e7e721 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 19:50:44 -0800 Subject: [PATCH 2/5] feat(audiobookshelf): provision authentik SSO and send the redirect subfolder The live round trip died on authentik with Redirect URI Error. ABS builds redirect_uri as /auth/openid/callback, and that key is unset on a database created before SSO (the 2.17.4 migration only fills a DB that already had OIDC enabled), so the URL carried the literal string undefined. Arming through the API therefore has to send the value; it is the mounted base path, so it is derived from ABS_URL rather than becoming a second knob an operator has to keep in sync. authentik-audiobookshelf-sso.sh is the IdP half kept in git: provider, groups, the abs_groups scope mapping and the redirect URIs. That state lives in authentik's database, and a blueprint re-import reverts hand-made rows silently, so it needs a re-runnable door. Tokens are HS256 because no provider on the fleet carries a signing key, which makes the client secret the signature key. The stub server now records the auth-settings PATCH body, so the seam asserts the subfolder that reaches the wire follows ABS_URL for both the prefixed and root shapes, and a second seam pins that the provider script and the bootstrapper build the same redirect path. --- scripts/audiobookshelf-init.sh | 129 ++++++++++++++- scripts/authentik-audiobookshelf-sso.sh | 203 ++++++++++++++++++++++++ tests/audiobookshelf_init.bats | 84 +++++++++- 3 files changed, 408 insertions(+), 8 deletions(-) create mode 100755 scripts/authentik-audiobookshelf-sso.sh diff --git a/scripts/audiobookshelf-init.sh b/scripts/audiobookshelf-init.sh index c1ebd8b..f4ac4c1 100755 --- a/scripts/audiobookshelf-init.sh +++ b/scripts/audiobookshelf-init.sh @@ -21,26 +21,64 @@ # The credential is generated once into a mode-600 host file and never enters # the repo, the URL, or a process argument list. Same pattern as calibre-server. # +# Single sign-on (authentik) is armed by the same run, and only when the credential +# file carries ABS_OIDC_CLIENT_SECRET, so a local-only deployment is unchanged by a +# re-run. These OIDC facts came from the same pinned image, and each one is a trap: +# * isOpenIDAuthSettingsValid demands issuer + authorization + token + userinfo + +# jwks + clientID + clientSecret + signingAlgorithm at once. Miss one and the +# server SILENTLY strips 'openid' from authActiveAuthMethods and falls back to +# ['local'] while answering 200 everywhere — so the only honest check is what +# /status says AFTER the write, which is what this script does. +# * the algorithm is HS256, not the image's RS256 default: no provider on the +# fleet's authentik carries a signing key, so the client secret IS the signature +# key (scripts/authentik-audiobookshelf-sso.sh). Left at RS256, every id_token +# fails verification and the failure looks like a broken IdP. +# * the auth block lives on PATCH /api/auth-settings. PATCH /api/settings has a +# 20-key allowlist (PATCHABLE_SETTINGS_KEYS) with NO auth key in it, so writing +# the OIDC config there answers 200 and changes nothing — measured on this host +# 2026-11-02. The keys /api/auth-settings accepts are exactly its own +# authenticationSettings getters; anything else is skipped with a debug line. +# * authOpenIDSubfolderForRedirectURLs must be sent explicitly. The 2.17.4 migration only +# fills it on a DB that ALREADY had OIDC on, so a database created before SSO leaves it +# UNDEFINED and the server builds redirect_uri +# "http://host/undefined/auth/openid/callback" — authentik answers the authorize request +# with "Redirect URI Error" (measured 2026-11-02). This script derives the value from +# ABS_URL's path, so it cannot drift from the unit's ROUTER_BASE_PATH. +# * 'local' stays in authActiveAuthMethods on purpose: if authentik is ever down, +# the root account must still be able to sign into its own server. +# # usage: audiobookshelf-init.sh # create what is missing, then scan # ABS_SCAN=0 audiobookshelf-init.sh # bootstrap without a scan +# ABS_OIDC=0 audiobookshelf-init.sh # never touch the auth methods # # environment (defaults are the deployed shape): -# ABS_URL server base URL (http://127.0.0.1:8000) +# ABS_URL server base URL INCLUDING the mounted base path +# (http://127.0.0.1:8000/audiobookshelf) # ABS_SECRET credential file (/home/dank/.config/secrets/audiobookshelf) # ABS_LIBRARY library name to ensure (Audiobooks) # ABS_FOLDER folder to bind inside the server (/audiobooks) # ABS_SCAN 1 triggers a scan (default), 0 skips it +# ABS_OIDC auto (default) arms SSO when the credential file has a client +# secret, 1 requires one, 0 leaves the auth methods alone +# ABS_OIDC_ISSUER authentik issuer URL for this client +# ABS_OIDC_CLIENT_ID provider client id (audiobookshelf) +# ABS_OIDC_ALG id_token signing algorithm the IdP actually uses (HS256) +# ABS_OIDC_SUBFOLDER override for the redirect-URI subfolder (default: ABS_URL's path) # # exit 0 every step is satisfied (created or already present) # exit 1 a step was refused or the server misbehaved (named on stderr) # exit 2 usage / unreadable credential file set -euo pipefail -ABS_URL=${ABS_URL:-http://127.0.0.1:8000} +ABS_URL=${ABS_URL:-http://127.0.0.1:8000/audiobookshelf} ABS_SECRET=${ABS_SECRET:-/home/dank/.config/secrets/audiobookshelf} ABS_LIBRARY=${ABS_LIBRARY:-Audiobooks} ABS_FOLDER=${ABS_FOLDER:-/audiobooks} ABS_SCAN=${ABS_SCAN:-1} +ABS_OIDC=${ABS_OIDC:-auto} +ABS_OIDC_ISSUER=${ABS_OIDC_ISSUER:-https://auth.franksdank.cloud/application/o/audiobookshelf/} +ABS_OIDC_CLIENT_ID=${ABS_OIDC_CLIENT_ID:-audiobookshelf} +ABS_OIDC_ALG=${ABS_OIDC_ALG:-HS256} die() { printf 'refused: %s\n' "$*" >&2; exit 1; } @@ -49,6 +87,11 @@ case "$ABS_SCAN" in *) printf 'refused: ABS_SCAN wants 0 or 1, got %s\n' "$ABS_SCAN" >&2; exit 2 ;; esac +case "$ABS_OIDC" in + auto | 0 | 1) ;; + *) printf 'refused: ABS_OIDC wants auto, 0 or 1, got %s\n' "$ABS_OIDC" >&2; exit 2 ;; +esac + # ---- credential ------------------------------------------------------------- umask 077 if [ ! -f "$ABS_SECRET" ]; then @@ -72,8 +115,10 @@ chmod 600 "$ABS_SECRET" # python3 rather than curl so the password and the bearer token never appear in # any argv (they would be world-readable in /proc//cmdline otherwise). ABS_URL="$ABS_URL" ABS_SECRET="$ABS_SECRET" ABS_LIBRARY="$ABS_LIBRARY" \ -ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" python3 - <<'PY' -import json, os, sys, urllib.error, urllib.request +ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" ABS_OIDC="$ABS_OIDC" \ +ABS_OIDC_ISSUER="$ABS_OIDC_ISSUER" ABS_OIDC_CLIENT_ID="$ABS_OIDC_CLIENT_ID" \ +ABS_OIDC_ALG="$ABS_OIDC_ALG" python3 - <<'PY' +import json, os, sys, urllib.error, urllib.parse, urllib.request BASE = os.environ["ABS_URL"].rstrip("/") LIB = os.environ["ABS_LIBRARY"] @@ -184,5 +229,81 @@ code, items = call("GET", "/api/libraries/%s/items?limit=1" % lib["id"], token=t if code == 200 and isinstance(items, dict): print("items: %s (a fresh scan raises this from 0; watch it, not /healthcheck)" % items.get("total")) + +# ---- single sign-on (authentik) -------------------------------------------------- +# The provider this points at is provisioned by scripts/authentik-audiobookshelf-sso.sh; +# the two share exactly one value, the client secret in the credential file. +mode = os.environ["ABS_OIDC"] +oidc_secret = cred.get("ABS_OIDC_CLIENT_SECRET") or "" +if mode == "0": + print("oidc: skipped (ABS_OIDC=0)") +elif not oidc_secret: + if mode == "1": + sys.exit("refused: ABS_OIDC=1 but %s has no ABS_OIDC_CLIENT_SECRET — run " + "scripts/authentik-audiobookshelf-sso.sh first" % os.environ["ABS_SECRET"]) + print("oidc: not configured (no ABS_OIDC_CLIENT_SECRET in the credential file)" + " — local auth only") +else: + client_id = os.environ["ABS_OIDC_CLIENT_ID"] + issuer = os.environ["ABS_OIDC_ISSUER"].rstrip("/") + "/" + # redirect_uri = /auth/openid/callback, and the subfolder IS the + # base path the app is mounted under — taken from ABS_URL so unit and config agree. + subfolder = os.environ.get("ABS_OIDC_SUBFOLDER") + if subfolder is None: + subfolder = urllib.parse.urlparse(os.environ["ABS_URL"]).path.rstrip("/") + origin = issuer.split("/application/")[0] + body = { + "authActiveAuthMethods": ["local", "openid"], + "authOpenIDIssuerURL": issuer, + "authOpenIDAuthorizationURL": origin + "/application/o/authorize/", + "authOpenIDTokenURL": origin + "/application/o/token/", + "authOpenIDUserInfoURL": origin + "/application/o/userinfo/", + "authOpenIDJwksURL": issuer + "jwks/", + "authOpenIDLogoutURL": issuer + "end-session/", + "authOpenIDClientID": client_id, + "authOpenIDClientSecret": oidc_secret, + "authOpenIDTokenSigningAlgorithm": os.environ["ABS_OIDC_ALG"], + "authOpenIDSubfolderForRedirectURLs": subfolder, + "authOpenIDButtonText": "Sign in with Authentik", + # 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). + "authOpenIDAutoRegister": True, + "authOpenIDGroupClaim": "abs_groups", + # The app's own scheme lives HERE, never in the IdP: ABS swaps it for + # /auth/openid/mobile-redirect before the provider sees a redirect_uri. + "authOpenIDMobileRedirectURIs": ["audiobookshelf://oauth"], + } + code, res = call("PATCH", "/api/auth-settings", body, token=token) + if code != 200: + sys.exit("refused: PATCH /api/auth-settings answered %s (%s)" % (code, res)) + # Verify twice: what was stored, and what the server will honour. A config that + # misses any of the eight required OIDC values is accepted with 200 and then + # silently dropped at the next load, leaving authMethods=['local']. + code, stored = call("GET", "/api/auth-settings", token=token) + if code != 200 or not isinstance(stored, dict): + sys.exit("refused: GET /api/auth-settings answered %s (%s)" % (code, stored)) + for key, want in (("authOpenIDIssuerURL", issuer), + ("authOpenIDTokenSigningAlgorithm", os.environ["ABS_OIDC_ALG"]), + ("authOpenIDSubfolderForRedirectURLs", subfolder), + ("authOpenIDButtonText", "Sign in with Authentik")): + if stored.get(key) != want: + sys.exit("refused: stored %s=%r, expected %r" % (key, stored.get(key), want)) + if sorted(stored.get("authActiveAuthMethods") or []) != ["local", "openid"]: + sys.exit("refused: stored authActiveAuthMethods=%r, expected local+openid " + "('openid' is dropped when the block is incomplete)" + % stored.get("authActiveAuthMethods")) + code, after = call("GET", "/status") + methods = after.get("authMethods") if isinstance(after, dict) else None + if not isinstance(methods, list) or "openid" not in methods: + sys.exit("refused: /status reports authMethods=%s after a stored config that " + "looks complete — restart the service, the strategy is only armed at " + "load for a config that was invalid when the server read it" % methods) + print("oidc: armed (issuer %s | alg %s | claim abs_groups | redirect subfolder %r | " + "client %s | secret %s chars | authMethods %s)" + % (issuer, os.environ["ABS_OIDC_ALG"], subfolder, client_id, len(oidc_secret), + methods)) PY exit 0 diff --git a/scripts/authentik-audiobookshelf-sso.sh b/scripts/authentik-audiobookshelf-sso.sh new file mode 100755 index 0000000..14d7cd3 --- /dev/null +++ b/scripts/authentik-audiobookshelf-sso.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# authentik-audiobookshelf-sso.sh — provision the authentik side of Audiobookshelf SSO. +# +# Why a script: this state lives in authentik's DATABASE, not in any repo file, and a +# blueprint re-import or a rebuild-from-scratch reverts hand-made provider rows +# silently. Same reasoning (and same door) as +# authentik-set-login-session-duration.sh: ssh into Dankclaw, `ak shell` from stdin. +# Re-run this after such a rebuild. +# +# The contract with Audiobookshelf, read off the pinned image (2.37.2, +# dist-server/server/auth/OidcAuthStrategy.js + objects/settings/ServerSettings.js) +# rather than a forum, because each of these is a dead-login trap: +# +# * TWO redirect URIs on the provider: /auth/openid/callback (browser) and +# /auth/openid/mobile-redirect (mobile). 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. +# * CARRIES THE BASE PATH. ABS builds redirect_uri as +# /auth/openid/callback, and that subfolder is +# the ROUTER_BASE_PATH the container is mounted at — /audiobookshelf, which is also the only +# base path the shipped SPA can boot under. Left unset (the fresh-DB default) the URL +# becomes "/undefined/…"; a provider registered without the prefix mismatches the same way +# (measured 2026-11-02, "Redirect URI Error"). audiobookshelf-init.sh derives its copy from +# ABS_URL, so the two sides each move with one variable. +# * ABS requests the scopes "openid profile email []": the +# CLAIM NAME is a scope name. So authentik needs a scope mapping whose scope_name +# is exactly the claim name, or the claim never arrives and the callback dies on +# "Group claim not found in userinfo". +# * the claim's VALUES must be ABS role names — the callback picks the first of +# ['admin','user','guest'] present in the list. Group names like +# audiobookshelf_admins are therefore translated by the mapping, never passed +# through. A user in neither group gets [] and is denied (fail closed). +# * tokens are HS256 here: no provider on this box carries a signing_key (census +# 2026-11-02), which makes the client secret the signature key. ABS's own default +# is RS256, so the ABS side MUST be set to HS256 or every id_token fails +# verification. +# * provider.authorization_flow must be non-NULL or /application/o/authorize/ +# answers a 500 with 'NoneType' object has no attribute 'slug' (measured on +# grafana, same authentik build). +# +# Idempotent: every object is created or asserted. Safe to re-run. +# +# usage: ABS_OIDC_CLIENT_SECRET= authentik-audiobookshelf-sso.sh +# (the secret is the value stored in +# /home/dank/.config/secrets/audiobookshelf on Dankserver — get it with +# ssh dankserver awk -F= '/^ABS_OIDC_CLIENT_SECRET=/{print $2}' \ +# ~/.config/secrets/audiobookshelf) +# +# environment: +# ABS_OIDC_CLIENT_SECRET REQUIRED. The provider's client secret. +# 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) +# ABS_BASE ABS URL as the browser sees it, base path included +# (http://192.168.0.235:8000/audiobookshelf — LAN-only by policy: +# no vhost, no reverse proxy in front) +# AUTHENTIK_HOST ssh target (dankclaw) +set -euo pipefail + +SECRET="${ABS_OIDC_CLIENT_SECRET:?set ABS_OIDC_CLIENT_SECRET (see the header)}" +[ "${#SECRET}" -ge 32 ] || { echo "refused: ABS_OIDC_CLIENT_SECRET is too short to be a secret" >&2; exit 2; } + +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:-}" +BASE="${ABS_BASE:-http://192.168.0.235:8000/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. +read -r -d '' EXPR < %s" % (probe.username, _ns["_probe"](user=probe, request=None))) +p.refresh_from_db() +print("provider %s | client_id %s | flow %s | uris %s" % ( + p.name, p.client_id, p.authorization_flow.slug, [u.url for u in p.redirect_uris])) +PYEOF + +cat </auth/openid/callback, and the +# value must be the path the app is mounted under — never the literal +# "undefined" a fresh database leaves there. load helpers @@ -29,7 +33,7 @@ import json, sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer port, logf = int(sys.argv[1]), sys.argv[2] -state = {"init": False, "user": None, "pw": None, "libs": []} +state = {"init": False, "user": None, "pw": None, "libs": [], "oidc": None} def log(line): @@ -37,6 +41,12 @@ def log(line): f.write(line + "\n") +def norm(p): + # ABS mounts every route under ROUTER_BASE_PATH. The stub accepts the API at the + # root or under that prefix, so a test can still assert which one the client used. + return p[len("/audiobookshelf"):] if p.startswith("/audiobookshelf") else p + + class H(BaseHTTPRequestHandler): def log_message(self, *a): pass @@ -57,20 +67,24 @@ class H(BaseHTTPRequestHandler): return self.headers.get("Authorization") == "Bearer stub-token" def do_GET(self): - path = self.path.split("?")[0] + path = norm(self.path.split("?")[0]) log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no")) if path == "/status": - return self.send(200, {"app": "audiobookshelf", "isInit": state["init"]}, True) + methods = ["local", "openid"] if state["oidc"] else ["local"] + return self.send(200, {"app": "audiobookshelf", "isInit": state["init"], + "authMethods": methods}, True) if not self.authed(): return self.send(401, "Unauthorized") if path == "/api/libraries": return self.send(200, {"libraries": state["libs"]}, True) + if path == "/api/auth-settings": + return self.send(200, state["oidc"] or {}, True) if path.endswith("/items"): return self.send(200, {"total": 42, "results": []}, True) return self.send(404, "nope") def do_POST(self): - path = self.path.split("?")[0] + path = norm(self.path.split("?")[0]) payload = self.body() log("POST %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no")) if path == "/init": @@ -101,6 +115,21 @@ class H(BaseHTTPRequestHandler): return self.send(200, "OK") return self.send(404, "nope") + def do_PATCH(self): + path = norm(self.path.split("?")[0]) + payload = self.body() + log("PATCH %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no")) + if not self.authed(): + return self.send(401, "Unauthorized") + if path == "/api/auth-settings": + # Stored and echoed verbatim: the test asserts the wire, not the stub's + # opinion about which keys are patchable. + state["oidc"] = payload + for k in sorted(payload): + log("OIDCFG %s=%r" % (k, payload[k])) + return self.send(200, payload, True) + return self.send(404, "nope") + ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever() PY @@ -198,3 +227,50 @@ boot() { /audiobookshelf-init\.sh/ { init = NR } END { exit !(health && init && init > health) }' } + +@test "the SSO arm sends the base path as the redirect subfolder" { + # What authentik has registered must equal what ABS builds: + # /auth/openid/callback. A database + # created before SSO leaves that key unset and ABS then sends the literal + # "/undefined/auth/openid/callback" — measured 2026-11-02, where the live login died + # on authentik's "Redirect URI Error" page. So the value has to reach the wire, and + # it has to be the path the app is served under. + umask 077 + cat > "$TMP/cred" <<'CRED' +ABS_ADMIN_USER=frank +ABS_ADMIN_PASSWORD=0123456789abcdef0123456789 +ABS_OIDC_CLIENT_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789 +CRED + run env ABS_URL="http://127.0.0.1:$PORT/audiobookshelf" ABS_SECRET="$TMP/cred" \ + ABS_SCAN=0 bash "$INIT" + [ "$status" -eq 0 ] + printf '%s\n' "${lines[@]}" | grep -q "^oidc: armed" + grep -q "^OIDCFG authOpenIDSubfolderForRedirectURLs='/audiobookshelf'$" "$TMP/requests.log" + # every route the bootstrapper calls rides under the same prefix it armed with + grep -q "^POST /audiobookshelf/init auth=no$" "$TMP/requests.log" + grep -q "^GET /audiobookshelf/api/auth-settings auth=yes$" "$TMP/requests.log" + + # A root-mounted deployment sends the EMPTY subfolder (the value the 2.17.4 upgrade + # uses), not whatever the last URL happened to carry. + run env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" ABS_SCAN=0 bash "$INIT" + [ "$status" -eq 0 ] + [ "$(grep "^OIDCFG authOpenIDSubfolderForRedirectURLs=" "$TMP/requests.log" | tail -1)" \ + = "OIDCFG authOpenIDSubfolderForRedirectURLs=''" ] +} + +@test "the app and the IdP provider script build the same redirect path" { + # The provider row is written by scripts/authentik-audiobookshelf-sso.sh from + # ABS_BASE; the app builds its redirect_uri from the base path the deployer hands + # audiobookshelf-init.sh, which comes from the unit. Drift between the two is a + # redirect_uri mismatch — the login dies at the IdP before any credential is issued. + unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container" + mounted=$(awk -F= '/^Environment=ROUTER_BASE_PATH=/{print $3}' "$unit") + [ -n "$mounted" ] + provider=$(grep -m1 '^BASE=' "$REPO_ROOT/scripts/authentik-audiobookshelf-sso.sh" \ + | grep -oE 'https?://[^"}]+') + bootstrapper=$(grep -m1 '^ABS_URL=' "$INIT" | grep -oE 'https?://[^"}]+') + # Origins legitimately differ (LAN address vs loopback); the PATH must not. + [ "${provider#http://192.168.0.235:8000}/auth/openid/callback" \ + = "${bootstrapper#http://127.0.0.1:8000}/auth/openid/callback" ] + [ "${bootstrapper#http://127.0.0.1:8000}" = "$mounted" ] +} -- 2.54.0 From 3138e782bdac343c59c7194ba4131a4632c6fe5d Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 19:50:44 -0800 Subject: [PATCH 3/5] docs(audiobookshelf): the base-path law, the SSO contract, the phone address The page owned the URL as a plain host:port; every client now carries the /audiobookshelf prefix. Added the two laws a next operator needs (why the base path is not a choice, why the redirect subfolder must be sent) and the Single sign-on section naming which script owns which half. --- .../Dankserver/Audiobookshelf.md | 60 +++++++++++++++---- serverAppConfigs-docs/index.md | 2 +- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md index f81a2e5..c4aefed 100644 --- a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md +++ b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md @@ -1,6 +1,6 @@ --- type: reference -description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, laws." +description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, authentik SSO, 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) — **Internal-only:** LAN address, no vhost, no DNS name. +manager) — **Internal-only:** LAN address `http://192.168.0.235:8000/audiobookshelf`, no vhost, no DNS name. ## Shape @@ -26,7 +26,7 @@ manager) — **Internal-only:** LAN address, no vhost, no DNS name. | Unit file | `dankserver/audiobookshelf/quadlet/audiobookshelf.container` (the authority for every number here) | | Image | `ghcr.io/advplyr/audiobookshelf` pinned by **amd64 manifest digest** — see Laws | | Port | `Environment=PORT=8000` + `PublishPort=8000:8000` — web UI and API on one port | -| Base path | `Environment=ROUTER_BASE_PATH=/` — the URL is the plain `host:port`, no `/audiobookshelf` prefix | +| Base path | `Environment=ROUTER_BASE_PATH=/audiobookshelf` — every URL below carries this prefix, and the shipped SPA cannot boot without it (Laws) | | Network | `pasta` (standalone; publishes nothing to other containers) | | Audio bind | `/mnt/entropy/Audiobooks:/audiobooks:ro` — read-only, by design | | State | `/home/dank/Containers/audiobookshelf/config` → `/config`, `…/metadata` → `/metadata` (both NVMe) | @@ -36,6 +36,7 @@ manager) — **Internal-only:** LAN address, no vhost, no DNS name. | Self-healing | `Restart=always`, `RestartSec=5`, `WantedBy=default.target` | | Deploy | `./scripts/deploy.sh dankserver --service audiobookshelf` | | Handler | `deploy_audiobookshelf` in `scripts/deploy-remote.sh` | +| Single sign-on | authentik provider `audiobookshelf`; the local `frank` account stays enabled as break-glass — see *Single sign-on* | | Library layout | one book per folder — ABS groups by the first directory holding a media file (see Laws); every move is journalled at `/mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl` | ## Bootstrap @@ -54,7 +55,7 @@ difference. The script is idempotent; re-running it costs one incremental scan. | scan | `POST /api/libraries//scan` | `scan: accepted` | | proof | `GET /api/libraries//items?limit=1` | `items: ` | -Sign in at `http://192.168.0.235:8000` as `frank`; the password is +Sign in at `http://192.168.0.235:8000/audiobookshelf` as `frank`; the password is `awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' ~/.config/secrets/audiobookshelf` on Dankserver. Change it in **Settings → Users → frank → Password**; the secret file then holds a stale value and only matters again if the `config` bind is ever @@ -68,8 +69,9 @@ 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`, username `frank`, and the same password as the web UI — -no base-path prefix, plain HTTP. Verified from a second host 2026-11-03: `/healthcheck` +`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. Verified from a +second host 2026-11-02: `/healthcheck` and `/ping` answer 200 across the LAN and `iptables -S INPUT` carries no rule for 8000, so a phone on `192.168.0.0/24` connects with no further setup; `download: true` on the root account is what makes offline listening work. Away from the LAN it cannot connect — @@ -81,6 +83,25 @@ 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. +## Single sign-on (authentik) + +Both doors take the household identity: the web UI puts **Sign in with Authentik** beside +the local form, and the phone app offers SSO on its server-address screen. The local +`frank` account stays enabled on purpose — `authActiveAuthMethods` is `local` **and** +`openid`, and `local` is how you get in when the IdP is down. + +| 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 | +| 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) | + +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. + ## Laws - **Speak the image's env names, not the README's.** The server reads `PORT`, @@ -91,6 +112,21 @@ as though the password were wrong. `active (running)` while every probe of `:8000` was refused and the deploy gate failed at 180 s. Two seconds with the pinned digest settles it: `podman inspect --format '{{.Config.Env}}' `. +- **The URL carries `/audiobookshelf`, and that is not a style choice.** The SPA in + `/app/client/dist/index.html` is built with `routerBasePath:"/audiobookshelf"`, so it asks + for its assets under that prefix whatever the server is mounted at. Mounted at `/`, every + script request falls through to the SPA fallback, comes back `text/html`, Chrome refuses to + execute it, and the page never leaves "Loading…" — while `/healthcheck`, the API and the + phone app stay perfectly healthy, which is how this hid behind an API-only verification + (measured 2026-11-02). Prove a UI claim in a browser, never from `/health`. +- **The OIDC redirect subfolder is a value you send, not a default you inherit.** The 2.17.4 + upgrade only fills `authOpenIDSubfolderForRedirectURLs` on a database that ALREADY had OIDC + enabled, so a DB created before SSO leaves it unset and the server builds + `http://host/undefined/auth/openid/callback`. authentik answers the authorize request with + "Redirect URI Error" and nothing on the ABS side looks wrong (measured 2026-11-02). The + provider's registered URI must equal `/auth/openid/callback`, so each + script derives the value from one variable of its own (`ABS_URL`, `ABS_BASE`) and + `tests/audiobookshelf_init.bats` pins what reaches the wire. - **The audio bind stays `:ro`.** ABS writes nothing that belongs beside the audio: its library, covers, progress and sidecar metadata all land in `/metadata` on the SSD. A writable bind would put stray writes on the spindle @@ -207,12 +243,12 @@ as though the password were wrong. ## Verification `./tests/run.sh` covers this unit through `tests/dankserver_quadlet.bats` -(boot persistence, the SSD state roots, the `:ro` array bind, the mount gate, the -`wget` health check), `tests/audiobookshelf_init.bats` (the bootstrapper against a -stub of the API above: create-once, idempotent re-run, no secret in a URL, refusal -by URL when the server does not answer, and the deployer calling it only after the -health gate) and `tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may -reference the metadata view). +(boot persistence, the SSD state roots, the `:ro` array bind, the mount gate, the `wget` +health check, the base path the shipped SPA needs), `tests/audiobookshelf_init.bats` (the +bootstrapper against a stub of the API above: create-once, idempotent re-run, no secret in a +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) and +`tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view). 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, diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index 498a94d..2c5eeaa 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers. | Page | Owns | |---|---| -| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, laws. | +| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, authentik SSO, 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. | -- 2.54.0 From 98522cecba626a579e3d2ff0b415c149c2d4aea5 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 19:50:44 -0800 Subject: [PATCH 4/5] docs(jellyfin): correct a future-dated measurement stamp --- serverAppConfigs-docs/Dankserver/Jellyfin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index 14ed8c0..2aff1db 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -66,7 +66,7 @@ indexes nothing, because it never looks there. Check coverage before re-scanning **Which read proves an item is indexed: the folder view, never the recursive type list.** `GET /Items?IncludeItemTypes=Movie&Recursive=true` answered 2,101 items on -2026-11-03 while `GET /Items?ParentId=` listed 2,548 children — every +2026-11-02 while `GET /Items?ParentId=` listed 2,548 children — every folder on disk — and the eight folders the recursive read "lost" (three `Gundam 00 Special Edition`, five `Sailor Moon Memorial`) each answer as `Type: Movie` through `SearchTerm` and sit in the folder listing. The two reads are not interchangeable, so -- 2.54.0 From f5410bd253cf8d1c5d106d855dab852e28e0ad80 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 19:53:16 -0800 Subject: [PATCH 5/5] docs(audiobookshelf): the probe routes answer at the root under either base path Measured on both shapes: ping returns success:true and healthcheck returns OK at the root whether or not the app is mounted under a prefix, which is why the phone app's connectivity check works before it ever resolves the base path. The paragraph now says which routes were probed instead of asserting 200s in general, and the frontmatter description fits the OKF 120-char budget the linter enforces. --- .../Dankserver/Audiobookshelf.md | 15 ++++++++------- serverAppConfigs-docs/index.md | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md index c4aefed..4a43943 100644 --- a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md +++ b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md @@ -1,6 +1,6 @@ --- type: reference -description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, authentik SSO, laws." +description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, SSD state, API bootstrap, authentik SSO, laws." tags: - dankserver - audiobooks @@ -70,12 +70,13 @@ the file, not the bind: the same line count grows while the total climbs. 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. Verified from a -second host 2026-11-02: `/healthcheck` -and `/ping` answer 200 across the LAN and `iptables -S INPUT` carries no rule for 8000, -so a phone on `192.168.0.0/24` connects with no further setup; `download: true` on the -root account is what makes offline listening work. Away from the LAN it cannot connect — -see the LAN-only law, and note that the guard chain is not the remedy there. +`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 LAN-only law, and note that the guard chain is not the remedy there. **The login token is at `user.token`, not top level.** `POST /login` answers `{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index 2c5eeaa..d4f9f9e 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers. | Page | Owns | |---|---| -| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, authentik SSO, laws. | +| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, SSD state, API bootstrap, authentik SSO, 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. | -- 2.54.0