diff --git a/scripts/authentik-audiobookshelf-sso.sh b/scripts/authentik-audiobookshelf-sso.sh index 14d7cd3..caafca7 100755 --- a/scripts/authentik-audiobookshelf-sso.sh +++ b/scripts/authentik-audiobookshelf-sso.sh @@ -39,6 +39,18 @@ # * 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. # @@ -56,6 +68,9 @@ # 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 — LAN-only by policy: # no vhost, no reverse proxy in front) @@ -71,6 +86,7 @@ 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}" HOST="${AUTHENTIK_HOST:-dankclaw}" @@ -94,7 +110,7 @@ from authentik.providers.oauth2.models import ( ADMIN_GROUP, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP" CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE" -SSO_USER = "$SSO_USER" +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 @@ -133,15 +149,29 @@ uris = [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) + authorization_flow=authz_flow, grant_types=list(GRANTS)) p.redirect_uris = uris p.save() - p.property_mappings.set([mapping]) - print("provider: created") + p.property_mappings.set(list(scoped.values())) + print("provider: created (grant_types %s)" % GRANTS) else: changed = [] if p.client_id != CLIENT_ID: @@ -153,14 +183,17 @@ else: 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()) != [mapping.pk]: - p.property_mappings.set([mapping]) + 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")) @@ -180,6 +213,14 @@ else: # --- 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) @@ -187,11 +228,13 @@ for name in MEMBERS: 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]: +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 | uris %s" % ( - p.name, p.client_id, p.authorization_flow.slug, [u.url for u in p.redirect_uris])) +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 <