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.
295 lines
17 KiB
Bash
Executable File
295 lines
17 KiB
Bash
Executable File
#!/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 URIs per origin ABS is reached at — <base>/auth/openid/callback and
|
|
# <base>/auth/openid/mobile-redirect — for EVERY entry in BASES (the LAN address
|
|
# and the public vhost). 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. The mobile URI exists on every origin because the app sends
|
|
# the one belonging to the door it was configured with: the vhost started serving
|
|
# non-browser clients on 2026-11-03 (it left the guard chain), so a phone pointed
|
|
# at the public name sends the public mobile-redirect URI, and STRICT matching
|
|
# answers an unregistered one with "Redirect URI Error".
|
|
# * <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).
|
|
# * provider.grant_types MUST contain authorization_code. authentik 2026.8 defaults an
|
|
# ORM-created provider to the EMPTY list, and /application/o/authorize/ then refuses the
|
|
# code flow with oauthlib's generic "The request is otherwise malformed"; the only line
|
|
# that names the cause is the server log's 'Invalid grant_type for provider'. The
|
|
# redirect-URI check runs FIRST, so a provider carrying both faults shows only the URI
|
|
# error until the URI is fixed (measured 2026-11-02, where the login survived the URI fix
|
|
# and died here).
|
|
# * property_mappings must carry the built-in OpenID scope mappings ('openid', 'profile',
|
|
# 'email') alongside the claim mapping. A provider holding only the claim mapping grants a
|
|
# token scoped shorter than the scopes ABS requests, and ABS's /userinfo call then dies
|
|
# 'insufficient_scope (The request requires higher privileges than provided by the access
|
|
# token)' after authentik has already issued a good code (measured 2026-11-02).
|
|
#
|
|
# 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)
|
|
# There is deliberately NO user list. The claim grants the LISTENER role to every
|
|
# authentik account that signs in (2026-11-03), so onboarding a listener is exactly
|
|
# "create the authentik account". Before this, a household account authenticated at
|
|
# the IdP and was then refused by ABS for being in no group — a policy decision that
|
|
# read as a broken login. Revocation lives on the ABS side (disable or delete the
|
|
# user there): a group can no longer withhold a role the claim always sends.
|
|
# ABS_E2E_ADMIN authentik user kept in the ADMIN group for the Playwright gate
|
|
# (qa-e2e-admin — tests/e2e/specs/23-audiobookshelf-sso.spec.ts
|
|
# signs in as this identity, so a re-run restores the fixture)
|
|
# ABS_BASE ABS URL as the browser sees it, base path included
|
|
# (http://192.168.0.235:8000/audiobookshelf — the LAN
|
|
# origin, reached directly, which is also what the phone
|
|
# app is configured with)
|
|
# ABS_EXTRA_BASES space-separated additional origins, base path included
|
|
# (default: https://audiobooks.franksdank.cloud/audiobookshelf
|
|
# — the guarded vhost, dankclaw/Caddyfile).
|
|
# ABS builds its redirect_uri from the ORIGIN of the
|
|
# request, so each origin the app is served at needs its
|
|
# own registered URI — an unregistered origin is a
|
|
# redirect_uri_mismatch at the IdP, before any credential.
|
|
# Set it empty to register the LAN origin alone.
|
|
# 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"
|
|
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
|
|
E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}"
|
|
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}"
|
|
EXTRA_BASES="${ABS_EXTRA_BASES:-https://audiobooks.franksdank.cloud/audiobookshelf}"
|
|
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
|
|
|
# The claim expression: every signer is a listener; only the admin group escalates.
|
|
# Stored verbatim so a later read of the provider shows exactly this policy. The default
|
|
# lives HERE and nowhere else — ABS reads this list off the token, so an account in no
|
|
# Audiobookshelf group still arrives with ["user"] and is auto-registered as a listener
|
|
# instead of being refused after a successful authentication.
|
|
read -r -d '' EXPR <<PYEXPR || true
|
|
groups = {g.name for g in user.groups.all()}
|
|
# Order is deliberate: an admin's claim comes out ["admin","user"], byte-identical to what
|
|
# this mapping emitted before the default existed, because ABS's group-tag matching has never
|
|
# been measured for order-sensitivity and the working admin login is not the place to find out.
|
|
roles = []
|
|
if "$ADMIN_GROUP" in groups:
|
|
roles.append("admin")
|
|
roles.append("user")
|
|
return {"$CLAIM": roles}
|
|
PYEXPR
|
|
|
|
# NOTE: the heredoc below is UNQUOTED on purpose — its body interpolates the variables
|
|
# above. The consequence is that a backtick anywhere in that python body is a LOCAL command
|
|
# substitution whose output is spliced into the program before it ever runs, and comments
|
|
# are not exempt, because bash does not know what a python comment is. Write names plainly
|
|
# in the body: measured 2026-11-03, two comments in this body had the shell run
|
|
# audiobookshelf_users and user as commands on every execution of this script.
|
|
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 = "$ADMIN_GROUP"
|
|
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
|
|
BASES = [BASE] + """$EXTRA_BASES""".split()
|
|
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
|
|
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.
|
|
|
|
Group.objects.get_or_create(name=ADMIN_GROUP)
|
|
admin_g = Group.objects.get(name=ADMIN_GROUP)
|
|
# The audiobookshelf_users group is retired. Its membership used to be the only door to the
|
|
# listener role; the claim now sends that role to every signer, so the group decides
|
|
# nothing — and a group that decides nothing is a trap for the next reader, who will
|
|
# assume it gates something and start adding people to it.
|
|
for stale in Group.objects.filter(name="audiobookshelf_users"):
|
|
stale.delete()
|
|
print("group: retired audiobookshelf_users (the claim grants every signer)")
|
|
|
|
# --- 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 roles: every signer is a listener, the admin group escalates")
|
|
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 the name "user".
|
|
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
|
|
_ns = {}
|
|
exec(_src, _ns)
|
|
|
|
# --- provider --------------------------------------------------------------------
|
|
# One callback pair per origin (LAN address and the public vhost): ABS derives the
|
|
# browser callback from the request origin and the mobile callback from the door the
|
|
# app was configured with, so each origin needs both or that door cannot sign in.
|
|
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url="%s%s" % (b, p))
|
|
for b in BASES
|
|
for p in ("/auth/openid/callback", "/auth/openid/mobile-redirect")]
|
|
authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
|
|
|
|
# Three things beyond the name, each one a silent dead login: the grant-type list (2026.8
|
|
# defaults it empty), the built-in OpenID scope mappings beside the claim mapping, and the
|
|
# redirect URIs with the base path baked in.
|
|
GRANTS = ["authorization_code", "refresh_token"]
|
|
BUILTIN_SCOPES = ["authentik default OAuth Mapping: OpenID 'openid'",
|
|
"authentik default OAuth Mapping: OpenID 'profile'",
|
|
"authentik default OAuth Mapping: OpenID 'email'"]
|
|
scoped = {m.pk: m for m in ScopeMapping.objects.filter(name__in=BUILTIN_SCOPES)}
|
|
scoped[mapping.pk] = mapping
|
|
_have = {m.name for m in scoped.values()}
|
|
if len(_have) < len(BUILTIN_SCOPES):
|
|
print("scope mappings: NOT PROVIDED by this authentik install: %s"
|
|
% ", ".join(sorted(set(BUILTIN_SCOPES) - _have)))
|
|
|
|
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, grant_types=list(GRANTS))
|
|
p.redirect_uris = uris
|
|
p.save()
|
|
p.property_mappings.set(list(scoped.values()))
|
|
print("provider: created (grant_types %s)" % GRANTS)
|
|
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 list(p.grant_types) != GRANTS:
|
|
p.grant_types = list(GRANTS)
|
|
changed.append("grant_types")
|
|
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()) != sorted(scoped):
|
|
p.property_mappings.set(list(scoped.values()))
|
|
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)
|
|
# The deploy gate's Playwright spec signs in as this identity, so the admin fixture is
|
|
# provisioned with the rest: a blueprint re-import that drops it fails the gate, not a phone.
|
|
e2e = User.objects.filter(username=E2E_ADMIN).first()
|
|
if e2e is None:
|
|
print("e2e fixture: authentik user %s absent — the e2e SSO spec will fail" % E2E_ADMIN)
|
|
else:
|
|
e2e.groups.add(admin_g)
|
|
print("e2e fixture: %s in %s" % (E2E_ADMIN, ADMIN_GROUP))
|
|
print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
|
|
# The default is printed at provision time, not merely asserted: an expression that regressed
|
|
# to returning nothing for a non-member refuses every household login, and this is where a
|
|
# re-run notices it. The population matters — authentik ships an AnonymousUser row, a
|
|
# superuser, and ak-outpost-* service identities, and proving the default on one of those says
|
|
# nothing about the friend who logs in. Prefer the fleet's plain-user marker group (owned by
|
|
# serverAppConfigs-docs/Infrastructure/Authentik.md; a preference only, since that group
|
|
# belongs to other consumers), which normally lands on qa-e2e — the identity the e2e gate
|
|
# signs in as. is_superuser is a python property on authentik's User, not a queryable field.
|
|
pool = User.objects.filter(is_active=True).exclude(groups__name__in=[ADMIN_GROUP])
|
|
pool = pool.exclude(username__in=[root.username, E2E_ADMIN, "AnonymousUser"])
|
|
pool = [u for u in pool.order_by("username")
|
|
if not u.is_superuser and not u.username.startswith("ak-outpost-")]
|
|
household = [u for u in pool if any(g.name == "Non-Admin Users" for g in u.groups.all())]
|
|
for u in (household or pool)[:3]:
|
|
print("default probe: %s %s -> %s" % (
|
|
u.username, sorted(g.name for g in u.groups.all()),
|
|
_ns["_probe"](user=u, request=None)))
|
|
|
|
# --- verification: the claim the callback will actually receive ---------------------
|
|
for probe in [root] + ([e2e] if e2e else []):
|
|
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 | grants %s | uris %s" % (
|
|
p.name, p.client_id, p.authorization_flow.slug, list(p.grant_types),
|
|
[u.url for u in p.redirect_uris]))
|
|
print("scopes: %s" % sorted(m.scopemapping.scope_name for m in p.property_mappings.all()))
|
|
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
|