Files
HealthLog/tests/integration/routes/parity/oidc-callback.parity.test.ts
T
Marc-André Bombeck 5a875d2840 fix(auth): issue a Secure session cookie under the __Host- prefix
A cookie named healthlog_session can be set by any host under the same
registrable domain, so a page on a sibling subdomain could plant a session of
its choosing in a visitor's browser. Wherever the session cookie is Secure it
is now issued as __Host-healthlog_session, which a browser accepts only from
this exact host. Plain-HTTP installs, which cannot hold a __Host- cookie,
keep the old name.

Nobody is signed out: the old name is still read after the new one, a
session found under it is re-issued under the new name and the old cookie
expired, and a browser holding the new cookie is never answered from the old
one. Every reader and writer goes through one module, which the proxy, the
native-caller check, the native handoff and the OIDC callback use; the
session-surface guard follows the new reader.
2026-10-25 17:44:31 +01:00

396 lines
13 KiB
TypeScript

/**
* Parity for `GET /api/auth/oidc/callback` while its sign-in resolution moves
* into `src/lib/auth/`.
*
* The provider is replaced at the four calls that would leave the process
* (discovery, code exchange, ID-token verification, userinfo); everything
* else is real: the sealed state cookie, sessions, MFA challenges, native
* handoffs, the pending-link cookie, the audit ledger. The walk covers each
* outcome the route tells apart: SSO switched off, a provider refusal, a
* broken round trip, a missing state blob with and without a live session,
* a forged state, a failed exchange, an exchange with no ID token,
* first-login provisioning (the first account becomes admin), a returning
* identity whose verified email changed, the userinfo fallback, the email
* refusals, a link conflict, a pending link on the web and on the phone,
* closed registration, the second factor on the web and on the phone, the
* native handoff, and the two claim refusals. After each request the
* snapshot holds the redirect, the cookies it set, the wide event, the
* recorded sign-in device tail and the accounts, sessions and ledger rows.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { GET } from "@/app/api/auth/oidc/callback/route";
import { encrypt } from "@/lib/crypto";
import { OIDC_STATE_COOKIE } from "@/lib/auth/oidc-cookie";
import { sessionCookieName } from "@/lib/auth/session-cookie";
import { anonymous, callRoute } from "../../../kit/call-route";
import { makeSession, makeUser } from "../../../kit/factories";
import {
effectRecorder,
eventFacts,
expectParity,
stable,
wideEventRecorder,
} from "../../../kit/parity";
import { getPrismaClient, truncateAllTables } from "../../setup";
vi.mock("@/lib/logging/transports", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/transports")>();
const { wideEventRecorder: recorder } = await import("../../../kit/parity");
return {
...actual,
emitIfSampled: (event: (typeof recorder.events)[number]) => {
recorder.events.push(event);
},
};
});
vi.mock("@/lib/auth/login-alert", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/auth/login-alert")>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
recordSignInDevice: async (...args: unknown[]) => {
effectRecorder.record("recordSignInDevice", args);
},
};
});
/** What the fake provider answers for the next callback. */
const idp = vi.hoisted(() => ({
exchange: "ok" as "ok" | "no_id_token" | "throws",
accessToken: false,
identity: {
sub: "sub-1",
email: null as string | null,
emailVerified: undefined as boolean | undefined,
},
userinfo: {
email: null as string | null,
emailVerified: undefined as boolean | undefined,
},
}));
const ISSUER = "https://id.example.test";
vi.mock("@/lib/auth/oidc", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/auth/oidc")>();
return {
...actual,
discoverOidcMetadata: async () => ({
issuer: "https://id.example.test",
authorization_endpoint: "https://id.example.test/authorize",
token_endpoint: "https://id.example.test/token",
jwks_uri: "https://id.example.test/jwks",
}),
exchangeCodeForTokens: async () => {
if (idp.exchange === "throws") throw new Error("invalid_grant");
return {
...(idp.exchange === "ok" ? { id_token: "id-token" } : {}),
...(idp.accessToken ? { access_token: "access-token" } : {}),
token_type: "Bearer",
};
},
verifyIdToken: async () => ({ ...idp.identity, name: null }),
fetchUserinfoEmail: async () => ({ ...idp.userinfo }),
};
});
const prisma = getPrismaClient();
const NOW = new Date("2026-10-15T12:00:00.000Z");
const APP = "http://localhost:3000";
const SAVED_ENV = { ...process.env };
interface State {
native?: true;
appCodeChallenge?: string;
intent?: "claim";
claimBinding?: string;
next?: string;
/** Send a different `state` in the query than the blob carries. */
forged?: true;
}
function stateCookie(state: State): string {
return encrypt(
JSON.stringify({
state: "state-1",
nonce: "nonce-1",
codeVerifier: "verifier-1",
next: state.next ?? "/",
...(state.native
? { native: true, appCodeChallenge: state.appCodeChallenge }
: {}),
...(state.intent
? { intent: state.intent, claimBinding: state.claimBinding }
: {}),
}),
);
}
/** The redirect target with its one-time values replaced. */
function location(headers: Headers): string | null {
const raw = headers.get("location");
if (!raw) return null;
return raw.replace(
/([?&](?:code|mfa_ticket)=)[^&]+/g,
(_, key: string) => `${key}<value>`,
);
}
/** The cookies the response set or cleared, by name and attributes. */
function setCookies(headers: Headers): string[] {
return headers
.getSetCookie()
.map((c) =>
c.replace(
/^([^=]+)=([^;]*)/,
(_, n: string, v: string) => `${n}=${v ? "<value>" : ""}`,
),
)
.map((c) => c.replace(/Expires=[^;]+/i, "Expires=<date>"))
.sort();
}
async function stored() {
const [users, sessions, audit, challenges, handoffs] = await Promise.all([
prisma.user.findMany({
orderBy: { username: "asc" },
select: {
username: true,
email: true,
role: true,
timezone: true,
oidcIssuer: true,
oidcSub: true,
},
}),
prisma.session.findMany({
orderBy: [{ createdAt: "asc" }, { userId: "asc" }],
select: {
user: { select: { username: true } },
mfaVerifiedAt: true,
},
}),
prisma.auditLog.findMany({
orderBy: [{ createdAt: "asc" }, { action: "asc" }],
select: { action: true, user: { select: { username: true } } },
}),
prisma.mfaChallenge.count(),
prisma.oidcNativeHandoff.count(),
]);
return { users, sessions, audit, challenges, handoffs };
}
async function step(
name: string,
options: {
query?: Record<string, string>;
state?: State | null;
cookies?: Record<string, string>;
},
): Promise<void> {
await prisma.rateLimit.deleteMany({});
wideEventRecorder.take();
effectRecorder.take();
const query =
options.query ??
({
code: "code-1",
state: options.state?.forged ? "state-x" : "state-1",
} as Record<string, string>);
const cookies: Record<string, string> = {
...(options.state
? { [OIDC_STATE_COOKIE]: stateCookie(options.state) }
: {}),
...options.cookies,
};
const res = await callRoute(GET, {
method: "GET",
route: "/api/auth/oidc/callback",
query,
headers: {
cookie: Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; "),
},
auth: anonymous,
contract: {
skip: "a browser redirect target, not part of the published API",
},
});
const events = wideEventRecorder.take();
expect(events.length).toBeGreaterThan(0);
await expectParity(
`oidc-callback.${name}`,
stable(
{
status: res.status,
location: location(res.headers),
setCookies: setCookies(res.headers),
events: events.map(eventFacts),
effects: effectRecorder.take(),
stored: await stored(),
},
{ since: NOW },
),
);
}
function identity(
sub: string,
email: string | null,
emailVerified: boolean | undefined,
): void {
idp.exchange = "ok";
idp.accessToken = false;
idp.identity = { sub, email, emailVerified };
}
beforeAll(async () => {
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
await truncateAllTables(prisma);
process.env.APP_URL = APP;
process.env.NEXT_PUBLIC_APP_URL = APP;
delete process.env.OIDC_ISSUER_URL;
delete process.env.OIDC_ONLY;
});
afterAll(() => {
vi.useRealTimers();
for (const key of [
"APP_URL",
"NEXT_PUBLIC_APP_URL",
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
]) {
if (SAVED_ENV[key] === undefined) delete process.env[key];
else process.env[key] = SAVED_ENV[key];
}
});
describe("GET /api/auth/oidc/callback parity", () => {
it("01 SSO switched off", async () => {
await step("01-disabled", { state: {} });
process.env.OIDC_ISSUER_URL = ISSUER;
process.env.OIDC_CLIENT_ID = "healthlog-test";
process.env.OIDC_CLIENT_SECRET = "client-secret";
});
it("02 the round-trip refusals", async () => {
await step("02-idp-denied", {
query: { error: "access_denied", state: "state-1" },
state: {},
});
await step("03-idp-denied-unknown-code", {
query: { error: "something_else" },
state: { native: true, appCodeChallenge: "challenge-1" },
});
await step("04-no-code", { query: { state: "state-1" }, state: {} });
await step("05-no-state-blob", { state: null });
await step("06-forged-state", { state: { forged: true } });
idp.exchange = "throws";
await step("07-exchange-failed", { state: {} });
idp.exchange = "no_id_token";
await step("08-no-id-token", { state: {} });
});
it("09 the first login provisions an admin", async () => {
identity("sub-first", "First@Example.test", true);
await step("09-provision-first", { state: { next: "/insights" } });
identity("sub-second", "second@example.test", true);
await step("10-provision-second", { state: {} });
});
it("11 a returning identity, and a duplicate callback", async () => {
identity("sub-first", "first.new@example.test", true);
await step("11-returning-email-refresh", { state: {} });
const first = await prisma.user.findFirstOrThrow({
where: { oidcSub: "sub-first" },
});
const session = await makeSession(first);
await step("12-duplicate-with-session", {
state: null,
cookies: { [sessionCookieName()]: session.cookie },
});
});
it("13 the email refusals and the userinfo fallback", async () => {
identity("sub-noemail", null, undefined);
await step("13-no-email", { state: {} });
identity("sub-unverified", "unverified@example.test", false);
await step("14-email-unverified", { state: {} });
identity("sub-userinfo", null, undefined);
idp.accessToken = true;
idp.userinfo = { email: "userinfo@example.test", emailVerified: true };
await step("15-userinfo-provision", { state: {} });
});
it("16 an account already holding the email", async () => {
await makeUser({
username: "linked-elsewhere",
email: "conflict@example.test",
oidcIssuer: "https://other.example.test",
oidcSub: "other-sub",
});
identity("sub-conflict", "conflict@example.test", true);
await step("16-link-conflict", { state: {} });
await makeUser({ username: "local-only", email: "local@example.test" });
identity("sub-local", "local@example.test", true);
await step("17-link-pending-web", { state: {} });
await step("18-link-pending-native", {
state: { native: true, appCodeChallenge: "challenge-1" },
});
});
it("19 closed registration", async () => {
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", registrationEnabled: false },
update: { registrationEnabled: false },
});
identity("sub-late", "late@example.test", true);
await step("19-registration-closed", { state: {} });
await prisma.appSettings.update({
where: { id: "singleton" },
data: { registrationEnabled: true },
});
});
it("20 the second factor and the native handoff", async () => {
await prisma.user.updateMany({
where: { oidcSub: "sub-second" },
data: { totpConfirmedAt: new Date("2026-09-01T00:00:00.000Z") },
});
identity("sub-second", "second@example.test", true);
await step("20-mfa-web", { state: { next: "/medications" } });
await step("21-mfa-native", {
state: { native: true, appCodeChallenge: "challenge-1" },
});
identity("sub-userinfo", "userinfo@example.test", true);
await step("22-native-handoff", {
state: { native: true, appCodeChallenge: "challenge-2" },
});
});
it("23 the claim refusals", async () => {
const someone = await prisma.user.findFirstOrThrow({
where: { oidcSub: "sub-first" },
});
const session = await makeSession(someone);
identity("sub-claimer", "claimer@example.test", true);
await step("23-claim-signed-in", {
state: { intent: "claim", claimBinding: "binding-1" },
cookies: { [sessionCookieName()]: session.cookie },
});
await step("24-claim-no-cookie", {
state: { intent: "claim", claimBinding: "binding-1" },
});
await step("25-claim-no-code", {
query: { state: "state-1" },
state: { intent: "claim", claimBinding: "binding-1" },
});
});
});