Merge pull request 'fix(audiobookshelf): serve under /audiobookshelf, send the OIDC redirect subfolder' (#128) from fix/abs-base-path-sso into main

This commit was merged in pull request #128.
This commit is contained in:
dank committed 2026-11-02 20:02:57 -08:00
commit 7e9bfbc2be
9 files changed
+488 -31

No files matched your search

@@ -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
+125 -4
View File
@@ -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/<pid>/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 = <origin><subfolder>/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
# <base>/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
+203
View File
@@ -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: <base>/auth/openid/callback (browser) and
# <base>/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.
# * <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
# 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 <GroupClaim> [<PermsClaim>]": 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 <name> 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=<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 <<PYEXPR || true
groups = {g.name for g in user.groups.all()}
roles = []
if "$ADMIN_GROUP" in groups:
roles.append("admin")
if groups & {"$ADMIN_GROUP", "$USER_GROUP"}:
roles.append("user")
return {"$CLAIM": roles}
PYEXPR
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"
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
SSO_USER = "$SSO_USER"
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)
# --- 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")
print("mapping: created (scope_name %s)" % CLAIM)
elif mapping.scope_name != CLAIM or mapping.expression != EXPR:
mapping.scope_name, mapping.expression = CLAIM, EXPR
mapping.save(update_fields=["scope_name", "expression"])
print("mapping: scope/expression updated (scope_name %s)" % CLAIM)
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`.
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
_ns = {}
exec(_src, _ns)
# --- provider --------------------------------------------------------------------
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
url="%s/auth/openid/callback" % BASE),
RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
url="%s/auth/openid/mobile-redirect" % BASE)]
authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
p = OAuth2Provider.objects.filter(name="audiobookshelf").first()
if p is None:
p = OAuth2Provider(name="audiobookshelf", client_id=CLIENT_ID,
client_type=ClientType.CONFIDENTIAL, client_secret=SECRET,
authorization_flow=authz_flow)
p.redirect_uris = uris
p.save()
p.property_mappings.set([mapping])
print("provider: created")
else:
changed = []
if p.client_id != CLIENT_ID:
p.client_id = CLIENT_ID
changed.append("client_id")
if p.client_secret != SECRET:
p.client_secret = SECRET
changed.append("client_secret")
if p.authorization_flow_id != authz_flow.pk:
p.authorization_flow = authz_flow
changed.append("authorization_flow")
if [(u.url, str(u.matching_mode)) for u in p.redirect_uris] != \
[(u.url, str(u.matching_mode)) for u in uris]:
p.redirect_uris = uris
changed.append("redirect_uris")
if changed:
p.save()
if sorted(m.pk for m in p.property_mappings.all()) != [mapping.pk]:
p.property_mappings.set([mapping])
changed.append("property_mappings")
print("provider: asserted (%s)" % (", ".join(changed) or "nothing to change"))
# --- application (the launchpad entry; binds the provider) ------------------------
app = Application.objects.filter(slug="audiobookshelf").first()
if app is None:
app = Application.objects.create(name="audiobookshelf", slug="audiobookshelf",
provider=p, meta_launch_url=BASE)
print("application: created")
elif app.provider_id != p.pk or app.meta_launch_url != BASE:
app.provider, app.meta_launch_url = p, BASE
app.save(update_fields=["provider", "meta_launch_url"])
print("application: asserted")
else:
print("application: already bound")
# --- membership --------------------------------------------------------------------
root = User.objects.get(username=SSO_USER)
root.groups.add(admin_g)
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))
# --- verification: the claim the callback will actually receive ---------------------
for probe in [root] + [User.objects.get(username=n) for n in MEMBERS]:
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 | uris %s" % (
p.name, p.client_id, p.authorization_flow.slug, [u.url for u in p.redirect_uris]))
PYEOF
cat <<DONE
Next: arm the Audiobookshelf side (issuer, client, HS256, claim) —
ssh dankserver 'ABS_OIDC=1 bash /home/dank/serverAppConfigs/scripts/audiobookshelf-init.sh'
It reads the same secret from /home/dank/.config/secrets/audiobookshelf and verifies
/status answers openid among its auth methods.
DONE
+8 -2
View File
@@ -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
@@ -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, SSD state, 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/<id>/scan` | `scan: accepted` |
| proof | `GET /api/libraries/<id>/items?limit=1` | `items: <total>` |
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}}' <image>`.
- **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 `<origin><subfolder>/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,
+1 -1
View File
@@ -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=<Movies ItemId>` listed 2,548 children — every
2026-11-02 while `GET /Items?ParentId=<Movies ItemId>` 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
+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, 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. |
+80 -4
View File
@@ -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
# <origin><authOpenIDSubfolderForRedirectURLs>/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:
# <origin><authOpenIDSubfolderForRedirectURLs>/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" ]
}
+6 -1
View File
@@ -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
}