fix(authentik): provision the Audiobookshelf provider with grant types and OpenID scopes #129
No files matched your search
@@ -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