feat(audiobookshelf): provision authentik SSO and send the redirect subfolder

The live round trip died on authentik with Redirect URI Error. ABS builds redirect_uri as <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.
This commit is contained in:
Frank committed 2026-11-02 19:50:44 -08:00
1 parent cfdd3435bc
commit 393b77dfd2
3 files changed
+408 -8

No files matched your search

+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