Files
HealthLog/tests/integration/oidc-pending-link.test.ts
T
Marc-André Bombeck 8da7583ec6 fix(auth): link single sign-on to an existing account only after its own sign-in
The SSO callback bound a verified provider email to the local account with
the same address on the spot. The local address is not verified: anyone
could register with it, or type it into their profile, and the SSO user
then landed in an account the other person still held the password to.

The callback now links nothing. It records the link it would make in a
sealed, ten-minute, HttpOnly cookie and sends the browser to the sign-in
page, and the link is made only when that browser then signs in to that
same account with its password (and second factor) or passkey. Under an
SSO-only policy the password form opens for exactly that account. The
native sign-in answers oidc_link_required; the link is confirmed once on
the web.

Moving an account to a new email address now also needs a fresh proof: a
sign-in or second factor in the last five minutes, or the current password
sent alongside the change. Without it the address is left as it was and
reported in rejectedFields; the rest of the save still lands.
2026-09-26 16:10:32 +02:00

190 lines
5.7 KiB
TypeScript

/**
* A single sign-on identity is linked to an existing account only after the
* account's own credential is proved in the same browser.
*
* The callback leaves a sealed pending link (unit-tested in the callback's own
* suite); this pins the other half against real Postgres: which sign-ins
* complete it, which do not, and that an SSO-only policy opens the password
* form for exactly the account the IdP vouched for and nothing else.
*/
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-oidc-pending-link-32-bytes-minimum-1234567890";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const { hashPassword } = await import("@/lib/auth/password");
const { encrypt } = await import("@/lib/crypto");
const PASSWORD = "Correct horse battery staple!42";
const ISSUER = "https://idp.example.test";
const OIDC_ENV = {
OIDC_ISSUER_URL: ISSUER,
OIDC_CLIENT_ID: "client",
OIDC_CLIENT_SECRET: "secret",
};
function pendingLinkFor(userId: string, expiresInMs = 60_000): void {
cookieJar.set(
"hl_oidc_link",
encrypt(
JSON.stringify({
u: userId,
i: ISSUER,
s: "idp-sub-1",
e: Date.now() + expiresInMs,
}),
),
);
}
async function passwordLogin(identifier: string, password = PASSWORD) {
const { POST } = await import("@/app/api/auth/login/route");
return POST(
new NextRequest("http://localhost/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: identifier, password }),
}),
);
}
async function linkOf(id: string) {
return getPrismaClient().user.findUniqueOrThrow({
where: { id },
select: { oidcIssuer: true, oidcSub: true },
});
}
beforeEach(async () => {
vi.clearAllMocks();
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
process.env.APP_URL = "http://localhost:3000";
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
const hash = await hashPassword(PASSWORD);
await getPrismaClient().user.createMany({
data: [
{
id: "acct-a",
username: "acct-a",
email: "a@example.test",
passwordHash: hash,
onboardingCompletedAt: new Date(),
},
{
id: "acct-b",
username: "acct-b",
email: "b@example.test",
passwordHash: hash,
onboardingCompletedAt: new Date(),
},
],
});
});
afterEach(() => {
for (const key of [...Object.keys(OIDC_ENV), "OIDC_ONLY"]) {
delete process.env[key];
}
});
describe("pending single sign-on link", () => {
it("is completed by a password sign-in to the named account", async () => {
pendingLinkFor("acct-a");
const res = await passwordLogin("acct-a");
expect(res.status).toBe(200);
expect(await linkOf("acct-a")).toEqual({
oidcIssuer: ISSUER,
oidcSub: "idp-sub-1",
});
expect(
await getPrismaClient().auditLog.count({
where: { userId: "acct-a", action: "auth.oidc.linked" },
}),
).toBe(1);
});
it("is not completed by a sign-in to a different account", async () => {
pendingLinkFor("acct-a");
const res = await passwordLogin("acct-b");
expect(res.status).toBe(200);
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
expect(await linkOf("acct-b")).toEqual({ oidcIssuer: null, oidcSub: null });
});
it("is not completed by a wrong password", async () => {
pendingLinkFor("acct-a");
const res = await passwordLogin("acct-a", "not the password");
expect(res.status).toBe(401);
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
});
it("expires", async () => {
pendingLinkFor("acct-a", -1);
await passwordLogin("acct-a");
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
});
it("refuses a forged cookie", async () => {
cookieJar.set(
"hl_oidc_link",
JSON.stringify({ u: "acct-a", i: ISSUER, s: "x", e: Date.now() + 1e6 }),
);
await passwordLogin("acct-a");
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
});
});
describe("under an SSO-only policy", () => {
beforeEach(() => {
Object.assign(process.env, OIDC_ENV, { OIDC_ONLY: "true" });
});
it("keeps password sign-in closed without a pending link", async () => {
const res = await passwordLogin("acct-a");
expect(res.status).toBe(403);
});
it("opens it for the account the pending link names, and links it", async () => {
pendingLinkFor("acct-a");
const res = await passwordLogin("acct-a");
expect(res.status).toBe(200);
expect((await linkOf("acct-a")).oidcSub).toBe("idp-sub-1");
});
it("keeps it closed for any other account", async () => {
pendingLinkFor("acct-a");
const res = await passwordLogin("acct-b");
expect(res.status).toBe(403);
expect(
await getPrismaClient().session.count({ where: { userId: "acct-b" } }),
).toBe(0);
});
});