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.
This commit is contained in:
1 parent
7e9bfbc2be
commit
27b3c24065
1 file changed
+52
-9
@@ -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 <<DONE
|
||||
|
||||
Reference in new issue
Block a user