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/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 </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,12 +69,14 @@ 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` -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. +`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 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 @@ -81,6 +84,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 +113,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 +244,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/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 diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index 498a94d..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, 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. | diff --git a/tests/audiobookshelf_init.bats b/tests/audiobookshelf_init.bats index 8a7f92e..b3a7ca1 100755 --- a/tests/audiobookshelf_init.bats +++ b/tests/audiobookshelf_init.bats @@ -17,6 +17,10 @@ # reported as success (a green deploy over a dead API is a lie); # 5. ABS_SCAN outside 0/1 is a usage error raised before anything is touched; # 6. the deployer runs the bootstrapper only after the health gate answered. +# 7. the SSO arm sends the base path: ABS builds its redirect_uri as +# /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" ] +} 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 }