The live round trip died on authentik with Redirect URI Error. ABS builds redirect_uri as <origin><authOpenIDSubfolderForRedirectURLs>/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.
277 lines
13 KiB
Bash
Executable File
277 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bats
|
|
# Seam: scripts/audiobookshelf-init.sh — the Audiobookshelf bootstrapper.
|
|
#
|
|
# A freshly deployed Audiobookshelf answers /healthcheck 200 while it has no
|
|
# user, no library and no scan, so "healthy" proves nothing about usefulness.
|
|
# This seam pins what the bootstrapper must make true, against a stub that
|
|
# speaks the same API as the pinned image (POST /init + POST /login at the
|
|
# root, bearer-authenticated /api/*, library count only on /items):
|
|
# 1. an unregistered server gains the account, the library and a scan, and the
|
|
# credential lands mode 600 with both keys;
|
|
# 2. a second run creates nothing new (exactly one /init, one library) and
|
|
# still fires the incremental scan;
|
|
# 3. neither the bearer token nor the password ever rides a URL — a secret in
|
|
# a URL or an argv is world-readable in /proc/<pid>/cmdline, which is how
|
|
# an HF token leaked into a transcript on 2026-09-16;
|
|
# 4. a server that cannot be reached is refused by URL with exit 1, never
|
|
# 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
|
|
|
|
setup() {
|
|
INIT="$REPO_ROOT/scripts/audiobookshelf-init.sh"
|
|
TMP="$(mktemp -d)"
|
|
PORT=18937
|
|
cat > "$TMP/stub.py" <<'PY'
|
|
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": [], "oidc": None}
|
|
|
|
|
|
def log(line):
|
|
with open(logf, "a") as f:
|
|
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
|
|
|
|
def send(self, code, body, as_json=False):
|
|
data = json.dumps(body).encode() if as_json else str(body).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json" if as_json else "text/plain")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def body(self):
|
|
n = int(self.headers.get("Content-Length") or 0)
|
|
return json.loads(self.rfile.read(n) or b"{}")
|
|
|
|
def authed(self):
|
|
return self.headers.get("Authorization") == "Bearer stub-token"
|
|
|
|
def do_GET(self):
|
|
path = norm(self.path.split("?")[0])
|
|
log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
|
|
if path == "/status":
|
|
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 = 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":
|
|
if state["init"]:
|
|
return self.send(500, "already initialized")
|
|
root = payload.get("newRoot", {})
|
|
state["init"] = True
|
|
state["user"] = root.get("username")
|
|
state["pw"] = root.get("password")
|
|
log("INIT %s" % root.get("username"))
|
|
return self.send(200, "OK")
|
|
if path == "/login":
|
|
if not state["init"]:
|
|
return self.send(401, "Unauthorized")
|
|
if (payload.get("username"), payload.get("password")) != (state["user"], state["pw"]):
|
|
return self.send(401, "Unauthorized")
|
|
return self.send(200, {"user": {"username": state["user"], "type": "root",
|
|
"id": "u1", "token": "stub-token"}}, True)
|
|
if not self.authed():
|
|
return self.send(401, "Unauthorized")
|
|
if path == "/api/libraries":
|
|
lib = {"id": "lib-1", "name": payload.get("name"), "folders": payload.get("folders", [])}
|
|
state["libs"].append(lib)
|
|
log("LIBRARY %s %s" % (lib["name"], lib["folders"]))
|
|
return self.send(200, lib, True)
|
|
if path.endswith("/scan"):
|
|
log("SCAN")
|
|
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
|
|
python3 "$TMP/stub.py" "$PORT" "$TMP/requests.log" > "$TMP/stub.out" 2>&1 &
|
|
STUB_PID=$!
|
|
for _ in $(seq 1 50); do
|
|
curl -s -o /dev/null "http://127.0.0.1:$PORT/status" && break
|
|
sleep 0.1
|
|
done
|
|
}
|
|
|
|
teardown() {
|
|
[ -n "${STUB_PID:-}" ] && kill "$STUB_PID" 2>/dev/null || true
|
|
rm -rf "$TMP"
|
|
}
|
|
|
|
boot() {
|
|
env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" \
|
|
ABS_ADMIN_USER=frank ABS_LIBRARY=Audiobooks ABS_FOLDER=/audiobooks \
|
|
ABS_SCAN="${SCAN:-1}" bash "$INIT"
|
|
}
|
|
|
|
@test "an unregistered server gains the account, the library and the scan" {
|
|
run boot
|
|
[ "$status" -eq 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -qx "credential: created $TMP/cred"
|
|
printf '%s\n' "${lines[@]}" | grep -q "^account: created (frank)"
|
|
printf '%s\n' "${lines[@]}" | grep -q "^token: obtained (root)"
|
|
printf '%s\n' "${lines[@]}" | grep -q "^library: created Audiobooks (lib-1)"
|
|
printf '%s\n' "${lines[@]}" | grep -q "^scan: accepted"
|
|
printf '%s\n' "${lines[@]}" | grep -q "^items: 42"
|
|
# The state roots of this service hold an account credential, so the file
|
|
# that carries it must never be group- or world-readable.
|
|
[ "$(stat -c %a "$TMP/cred")" = "600" ]
|
|
grep -qx 'ABS_ADMIN_USER=frank' "$TMP/cred"
|
|
grep -Eq '^ABS_ADMIN_PASSWORD=[0-9a-f]{24}$' "$TMP/cred"
|
|
# The folder the library indexes is the bind inside the container, not the
|
|
# host path — a library pointed at /mnt/entropy would answer 0 items and
|
|
# look like an empty audiobook collection.
|
|
grep -q "LIBRARY Audiobooks \[{'fullPath': '/audiobooks'}\]" "$TMP/requests.log"
|
|
}
|
|
|
|
@test "a second run creates nothing new and still fires the incremental scan" {
|
|
boot > /dev/null
|
|
[ -e "$TMP/requests.log" ]
|
|
run boot
|
|
[ "$status" -eq 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -q "^account: already registered"
|
|
printf '%s\n' "${lines[@]}" | grep -q "^library: exists Audiobooks (lib-1)"
|
|
printf '%s\n' "${lines[@]}" | grep -q "^scan: accepted"
|
|
# No second credential, no second account, no duplicate library: the deployer
|
|
# may run this on every re-deploy of the service.
|
|
! printf '%s\n' "${lines[@]}" | grep -q "credential: created"
|
|
[ "$(grep -c '^INIT ' "$TMP/requests.log")" -eq 1 ]
|
|
[ "$(grep -c '^LIBRARY ' "$TMP/requests.log")" -eq 1 ]
|
|
[ "$(grep -c '^SCAN$' "$TMP/requests.log")" -eq 2 ]
|
|
}
|
|
|
|
@test "the bearer token and the password never ride a URL" {
|
|
boot
|
|
# The stub logs the request line (method + path). A secret showing up there
|
|
# means it travelled as a query parameter — visible in access logs, shell
|
|
# history and /proc/<pid>/cmdline.
|
|
! awk '{print $2}' "$TMP/requests.log" | grep -q 'stub-token'
|
|
pw=$(awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' "$TMP/cred")
|
|
[ -n "$pw" ]
|
|
! awk '{print $2}' "$TMP/requests.log" | grep -q -- "$pw"
|
|
# and the authenticated routes only answered once the header was present
|
|
grep -q '^GET /api/libraries auth=yes$' "$TMP/requests.log"
|
|
}
|
|
|
|
@test "a server that does not answer is refused by URL, not reported as success" {
|
|
run env ABS_URL="http://127.0.0.1:1" ABS_SECRET="$TMP/cred" bash "$INIT"
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *refused:* ]]
|
|
[[ "$output" == *"127.0.0.1:1"* ]]
|
|
}
|
|
|
|
@test "ABS_SCAN outside 0/1 is a usage error raised before anything is touched" {
|
|
run env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" ABS_SCAN=2 \
|
|
bash "$INIT"
|
|
[ "$status" -eq 2 ]
|
|
[[ "$output" == *ABS_SCAN* ]]
|
|
[ ! -e "$TMP/cred" ]
|
|
}
|
|
|
|
@test "the deployer bootstraps only after the health gate has answered" {
|
|
body=$(awk '/^deploy_audiobookshelf\(\)/,/^}/' "$REPO_ROOT/scripts/deploy-remote.sh")
|
|
[ -n "$body" ]
|
|
printf '%s\n' "$body" | grep -q \
|
|
'wait_for_health audiobookshelf http://127\.0\.0\.1:8000/healthcheck'
|
|
printf '%s\n' "$body" | grep -q 'scripts/audiobookshelf-init\.sh'
|
|
printf '%s\n' "$body" | awk '
|
|
/wait_for_health audiobookshelf/ { health = NR }
|
|
/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" ]
|
|
}
|