518 lines
21 KiB
TypeScript
518 lines
21 KiB
TypeScript
import Fastify from "fastify";
|
|
import cookie from "@fastify/cookie";
|
|
import { afterEach, expect, test, vi } 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 transactionCookieName = "__Host-thothii_oidc_tx";
|
|
const loopbackTransactionCookieName = "thothii_oidc_tx";
|
|
const createdApps: Array<ReturnType<typeof Fastify>> = [];
|
|
|
|
function setCookieHeaders(response: { headers: Record<string, string | string[] | undefined> }): string[] {
|
|
const header = response.headers["set-cookie"];
|
|
return header === undefined ? [] : Array.isArray(header) ? header : [header];
|
|
}
|
|
|
|
function transactionCookie(
|
|
response: { headers: Record<string, string | string[] | undefined> },
|
|
name = transactionCookieName,
|
|
): string | undefined {
|
|
return setCookieHeaders(response)
|
|
.find((header) => header.startsWith(`${name}=`) && !header.includes("Max-Age=0"))
|
|
?.split(";", 1)[0];
|
|
}
|
|
|
|
function expectTransactionCleared(
|
|
response: { headers: Record<string, string | string[] | undefined> },
|
|
name = transactionCookieName,
|
|
secure = true,
|
|
): void {
|
|
const header = setCookieHeaders(response).find((candidate) =>
|
|
candidate.startsWith(`${name}=`) && candidate.includes("Max-Age=0"));
|
|
expect(header).toBeDefined();
|
|
expect(header).toContain("HttpOnly");
|
|
expect(header).toContain("SameSite=Lax");
|
|
expect(header).toContain("Path=/");
|
|
expect(header).not.toContain("Domain=");
|
|
if (secure) expect(header).toContain("Secure");
|
|
else expect(header).not.toContain("Secure");
|
|
}
|
|
|
|
function expectBothTransactionVariantsCleared(
|
|
response: { headers: Record<string, string | string[] | undefined> },
|
|
activeName: string,
|
|
activeSecure: boolean,
|
|
): void {
|
|
expectTransactionCleared(response, activeName, activeSecure);
|
|
expectTransactionCleared(
|
|
response,
|
|
activeName === transactionCookieName ? loopbackTransactionCookieName : transactionCookieName,
|
|
activeName !== transactionCookieName,
|
|
);
|
|
}
|
|
|
|
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,
|
|
browserTransactionDigest: "b".repeat(64),
|
|
browserTransactionTransport: "https",
|
|
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 createTimes: Array<Date | undefined> = [];
|
|
const callbacks: URL[] = [];
|
|
let authorizationRequests = 0;
|
|
const protocol: OidcProtocol = {
|
|
authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => {
|
|
authorizationRequests += 1;
|
|
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,
|
|
});
|
|
(record as OidcStateRecord & { browserTransactionDigest: string }).browserTransactionDigest =
|
|
input.browserTransactionDigest as string;
|
|
(record as OidcStateRecord & { browserTransactionTransport?: string }).browserTransactionTransport =
|
|
input.browserTransactionTransport as string | undefined ?? "https";
|
|
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>, now?: Date) => {
|
|
creates.push(input);
|
|
createTimes.push(now);
|
|
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, createTimes, callbacks, stateInputs,
|
|
authorizationRequests: () => authorizationRequests,
|
|
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()));
|
|
});
|
|
|
|
async function beginOidcLogin(subject: ReturnType<typeof fixture>) {
|
|
const response = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
|
return {
|
|
response,
|
|
cookie: transactionCookie(response) ?? transactionCookie(response, loopbackTransactionCookieName),
|
|
};
|
|
}
|
|
|
|
async function finishOidcLogin(
|
|
subject: ReturnType<typeof fixture>,
|
|
cookie: string | undefined,
|
|
query = `code=good&state=${state}`,
|
|
) {
|
|
return await subject.app.inject({
|
|
method: "GET",
|
|
url: `/auth/oidc/callback?${query}`,
|
|
...(cookie ? { headers: { cookie } } : {}),
|
|
});
|
|
}
|
|
|
|
test("rate limits OIDC initiation before state creation and provider discovery", async () => {
|
|
const subject = fixture();
|
|
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
expect((await beginOidcLogin(subject)).response.statusCode).toBe(302);
|
|
}
|
|
const limited = await beginOidcLogin(subject);
|
|
|
|
expect(limited.response.statusCode).toBe(429);
|
|
expect(limited.response.json()).toEqual({
|
|
code: "login_rate_limited",
|
|
error: "Too many login attempts",
|
|
});
|
|
expect(subject.stateInputs).toHaveLength(20);
|
|
expect(subject.authorizationRequests()).toBe(20);
|
|
});
|
|
|
|
test("OIDC initiation rate-limit capacity expires after ten minutes", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2030-01-02T03:04:05.000Z"));
|
|
const subject = fixture();
|
|
try {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
expect((await beginOidcLogin(subject)).response.statusCode).toBe(302);
|
|
}
|
|
expect((await beginOidcLogin(subject)).response.statusCode).toBe(429);
|
|
|
|
vi.advanceTimersByTime(10 * 60_000 + 1);
|
|
|
|
expect((await beginOidcLogin(subject)).response.statusCode).toBe(302);
|
|
expect(subject.authorizationRequests()).toBe(21);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("creates digest-only bound state, maps exact groups, creates a cookie session, and redirects safely", async () => {
|
|
const subject = fixture();
|
|
const { response: start, cookie } = await beginOidcLogin(subject);
|
|
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,
|
|
browserTransactionDigest: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
browserTransactionTransport: "https",
|
|
});
|
|
expect(cookie).toMatch(new RegExp(`^${transactionCookieName}=[A-Za-z0-9_-]{43}$`));
|
|
const issued = setCookieHeaders(start)
|
|
.find((header) => header.startsWith(`${transactionCookieName}=`) && !header.includes("Max-Age=0"));
|
|
expect(issued).toContain("HttpOnly");
|
|
expect(issued).toContain("SameSite=Lax");
|
|
expect(issued).toContain("Secure");
|
|
expect(issued).toContain("Path=/");
|
|
expect(issued).not.toContain("Domain=");
|
|
expect(JSON.stringify(subject.stateInputs)).not.toContain(cookie?.split("=", 2)[1] ?? "missing-cookie");
|
|
|
|
const callback = await subject.app.inject({
|
|
method: "GET",
|
|
url: `/auth/oidc/callback?code=good&state=${state}`,
|
|
headers: { host: "attacker.example.test", cookie: cookie ?? "" },
|
|
});
|
|
expect(callback.statusCode).toBe(302);
|
|
expect(callback.headers.location).toBe("/");
|
|
expect(setCookieHeaders(callback).join("\n")).toContain("HttpOnly");
|
|
expect(setCookieHeaders(callback).join("\n")).toContain("SameSite=Lax");
|
|
expect(setCookieHeaders(callback).join("\n")).toContain("Secure");
|
|
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
|
|
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.each([
|
|
"http://127.42.0.1:8787",
|
|
"http://[::1]:8787",
|
|
])("uses the non-prefixed, non-Secure transaction cookie for literal loopback OIDC %s", async (publicUrl) => {
|
|
const subject = fixture({ loaded: config({ publicUrl }) });
|
|
const { response: start, cookie } = await beginOidcLogin(subject);
|
|
|
|
expect(start.statusCode).toBe(302);
|
|
expect(subject.stateInputs[0]).toMatchObject({ browserTransactionTransport: "loopback_http" });
|
|
expect(cookie).toMatch(new RegExp(`^${loopbackTransactionCookieName}=[A-Za-z0-9_-]{43}$`));
|
|
const issued = setCookieHeaders(start).find((header) => header.startsWith(`${loopbackTransactionCookieName}=`));
|
|
expect(issued).toContain("HttpOnly");
|
|
expect(issued).toContain("SameSite=Lax");
|
|
expect(issued).toContain("Path=/");
|
|
expect(issued).not.toContain("Secure");
|
|
expect(issued).not.toContain("Domain=");
|
|
|
|
const callback = await finishOidcLogin(subject, cookie);
|
|
expect(callback.statusCode).toBe(302);
|
|
expect(subject.callbacks[0]?.href).toBe(`${publicUrl}/api/auth/oidc/callback?code=good&state=${state}`);
|
|
expectBothTransactionVariantsCleared(callback, loopbackTransactionCookieName, false);
|
|
});
|
|
|
|
test("uses the consumed state transport to reject cross-mode and shadowed transaction cookies", async () => {
|
|
const subject = fixture({ loaded: config({ publicUrl: "http://127.0.0.1:8787" }) });
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
expect(cookie).toBeDefined();
|
|
|
|
const failed = await finishOidcLogin(subject, [
|
|
cookie,
|
|
`${transactionCookieName}=${"x".repeat(43)}`,
|
|
].join("; "));
|
|
|
|
expect(failed.statusCode).toBe(401);
|
|
expect(subject.callbacks).toEqual([]);
|
|
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
|
|
});
|
|
|
|
test("rejects a transaction presented only under the wrong transport cookie name", async () => {
|
|
const subject = fixture();
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const wrongTransportCookie = cookie?.replace(transactionCookieName, loopbackTransactionCookieName);
|
|
|
|
const failed = await finishOidcLogin(subject, wrongTransportCookie);
|
|
|
|
expect(failed.statusCode).toBe(401);
|
|
expect(subject.callbacks).toEqual([]);
|
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
|
});
|
|
|
|
test("rejects duplicate same-mode transaction cookies instead of trusting a parser-selected value", async () => {
|
|
const subject = fixture();
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
expect(cookie).toBeDefined();
|
|
|
|
const failed = await finishOidcLogin(subject, [
|
|
cookie,
|
|
`${transactionCookieName}=${"x".repeat(43)}`,
|
|
].join("; "));
|
|
|
|
expect(failed.statusCode).toBe(401);
|
|
expect(subject.callbacks).toEqual([]);
|
|
expect(subject.stateWasConsumed()).toBe(true);
|
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
|
});
|
|
|
|
test("pins the callback transaction transport to its captured configuration snapshot", async () => {
|
|
const subject = fixture({ loaded: config({ publicUrl: "http://127.42.0.1:8787" }) });
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
subject.setConfig(config({ publicUrl: "https://thothii.example.test" }));
|
|
|
|
const failed = await finishOidcLogin(subject, cookie);
|
|
|
|
expect(failed.statusCode).toBe(401);
|
|
expect(subject.callbacks).toEqual([]);
|
|
expect(subject.stateWasConsumed()).toBe(true);
|
|
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
|
|
});
|
|
|
|
test("clears the state-pinned loopback transaction variant after a terminal callback failure", async () => {
|
|
const subject = fixture({
|
|
loaded: config({ publicUrl: "http://127.0.0.1:8787" }),
|
|
callbackFailure: true,
|
|
});
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const failed = await finishOidcLogin(subject, cookie);
|
|
|
|
expect(failed.statusCode).toBe(401);
|
|
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
|
|
});
|
|
|
|
test("consumes state on callback failure and refuses replay", async () => {
|
|
const subject = fixture({ callbackFailure: true });
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const failed = await finishOidcLogin(subject, cookie);
|
|
expect(failed.statusCode).toBe(401);
|
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
|
const replay = await finishOidcLogin(subject, cookie);
|
|
expect(replay.statusCode).toBe(401);
|
|
expect(subject.callbacks).toHaveLength(1);
|
|
});
|
|
|
|
test("consumes state when the protocol becomes unavailable before callback", async () => {
|
|
const subject = fixture();
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
subject.setProtocolAvailable(false);
|
|
const failed = await finishOidcLogin(subject, cookie);
|
|
expect(failed.statusCode).toBe(401);
|
|
expect(subject.stateWasConsumed()).toBe(true);
|
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
|
});
|
|
|
|
test("rejects a consumed state with a non-root return target", async () => {
|
|
const subject = fixture({ stateReturnTo: "https://attacker.example.test" });
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const callback = await finishOidcLogin(subject, cookie);
|
|
expect(callback.statusCode).toBe(401);
|
|
expect(subject.callbacks).toEqual([]);
|
|
expect(subject.creates).toEqual([]);
|
|
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
|
|
});
|
|
|
|
test("rejects an OIDC state when its configuration revision changes before callback", async () => {
|
|
const subject = fixture();
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
subject.setConfig({ ...config(), revision: "b".repeat(64) });
|
|
const callback = await finishOidcLogin(subject, cookie);
|
|
expect(callback.statusCode).toBe(401);
|
|
expect(subject.callbacks).toEqual([]);
|
|
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
|
|
});
|
|
|
|
test("rejects an OIDC state when its issuer changes before callback", async () => {
|
|
const subject = fixture();
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const previous = config();
|
|
subject.setConfig({
|
|
...previous,
|
|
value: { ...previous.value, oidc: { ...previous.value.oidc, issuer: "https://other.example.test" } },
|
|
} as LoadedAuthConfig);
|
|
const callback = await finishOidcLogin(subject, cookie);
|
|
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),
|
|
} });
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const callback = await finishOidcLogin(subject, cookie);
|
|
expect(callback.statusCode).toBe(302);
|
|
expect(subject.creates[0]).toMatchObject({ principal: { roles: [], permissions: [], isAdmin: false } });
|
|
});
|
|
|
|
test("rejects a callback from a different browser and clears the transaction cookie", async () => {
|
|
const subject = fixture();
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
expect(cookie).toBeDefined();
|
|
|
|
const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`);
|
|
expect(failed.statusCode).toBe(401);
|
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
|
expect(subject.stateWasConsumed()).toBe(true);
|
|
expect(subject.creates).toEqual([]);
|
|
|
|
const replay = await finishOidcLogin(subject, cookie);
|
|
expect(replay.statusCode).toBe(401);
|
|
expect(subject.creates).toEqual([]);
|
|
});
|
|
|
|
test.each([
|
|
["unknown", `code=good&state=${state}&unexpected=value`],
|
|
["duplicate", `code=good&code=other&state=${state}`],
|
|
["malformed", `code=good&state=${state}&error_description=%00bad`],
|
|
])("burns canonical state before rejecting %s callback parameters", async (_label, query) => {
|
|
const subject = fixture();
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const failed = await finishOidcLogin(subject, cookie, query);
|
|
expect(failed.statusCode).toBe(401);
|
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
|
expect(subject.stateWasConsumed()).toBe(true);
|
|
expect(subject.callbacks).toEqual([]);
|
|
|
|
const replay = await finishOidcLogin(subject, cookie);
|
|
expect(replay.statusCode).toBe(401);
|
|
expect(subject.callbacks).toEqual([]);
|
|
});
|
|
|
|
test("boundedly burns a canonical state from an oversized callback URL", async () => {
|
|
const subject = fixture();
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const oversized = `state=${state}&code=good&padding=${"x".repeat(4096)}`;
|
|
|
|
const failed = await finishOidcLogin(subject, cookie, oversized);
|
|
|
|
expect(failed.statusCode).toBe(401);
|
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
|
expect(subject.stateWasConsumed()).toBe(true);
|
|
expect(subject.callbacks).toEqual([]);
|
|
expect((await finishOidcLogin(subject, cookie)).statusCode).toBe(401);
|
|
});
|
|
|
|
test("rejects an empty direct groups claim without creating a session cookie", async () => {
|
|
const subject = fixture({ identity: {
|
|
issuer, subject: "user-123", groups: [], tokenExpiresAt: new Date(Date.now() + 60_000),
|
|
} });
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const callback = await finishOidcLogin(subject, cookie);
|
|
expect(callback.statusCode).toBe(401);
|
|
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
|
|
expect(subject.creates).toEqual([]);
|
|
expect(setCookieHeaders(callback).join("\n")).not.toContain("opaque-session-token");
|
|
});
|
|
|
|
test("clears both transaction variants when the callback state cannot be consumed", async () => {
|
|
const subject = fixture();
|
|
const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`);
|
|
|
|
expect(failed.statusCode).toBe(401);
|
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
|
});
|
|
|
|
test("uses one captured instant for token TTL derivation and session creation", async () => {
|
|
const tokenExpiresAt = new Date(Date.now() + 120_000);
|
|
const subject = fixture({ identity: {
|
|
issuer, subject: "user-123", groups: ["Users"], tokenExpiresAt,
|
|
} });
|
|
const { cookie } = await beginOidcLogin(subject);
|
|
const callback = await finishOidcLogin(subject, cookie);
|
|
expect(callback.statusCode).toBe(302);
|
|
expect(subject.createTimes[0]).toBeInstanceOf(Date);
|
|
expect((subject.createTimes[0] as Date).getTime() + (subject.creates[0]?.absoluteTtlMs as number))
|
|
.toBe(tokenExpiresAt.getTime());
|
|
});
|