The abs_groups claim decided the listener role from membership of
audiobookshelf_users, a group nobody was ever added to, while the ABS
application carries no access policy of its own. The result: seventeen
household accounts authenticated at the IdP successfully and were then
refused by the app — a policy decision that reads as a broken login.
The claim now answers ["user"] for every signer and appends "admin" only
for audiobookshelf_admins, so the IdP login is the gate and onboarding a
listener is exactly "create the authentik account". Admins keep the exact
claim they always received, ["admin","user"], in that order: ABS's
group-tag matching has never been measured for order-sensitivity and the
working admin login is not where to find out. audiobookshelf_users is
deleted rather than left empty — a group that decides nothing is a trap
for the next reader, who will assume it gates something.
Two side fixes found by running the script:
- Two comments inside the unquoted PYEOF heredoc carried backticks, so
bash command-substituted them locally on EVERY run ("audiobookshelf_users:
command not found", "user: command not found") and spliced the output
into the python program. Names in that body are plain now, with a note
on why. A quoted heredoc is not an option: the body interpolates.
- The default-grant probe picked authentik's AnonymousUser row, then its
admin superuser. It now walks real non-superuser accounts, preferring
the fleet's plain-user marker group, and prints up to three claims.
312 lines
16 KiB
Bash
Executable File
312 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# audiobookshelf-init.sh — make an Audiobookshelf server usable, from nothing.
|
|
#
|
|
# Why this is a script and not a browser session (live 2026-11-02, Dankserver):
|
|
# a freshly deployed Audiobookshelf answers /healthcheck with 200 while it has no
|
|
# user, no library and no scan — "healthy" says nothing about usefulness, and a
|
|
# service that only works after someone remembers to click through a first-run
|
|
# wizard is not deployed, it is installed. Everything below is the API the React
|
|
# client itself calls, so the same procedure runs unattended and lands in git.
|
|
#
|
|
# The API facts this script depends on, read off the pinned image rather than
|
|
# guessed (dist-server/server/Server.js inside the digest this repo pins):
|
|
# * the creation route is POST /init with {"newRoot":{"username","password"}}.
|
|
# There is NO /setup route in 2.37.x — POST /setup answers "Unauthorized",
|
|
# which reads exactly like an auth fault and is instead a missing route.
|
|
# * /status (no /api prefix) reports {"isInit": <has a root user>}; every route
|
|
# under /api needs the bearer token that POST /login returns.
|
|
# * the library count is NOT a field on the library DTO; the door is
|
|
# GET /api/libraries/{id}/items -> {"total": N}.
|
|
#
|
|
# 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 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/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; }
|
|
|
|
case "$ABS_SCAN" in
|
|
0 | 1) ;;
|
|
*) 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
|
|
mkdir -p "$(dirname "$ABS_SECRET")"
|
|
user=${ABS_ADMIN_USER:-frank}
|
|
# `tr … | head -c 24` is the obvious line and it is a trap under
|
|
# `set -o pipefail`: head exits at 24 bytes, tr takes SIGPIPE, the pipeline
|
|
# status is 141 and set -e kills the script before the credential is ever
|
|
# written (measured 2026-11-02). od reads a FIXED byte count and exits on its
|
|
# own, so no consumer closes a pipe early; bash slices the length.
|
|
pass=$(od -An -tx1 -N 32 /dev/urandom | tr -d ' \n')
|
|
pass=${pass:0:24}
|
|
[ "${#pass}" -eq 24 ] || die "could not generate a password (no /dev/urandom)"
|
|
printf 'ABS_ADMIN_USER=%s\nABS_ADMIN_PASSWORD=%s\n' "$user" "$pass" > "$ABS_SECRET"
|
|
printf 'credential: created %s\n' "$ABS_SECRET"
|
|
fi
|
|
[ -r "$ABS_SECRET" ] || die "$ABS_SECRET exists but is not readable by $(id -un)"
|
|
chmod 600 "$ABS_SECRET"
|
|
|
|
# ---- everything else, over HTTP ---------------------------------------------
|
|
# 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" 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"]
|
|
FOLDER = os.environ["ABS_FOLDER"]
|
|
SCAN = os.environ["ABS_SCAN"] == "1"
|
|
cred = {}
|
|
for line in open(os.environ["ABS_SECRET"]):
|
|
if "=" in line:
|
|
k, _, v = line.strip().partition("=")
|
|
cred[k] = v
|
|
user = cred.get("ABS_ADMIN_USER") or "frank"
|
|
password = cred.get("ABS_ADMIN_PASSWORD") or ""
|
|
if not password:
|
|
sys.exit("refused: %s has no ABS_ADMIN_PASSWORD" % os.environ["ABS_SECRET"])
|
|
|
|
|
|
def call(method, path, body=None, token=None, want_json=True):
|
|
data = None
|
|
headers = {"X-Requested-With": "audiobookshelf-init"}
|
|
if body is not None:
|
|
data = json.dumps(body).encode()
|
|
headers["Content-Type"] = "application/json"
|
|
if token:
|
|
headers["Authorization"] = "Bearer " + token
|
|
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
raw = r.read()
|
|
if not raw or not want_json:
|
|
return r.status, raw
|
|
try:
|
|
return r.status, json.loads(raw)
|
|
except ValueError:
|
|
# Express answers sendStatus() with the STATUS TEXT as the body:
|
|
# POST /init and the scan route both reply "OK" (measured on the
|
|
# pinned 2.37.2 image). A successful call must never be judged by
|
|
# whether its body happens to parse as JSON.
|
|
return r.status, raw.decode("utf-8", "replace")[:200]
|
|
except urllib.error.HTTPError as e:
|
|
raw = e.read()
|
|
try:
|
|
payload = json.loads(raw)
|
|
except Exception:
|
|
payload = raw.decode("utf-8", "replace")[:200]
|
|
return e.code, payload
|
|
except urllib.error.URLError as e:
|
|
# Not an HTTP answer at all: nothing listening, DNS dead, connection
|
|
# refused. HTTPError is a URLError subclass, so this arm must come after.
|
|
return 0, "unreachable (%s)" % e.reason
|
|
|
|
|
|
status, st = call("GET", "/status")
|
|
if status != 200 or not isinstance(st, dict):
|
|
sys.exit("refused: %s/status answered %s (%s) — is the service up?" % (BASE, status, st))
|
|
if st.get("isInit"):
|
|
print("account: already registered")
|
|
else:
|
|
code, body = call("POST", "/init", {"newRoot": {"username": user, "password": password}})
|
|
if code != 200:
|
|
sys.exit("refused: POST /init answered %s (%s)" % (code, body))
|
|
print("account: created (%s)" % user)
|
|
|
|
code, login = call("POST", "/login", {"username": user, "password": password})
|
|
if code != 200 or not isinstance(login, dict) or not login.get("user", {}).get("token"):
|
|
sys.exit("refused: login answered %s (%s) — the credential file does not match the server"
|
|
% (code, login))
|
|
token = login["user"]["token"]
|
|
print("token: obtained (%s)" % login["user"].get("type", "user"))
|
|
|
|
code, libs = call("GET", "/api/libraries", token=token)
|
|
if code != 200 or not isinstance(libs, dict) or "libraries" not in libs:
|
|
sys.exit("refused: GET /api/libraries answered %s (%s)" % (code, libs))
|
|
lib = next((l for l in libs["libraries"] if l.get("name") == LIB), None)
|
|
if lib:
|
|
print("library: exists %s (%s)" % (LIB, lib["id"]))
|
|
else:
|
|
body = {
|
|
"name": LIB,
|
|
"provider": "audiobook",
|
|
"icon": "book",
|
|
"folders": [{"fullPath": FOLDER}],
|
|
"settings": {
|
|
"coverEncoding": "jpg",
|
|
"skipMatchingMediaWithAsin": False,
|
|
"skipMatchingMediaWithIsbn": False,
|
|
"autoDetectCrossoverAudiobooks": False,
|
|
"ignoreAudiobookMetadata": False,
|
|
"useAudiobookFileMetadata": False,
|
|
},
|
|
"providerMetadata": {},
|
|
}
|
|
code, made = call("POST", "/api/libraries", body, token=token)
|
|
if code != 200:
|
|
sys.exit("refused: POST /api/libraries answered %s (%s)" % (code, made))
|
|
lib = made
|
|
print("library: created %s (%s) folder %s" % (LIB, lib["id"], FOLDER))
|
|
|
|
if SCAN:
|
|
code, body = call("POST", "/api/libraries/%s/scan" % lib["id"], {}, token=token,
|
|
want_json=False)
|
|
if code != 200:
|
|
sys.exit("refused: scan answered %s (%s)" % (code, body))
|
|
print("scan: accepted")
|
|
else:
|
|
print("scan: skipped (ABS_SCAN=0)")
|
|
|
|
code, items = call("GET", "/api/libraries/%s/items?limit=1" % lib["id"], token=token)
|
|
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 role comes from
|
|
# the abs_groups claim, which grants the listener role to every signer; only the
|
|
# authentik admin group escalates. Revoking a listener therefore happens HERE in
|
|
# ABS (disable or delete the user), never by removing an IdP group membership.
|
|
"authOpenIDAutoRegister": True,
|
|
"authOpenIDGroupClaim": "abs_groups",
|
|
# The app's own scheme lives HERE, never in the IdP: ABS swaps it for
|
|
# <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
|