Every authentik account that signs in to Audiobookshelf becomes a listener #134
No files matched your search
@@ -268,8 +268,10 @@ else:
|
|||||||
# not auto-launch: the login page keeps offering the local form, which is the
|
# not auto-launch: the login page keeps offering the local form, which is the
|
||||||
# break-glass path when the IdP is unreachable.
|
# break-glass path when the IdP is unreachable.
|
||||||
"authOpenIDAutoLaunch": False,
|
"authOpenIDAutoLaunch": False,
|
||||||
# a household user's first SSO sign-in creates their account; the group claim
|
# a household user's first SSO sign-in creates their account. The role comes from
|
||||||
# decides the role, and a user in neither group is denied (fail closed).
|
# the abs_groups claim, which grants the listener role to every signer; only the
|
||||||
|
# authentik admin group escalates. Revoking a listener therefore happens HERE in
|
||||||
|
# ABS (disable or delete the user), never by removing an IdP group membership.
|
||||||
"authOpenIDAutoRegister": True,
|
"authOpenIDAutoRegister": True,
|
||||||
"authOpenIDGroupClaim": "abs_groups",
|
"authOpenIDGroupClaim": "abs_groups",
|
||||||
# The app's own scheme lives HERE, never in the IdP: ABS swaps it for
|
# The app's own scheme lives HERE, never in the IdP: ABS swaps it for
|
||||||
|
|||||||
@@ -70,9 +70,12 @@
|
|||||||
# ABS_OIDC_CLIENT_ID provider/client id (audiobookshelf)
|
# ABS_OIDC_CLIENT_ID provider/client id (audiobookshelf)
|
||||||
# ABS_OIDC_GROUPS_CLAIM claim ABS reads for roles (abs_groups)
|
# ABS_OIDC_GROUPS_CLAIM claim ABS reads for roles (abs_groups)
|
||||||
# ABS_OIDC_USERNAME authentik user made admin (akadmin)
|
# ABS_OIDC_USERNAME authentik user made admin (akadmin)
|
||||||
# ABS_OIDC_USERS space-separated authentik usernames given the USER group
|
# There is deliberately NO user list. The claim grants the LISTENER role to every
|
||||||
# (default: nobody — an empty list is fail-closed, and the
|
# authentik account that signs in (2026-11-03), so onboarding a listener is exactly
|
||||||
# household decides who listens to audiobooks)
|
# "create the authentik account". Before this, a household account authenticated at
|
||||||
|
# the IdP and was then refused by ABS for being in no group — a policy decision that
|
||||||
|
# read as a broken login. Revocation lives on the ABS side (disable or delete the
|
||||||
|
# user there): a group can no longer withhold a role the claim always sends.
|
||||||
# ABS_E2E_ADMIN authentik user kept in the ADMIN group for the Playwright gate
|
# 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
|
# (qa-e2e-admin — tests/e2e/specs/23-audiobookshelf-sso.spec.ts
|
||||||
# signs in as this identity, so a re-run restores the fixture)
|
# signs in as this identity, so a re-run restores the fixture)
|
||||||
@@ -97,53 +100,67 @@ SECRET="${ABS_OIDC_CLIENT_SECRET:?set ABS_OIDC_CLIENT_SECRET (see the header)}"
|
|||||||
CLIENT_ID="${ABS_OIDC_CLIENT_ID:-audiobookshelf}"
|
CLIENT_ID="${ABS_OIDC_CLIENT_ID:-audiobookshelf}"
|
||||||
CLAIM="${ABS_OIDC_GROUPS_CLAIM:-abs_groups}"
|
CLAIM="${ABS_OIDC_GROUPS_CLAIM:-abs_groups}"
|
||||||
ADMIN_GROUP="audiobookshelf_admins"
|
ADMIN_GROUP="audiobookshelf_admins"
|
||||||
USER_GROUP="audiobookshelf_users"
|
|
||||||
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
|
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
|
||||||
MEMBERS="${ABS_OIDC_USERS:-}"
|
|
||||||
E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}"
|
E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}"
|
||||||
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}"
|
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}"
|
||||||
EXTRA_BASES="${ABS_EXTRA_BASES:-https://audiobooks.franksdank.cloud/audiobookshelf}"
|
EXTRA_BASES="${ABS_EXTRA_BASES:-https://audiobooks.franksdank.cloud/audiobookshelf}"
|
||||||
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
||||||
|
|
||||||
# The claim expression: translate group membership into ABS role names. It is stored
|
# The claim expression: every signer is a listener; only the admin group escalates.
|
||||||
# verbatim so a later read of the provider shows exactly this policy.
|
# Stored verbatim so a later read of the provider shows exactly this policy. The default
|
||||||
|
# lives HERE and nowhere else — ABS reads this list off the token, so an account in no
|
||||||
|
# Audiobookshelf group still arrives with ["user"] and is auto-registered as a listener
|
||||||
|
# instead of being refused after a successful authentication.
|
||||||
read -r -d '' EXPR <<PYEXPR || true
|
read -r -d '' EXPR <<PYEXPR || true
|
||||||
groups = {g.name for g in user.groups.all()}
|
groups = {g.name for g in user.groups.all()}
|
||||||
|
# Order is deliberate: an admin's claim comes out ["admin","user"], byte-identical to what
|
||||||
|
# this mapping emitted before the default existed, because ABS's group-tag matching has never
|
||||||
|
# been measured for order-sensitivity and the working admin login is not the place to find out.
|
||||||
roles = []
|
roles = []
|
||||||
if "$ADMIN_GROUP" in groups:
|
if "$ADMIN_GROUP" in groups:
|
||||||
roles.append("admin")
|
roles.append("admin")
|
||||||
if groups & {"$ADMIN_GROUP", "$USER_GROUP"}:
|
|
||||||
roles.append("user")
|
roles.append("user")
|
||||||
return {"$CLAIM": roles}
|
return {"$CLAIM": roles}
|
||||||
PYEXPR
|
PYEXPR
|
||||||
|
|
||||||
|
# NOTE: the heredoc below is UNQUOTED on purpose — its body interpolates the variables
|
||||||
|
# above. The consequence is that a backtick anywhere in that python body is a LOCAL command
|
||||||
|
# substitution whose output is spliced into the program before it ever runs, and comments
|
||||||
|
# are not exempt, because bash does not know what a python comment is. Write names plainly
|
||||||
|
# in the body: measured 2026-11-03, two comments in this body had the shell run
|
||||||
|
# audiobookshelf_users and user as commands on every execution of this script.
|
||||||
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
|
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
|
||||||
from authentik.core.models import Application, Group, User
|
from authentik.core.models import Application, Group, User
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow
|
||||||
from authentik.providers.oauth2.models import (
|
from authentik.providers.oauth2.models import (
|
||||||
ClientType, OAuth2Provider, RedirectURI, RedirectURIMatchingMode, RedirectURIType, ScopeMapping)
|
ClientType, OAuth2Provider, RedirectURI, RedirectURIMatchingMode, RedirectURIType, ScopeMapping)
|
||||||
|
|
||||||
ADMIN_GROUP, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP"
|
ADMIN_GROUP = "$ADMIN_GROUP"
|
||||||
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
|
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
|
||||||
BASES = [BASE] + """$EXTRA_BASES""".split()
|
BASES = [BASE] + """$EXTRA_BASES""".split()
|
||||||
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
|
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
|
||||||
MEMBERS = """$MEMBERS""".split()
|
|
||||||
EXPR = '''$EXPR'''
|
EXPR = '''$EXPR'''
|
||||||
# The expression reads user.groups, NOT user.ak_groups: authentik 2026.8 logs
|
# 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
|
# "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
|
# 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.
|
# accessor is a future upgrade failure sitting inside the login path.
|
||||||
|
|
||||||
for name in (ADMIN_GROUP, USER_GROUP):
|
Group.objects.get_or_create(name=ADMIN_GROUP)
|
||||||
Group.objects.get_or_create(name=name)
|
admin_g = Group.objects.get(name=ADMIN_GROUP)
|
||||||
admin_g, user_g = Group.objects.get(name=ADMIN_GROUP), Group.objects.get(name=USER_GROUP)
|
# The audiobookshelf_users group is retired. Its membership used to be the only door to the
|
||||||
|
# listener role; the claim now sends that role to every signer, so the group decides
|
||||||
|
# nothing — and a group that decides nothing is a trap for the next reader, who will
|
||||||
|
# assume it gates something and start adding people to it.
|
||||||
|
for stale in Group.objects.filter(name="audiobookshelf_users"):
|
||||||
|
stale.delete()
|
||||||
|
print("group: retired audiobookshelf_users (the claim grants every signer)")
|
||||||
|
|
||||||
# --- the claim mapping (scope_name IS the claim name ABS requests) ---------------
|
# --- the claim mapping (scope_name IS the claim name ABS requests) ---------------
|
||||||
mapping = ScopeMapping.objects.filter(name="audiobookshelf-groups").first()
|
mapping = ScopeMapping.objects.filter(name="audiobookshelf-groups").first()
|
||||||
if mapping is None:
|
if mapping is None:
|
||||||
mapping = ScopeMapping.objects.create(name="audiobookshelf-groups", scope_name=CLAIM,
|
mapping = ScopeMapping.objects.create(name="audiobookshelf-groups", scope_name=CLAIM,
|
||||||
expression=EXPR,
|
expression=EXPR,
|
||||||
description="Audiobookshelf role names from group membership")
|
description="Audiobookshelf roles: every signer is a listener, the admin group escalates")
|
||||||
print("mapping: created (scope_name %s)" % CLAIM)
|
print("mapping: created (scope_name %s)" % CLAIM)
|
||||||
elif mapping.scope_name != CLAIM or mapping.expression != EXPR:
|
elif mapping.scope_name != CLAIM or mapping.expression != EXPR:
|
||||||
mapping.scope_name, mapping.expression = CLAIM, EXPR
|
mapping.scope_name, mapping.expression = CLAIM, EXPR
|
||||||
@@ -153,7 +170,7 @@ else:
|
|||||||
print("mapping: already current")
|
print("mapping: already current")
|
||||||
|
|
||||||
# Prove the expression runs and yields an ABS role BEFORE anyone tries to log in:
|
# 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`.
|
# authentik execs the expression inside a function whose context has the name "user".
|
||||||
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
|
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
|
||||||
_ns = {}
|
_ns = {}
|
||||||
exec(_src, _ns)
|
exec(_src, _ns)
|
||||||
@@ -240,13 +257,26 @@ else:
|
|||||||
e2e.groups.add(admin_g)
|
e2e.groups.add(admin_g)
|
||||||
print("e2e fixture: %s in %s" % (E2E_ADMIN, ADMIN_GROUP))
|
print("e2e fixture: %s in %s" % (E2E_ADMIN, ADMIN_GROUP))
|
||||||
print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
|
print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
|
||||||
for name in MEMBERS:
|
# The default is printed at provision time, not merely asserted: an expression that regressed
|
||||||
u = User.objects.get(username=name)
|
# to returning nothing for a non-member refuses every household login, and this is where a
|
||||||
u.groups.add(user_g)
|
# re-run notices it. The population matters — authentik ships an AnonymousUser row, a
|
||||||
print("users: %s in %s" % (", ".join(MEMBERS) or "(none — fail closed)", USER_GROUP))
|
# superuser, and ak-outpost-* service identities, and proving the default on one of those says
|
||||||
|
# nothing about the friend who logs in. Prefer the fleet's plain-user marker group (owned by
|
||||||
|
# serverAppConfigs-docs/Infrastructure/Authentik.md; a preference only, since that group
|
||||||
|
# belongs to other consumers), which normally lands on qa-e2e — the identity the e2e gate
|
||||||
|
# signs in as. is_superuser is a python property on authentik's User, not a queryable field.
|
||||||
|
pool = User.objects.filter(is_active=True).exclude(groups__name__in=[ADMIN_GROUP])
|
||||||
|
pool = pool.exclude(username__in=[root.username, E2E_ADMIN, "AnonymousUser"])
|
||||||
|
pool = [u for u in pool.order_by("username")
|
||||||
|
if not u.is_superuser and not u.username.startswith("ak-outpost-")]
|
||||||
|
household = [u for u in pool if any(g.name == "Non-Admin Users" for g in u.groups.all())]
|
||||||
|
for u in (household or pool)[:3]:
|
||||||
|
print("default probe: %s %s -> %s" % (
|
||||||
|
u.username, sorted(g.name for g in u.groups.all()),
|
||||||
|
_ns["_probe"](user=u, request=None)))
|
||||||
|
|
||||||
# --- verification: the claim the callback will actually receive ---------------------
|
# --- 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]:
|
for probe in [root] + ([e2e] if e2e else []):
|
||||||
print("claim for %s -> %s" % (probe.username, _ns["_probe"](user=probe, request=None)))
|
print("claim for %s -> %s" % (probe.username, _ns["_probe"](user=probe, request=None)))
|
||||||
p.refresh_from_db()
|
p.refresh_from_db()
|
||||||
print("provider %s | client_id %s | flow %s | grants %s | uris %s" % (
|
print("provider %s | client_id %s | flow %s | grants %s | uris %s" % (
|
||||||
|
|||||||
Reference in new issue
Block a user