Every authentik account that signs in to Audiobookshelf becomes a listener #134

Merged
dank merged 3 commits from feat/audiobookshelf-sso-default-listener into main 2026-11-03 02:19:14 -08:00
5 changed files with 137 additions and 41 deletions

No files matched your search

+4 -2
View File
@@ -268,8 +268,10 @@ else:
# not auto-launch: the login page keeps offering the local form, which is the
# break-glass path when the IdP is unreachable.
"authOpenIDAutoLaunch": False,
# a household user's first SSO sign-in creates their account; the group claim
# decides the role, and a user in neither group is denied (fail closed).
# a household user's first SSO sign-in creates their account. The role comes from
# 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,
"authOpenIDGroupClaim": "abs_groups",
# The app's own scheme lives HERE, never in the IdP: ABS swaps it for
+50 -20
View File
@@ -70,9 +70,12 @@
# 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)
# There is deliberately NO user list. The claim grants the LISTENER role to every
# authentik account that signs in (2026-11-03), so onboarding a listener is exactly
# "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
# (qa-e2e-admin — tests/e2e/specs/23-audiobookshelf-sso.spec.ts
# 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}"
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.
# The claim expression: every signer is a listener; only the admin group escalates.
# 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
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 = []
if "$ADMIN_GROUP" in groups:
roles.append("admin")
if groups & {"$ADMIN_GROUP", "$USER_GROUP"}:
roles.append("user")
return {"$CLAIM": roles}
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
from authentik.core.models import Application, Group, User
from authentik.flows.models import Flow
from authentik.providers.oauth2.models import (
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"
BASES = [BASE] + """$EXTRA_BASES""".split()
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
# "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
# accessor is a future upgrade failure sitting inside the login path.
for name in (ADMIN_GROUP, USER_GROUP):
Group.objects.get_or_create(name=name)
admin_g, user_g = Group.objects.get(name=ADMIN_GROUP), Group.objects.get(name=USER_GROUP)
Group.objects.get_or_create(name=ADMIN_GROUP)
admin_g = Group.objects.get(name=ADMIN_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) ---------------
mapping = ScopeMapping.objects.filter(name="audiobookshelf-groups").first()
if mapping is None:
mapping = ScopeMapping.objects.create(name="audiobookshelf-groups", scope_name=CLAIM,
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)
elif mapping.scope_name != CLAIM or mapping.expression != EXPR:
mapping.scope_name, mapping.expression = CLAIM, EXPR
@@ -153,7 +170,7 @@ else:
print("mapping: already current")
# 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())
_ns = {}
exec(_src, _ns)
@@ -240,13 +257,26 @@ 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)
u.groups.add(user_g)
print("users: %s in %s" % (", ".join(MEMBERS) or "(none — fail closed)", USER_GROUP))
# The default is printed at provision time, not merely asserted: an expression that regressed
# to returning nothing for a non-member refuses every household login, and this is where a
# re-run notices it. The population matters — authentik ships an AnonymousUser row, a
# 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 ---------------------
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)))
p.refresh_from_db()
print("provider %s | client_id %s | flow %s | grants %s | uris %s" % (
@@ -98,9 +98,10 @@ Two ways in, both per listener:
- **Local account** — **Settings → Users → Add User**: type `user`, tick only the libraries
they may hear, and leave `download` off unless they need offline listening. Hand them the
server address above and their own password.
- **Household SSO** — create the authentik account, add it to `audiobookshelf_users`, and the
first **Sign in with Authentik** auto-registers the ABS account (`authOpenIDAutoRegister`);
a signer in neither group is denied on purpose, after a successful authentication.
- **Household SSO** — create the authentik account, and that is the whole of it: the first
**Sign in with Authentik** auto-registers the ABS account (`authOpenIDAutoRegister`) as a
listener, because the claim grants that role to every signer. To shut a listener off,
disable or delete their user in Audiobookshelf — no IdP group membership can withhold it.
Nothing rate-limits `/audiobookshelf/api/login` — this Caddy build carries no `rate_limit`
module and ABS 2.37.2 ships no lockout — so the form is a guessing target exactly like
@@ -116,20 +117,24 @@ the local form, and the phone app offers SSO on its server-address screen. The l
| Piece | Owner |
|-------|-------|
| Provider `audiobookshelf`, the two groups, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
| Provider `audiobookshelf`, the admin group, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
| Client secret | `ABS_OIDC_CLIENT_SECRET` in `~/.config/secrets/audiobookshelf` on Dankserver (mode 600; never in the repo, a URL or an argv) |
| The ABS half: issuer, the four IdP URLs, claim, auto-register | `scripts/audiobookshelf-init.sh`, armed automatically when that secret exists in the credential file |
| Role at first SSO sign-in | the `abs_groups` claim: `audiobookshelf_admins` → `admin`, `audiobookshelf_users` → `user`, in neither → denied (fail closed) |
| Role at first SSO sign-in | the `abs_groups` claim: every signer gets `user`; `audiobookshelf_admins` adds `admin`. Nothing in the IdP can deny the listener role — the login itself is the gate (law since 2026-11-03; the `audiobookshelf_users` group was retired, it had zero members and every household account was being refused) |
| Admin group members | `akadmin` (the operator's own authentik admin account — sign in as it and the claim grants site admin) and `qa-e2e-admin` (the identity the deploy gate signs in as, so the script re-adds it every run; drop it and the gate fails, not a phone) |
| Guard state of the door | none since 2026-11-03: proxy provider `audiobooks-proxy` and application "Audiobookshelf Guard" were deleted and the embedded outpost un-claimed them, because that chain answers a native client with challenge HTML ([[Caddy]] *Unguard a vhost* for the order). The SSO relying party `audiobookshelf` above is a different object and stays |
| Redirect URIs on the RP | `<base>/auth/openid/callback` **and** `<base>/auth/openid/mobile-redirect` for **both** origins (LAN address and public name): the app sends the mobile URI belonging to the door it was configured with, and STRICT matching answers an unregistered one with "Redirect URI Error" |
The IdP saying yes is not the same as being allowed in: with `authOpenIDAutoRegister` on,
a first sign-in creates the account, and the claim alone decides the type. A user in
neither group is refused after a successful authentication — that is the mapping working,
not a broken login.
With `authOpenIDAutoRegister` on, a first sign-in creates the account and the claim alone
decides its type — and the claim always carries `user`, so **the IdP is now the library gate**:
any authentik account that can reach this application becomes a listener. That is the intent
(the household signs in with the accounts it already has), which makes two things worth
checking when they change: whether authentik's own invitation/signup flow stays something only
Frank drives, and whether an access policy should be bound to the `audiobookshelf` application
to narrow who may even reach the login. Revoking one person is a disable/delete of their ABS
user; revoking everyone is that policy.
Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*: a sign-in as `qa-e2e-admin` that must land inside the app with site-admin rights.
Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*. `23-` signs in as `qa-e2e-admin` and must land inside the app with site-admin rights; `24-` signs in as the standard identity, which is in no Audiobookshelf group, and must land as a listener. Together they pin the escalation and the default: a mapping that stops granting silently locks the household out, and a mapping that over-grants turns the household into site admins.
## Laws
@@ -315,13 +320,16 @@ bootstrapper against a stub of the API above: create-once, idempotent re-run, no
URL, refusal by URL when the server does not answer, the deployer calling it only after the
health gate, and the SSO arm sending the mounted base path as the redirect subfolder);
`tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view);
and the live Playwright gate `tests/e2e/specs/23-audiobookshelf-sso.spec.ts`, run by
`tests/e2e/run.sh` at the end of every `deploy.sh` — the `admin` project signs in as
`qa-e2e-admin` against `http://192.168.0.235:8000/audiobookshelf` and asserts the two things
no stub can see: every `.js` the app loads arrives as JavaScript (a bundle built for the wrong
base path arrives as `text/html`, which 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 together.
and the live Playwright gates `tests/e2e/specs/23-audiobookshelf-sso.spec.ts` and
`tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts`, run by `tests/e2e/run.sh` at
the end of every `deploy.sh`. `23-` (the `admin` project) signs in as `qa-e2e-admin` against
`http://192.168.0.235:8000/audiobookshelf` and asserts the two things no stub can see: every
`.js` the app loads arrives as JavaScript (a bundle built for the wrong base path arrives as
`text/html`, which 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 together. `24-` (the
`standard` project) signs in as `qa-e2e`, an account in no Audiobookshelf group, and must come
back `type: "user"`: the default grant that lets the household in, so a scope-mapping
regression surfaces as a failed sign-in in the gate instead of as a friend's complaint.
The public door is pinned by the guard audit `tests/e2e/specs/14-guard.spec.ts`, which now
carries an **exemption** assertion where it used to carry a guarded one: an anonymous raw
+5 -2
View File
@@ -35,8 +35,11 @@ export default defineConfig({
// 2026-10-19 - the newest guarded app, in neither GUARDED list nor any
// spec until now: the 17-hermes "uncovered vhost broke silently" class).
// 22- joins Prometheus's app-is-really-there proof (its own API), closing
// the "not a login wall but maybe a 404" hole 20- closed for Grafana.
testMatch: /(10|12|13|16|17|18|20|21|22)-.*\.spec\.ts/,
// the "not a login wall but maybe a 404" hole 20- closed for Grafana. 24-
// joins 2026-11-03 (Audiobookshelf's DEFAULT listener grant) and belongs to
// this project precisely because the standard identity is in no Audiobookshelf
// group — the admin project could not observe the default.
testMatch: /(10|12|13|16|17|18|20|21|22|24)-.*\.spec\.ts/,
use: { storageState: ".auth/qa-e2e.json" },
dependencies: ["setup"],
},
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
// Audiobookshelf grants the LISTENER role to anyone the IdP lets sign in (2026-11-03).
//
// The role used to come from membership of `audiobookshelf_users` — a group nobody was ever
// added to. The household accounts exist in authentik, and every one of them was denied at ABS
// AFTER a successful authentication, which reads as a broken login rather than a policy decision.
// The scope mapping now answers `abs_groups: ["user"]` for every signer and reserves `admin` for
// `audiobookshelf_admins`, so the IdP is the only gate to the library and onboarding a listener
// is exactly "create the account".
//
// This is the counterpart to 23-: that spec proves the admin escalation, this one proves the
// DEFAULT grant, from an identity in neither group. Against the old mapping it is red — ABS
// refuses the sign-in and the SPA never leaves /login.
const HOST = "192.168.0.235:8000";
const ABS = `http://${HOST}/audiobookshelf`;
test("an SSO signer in no Audiobookshelf group lands as a listener", async ({ page }) => {
test.setTimeout(180_000);
await page.goto(`${ABS}/login`, { waitUntil: "domcontentloaded" });
const sso = page.getByText("Sign in with Authentik");
await expect(sso).toBeVisible();
await sso.click();
// The standard project's storageState carries a live authentik session, so the round trip is
// silent: authorize -> code -> callback -> token. Still being on /login means the app refused
// the identity (the denial this change removes) or the exchange failed.
await expect
.poll(() => !new URL(page.url()).pathname.endsWith("/login"), { timeout: 90_000 })
.toBe(true);
const grabToken = () =>
page.evaluate(() => {
for (let i = 0; i < localStorage.length; i++) {
const v = localStorage.getItem(localStorage.key(i) as string) || "";
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(v)) return v;
}
const c = document.cookie.match(/(?:^|;\s*)abs_token=([^;]+)/);
return c ? decodeURIComponent(c[1]) : "";
});
await expect.poll(grabToken, { timeout: 60_000 }).not.toBe("");
const me = await page.request.get(`${ABS}/api/me`, {
headers: { Authorization: `Bearer ${await grabToken()}` },
});
expect(me.status()).toBe(200);
const user = await me.json();
expect(user.username).toBe("qa-e2e");
// The default, not a grant: the listener role for a signer in neither group. Being `user`
// rather than `admin` is also the proof that the default never escalates.
expect(user.type).toBe("user");
});