fix(authentik): provision the Audiobookshelf provider with grant types and OpenID scopes #129
No files matched your search
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user