feat(auth): add generic OIDC login with mandatory groups

This commit is contained in:
2026-08-17 05:44:10 +02:00
parent 202822f3ba
commit 4fe51cbeb1
20 changed files with 1144 additions and 40 deletions
+4 -4
View File
@@ -6,7 +6,7 @@ import { stringify } from "yaml";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
test("configured OIDC starts with provider-neutral protocol placeholders that fail closed", async () => {
test("configured OIDC advertises login but fails closed without its runtime client secret", async () => {
const directory = mkdtempSync(join(tmpdir(), "thothii-app-oidc-mode-"));
const file = join(directory, "auth.yaml");
writeFileSync(file, stringify({
@@ -22,10 +22,10 @@ test("configured OIDC starts with provider-neutral protocol placeholders that fa
const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state") }));
try {
expect((await app.inject({ method: "GET", url: "/auth/config" })).json())
.toEqual({ mode: "oidc", localLogin: false, oidcLogin: false });
.toEqual({ mode: "oidc", localLogin: false, oidcLogin: true });
const placeholder = await app.inject({ method: "GET", url: "/auth/oidc/login" });
expect(placeholder.statusCode).toBe(501);
expect(placeholder.json()).toEqual({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
expect(placeholder.statusCode).toBe(503);
expect(placeholder.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
} finally {
await app.close();
}
+3 -3
View File
@@ -500,7 +500,7 @@ test("operational config failures return 503 and never consume login-failure cap
expect((await login(app, { password: `${password}!` })).statusCode).toBe(429);
});
test("public auth configuration is safe and OIDC protocol placeholders fail closed", async () => {
test("public auth configuration is safe and unavailable OIDC login fails closed", async () => {
const { app } = await createLocalApp();
const configuration = await app.inject({ method: "GET", url: "/auth/config" });
expect(configuration.statusCode).toBe(200);
@@ -508,6 +508,6 @@ test("public auth configuration is safe and OIDC protocol placeholders fail clos
expect(JSON.stringify(configuration.json())).not.toContain("users.yaml");
const placeholder = await app.inject({ method: "GET", url: "/auth/oidc/login" });
expect(placeholder.statusCode).toBe(501);
expect(placeholder.json()).toEqual({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
expect(placeholder.statusCode).toBe(503);
expect(placeholder.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
});
+215
View File
@@ -0,0 +1,215 @@
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import { afterEach, expect, test } from "vitest";
import { registerAuthRoutes } from "../src/auth/routes.js";
import type { LoadedAuthConfig, OidcStateRecord } from "../src/auth/types.js";
import type { AuthSessionStore } from "../src/auth/session-store.js";
import type { OidcProtocol } from "../src/auth/oidc-client.js";
const revision = "a".repeat(64);
const issuer = "https://issuer.example.test";
const state = "s".repeat(43);
const nonce = "n".repeat(43);
const verifier = "v".repeat(43);
const createdApps: Array<ReturnType<typeof Fastify>> = [];
function config(overrides: Partial<LoadedAuthConfig["value"]> = {}): LoadedAuthConfig {
return {
revision,
sourcePath: "/private/auth.yaml",
value: {
version: 1,
mode: "oidc",
publicUrl: "https://thothii.example.test",
session: {
regularTtlSeconds: 3600, regularIdleSeconds: 300,
rememberTtlSeconds: 3600, rememberIdleSeconds: 300, oidcTtlSeconds: 3600,
},
oidc: {
issuer, clientId: "thothii", clientSecretRef: "THT_OIDC_CLIENT_SECRET",
scopes: ["openid", "profile"], groupsClaim: "groups",
},
groupCatalog: { driver: "authentik", baseUrl: issuer, apiTokenRef: "THT_AUTHENTIK_API_TOKEN" },
authorization: { groupRoles: { Users: ["user"], Admins: ["admin"] } },
...overrides,
},
} as LoadedAuthConfig;
}
function stateRecord(extra: Partial<OidcStateRecord> = {}): OidcStateRecord {
return {
version: 1, nonce, codeVerifier: verifier, returnTo: "/",
authConfigRevision: revision, issuer,
createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z",
...extra,
} as OidcStateRecord;
}
function fixture(options: {
loaded?: LoadedAuthConfig;
identity?: Awaited<ReturnType<OidcProtocol["callback"]>>;
callbackFailure?: boolean;
stateReturnTo?: string;
} = {}) {
let loaded = options.loaded ?? config();
let protocolAvailable = true;
let storedState: OidcStateRecord | undefined;
const stateInputs: Array<Record<string, unknown>> = [];
const creates: Array<Record<string, unknown>> = [];
const callbacks: URL[] = [];
const protocol: OidcProtocol = {
authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => {
expect(received).toBe(state);
expect(receivedNonce).toHaveLength(43);
expect(codeVerifier).toHaveLength(43);
return new URL(`https://issuer.example.test/authorize?state=${received}`);
},
callback: async ({ currentUrl }) => {
callbacks.push(currentUrl);
if (options.callbackFailure) throw new Error("provider failure with access-token-must-not-leak");
return options.identity ?? {
issuer, subject: "user-123", displayName: "Ada", groups: ["Users", "Admins", "Unmapped"],
tokenExpiresAt: new Date(Date.now() + 120_000),
};
},
diagnose: async () => undefined,
};
const store = {
createOidcState: async (input: Record<string, unknown>) => {
stateInputs.push(input);
const record = stateRecord({
nonce: input.nonce as string,
codeVerifier: input.codeVerifier as string,
authConfigRevision: input.authConfigRevision as string,
issuer: input.issuer as string,
});
if (options.stateReturnTo) (record as { returnTo: string }).returnTo = options.stateReturnTo;
storedState = record;
return { state, record: storedState };
},
consumeOidcState: async (received: string) => {
if (received !== state) return undefined;
const consumed = storedState;
storedState = undefined;
return consumed;
},
create: async (input: Record<string, unknown>) => {
creates.push(input);
return { token: "opaque-session-token", csrfToken: "c".repeat(43), record: {} };
},
} as unknown as AuthSessionStore;
const app = Fastify();
app.decorateRequest("authConfigSnapshot", undefined);
app.decorateRequest("authConfigSnapshotCaptured", false);
app.decorateRequest("authConfigSnapshotUnavailable", false);
app.register(cookie);
registerAuthRoutes(app, {
authMode: "oidc",
authentication: { current: () => loaded },
sessionStore: store,
resolveOidcProtocol: () => protocolAvailable ? protocol : undefined,
});
createdApps.push(app);
return {
app, creates, callbacks, stateInputs,
setConfig(next: LoadedAuthConfig) { loaded = next; },
setProtocolAvailable(available: boolean) { protocolAvailable = available; },
stateWasConsumed: () => storedState === undefined,
};
}
afterEach(async () => {
await Promise.all(createdApps.splice(0).map((app) => app.close()));
});
test("creates digest-only bound state, maps exact groups, creates a cookie session, and redirects safely", async () => {
const subject = fixture();
const start = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
expect(start.statusCode).toBe(302);
expect(new URL(start.headers.location ?? "").searchParams.get("state")).toBe(state);
expect(subject.stateInputs[0]).toMatchObject({ returnTo: "/", authConfigRevision: revision, issuer });
const callback = await subject.app.inject({
method: "GET",
url: `/auth/oidc/callback?code=good&state=${state}`,
headers: { host: "attacker.example.test" },
});
expect(callback.statusCode).toBe(302);
expect(callback.headers.location).toBe("/");
expect(callback.headers["set-cookie"]).toContain("HttpOnly");
expect(callback.headers["set-cookie"]).toContain("SameSite=Lax");
expect(callback.headers["set-cookie"]).toContain("Secure");
expect(subject.callbacks[0]?.href).toBe(`https://thothii.example.test/api/auth/oidc/callback?code=good&state=${state}`);
expect(subject.creates).toHaveLength(1);
expect(subject.creates[0]).toMatchObject({
method: "oidc", remembered: false, authConfigRevision: revision,
principal: { issuer, subject: "user-123", roles: ["user", "admin"] },
idleTtlMs: 300_000,
});
const absoluteTtlMs = subject.creates[0]?.absoluteTtlMs;
expect(typeof absoluteTtlMs).toBe("number");
expect(absoluteTtlMs as number).toBeGreaterThan(0);
expect(absoluteTtlMs as number).toBeLessThanOrEqual(120_000);
expect(JSON.stringify(subject.creates)).not.toContain("access-token-must-not-leak");
expect(JSON.stringify(subject.creates)).not.toContain("refresh-token-must-not-leak");
});
test("consumes state on callback failure and refuses replay", async () => {
const subject = fixture({ callbackFailure: true });
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
const failed = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
expect(failed.statusCode).toBe(401);
const replay = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
expect(replay.statusCode).toBe(401);
expect(subject.callbacks).toHaveLength(1);
});
test("consumes state when the protocol becomes unavailable before callback", async () => {
const subject = fixture();
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
subject.setProtocolAvailable(false);
const failed = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
expect(failed.statusCode).toBe(401);
expect(subject.stateWasConsumed()).toBe(true);
});
test("rejects a consumed state with a non-root return target", async () => {
const subject = fixture({ stateReturnTo: "https://attacker.example.test" });
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
expect(callback.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expect(subject.creates).toEqual([]);
});
test("rejects an OIDC state when its configuration revision changes before callback", async () => {
const subject = fixture();
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
subject.setConfig({ ...config(), revision: "b".repeat(64) });
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
expect(callback.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
});
test("rejects an OIDC state when its issuer changes before callback", async () => {
const subject = fixture();
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
const previous = config();
subject.setConfig({
...previous,
value: { ...previous.value, oidc: { ...previous.value.oidc, issuer: "https://other.example.test" } },
} as LoadedAuthConfig);
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
expect(callback.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
});
test("creates an authenticated but forbidden principal for extra unmapped groups", async () => {
const subject = fixture({ identity: {
issuer, subject: "user-123", groups: ["Unmapped"], tokenExpiresAt: new Date(Date.now() + 60_000),
} });
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
expect(callback.statusCode).toBe(302);
expect(subject.creates[0]).toMatchObject({ principal: { roles: [], permissions: [], isAdmin: false } });
});
+19 -16
View File
@@ -69,6 +69,10 @@ const base = new Date("2030-01-02T03:04:05.000Z");
const revision = "a".repeat(64);
const validLocalUser = { enabled: true, authRevision: 7, roles: ["admin"] as const };
function oidcInput(nonce: string, codeVerifier: string) {
return { nonce, codeVerifier, returnTo: "/" as const, authConfigRevision: revision, issuer: "https://issuer.example.test" };
}
afterEach(() => {
fsHooks.afterRead = undefined;
fsHooks.afterWrite = undefined;
@@ -258,7 +262,7 @@ describe("file-backed auth session store", () => {
const store = validStore(storageRoot);
const revoked = await create(store);
const expired = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const oidc = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const oidc = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
await store.revoke(revoked.token);
await expect(store.resolve(revoked.token)).resolves.toBeUndefined();
@@ -272,20 +276,19 @@ describe("file-backed auth session store", () => {
test("creates bounded OIDC state records that expire and are single-use", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({
nonce: "n".repeat(43),
codeVerifier: "v".repeat(43),
returnTo: "/",
}, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const path = digestPath(storageRoot, "oidc", created.state);
expect(created.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(readFileSync(path, "utf8")).not.toContain(created.state);
await expect(store.consumeOidcState(created.state, new Date(base.getTime() + 9 * 60_000)))
.resolves.toMatchObject({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" });
.resolves.toMatchObject({
nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/",
authConfigRevision: revision, issuer: "https://issuer.example.test",
});
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
const expired = await store.createOidcState({ nonce: "x".repeat(43), codeVerifier: "y".repeat(43), returnTo: "/" }, base);
const expired = await store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base);
await expect(store.consumeOidcState(expired.state, new Date(base.getTime() + 10 * 60_000)))
.resolves.toBeUndefined();
});
@@ -293,7 +296,7 @@ describe("file-backed auth session store", () => {
test("fails closed when an OIDC state already has an atomic filesystem claim", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const statePath = digestPath(storageRoot, "oidc", created.state);
linkSync(statePath, claimPath(storageRoot, created.state));
@@ -304,7 +307,7 @@ describe("file-backed auth session store", () => {
test("treats a competing OIDC claim installed between availability and state checks as unavailable", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const statePath = digestPath(storageRoot, "oidc", created.state);
const stateClaimPath = claimPath(storageRoot, created.state);
fsHooks.beforeLstat = (observed) => {
@@ -321,7 +324,7 @@ describe("file-backed auth session store", () => {
test("prunes an expired OIDC state abandoned after an atomic claim", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const statePath = digestPath(storageRoot, "oidc", created.state);
const stateClaimPath = claimPath(storageRoot, created.state);
linkSync(statePath, stateClaimPath);
@@ -334,7 +337,7 @@ describe("file-backed auth session store", () => {
test("retains an in-flight orphan claim but removes it after the bounded recovery window", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const statePath = digestPath(storageRoot, "oidc", created.state);
const stateClaimPath = claimPath(storageRoot, created.state);
linkSync(statePath, stateClaimPath);
@@ -349,7 +352,7 @@ describe("file-backed auth session store", () => {
test("allows exactly one separate Node isolate to consume an OIDC state", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const [first, second] = await Promise.all([
isolatedOidcConsumer(storageRoot, created.state),
isolatedOidcConsumer(storageRoot, created.state),
@@ -587,12 +590,12 @@ describe("file-backed auth session store", () => {
await store.revoke(session.token);
await expect(store.resolve(session.token)).resolves.toBeUndefined();
const oidc = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const oidc = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
await expect(store.consumeOidcState(oidc.state, new Date(base.getTime() + 9 * 60_000)))
.resolves.toMatchObject({ nonce: "n".repeat(43) });
await expect(store.consumeOidcState(oidc.state)).resolves.toBeUndefined();
await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
await store.createOidcState({ nonce: "x".repeat(43), codeVerifier: "y".repeat(43), returnTo: "/" }, base);
await store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base);
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2);
expect(calls).toEqual(expect.arrayContaining(["create", "read", "replace", "remove", "claim-consume", "list"]));
expect(records).toHaveLength(0);
@@ -651,7 +654,7 @@ describe("file-backed auth session store", () => {
findLocalUser: async () => validLocalUser,
}, { windowsStorageBridge: bridge });
await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2);
expect(records).toHaveLength(0);
+142
View File
@@ -0,0 +1,142 @@
import { createSign, generateKeyPairSync } from "node:crypto";
import { expect, test } from "vitest";
import { createOidcProtocol, OidcProtocolError } from "../src/auth/oidc-client.js";
const issuer = "https://issuer.example.test";
const clientId = "thothii";
const callbackUrl = "https://thothii.example.test/api/auth/oidc/callback";
const verifier = "v".repeat(43);
const nonce = "n".repeat(43);
const state = "s".repeat(43);
const keys = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = { ...keys.publicKey.export({ format: "jwk" }), kid: "test-key", use: "sig", alg: "RS256" };
function token(claims: Record<string, unknown>, invalidSignature = false): string {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const input = `${encode({ alg: "RS256", kid: "test-key", typ: "JWT" })}.${encode(claims)}`;
const signer = createSign("RSA-SHA256");
signer.update(input);
signer.end();
const signature = signer.sign(keys.privateKey).toString("base64url");
const corruptedSignature = signature.startsWith("A") ? `B${signature.slice(1)}` : `A${signature.slice(1)}`;
return `${input}.${invalidSignature ? corruptedSignature : signature}`;
}
function protocol(options: {
claims?: Record<string, unknown>;
discoveryIssuer?: string;
invalidSignature?: boolean;
seen?: URL[];
} = {}) {
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: issuer,
sub: "user-123",
aud: clientId,
exp: now + 300,
iat: now,
nonce,
name: "Ada Lovelace",
groups: ["TOT Users", "Unmapped group"],
...options.claims,
};
const fetch = async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : typeof input === "string" ? input : input.toString());
options.seen?.push(url);
if (url.pathname.includes(".well-known/")) {
return Response.json({
issuer: options.discoveryIssuer ?? issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
});
}
if (url.pathname === "/jwks") return Response.json({ keys: [jwk] });
if (url.pathname === "/token") {
return Response.json({
token_type: "Bearer",
access_token: "access-token-must-not-be-persisted",
refresh_token: "refresh-token-must-not-be-persisted",
id_token: token(claims, options.invalidSignature),
});
}
return new Response(null, { status: 404 });
};
return createOidcProtocol({
issuer,
clientId,
clientSecret: "client-secret-must-not-be-persisted",
callbackUrl,
scopes: ["openid", "profile"],
groupsClaim: "groups",
fetch,
});
}
async function callback(subject = protocol()) {
return subject.callback({
currentUrl: new URL(`${callbackUrl}?code=good&state=${state}`), state, nonce, codeVerifier: verifier,
});
}
test("uses HTTPS discovery, Authorization Code, and PKCE S256 without external network", async () => {
const seen: URL[] = [];
const subject = protocol({ seen });
const authorization = await subject.authorizationUrl({ state, nonce, codeVerifier: verifier });
expect(authorization.origin).toBe(issuer);
expect(authorization.pathname).toBe("/authorize");
expect(Object.fromEntries(authorization.searchParams)).toMatchObject({
response_type: "code", client_id: clientId, redirect_uri: callbackUrl, state, nonce,
code_challenge_method: "S256", scope: "openid profile",
});
expect(authorization.searchParams.get("code_challenge")).not.toBe(verifier);
await expect(callback(subject)).resolves.toEqual({
issuer, subject: "user-123", displayName: "Ada Lovelace",
groups: ["TOT Users", "Unmapped group"], tokenExpiresAt: expect.any(Date),
});
expect(seen.map((url) => url.origin)).toEqual([issuer, issuer, issuer]);
});
test("rejects non-HTTPS issuer configuration and a discovery issuer mismatch", async () => {
expect(() => createOidcProtocol({
issuer: "http://issuer.example.test", clientId, clientSecret: "secret", callbackUrl,
scopes: ["openid"], groupsClaim: "groups",
})).toThrow(OidcProtocolError);
await expect(protocol({ discoveryIssuer: "https://other.example.test" }).authorizationUrl({ state, nonce, codeVerifier: verifier }))
.rejects.toThrow(OidcProtocolError);
});
test.each([
["state", new URL(`${callbackUrl}?code=good&state=wrong`), {}],
["nonce", new URL(`${callbackUrl}?code=good&state=${state}`), { nonce: "wrong" }],
["audience", new URL(`${callbackUrl}?code=good&state=${state}`), { aud: "someone-else" }],
["issuer", new URL(`${callbackUrl}?code=good&state=${state}`), { iss: "https://other.example.test" }],
["expiry", new URL(`${callbackUrl}?code=good&state=${state}`), { exp: Math.floor(Date.now() / 1000) - 1 }],
["subject", new URL(`${callbackUrl}?code=good&state=${state}`), { sub: undefined }],
])("rejects invalid %s claims or callback bindings", async (_label, currentUrl, claims) => {
const subject = protocol({ claims });
await expect(subject.callback({ currentUrl, state, nonce, codeVerifier: verifier })).rejects.toThrow(OidcProtocolError);
});
test("rejects invalid ID-token signatures", async () => {
await expect(callback(protocol({ invalidSignature: true }))).rejects.toThrow(OidcProtocolError);
});
test.each([
["absent", { groups: undefined }],
["non-array", { groups: "TOT Users" }],
["empty", { groups: [""] }],
["duplicate", { groups: ["TOT Users", "TOT Users"] }],
["control", { groups: ["TOT\u0000Users"] }],
["oversized", { groups: ["x".repeat(257)] }],
["distributed", { _claim_names: { groups: "source" }, _claim_sources: { source: { endpoint: "https://issuer.example.test/claims" } } }],
["overage", { hasgroups: true }],
])("rejects %s mandatory groups claims", async (_label, claims) => {
await expect(callback(protocol({ claims }))).rejects.toThrow(OidcProtocolError);
});