mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
282 lines
8.9 KiB
TypeScript
282 lines
8.9 KiB
TypeScript
/**
|
|
* Confirming at the identity provider (#1296): which accounts are offered it,
|
|
* what the callback accepts as fresh, whose identity counts, and what the
|
|
* sealed state must carry.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const db = vi.hoisted(() => ({
|
|
findUser: vi.fn<(args: unknown) => Promise<unknown>>(),
|
|
updateSession: vi.fn<(args: unknown) => Promise<unknown>>(),
|
|
countKeys: vi.fn<(args: unknown) => Promise<number>>(),
|
|
countPasskeys: vi.fn<(args: unknown) => Promise<number>>(),
|
|
}));
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
user: { findUnique: db.findUser },
|
|
session: { update: db.updateSession },
|
|
webauthnMfaCredential: { count: db.countKeys },
|
|
passkey: { count: db.countPasskeys },
|
|
},
|
|
}));
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
|
vi.mock("@/lib/rate-limit", () => ({
|
|
checkRateLimit: vi.fn(),
|
|
rateLimitHeaders: vi.fn(() => ({})),
|
|
refundRateLimit: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/crypto", () => ({
|
|
decrypt: vi.fn((s: string) => s),
|
|
encrypt: vi.fn((s: string) => s),
|
|
}));
|
|
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { refundRateLimit } from "@/lib/rate-limit";
|
|
import {
|
|
isFirstFactorOnlyProof,
|
|
recentProofMethods,
|
|
} from "@/lib/auth/second-factor";
|
|
import {
|
|
isFreshAuthTime,
|
|
isLinkedIdentity,
|
|
isReproofSession,
|
|
recordOidcReproof,
|
|
reproofReturnUrl,
|
|
} from "@/lib/auth/oidc-reproof";
|
|
import { readStoredOidcState } from "@/lib/auth/oidc-callback/state";
|
|
|
|
const ISSUER = "https://idp.example.test";
|
|
const OIDC_ENV = {
|
|
OIDC_ISSUER_URL: ISSUER,
|
|
OIDC_CLIENT_ID: "client",
|
|
OIDC_CLIENT_SECRET: "secret",
|
|
};
|
|
|
|
function accountRow(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
passwordHash: null,
|
|
oidcIssuer: ISSUER,
|
|
oidcSub: "sub-1",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
Object.assign(process.env, OIDC_ENV);
|
|
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.test";
|
|
db.countKeys.mockResolvedValue(0);
|
|
db.countPasskeys.mockResolvedValue(0);
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
|
|
});
|
|
|
|
describe("recentProofMethods offers the identity provider", () => {
|
|
const user = { id: "u1", totpConfirmedAt: null };
|
|
|
|
it("to an account without a second factor whose identity is linked", async () => {
|
|
db.findUser.mockResolvedValue(accountRow());
|
|
expect(await recentProofMethods(user, false)).toEqual(["oidc"]);
|
|
});
|
|
|
|
it("beside the password and passkey the account also holds", async () => {
|
|
db.countPasskeys.mockResolvedValue(1);
|
|
db.findUser.mockResolvedValue(accountRow({ passwordHash: "hash" }));
|
|
expect(await recentProofMethods(user, false)).toEqual([
|
|
"password",
|
|
"oidc",
|
|
"passkey",
|
|
]);
|
|
});
|
|
|
|
it("not to an account without a linked identity", async () => {
|
|
db.findUser.mockResolvedValue(
|
|
accountRow({ oidcIssuer: null, oidcSub: null }),
|
|
);
|
|
expect(await recentProofMethods(user, false)).toEqual([]);
|
|
});
|
|
|
|
it("not when the instance has no provider configured", async () => {
|
|
delete process.env.OIDC_CLIENT_SECRET;
|
|
db.findUser.mockResolvedValue(accountRow());
|
|
expect(await recentProofMethods(user, false)).toEqual([]);
|
|
});
|
|
|
|
it("not to an account with a second factor, where it could not clear the gate", async () => {
|
|
db.countKeys.mockResolvedValue(1);
|
|
db.findUser.mockResolvedValue(accountRow());
|
|
expect(await recentProofMethods(user, true)).toEqual(["webauthn"]);
|
|
});
|
|
|
|
it("and never counts it as a second factor", () => {
|
|
expect(isFirstFactorOnlyProof("oidc")).toBe(true);
|
|
expect(isFirstFactorOnlyProof("password")).toBe(true);
|
|
expect(isFirstFactorOnlyProof("passkey")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isFreshAuthTime", () => {
|
|
const now = 1_800_000_000_000;
|
|
const seconds = (ms: number) => Math.floor(ms / 1000);
|
|
|
|
it("accepts an authentication inside the five-minute window", () => {
|
|
expect(isFreshAuthTime(seconds(now - 10_000), now)).toBe(true);
|
|
expect(isFreshAuthTime(seconds(now - 300_000), now)).toBe(true);
|
|
});
|
|
|
|
it("refuses one older than the window", () => {
|
|
expect(isFreshAuthTime(seconds(now - 301_000), now)).toBe(false);
|
|
expect(isFreshAuthTime(seconds(now - 3_600_000), now)).toBe(false);
|
|
});
|
|
|
|
it("refuses a token that does not say when", () => {
|
|
expect(isFreshAuthTime(null, now)).toBe(false);
|
|
});
|
|
|
|
it("tolerates a provider clock a minute ahead, and no more", () => {
|
|
expect(isFreshAuthTime(seconds(now + 30_000), now)).toBe(true);
|
|
expect(isFreshAuthTime(seconds(now + 120_000), now)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isLinkedIdentity", () => {
|
|
it("needs both the issuer and the subject to match", () => {
|
|
const account = { oidcIssuer: ISSUER, oidcSub: "sub-1" };
|
|
expect(isLinkedIdentity(account, ISSUER, "sub-1")).toBe(true);
|
|
expect(isLinkedIdentity(account, ISSUER, "sub-2")).toBe(false);
|
|
expect(isLinkedIdentity(account, "https://other.test", "sub-1")).toBe(
|
|
false,
|
|
);
|
|
expect(
|
|
isLinkedIdentity({ oidcIssuer: null, oidcSub: null }, ISSUER, "sub-1"),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isReproofSession", () => {
|
|
const bound = { reproofUserId: "u1", reproofSessionId: "s1" };
|
|
|
|
it("holds only for the session and account the start bound", () => {
|
|
expect(
|
|
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u1" } }),
|
|
).toBe(true);
|
|
expect(
|
|
isReproofSession(bound, { session: { id: "s2" }, user: { id: "u1" } }),
|
|
).toBe(false);
|
|
expect(
|
|
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u2" } }),
|
|
).toBe(false);
|
|
expect(isReproofSession(bound, null)).toBe(false);
|
|
expect(
|
|
isReproofSession({}, { session: { id: "s1" }, user: { id: "u1" } }),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("recordOidcReproof", () => {
|
|
const now = 1_800_000_000_000;
|
|
const args = {
|
|
userId: "u1",
|
|
sessionId: "s1",
|
|
issuer: ISSUER,
|
|
ipAddress: "203.0.113.5",
|
|
nowMs: now,
|
|
};
|
|
|
|
it("stamps reproofAt only, refunds the attempt and audits the success", async () => {
|
|
db.findUser.mockResolvedValue(accountRow());
|
|
const outcome = await recordOidcReproof({
|
|
...args,
|
|
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 5 },
|
|
});
|
|
expect(outcome).toEqual({ ok: true });
|
|
expect(db.updateSession).toHaveBeenCalledWith({
|
|
where: { id: "s1" },
|
|
data: { reproofAt: new Date(now) },
|
|
});
|
|
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:u1");
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"auth.reproof.succeeded",
|
|
expect.objectContaining({ details: { method: "oidc" } }),
|
|
);
|
|
});
|
|
|
|
it("refuses a stale auth_time without stamping", async () => {
|
|
db.findUser.mockResolvedValue(accountRow());
|
|
const outcome = await recordOidcReproof({
|
|
...args,
|
|
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 3600 },
|
|
});
|
|
expect(outcome).toEqual({ ok: false, reason: "stale_auth_time" });
|
|
expect(db.updateSession).not.toHaveBeenCalled();
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"auth.mfa.failed",
|
|
expect.objectContaining({
|
|
details: {
|
|
stage: "oidc_reproof",
|
|
method: "oidc",
|
|
reason: "stale_auth_time",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("refuses a different subject without stamping", async () => {
|
|
db.findUser.mockResolvedValue(accountRow());
|
|
const outcome = await recordOidcReproof({
|
|
...args,
|
|
identity: { sub: "someone-else", authTime: Math.floor(now / 1000) },
|
|
});
|
|
expect(outcome).toEqual({ ok: false, reason: "identity_mismatch" });
|
|
expect(db.updateSession).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("the re-proof return and state", () => {
|
|
it("returns to the page it left with the outcome attached", () => {
|
|
const url = reproofReturnUrl("/settings/api?tab=mcp", "done");
|
|
expect(url.origin).toBe("https://healthlog.example.test");
|
|
expect(url.pathname).toBe("/settings/api");
|
|
expect(url.searchParams.get("tab")).toBe("mcp");
|
|
expect(url.searchParams.get("reproof")).toBe("done");
|
|
});
|
|
|
|
it("reads a reproof state only with both bindings and no other intent's fields", () => {
|
|
const base = { state: "s", nonce: "n", codeVerifier: "v", next: "/" };
|
|
const ok = readStoredOidcState(
|
|
JSON.stringify({
|
|
...base,
|
|
intent: "reproof",
|
|
reproofUserId: "u1",
|
|
reproofSessionId: "s1",
|
|
}),
|
|
);
|
|
expect(ok?.intent).toBe("reproof");
|
|
expect(
|
|
readStoredOidcState(
|
|
JSON.stringify({ ...base, intent: "reproof", reproofUserId: "u1" }),
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
readStoredOidcState(
|
|
JSON.stringify({
|
|
...base,
|
|
intent: "reproof",
|
|
reproofUserId: "u1",
|
|
reproofSessionId: "s1",
|
|
native: true,
|
|
appCodeChallenge: "c",
|
|
}),
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
readStoredOidcState(JSON.stringify({ ...base, intent: "other" })),
|
|
).toBeNull();
|
|
});
|
|
});
|