From 27b3c24065c11ea1884def001185aa9a2238bc9e Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 20:21:21 -0800 Subject: [PATCH 1/3] fix(authentik): provision the Audiobookshelf provider with grant types and OpenID scopes The provider row this script creates was missing two fields, and each one is a dead login that names nothing on the wire (both measured 2026-11-02 in the browser): * grant_types defaults to [] on an ORM-created provider in authentik 2026.8, so /application/o/authorize/ refused the code flow with oauthlib's generic 'The request is otherwise malformed'; the log line that names the cause is 'Invalid grant_type for provider'. Set authorization_code + refresh_token, and reconcile it on re-run. * property_mappings held only the abs_groups claim mapping, so the granted token was scoped shorter than the scopes ABS requests and ABS's /userinfo call died 'insufficient_scope' after a good code exchange. Attach the built-in OpenID openid/profile/email mappings beside the claim. Also provisions qa-e2e-admin into the admin group: it is the identity the new Playwright gate signs in as, so a blueprint re-import restores the fixture. --- scripts/authentik-audiobookshelf-sso.sh | 61 +++++++++++++++++++++---- 1 file changed, 52 insertions(+), 9 deletions(-) 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 < Date: Mon, 2 Nov 2026 20:21:21 -0800 Subject: [PATCH 2/3] test(e2e): gate the Audiobookshelf SSO round trip end to end The service answers on a LAN address with no vhost, so it was in no guarded-vhost list and no spec touched it: every deploy gate passed while its login was dead. The gate signs in as qa-e2e-admin and asserts the two consumer-visible outcomes that the two defect classes break - every .js the SPA loads arrives as JavaScript (a bundle built for the wrong base path comes back text/html and boots nothing), and /api/me answers 200 with type 'admin' (true only while grant types, the OpenID scope mappings and the group claim all hold). Joined to the admin project, so it rides every deploy e2e gate. --- tests/e2e/playwright.config.ts | 5 +- tests/e2e/specs/23-audiobookshelf-sso.spec.ts | 92 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/specs/23-audiobookshelf-sso.spec.ts diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 986b2e8..e0e1f3e 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -42,7 +42,10 @@ export default defineConfig({ }, { name: "admin", - testMatch: /11-.*\.spec\.ts/, + // 23- joins 2026-11-02 (Audiobookshelf SSO): it needs BOTH halves of the admin + // fixture — a live authentik session to ride the OIDC round trip, and the + // qa-e2e-admin group membership that grants site admin in the app. + testMatch: /(11|23)-.*\.spec\.ts/, use: { storageState: ".auth/qa-e2e-admin.json" }, dependencies: ["setup"], }, diff --git a/tests/e2e/specs/23-audiobookshelf-sso.spec.ts b/tests/e2e/specs/23-audiobookshelf-sso.spec.ts new file mode 100644 index 0000000..9dc341c --- /dev/null +++ b/tests/e2e/specs/23-audiobookshelf-sso.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from "@playwright/test"; + +// Audiobookshelf: the SSO round trip must land a session with a real permission level. +// +// The service answers on a LAN address with no DNS name and no Caddy vhost, so it is in no +// guarded-vhost list and no other spec ever fetched a byte of it — every deploy gate passed +// while its login was dead. Two defect classes, both caught live on 2026-11-02: +// +// 1. The bundle must be BUILT for /audiobookshelf (ROUTER_BASE_PATH in the quadlet). Built +// for "/" the container still answers /healthcheck and still serves index.html, so the +// deploy health gate is green while the browser sees a blank page: every