From ada147522f789f350c9506473fe45c1b9c4ada99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 16:49:15 +0200 Subject: [PATCH] test(auth): cover the native OIDC SSO handoff Unit-test the handoff library (mint stores only the hash + binds the challenge + 90s TTL; consume covers not-found / replay-with-revoke / expiry-burn / PKCE-burn / atomic consume / lost-race), the token-exchange route (rate limit, transport gate, one identical generic 401 across every invalid-code class, success with issued-hash stamp + audit, and OIDC_ONLY does not block it), the callback native branches (handoff mint + scheme redirect with no session, MFA ticket on the scheme with no session/cookie, post-identity + IdP errors to the scheme, CSRF web-fallback), the login native-param handling, and the by-hash refresh-token revoke used for replay containment. --- .../oidc/callback/__tests__/route.test.ts | 137 +++++++++++ .../auth/oidc/login/__tests__/route.test.ts | 63 +++++ .../oidc/native/token/__tests__/route.test.ts | 225 +++++++++++++++++ .../__tests__/oidc-native-handoff.test.ts | 227 ++++++++++++++++++ src/lib/auth/__tests__/refresh-token.test.ts | 40 +++ 5 files changed, 692 insertions(+) create mode 100644 src/app/api/auth/oidc/native/token/__tests__/route.test.ts create mode 100644 src/lib/auth/__tests__/oidc-native-handoff.test.ts diff --git a/src/app/api/auth/oidc/callback/__tests__/route.test.ts b/src/app/api/auth/oidc/callback/__tests__/route.test.ts index d547e64e3..aa41f7b01 100644 --- a/src/app/api/auth/oidc/callback/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/callback/__tests__/route.test.ts @@ -68,6 +68,18 @@ vi.mock("@/lib/auth/oidc", () => ({ deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]), })); +// The handoff lib is unit-tested separately; here we assert the callback +// branches into it and redirects to the custom scheme (real +// `buildNativeCallbackUrl`, mocked `mintNativeHandoff`). +vi.mock("@/lib/auth/oidc-native-handoff", () => ({ + buildNativeCallbackUrl: (params: Record) => + `healthlog://oidc-callback?${new URLSearchParams(params).toString()}`, + mintNativeHandoff: vi.fn(async () => ({ + code: "hlh_test_code", + handoffId: "ho-1", + })), +})); + import { GET } from "../route"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; @@ -80,6 +92,7 @@ import { exchangeCodeForTokens, verifyIdToken, } from "@/lib/auth/oidc"; +import { mintNativeHandoff } from "@/lib/auth/oidc-native-handoff"; const CONFIG = { issuerUrl: "https://idp.example.com", @@ -577,3 +590,127 @@ describe("GET /api/auth/oidc/callback", () => { expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc"); }); }); + +describe("GET /api/auth/oidc/callback — native branch", () => { + beforeEach(() => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 20, + reset: 0, + ip: "1.2.3.4", + } as never); + vi.mocked(getOidcConfig).mockReturnValue(CONFIG); + vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0); + vi.mocked(mintNativeHandoff).mockResolvedValue({ + code: "hlh_test_code", + handoffId: "ho-1", + }); + }); + + function nativeStateCookie(overrides: Record = {}): string { + return stateCookie({ + state: "STATE", + nonce: "nonce-1", + codeVerifier: "verifier-1", + next: "/", + native: true, + appCodeChallenge: "app-challenge-123", + ...overrides, + }); + } + + function nativeRequest(): NextRequest { + return makeRequest({ + query: "?code=abc&state=STATE", + cookie: nativeStateCookie(), + }); + } + + it("mints a handoff code and 302s to the custom scheme — no session", async () => { + mockIdentity(); + mockUserLookups({ + byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }), + }); + + const res = await GET(nativeRequest()); + const location = res.headers.get("location")!; + expect(location.startsWith("healthlog://oidc-callback?code=")).toBe(true); + expect(location).toContain("code=hlh_test_code"); + // The app's challenge (from the tamper-authenticated blob) is bound at mint. + expect(mintNativeHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + appCodeChallenge: "app-challenge-123", + }), + ); + // No web session is minted on the native branch. + expect(createSession).not.toHaveBeenCalled(); + }); + + it("hands the MFA ticket to the custom scheme (no session, no MFA cookie)", async () => { + mockIdentity(); + mockUserLookups({ + byIdentity: userRow({ + oidcIssuer: METADATA.issuer, + oidcSub: "sub-1", + totpConfirmedAt: new Date(), + }), + }); + + const res = await GET(nativeRequest()); + const location = res.headers.get("location")!; + expect(location.startsWith("healthlog://oidc-callback?")).toBe(true); + expect(location).toContain("mfa_ticket=mfa-ticket-1"); + expect(location).toContain("methods=totp%2Crecovery"); + // No handoff code on the MFA branch, and no session. + expect(location).not.toContain("code=hlh_"); + expect(mintNativeHandoff).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + // The web-only MFA handoff cookie is never set on the native branch. + const mfaCookie = res.headers + .getSetCookie() + .find((c) => c.startsWith("oidc_mfa_handoff=")); + expect(mfaCookie).toBeUndefined(); + }); + + it("delivers a post-identity error to the custom scheme, not a web page", async () => { + mockIdentity(); + // An account already pinned to a different IdP identity → identity_conflict. + mockUserLookups({ + byIdentity: null, + byEmail: userRow({ oidcIssuer: "https://other.example", oidcSub: "x" }), + }); + + const res = await GET(nativeRequest()); + const location = res.headers.get("location")!; + expect(location).toBe( + "healthlog://oidc-callback?error=oidc_identity_conflict", + ); + }); + + it("delivers an IdP denial to the custom scheme", async () => { + const res = await GET( + makeRequest({ + query: "?error=access_denied&state=STATE", + cookie: nativeStateCookie(), + }), + ); + expect(res.headers.get("location")).toBe( + "healthlog://oidc-callback?error=oidc_denied", + ); + }); + + it("falls back to the WEB oidc_state page on a CSRF mismatch (broken flow)", async () => { + const res = await GET( + makeRequest({ + query: "?code=abc&state=WRONG", + cookie: nativeStateCookie(), + }), + ); + // A tampered/broken round-trip never hands the app a scheme redirect. + expect(res.headers.get("location")).toContain( + "/auth/login?error=oidc_state", + ); + }); +}); diff --git a/src/app/api/auth/oidc/login/__tests__/route.test.ts b/src/app/api/auth/oidc/login/__tests__/route.test.ts index 52b15b8eb..38c13a58f 100644 --- a/src/app/api/auth/oidc/login/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/login/__tests__/route.test.ts @@ -150,4 +150,67 @@ describe("GET /api/auth/oidc/login", () => { const res = await GET(makeRequest()); expect(res.headers.get("location")).toContain("error=oidc_failed"); }); + + describe("native SSO start", () => { + const CONFIG = { + issuerUrl: "https://idp.example.com", + clientId: "client-1", + clientSecret: "secret-1", + scopes: "openid email profile", + buttonLabel: "SSO", + }; + const CHALLENGE = "a".repeat(43); // valid S256 length + + function storedFromCookie(res: Awaited>) { + const cookie = res.cookies.get("oidc_auth_state"); + return JSON.parse(cookie!.value.replace(/^enc\(/, "").replace(/\)$/, "")); + } + + it("rejects a native start with a missing challenge to the custom scheme", async () => { + vi.mocked(getOidcConfig).mockReturnValue(CONFIG); + const res = await GET(makeRequest("/api/auth/oidc/login?client=native")); + expect(res.headers.get("location")).toBe( + "healthlog://oidc-callback?error=oidc_invalid_request", + ); + }); + + it("routes native login errors (disabled) to the custom scheme", async () => { + vi.mocked(getOidcConfig).mockReturnValue(null); + const res = await GET( + makeRequest( + `/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}`, + ), + ); + expect(res.headers.get("location")).toBe( + "healthlog://oidc-callback?error=oidc_disabled", + ); + }); + + it("folds native:true + appCodeChallenge into the state blob and pins next to /", async () => { + vi.mocked(getOidcConfig).mockReturnValue(CONFIG); + vi.mocked(discoverOidcMetadata).mockResolvedValue({ + issuer: "https://idp.example.com", + authorization_endpoint: "https://idp.example.com/authorize", + token_endpoint: "https://idp.example.com/token", + jwks_uri: "https://idp.example.com/jwks", + }); + + const res = await GET( + makeRequest( + `/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}&next=/dashboard`, + ), + ); + // Still a normal redirect to the IdP — the native fact rides the blob. + expect(res.headers.get("location")).toBe( + "https://idp.example.com/authorize?x=1", + ); + const stored = storedFromCookie(res); + expect(stored.native).toBe(true); + expect(stored.appCodeChallenge).toBe(CHALLENGE); + // `next` is meaningless to the app — pinned to "/". + expect(stored.next).toBe("/"); + // The server↔IdP verifier stays independent and present. + expect(typeof stored.codeVerifier).toBe("string"); + }); + }); }); diff --git a/src/app/api/auth/oidc/native/token/__tests__/route.test.ts b/src/app/api/auth/oidc/native/token/__tests__/route.test.ts new file mode 100644 index 000000000..774627b3d --- /dev/null +++ b/src/app/api/auth/oidc/native/token/__tests__/route.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/native-client", () => ({ + isCookielessNativeCaller: vi.fn(() => true), +})); + +vi.mock("@/lib/auth/oidc-native-handoff", () => ({ + consumeNativeHandoff: vi.fn(), + stampIssuedRefreshToken: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { isCookielessNativeCaller } from "@/lib/auth/native-client"; +import { + consumeNativeHandoff, + stampIssuedRefreshToken, +} from "@/lib/auth/oidc-native-handoff"; +import { finishLogin } from "@/lib/auth/login-response"; +import { auditLog } from "@/lib/auth/audit"; + +const VALID_BODY = { + code: "hlh_" + "a".repeat(43), + codeVerifier: "a".repeat(43), +}; + +function makeRequest( + body: unknown = VALID_BODY, + headers: Record = {}, +): NextRequest { + return new NextRequest("http://localhost/api/auth/oidc/native/token", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +function nativeBundleResponse() { + return NextResponse.json({ + data: { + user: { id: "user-1", username: "alice" }, + token: "hlk_access", + tokenExpiresAt: new Date().toISOString(), + refreshToken: "hlr_refresh", + refreshTokenExpiresAt: new Date().toISOString(), + }, + error: null, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 10, + resetAt: 0, + ip: "1.2.3.4", + } as never); + vi.mocked(isCookielessNativeCaller).mockReturnValue(true); +}); + +describe("POST /api/auth/oidc/native/token", () => { + it("429 when rate-limited (before any state is touched)", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: 0, + ip: "1.2.3.4", + } as never); + const res = await POST(makeRequest()); + expect(res.status).toBe(429); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("401 and never consumes when the transport is not cookie-less native", async () => { + vi.mocked(isCookielessNativeCaller).mockReturnValue(false); + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("422 on a malformed body", async () => { + const res = await POST(makeRequest({ code: "nope", codeVerifier: "x" })); + expect(res.status).toBe(422); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("returns ONE identical generic 401 across every invalid-code class", async () => { + const statuses = [ + { status: "not_found" }, + { status: "replayed", userId: "user-1" }, + { status: "expired" }, + { status: "pkce_mismatch" }, + { status: "race_lost" }, + ] as const; + + const bodies: string[] = []; + for (const s of statuses) { + vi.mocked(consumeNativeHandoff).mockResolvedValue(s as never); + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + bodies.push(await res.text()); + } + // Also the deleted-user-mid-flight branch. + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + bodies.push(await res.text()); + + // Byte-identical envelope — a code-guesser learns nothing. + expect(new Set(bodies).size).toBe(1); + }); + + it("does not mint on the deleted-user branch", async () => { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + await POST(makeRequest()); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("200 on success: mints via the native source, stamps the issued hash, audits", async () => { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + username: "alice", + onboardingCompletedAt: new Date(), + } as never); + vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse()); + + const res = await POST( + makeRequest(VALID_BODY, { "x-client-type": "native" }), + ); + expect(res.status).toBe(200); + + const finishArg = vi.mocked(finishLogin).mock.calls[0][0]; + expect(finishArg.source).toBe("login.oidc.native"); + // `mfaVerified` is never set — the SSO login can't satisfy step-up. + expect(finishArg.mfaVerified).toBeUndefined(); + + // The issued refresh token's hash is stamped for replay reach-back. + expect(stampIssuedRefreshToken).toHaveBeenCalledWith("ho-1", "hlr_refresh"); + + expect(auditLog).toHaveBeenCalledWith( + "auth.oidc.login", + expect.objectContaining({ + userId: "user-1", + details: { transport: "native" }, + }), + ); + + // The client still receives the bundle (clone left the stream intact). + const body = await res.json(); + expect(body.data.refreshToken).toBe("hlr_refresh"); + }); + + it("is NOT blocked by OIDC_ONLY (it completes an OIDC-initiated login)", async () => { + const prev = process.env.OIDC_ONLY; + process.env.OIDC_ONLY = "true"; + try { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + username: "alice", + onboardingCompletedAt: new Date(), + } as never); + vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse()); + + const res = await POST( + makeRequest(VALID_BODY, { "x-client-type": "native" }), + ); + expect(res.status).toBe(200); + } finally { + if (prev === undefined) delete process.env.OIDC_ONLY; + else process.env.OIDC_ONLY = prev; + } + }); +}); diff --git a/src/lib/auth/__tests__/oidc-native-handoff.test.ts b/src/lib/auth/__tests__/oidc-native-handoff.test.ts new file mode 100644 index 000000000..fe9dfd9f3 --- /dev/null +++ b/src/lib/auth/__tests__/oidc-native-handoff.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + oidcNativeHandoff: { + create: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +// Deterministic stand-in for the real HMAC — length-keyed so it never contains +// the raw code, making the "raw code is never persisted" assertion meaningful. +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn((t: string) => `H(${t.length})`), +})); + +const revokeRefreshTokenByHash = vi.fn(); +vi.mock("@/lib/auth/refresh-token", () => ({ + revokeRefreshTokenByHash: (h: string) => revokeRefreshTokenByHash(h), +})); + +const auditLog = vi.fn(); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: (...a: unknown[]) => auditLog(...a), +})); + +import { + mintNativeHandoff, + consumeNativeHandoff, + buildNativeCallbackUrl, + stampIssuedRefreshToken, + NATIVE_OIDC_REDIRECT_URI, + OIDC_NATIVE_HANDOFF_TTL_MS, +} from "../oidc-native-handoff"; +import { s256Challenge } from "@/lib/mcp/oauth/pkce"; +import { prisma } from "@/lib/db"; + +// A real RFC 7636 verifier + its real S256 challenge, so the constant-time +// verify inside `consumeNativeHandoff` runs for real. +const VERIFIER = "a".repeat(43); +const CHALLENGE = s256Challenge(VERIFIER); + +function storedRow(overrides: Record = {}) { + return { + id: "ho-1", + userId: "user-1", + codeHash: "H(47)", + codeChallenge: CHALLENGE, + expiresAt: new Date(Date.now() + OIDC_NATIVE_HANDOFF_TTL_MS), + consumedAt: null, + issuedRefreshTokenHash: null, + createdAt: new Date(), + ipAddress: "1.2.3.4", + userAgent: "HealthLog-iOS/1", + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("mintNativeHandoff", () => { + it("mints an hlh_ code, stores only its hash, and binds the challenge + TTL", async () => { + vi.mocked(prisma.oidcNativeHandoff.create).mockResolvedValue({ + id: "ho-1", + } as never); + + const before = Date.now(); + const { code, handoffId } = await mintNativeHandoff({ + userId: "user-1", + appCodeChallenge: CHALLENGE, + ipAddress: "1.2.3.4", + userAgent: "HealthLog-iOS/1", + }); + + expect(code.startsWith("hlh_")).toBe(true); + expect(handoffId).toBe("ho-1"); + + const arg = vi.mocked(prisma.oidcNativeHandoff.create).mock.calls[0][0]; + // userId comes from the resolved identity; challenge bound at mint. + expect(arg.data.userId).toBe("user-1"); + expect(arg.data.codeChallenge).toBe(CHALLENGE); + // Only the hash is persisted — never the raw code. + expect(arg.data.codeHash).toBe(`H(${code.length})`); + expect(JSON.stringify(arg)).not.toContain(code); + // 90-second TTL. + const ttl = (arg.data.expiresAt as Date).getTime() - before; + expect(ttl).toBeGreaterThan(OIDC_NATIVE_HANDOFF_TTL_MS - 2000); + expect(ttl).toBeLessThanOrEqual(OIDC_NATIVE_HANDOFF_TTL_MS + 100); + }); +}); + +describe("buildNativeCallbackUrl", () => { + it("targets the fixed scheme and escapes params", () => { + const url = buildNativeCallbackUrl({ + code: "hlh_abc", + methods: "totp,recovery", + }); + expect(url.startsWith(`${NATIVE_OIDC_REDIRECT_URI}?`)).toBe(true); + expect(url).toContain("code=hlh_abc"); + // Comma is percent-escaped — no unescaped client value in the Location. + expect(url).toContain("methods=totp%2Crecovery"); + }); +}); + +describe("consumeNativeHandoff", () => { + it("returns not_found for an unknown code", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + null as never, + ); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("not_found"); + expect( + vi.mocked(prisma.oidcNativeHandoff.findUnique).mock.calls[0][0], + ).toMatchObject({ where: { codeHash: "H(47)" } }); + }); + + it("replay: a consumed row revokes exactly the issued pair and audits", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ + consumedAt: new Date(), + issuedRefreshTokenHash: "issued-refresh-hash", + }) as never, + ); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("replayed"); + expect(revokeRefreshTokenByHash).toHaveBeenCalledWith( + "issued-refresh-hash", + ); + expect(auditLog).toHaveBeenCalledWith( + "auth.oidc.native.handoff_replay", + expect.objectContaining({ userId: "user-1" }), + ); + // A consumed row is never re-consumed. + expect(prisma.oidcNativeHandoff.updateMany).not.toHaveBeenCalled(); + }); + + it("replay without an issued hash still audits but revokes nothing", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ + consumedAt: new Date(), + issuedRefreshTokenHash: null, + }) as never, + ); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("replayed"); + expect(revokeRefreshTokenByHash).not.toHaveBeenCalled(); + expect(auditLog).toHaveBeenCalledTimes(1); + }); + + it("expired: burns the row (guarded) and never issues", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ expiresAt: new Date(Date.now() - 1000) }) as never, + ); + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("expired"); + expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({ + where: { id: "ho-1", consumedAt: null }, + data: { consumedAt: expect.any(Date) }, + }); + expect(revokeRefreshTokenByHash).not.toHaveBeenCalled(); + }); + + it("pkce_mismatch: a wrong verifier burns the code", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await consumeNativeHandoff( + "hlh_" + "x".repeat(43), + "b".repeat(43), + ); + expect(res.status).toBe("pkce_mismatch"); + // Burned via the guarded update — no issuance. + expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({ + where: { id: "ho-1", consumedAt: null }, + data: { consumedAt: expect.any(Date) }, + }); + }); + + it("ok: a live row + correct verifier consumes atomically and returns the identity", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res).toEqual({ status: "ok", userId: "user-1", handoffId: "ho-1" }); + expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({ + where: { id: "ho-1", consumedAt: null }, + data: { consumedAt: expect.any(Date) }, + }); + }); + + it("race_lost: a lost consume returns race_lost and revokes nothing", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("race_lost"); + expect(revokeRefreshTokenByHash).not.toHaveBeenCalled(); + }); +}); + +describe("stampIssuedRefreshToken", () => { + it("stores the hash of the issued refresh token on the handoff row", async () => { + vi.mocked(prisma.oidcNativeHandoff.update).mockResolvedValue({} as never); + await stampIssuedRefreshToken("ho-1", "hlr_secret"); + expect(prisma.oidcNativeHandoff.update).toHaveBeenCalledWith({ + where: { id: "ho-1" }, + data: { issuedRefreshTokenHash: `H(${"hlr_secret".length})` }, + }); + }); +}); diff --git a/src/lib/auth/__tests__/refresh-token.test.ts b/src/lib/auth/__tests__/refresh-token.test.ts index b4eb6d4df..1ee9835cc 100644 --- a/src/lib/auth/__tests__/refresh-token.test.ts +++ b/src/lib/auth/__tests__/refresh-token.test.ts @@ -133,6 +133,7 @@ import { issueAccessAndRefresh, rotateRefreshToken, revokeRefreshToken, + revokeRefreshTokenByHash, revokeBearerAccessToken, } from "../refresh-token"; import type { TokenPolicyDecision } from "../native-client"; @@ -461,3 +462,42 @@ describe("revokeBearerAccessToken (M-2 logout bearer revoke)", () => { expect(ok).toBe(false); }); }); + +describe("revokeRefreshTokenByHash (native OIDC handoff replay containment)", () => { + it("revokes the refresh row + its paired access token by stored hash", async () => { + const bundle = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.oidc.native", + deviceId: "dev-1", + }); + const refreshHash = `hash:${bundle.refreshToken}`; + const accessHash = `hash:${bundle.accessToken}`; + + // The handoff row only ever stored the HASH of the refresh token, never + // the raw secret — so replay containment must revoke by hash. + const ok = await revokeRefreshTokenByHash(refreshHash); + expect(ok).toBe(true); + + expect( + dbState.refreshTokens.find((r) => r.tokenHash === refreshHash)?.revokedAt, + ).not.toBeNull(); + expect( + dbState.apiTokens.find((a) => a.tokenHash === accessHash)?.revoked, + ).toBe(true); + }); + + it("returns false for an unknown or already-revoked hash", async () => { + expect(await revokeRefreshTokenByHash("hash:hlr_unknown")).toBe(false); + + const bundle = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.oidc.native", + }); + const refreshHash = `hash:${bundle.refreshToken}`; + await revokeRefreshTokenByHash(refreshHash); + // Second call is a no-op (idempotent containment). + expect(await revokeRefreshTokenByHash(refreshHash)).toBe(false); + }); +});