From 30186d8365096c2b90b7839f48bee68ef39e9e80 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 3 Nov 2026 02:06:20 -0800 Subject: [PATCH 1/3] feat(authentik): make every Audiobookshelf SSO signer a listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The abs_groups claim decided the listener role from membership of audiobookshelf_users, a group nobody was ever added to, while the ABS application carries no access policy of its own. The result: seventeen household accounts authenticated at the IdP successfully and were then refused by the app — a policy decision that reads as a broken login. The claim now answers ["user"] for every signer and appends "admin" only for audiobookshelf_admins, so the IdP login is the gate and onboarding a listener is exactly "create the authentik account". Admins keep the exact claim they always received, ["admin","user"], in that order: ABS's group-tag matching has never been measured for order-sensitivity and the working admin login is not where to find out. audiobookshelf_users is deleted rather than left empty — a group that decides nothing is a trap for the next reader, who will assume it gates something. Two side fixes found by running the script: - Two comments inside the unquoted PYEOF heredoc carried backticks, so bash command-substituted them locally on EVERY run ("audiobookshelf_users: command not found", "user: command not found") and spliced the output into the python program. Names in that body are plain now, with a note on why. A quoted heredoc is not an option: the body interpolates. - The default-grant probe picked authentik's AnonymousUser row, then its admin superuser. It now walks real non-superuser accounts, preferring the fleet's plain-user marker group, and prints up to three claims. --- scripts/audiobookshelf-init.sh | 6 ++- scripts/authentik-audiobookshelf-sso.sh | 72 +++++++++++++++++-------- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/scripts/audiobookshelf-init.sh b/scripts/audiobookshelf-init.sh index f4ac4c1..5d81f78 100755 --- a/scripts/audiobookshelf-init.sh +++ b/scripts/audiobookshelf-init.sh @@ -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 diff --git a/scripts/authentik-audiobookshelf-sso.sh b/scripts/authentik-audiobookshelf-sso.sh index d7b73de..e163b50 100755 --- a/scripts/authentik-audiobookshelf-sso.sh +++ b/scripts/authentik-audiobookshelf-sso.sh @@ -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 < %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" % ( From 5cf5416471098cf5d4055fe3321e34d22362132d Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 3 Nov 2026 02:06:20 -0800 Subject: [PATCH 2/3] test(e2e): pin the Audiobookshelf default listener grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec 24- signs in through SSO as qa-e2e — an identity in no Audiobookshelf group — and must land inside the app with type "user". It is the counterpart to 23-, which pins the admin escalation: together they catch both directions of a scope-mapping regression (household locked out, or household made site admin). Verified red against the pre-change mapping — the identity never received a token — and green after re-provisioning: 4 passed. Joined to the standard project, which is the only project whose identity observes the default. --- tests/e2e/playwright.config.ts | 7 ++- ...udiobookshelf-sso-default-listener.spec.ts | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index e0e1f3e..18d3794 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -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"], }, diff --git a/tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts b/tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts new file mode 100644 index 0000000..1db46d3 --- /dev/null +++ b/tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts @@ -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"); +}); From 66766f23ef8032294efe7621d21d4a10f38ebbd3 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 3 Nov 2026 02:06:20 -0800 Subject: [PATCH 3/3] docs(audiobookshelf): the IdP is the library gate now Rewrite the listener onboarding, the role row, and the fail-closed law: the claim grants every signer, revocation happens on the ABS side (disable or delete the user), and what actually guards the library is who can reach the audiobookshelf application in authentik. Name both e2e gates in Verification. --- .../Dankserver/Audiobookshelf.md | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md index d276d6e..312a0c1 100644 --- a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md +++ b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md @@ -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 | `/auth/openid/callback` **and** `/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