fix(authentik): provision the Audiobookshelf provider with grant types and OpenID scopes #129

Merged
dank merged 3 commits from fix/abs-grant-types into main 2026-11-02 20:24:53 -08:00
4 changed files with 175 additions and 12 deletions

No files matched your search

+52 -9
View File
@@ -39,6 +39,18 @@
# * provider.authorization_flow must be non-NULL or /application/o/authorize/ # * provider.authorization_flow must be non-NULL or /application/o/authorize/
# answers a 500 with 'NoneType' object has no attribute 'slug' (measured on # answers a 500 with 'NoneType' object has no attribute 'slug' (measured on
# grafana, same authentik build). # 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. # 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 # ABS_OIDC_USERS space-separated authentik usernames given the USER group
# (default: nobody — an empty list is fail-closed, and the # (default: nobody — an empty list is fail-closed, and the
# household decides who listens to audiobooks) # 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 # ABS_BASE ABS URL as the browser sees it, base path included
# (http://192.168.0.235:8000/audiobookshelf — LAN-only by policy: # (http://192.168.0.235:8000/audiobookshelf — LAN-only by policy:
# no vhost, no reverse proxy in front) # no vhost, no reverse proxy in front)
@@ -71,6 +86,7 @@ ADMIN_GROUP="audiobookshelf_admins"
USER_GROUP="audiobookshelf_users" USER_GROUP="audiobookshelf_users"
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}" SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
MEMBERS="${ABS_OIDC_USERS:-}" MEMBERS="${ABS_OIDC_USERS:-}"
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}"
HOST="${AUTHENTIK_HOST:-dankclaw}" HOST="${AUTHENTIK_HOST:-dankclaw}"
@@ -94,7 +110,7 @@ from authentik.providers.oauth2.models import (
ADMIN_GROUP, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP" ADMIN_GROUP, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP"
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE" 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() 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
@@ -133,15 +149,29 @@ uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
url="%s/auth/openid/mobile-redirect" % BASE)] url="%s/auth/openid/mobile-redirect" % BASE)]
authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent") 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() p = OAuth2Provider.objects.filter(name="audiobookshelf").first()
if p is None: if p is None:
p = OAuth2Provider(name="audiobookshelf", client_id=CLIENT_ID, p = OAuth2Provider(name="audiobookshelf", client_id=CLIENT_ID,
client_type=ClientType.CONFIDENTIAL, client_secret=SECRET, client_type=ClientType.CONFIDENTIAL, client_secret=SECRET,
authorization_flow=authz_flow) authorization_flow=authz_flow, grant_types=list(GRANTS))
p.redirect_uris = uris p.redirect_uris = uris
p.save() p.save()
p.property_mappings.set([mapping]) p.property_mappings.set(list(scoped.values()))
print("provider: created") print("provider: created (grant_types %s)" % GRANTS)
else: else:
changed = [] changed = []
if p.client_id != CLIENT_ID: if p.client_id != CLIENT_ID:
@@ -153,14 +183,17 @@ else:
if p.authorization_flow_id != authz_flow.pk: if p.authorization_flow_id != authz_flow.pk:
p.authorization_flow = authz_flow p.authorization_flow = authz_flow
changed.append("authorization_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] != \ if [(u.url, str(u.matching_mode)) for u in p.redirect_uris] != \
[(u.url, str(u.matching_mode)) for u in uris]: [(u.url, str(u.matching_mode)) for u in uris]:
p.redirect_uris = uris p.redirect_uris = uris
changed.append("redirect_uris") changed.append("redirect_uris")
if changed: if changed:
p.save() p.save()
if sorted(m.pk for m in p.property_mappings.all()) != [mapping.pk]: if sorted(m.pk for m in p.property_mappings.all()) != sorted(scoped):
p.property_mappings.set([mapping]) p.property_mappings.set(list(scoped.values()))
changed.append("property_mappings") changed.append("property_mappings")
print("provider: asserted (%s)" % (", ".join(changed) or "nothing to change")) print("provider: asserted (%s)" % (", ".join(changed) or "nothing to change"))
@@ -180,6 +213,14 @@ else:
# --- membership -------------------------------------------------------------------- # --- membership --------------------------------------------------------------------
root = User.objects.get(username=SSO_USER) root = User.objects.get(username=SSO_USER)
root.groups.add(admin_g) 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)) print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
for name in MEMBERS: for name in MEMBERS:
u = User.objects.get(username=name) 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)) print("users: %s in %s" % (", ".join(MEMBERS) or "(none — fail closed)", USER_GROUP))
# --- verification: the claim the callback will actually receive --------------------- # --- 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))) 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 | uris %s" % ( print("provider %s | client_id %s | flow %s | grants %s | uris %s" % (
p.name, p.client_id, p.authorization_flow.slug, [u.url for u in p.redirect_uris])) 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 PYEOF
cat <<DONE cat <<DONE
@@ -97,12 +97,15 @@ the local form, and the phone app offers SSO on its server-address screen. The l
| Client secret | `ABS_OIDC_CLIENT_SECRET` in `~/.config/secrets/audiobookshelf` on Dankserver (mode 600; never in the repo, a URL or an argv) | | 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 | | 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: `audiobookshelf_admins` → `admin`, `audiobookshelf_users` → `user`, in neither → denied (fail closed) |
| Admin group members | `akadmin` (operator stand-in) and `qa-e2e-admin` — the second is the identity the deploy gate signs in as, so the script re-adds it on every run; drop it and the gate fails, not a phone |
The IdP saying yes is not the same as being allowed in: with `authOpenIDAutoRegister` on, 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 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, neither group is refused after a successful authentication — that is the mapping working,
not a broken login. not a broken login.
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.
## Laws ## Laws
- **Speak the image's env names, not the README's.** The server reads `PORT`, - **Speak the image's env names, not the README's.** The server reads `PORT`,
@@ -128,6 +131,21 @@ not a broken login.
provider's registered URI must equal `<origin><subfolder>/auth/openid/callback`, so each provider's registered URI must equal `<origin><subfolder>/auth/openid/callback`, so each
script derives the value from one variable of its own (`ABS_URL`, `ABS_BASE`) and script derives the value from one variable of its own (`ABS_URL`, `ABS_BASE`) and
`tests/audiobookshelf_init.bats` pins what reaches the wire. `tests/audiobookshelf_init.bats` pins what reaches the wire.
- **An ORM-created provider has no grant types.** authentik 2026.8 defaults `grant_types` to
`[]`, and `/application/o/authorize/` then refuses the code flow with oauthlib's generic
"The request is otherwise malformed" — the wire names nothing, the server log line that names
it is `Invalid grant_type for provider`. Worse, the redirect-URI check runs *first*, so a
provider carrying both faults shows only "Redirect URI Error" until the URI is fixed, and the
second wall appears after the fix ships (measured 2026-11-02: the browser reached authentik,
got `code`+`state`, and ABS still bounced to `/login?error=undefined`). The script sets
`authorization_code` + `refresh_token`; a blueprint re-import reverts the row silently.
- **The claim mapping is not the whole `property_mappings` list.** A provider holding only the
custom mapping grants a token scoped shorter than the scopes ABS requests
(`openid profile email abs_groups`): the code is issued and exchanged normally, and ABS's
`/userinfo` call then dies `insufficient_scope (The request requires higher privileges than
provided by the access token)` — a fault that reads like an ABS bug and is an IdP row. Attach
the built-in OpenID `openid`/`profile`/`email` mappings beside the claim mapping, which is
what every working RP on this IdP carries (measured 2026-11-02).
- **The audio bind stays `:ro`.** ABS writes nothing that belongs beside the - **The audio bind stays `:ro`.** ABS writes nothing that belongs beside the
audio: its library, covers, progress and sidecar metadata all land in audio: its library, covers, progress and sidecar metadata all land in
`/metadata` on the SSD. A writable bind would put stray writes on the spindle `/metadata` on the SSD. A writable bind would put stray writes on the spindle
@@ -248,8 +266,15 @@ not a broken login.
health check, the base path the shipped SPA needs), `tests/audiobookshelf_init.bats` (the health check, the base path the shipped SPA needs), `tests/audiobookshelf_init.bats` (the
bootstrapper against a stub of the API above: create-once, idempotent re-run, no secret in a bootstrapper against a stub of the API above: create-once, idempotent re-run, no secret in a
URL, refusal by URL when the server does not answer, the deployer calling it only after the 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) and 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). `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.
Five book folders hold media no scanner can read, so they stay out of the library: Five book folders hold media no scanner can read, so they stay out of the library:
three 2024 Nebula nominees in the `_unsorted` box (two `.m4b` with no `moov` atom, three 2024 Nebula nominees in the `_unsorted` box (two `.m4b` with no `moov` atom,
+4 -1
View File
@@ -42,7 +42,10 @@ export default defineConfig({
}, },
{ {
name: "admin", 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" }, use: { storageState: ".auth/qa-e2e-admin.json" },
dependencies: ["setup"], dependencies: ["setup"],
}, },
@@ -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 <script>
// resolves against the root, misses the static allowlist, and falls through to the SPA
// catch-all that returns index.html as text/html. Hence the MIME assertion below — the
// one thing that distinguishes a booted SPA from a served HTML stub.
// 2. The authentik provider row must carry grant_types AND the built-in OpenID scope
// mappings (provisioned by scripts/authentik-audiobookshelf-sso.sh). A blueprint
// re-import reverts hand-made provider rows silently, and the wire errors are generic:
// oauthlib's "The request is otherwise malformed" for an empty grant list, then
// "insufficient_scope" from ABS's /userinfo call for a short scope. Only the level
// assertion below — that the abs_groups claim granted site admin — sees either.
const HOST = "192.168.0.235:8000";
const ABS = `http://${HOST}/audiobookshelf`;
test("SSO round trip boots the SPA and grants a real Audiobookshelf session", async ({
page,
}) => {
test.setTimeout(180_000);
const scripts: { url: string; type: string }[] = [];
page.on("response", (res) => {
const u = new URL(res.url());
if (u.host === HOST && u.pathname.endsWith(".js")) {
scripts.push({ url: u.pathname, type: res.headers()["content-type"] ?? "" });
}
});
await page.goto(`${ABS}/login`, { waitUntil: "domcontentloaded" });
const sso = page.getByText("Sign in with Authentik");
await expect(sso).toBeVisible();
await sso.click();
// The admin project's storageState carries a live authentik session, so this is a silent
// round trip: authorize -> code -> callback -> token. Still on /login means the IdP
// refused the authorize request or ABS rejected the code exchange.
await expect
.poll(() => !new URL(page.url()).pathname.endsWith("/login"), { timeout: 90_000 })
.toBe(true);
await expect
.poll(
async () => {
const u = new URL(page.url());
return u.host === HOST && u.pathname.startsWith("/audiobookshelf");
},
{ timeout: 30_000 },
)
.toBe(true);
// Defect 1 gate: nothing the app loaded may be HTML dressed as a script. The render proof
// is the SSO button above — it only exists once the bundle has executed, so no DOM-shape
// guess is needed (the shell's own mount markup says nothing about whether it booted).
expect(scripts.length, "the SPA loaded no JavaScript").toBeGreaterThan(2);
for (const s of scripts) {
expect(s.type, `${s.url} arrived as ${s.type}`).toContain("javascript");
}
// The token the app landed on is the credential its own API must accept. Give the SPA a
// moment to finish the exchange: the URL leaves /login the instant authentik bounces back
// through the callback route, which is BEFORE the app has stored what it exchanged.
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 token = await grabToken();
const me = await page.request.get(`${ABS}/api/me`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(me.status()).toBe(200);
const user = await me.json();
expect(user.username).toBe("qa-e2e-admin");
// Site role, derived ONLY from the abs_groups claim: it says "admin" if and only if the
// scope mapping reached ABS with audiobookshelf_admins in the list. A short token scope
// (missing OpenID mappings) never gets this far — the exchange dies before it.
expect(user.type).toBe("admin");
});