mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second factor, so the recent-proof gate in front of token minting, MCP connections, exports and the admin reset had nothing to ask it for and the web could only say "sign in again" (#1296). GET /api/auth/oidc/reproof sends the browser back to the configured provider with prompt=login and max_age=0, its own state, nonce and PKCE verifier, and the signed-in account and session sealed into the state cookie. The shared callback branches on that intent: it refuses unless the browser still carries the bound session (checked before the code is redeemed), the ID token names the account's linked issuer and subject, and auth_time lies inside the gate's five-minute window. It then stamps Session.reproofAt exactly as a password re-proof does, creates no session, and returns to the page it left with ?reproof=done|failed. recentProofMethods offers "oidc" only to an account without a second factor whose identity is linked while a provider is configured, and the fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the list as proofMethods, so the passkey card offers what the account can actually give, and register-options takes { method: "oidc" } on a fresh provider re-proof, so such an account can add a passkey as a second way to confirm. Both re-proof dialogs offer the provider and a toast reports the outcome on return. The callback's missing-state branch and the passkey card's enrollment flow move into their own functions to keep both handlers under the size ratchet.
This commit is contained in:
1 parent
35a0ceb12c
commit
bbef4eea99
35 files changed
+1654
-187
No files matched your search
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Confirming at the identity provider (#1296), against real Postgres.
|
||||
*
|
||||
* An account created by single sign-on holds no password, passkey or second
|
||||
* factor, so the recent-proof gate in front of token minting had nothing to
|
||||
* ask it for. The browser now goes back to the provider with a fresh login
|
||||
* request, and the callback stamps the signed-in session's `reproofAt` when
|
||||
* the ID token names this account's linked identity and an authentication
|
||||
* inside the window. The provider's network legs are stubbed; the state blob,
|
||||
* the session binding, the stamp, the gate and the passkey enrollment are
|
||||
* real.
|
||||
*/
|
||||
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-reproof-32-bytes-minimum-1234567890abcd";
|
||||
|
||||
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 ISSUER = "https://idp.example.test";
|
||||
const METADATA = {
|
||||
issuer: ISSUER,
|
||||
authorization_endpoint: `${ISSUER}/authorize`,
|
||||
token_endpoint: `${ISSUER}/token`,
|
||||
jwks_uri: `${ISSUER}/jwks`,
|
||||
};
|
||||
|
||||
vi.mock("@/lib/auth/oidc", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/auth/oidc")>();
|
||||
return {
|
||||
...actual,
|
||||
discoverOidcMetadata: vi.fn(async () => METADATA),
|
||||
exchangeCodeForTokens: vi.fn(async () => ({ id_token: "id-token" })),
|
||||
verifyIdToken: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const oidc = await import("@/lib/auth/oidc");
|
||||
|
||||
const USER_ID = "user-oidc-reproof";
|
||||
const SUB = "idp-sub-reproof";
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const OIDC_ENV = {
|
||||
OIDC_ISSUER_URL: ISSUER,
|
||||
OIDC_CLIENT_ID: "client",
|
||||
OIDC_CLIENT_SECRET: "secret",
|
||||
};
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
async function startSession(createdAgoMs: number): Promise<string> {
|
||||
const now = Date.now();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
createdAt: new Date(now - createdAgoMs),
|
||||
expiresAt: new Date(now + HOUR),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session.id;
|
||||
}
|
||||
|
||||
/** The provider says it authenticated `sub` this many seconds ago. */
|
||||
function providerSays(sub: string, secondsAgo: number) {
|
||||
vi.mocked(oidc.verifyIdToken).mockResolvedValue({
|
||||
sub,
|
||||
email: "sso@example.test",
|
||||
emailVerified: true,
|
||||
name: null,
|
||||
authTime: Math.floor(Date.now() / 1000) - secondsAgo,
|
||||
});
|
||||
}
|
||||
|
||||
async function startReproof(next: string) {
|
||||
const { GET } = await import("@/app/api/auth/oidc/reproof/route");
|
||||
const res = await (GET as unknown as RouteFn)(
|
||||
new NextRequest(
|
||||
`http://localhost:3000/api/auth/oidc/reproof?next=${encodeURIComponent(next)}`,
|
||||
),
|
||||
);
|
||||
return res as Response & { cookies?: unknown };
|
||||
}
|
||||
|
||||
/** Run the start, then come back through the callback with the browser's cookies. */
|
||||
async function roundTrip(next: string, sessionCookie: string) {
|
||||
const started = await startReproof(next);
|
||||
expect(started.status).toBe(307);
|
||||
const authorize = new URL(started.headers.get("location")!);
|
||||
const stateCookie = /oidc_auth_state=([^;]+)/.exec(
|
||||
started.headers.get("set-cookie") ?? "",
|
||||
)?.[1];
|
||||
expect(stateCookie).toBeTruthy();
|
||||
|
||||
const { GET } = await import("@/app/api/auth/oidc/callback/route");
|
||||
const res = await (GET as unknown as RouteFn)(
|
||||
new NextRequest(
|
||||
`http://localhost:3000/api/auth/oidc/callback?code=abc&state=${authorize.searchParams.get("state")}`,
|
||||
{
|
||||
headers: {
|
||||
cookie: `oidc_auth_state=${stateCookie}; healthlog_session=${sessionCookie}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
return { authorize, res };
|
||||
}
|
||||
|
||||
async function mintMeasurementToken() {
|
||||
const { POST } = await import("@/app/api/tokens/measurements/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
new NextRequest("http://localhost:3000/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "shortcut" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function reproofAtOf(sessionId: string) {
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: sessionId },
|
||||
select: { reproofAt: true, mfaVerifiedAt: true },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
Object.assign(process.env, OIDC_ENV);
|
||||
process.env.APP_URL = "http://localhost:3000";
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "sso-only",
|
||||
email: "sso@example.test",
|
||||
role: "USER",
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: SUB,
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
|
||||
});
|
||||
|
||||
describe("confirming at the identity provider", () => {
|
||||
it("is what the gate offers an account with nothing else to prove", async () => {
|
||||
await startSession(HOUR);
|
||||
const refused = await mintMeasurementToken();
|
||||
expect(refused.status).toBe(401);
|
||||
const body = (await refused.json()) as {
|
||||
meta?: { errorCode?: string; methods?: string[] };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("auth.reproof.required");
|
||||
expect(body.meta?.methods).toEqual(["oidc"]);
|
||||
});
|
||||
|
||||
it("asks the provider for a fresh, timed sign-in with state, nonce and PKCE", async () => {
|
||||
await startSession(HOUR);
|
||||
const started = await startReproof("/settings/api");
|
||||
const authorize = new URL(started.headers.get("location")!);
|
||||
expect(authorize.origin + authorize.pathname).toBe(
|
||||
METADATA.authorization_endpoint,
|
||||
);
|
||||
expect(authorize.searchParams.get("prompt")).toBe("login");
|
||||
expect(authorize.searchParams.get("max_age")).toBe("0");
|
||||
expect(authorize.searchParams.get("state")).toBeTruthy();
|
||||
expect(authorize.searchParams.get("nonce")).toBeTruthy();
|
||||
expect(authorize.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("stamps the session, returns to the page and lets the token mint", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
providerSays(SUB, 10);
|
||||
const { res } = await roundTrip("/settings/api?tab=tokens", sessionId);
|
||||
expect(res.status).toBe(307);
|
||||
const back = new URL(res.headers.get("location")!);
|
||||
expect(back.pathname).toBe("/settings/api");
|
||||
expect(back.searchParams.get("tab")).toBe("tokens");
|
||||
expect(back.searchParams.get("reproof")).toBe("done");
|
||||
|
||||
const row = await reproofAtOf(sessionId);
|
||||
expect(row.reproofAt).not.toBeNull();
|
||||
expect(row.mfaVerifiedAt).toBeNull();
|
||||
// No session was created or switched.
|
||||
expect(await getPrismaClient().session.count()).toBe(1);
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: USER_ID, action: "auth.reproof.succeeded" },
|
||||
}),
|
||||
).toBe(1);
|
||||
|
||||
const minted = await mintMeasurementToken();
|
||||
expect(minted.status).toBe(201);
|
||||
});
|
||||
|
||||
it("then lets the account enroll a passkey", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/passkey/register-options/route");
|
||||
const begin = () =>
|
||||
(POST as unknown as RouteFn)(
|
||||
new NextRequest(
|
||||
"http://localhost:3000/api/auth/passkey/register-options",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ method: "oidc" }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const before = await begin();
|
||||
expect(before.status).toBe(401);
|
||||
expect(
|
||||
((await before.json()) as { meta?: { methods?: string[] } }).meta
|
||||
?.methods,
|
||||
).toEqual(["oidc"]);
|
||||
|
||||
providerSays(SUB, 10);
|
||||
await roundTrip("/settings/security", sessionId);
|
||||
|
||||
const after = await begin();
|
||||
expect(after.status).toBe(200);
|
||||
const begun = (await after.json()) as {
|
||||
data: { challengeId: string; options: { challenge: string } };
|
||||
};
|
||||
expect(begun.data.challengeId).toBeTruthy();
|
||||
expect(begun.data.options.challenge).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses a provider sign-in older than the window", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
providerSays(SUB, 60 * 60);
|
||||
const { res } = await roundTrip("/settings/api", sessionId);
|
||||
expect(
|
||||
new URL(res.headers.get("location")!).searchParams.get("reproof"),
|
||||
).toBe("failed");
|
||||
expect((await reproofAtOf(sessionId)).reproofAt).toBeNull();
|
||||
expect((await mintMeasurementToken()).status).toBe(401);
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: USER_ID, action: "auth.mfa.failed" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a different subject at the same provider", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
providerSays("someone-else", 10);
|
||||
const { res } = await roundTrip("/settings/api", sessionId);
|
||||
expect(
|
||||
new URL(res.headers.get("location")!).searchParams.get("reproof"),
|
||||
).toBe("failed");
|
||||
expect((await reproofAtOf(sessionId)).reproofAt).toBeNull();
|
||||
expect((await mintMeasurementToken()).status).toBe(401);
|
||||
});
|
||||
|
||||
it("stamps nothing and spends nothing when the browser comes back with another session", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
const other = await getPrismaClient().session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + HOUR) },
|
||||
});
|
||||
providerSays(SUB, 10);
|
||||
const { res } = await roundTrip("/settings/api", other.id);
|
||||
expect(
|
||||
new URL(res.headers.get("location")!).searchParams.get("reproof"),
|
||||
).toBe("failed");
|
||||
expect(oidc.exchangeCodeForTokens).not.toHaveBeenCalled();
|
||||
expect((await reproofAtOf(sessionId)).reproofAt).toBeNull();
|
||||
expect((await reproofAtOf(other.id)).reproofAt).toBeNull();
|
||||
});
|
||||
|
||||
it("is not offered to an account with a second factor", async () => {
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: USER_ID },
|
||||
data: { totpConfirmedAt: new Date() },
|
||||
});
|
||||
await startSession(HOUR);
|
||||
const started = await startReproof("/settings/api");
|
||||
const back = new URL(started.headers.get("location")!);
|
||||
expect(back.origin).toBe("http://localhost:3000");
|
||||
expect(back.searchParams.get("reproof")).toBe("failed");
|
||||
expect(oidc.discoverOidcMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user