`abs.` was a three-letter abbreviation of the container; the library it serves is /mnt/entropy/Audiobooks, and `audiobooks.` says what the door is. Renamed in one commit on purpose: the pihole seam fails the suite if a Caddyfile vhost has no addn-hosts record, so any split would leave a red commit, and the `abs` name was never deployed at the IdP with this fix, so nothing has to keep answering it — no alias, no redirect, no stale URI. Moves together: the Caddyfile site block, its matcher (@abs -> @audiobooks), the LAN record, ABS_EXTRA_BASES in authentik-audiobookshelf-sso.sh (that script REPLACES the provider's redirect-URI list, so the old origin's callback leaves the RP when it is next run), the homepage admin tile, the guard spec's GUARDED list and leak marker, and the three vault pages that name the door. authentik's provider row follows in the deploy window (external_host + name), which re-derives its redirect_uris through scripts/authentik-proxy-provider-defaults.sh; the Application row keeps its own name, "Audiobookshelf Guard" — it names the app, not the vhost.
263 lines
14 KiB
Bash
Executable File
263 lines
14 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:
|
|
#
|
|
# * The browser callback URI <base>/auth/openid/callback for EVERY origin ABS is
|
|
# reached at, plus <base>/auth/openid/mobile-redirect on ABS_BASE only. 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 is registered on the LAN origin only because the phone is configured with
|
|
# the LAN address: it is a non-browser client and cannot solve the guard
|
|
# challenge that sits in front of the vhost.
|
|
# * <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)
|
|
# 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_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"
|
|
USER_GROUP="audiobookshelf_users"
|
|
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
|
|
MEMBERS="${ABS_OIDC_USERS:-}"
|
|
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: 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"
|
|
BASES = [BASE] + """$EXTRA_BASES""".split()
|
|
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
|
|
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 --------------------------------------------------------------------
|
|
# One browser callback per origin (LAN address and the guarded vhost), because ABS
|
|
# derives redirect_uri from the request origin; the mobile URI stays on the LAN origin.
|
|
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
|
|
url="%s/auth/openid/callback" % b) for b in BASES]
|
|
uris.append(RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
|
|
url="%s/auth/openid/mobile-redirect" % BASE))
|
|
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))
|
|
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] + ([e2e] if e2e else []) + [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 | 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
|