#!/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 /auth/openid/callback for EVERY origin ABS is # reached at, plus /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. # * CARRIES THE BASE PATH. ABS builds redirect_uri as # /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 []": 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 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= 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 < %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 <