/** * POST /api/auth/native/token * * The cookie-less native leg of the first-party web-handoff login (iOS #65). The * app presents the one-time handoff code it received on * `healthlog://login-callback?code=` plus its PKCE `codeVerifier`; on success it * receives the SAME native token bundle password login issues * (`{ token, tokenExpiresAt, refreshToken, refreshTokenExpiresAt }`). * * Byte-for-byte the posture of `POST /api/auth/oidc/native/token`, with the * `flow: "web_login"` gate on the shared consume: * - It IS the credential mint: no session or Bearer is required to reach it. * - The transport gate (`isCookielessNativeCaller` + `isNativeClientRequest`) * makes the endpoint structurally incapable of minting a web session or handing * a 60-day refresh token into a browser context. * - `userId` is derived SOLELY from the server-side handoff row. * - A single generic 401 covers not-found / expired / used / PKCE-mismatch / * cross-flow / deleted-user so a code-guesser learns nothing. * - `mfaVerifiedAt` is never set — a Bearer transport can never satisfy * `requireFreshMfa` step-up. */ import { NextRequest } from "next/server"; import { apiError } from "@/lib/api-response"; import { parseJsonBody } from "@/lib/api/body"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { isCookielessNativeCaller } from "@/lib/auth/native-client"; import { isNativeClientRequest } from "@/lib/auth/issue-token"; import { finishLogin } from "@/lib/auth/login-response"; import { consumeNativeHandoff, stampIssuedRefreshToken, } from "@/lib/auth/native-handoff"; import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff"; export const dynamic = "force-dynamic"; /** The single generic rejection for every invalid-code class. */ function invalidCode() { return apiError("Invalid or expired code", 401); } export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "auth.native.token" } }); // (1) Rate limit — the MFA-verify surface's posture, incl. the // trust-violation collapse semantics. const rl = await checkAuthSurfaceRateLimit( request, "auth:native:token", 10, 15 * 60 * 1000, ); const ip = rl.ip ?? "unknown"; if (!rl.allowed) { return apiError("Too many attempts. Please try again later.", 429, { headers: rateLimitHeaders(rl), }); } // (2) Transport gate — this endpoint only ever hands a native token bundle. A // browser (Mozilla UA or an inbound session cookie) is rejected, AND the // caller must present an explicit native marker, so the cookie branch of // `finishLogin` is structurally unreachable. if ( !isCookielessNativeCaller(request.headers) || !isNativeClientRequest(request.headers) ) { annotate({ action: { name: "auth.native.token.wrong_transport" } }); return invalidCode(); } // (3) Body — bounded, then the exact wire contract. const parsed = await parseJsonBody(request, nativeHandoffTokenSchema, { maxBytes: 16 * 1024, message: "Invalid request", }); if (!parsed.ok) return parsed.error; const { code, codeVerifier } = parsed.data; // (4) Lookup + flow check + replay/expiry/PKCE + atomic single-use consume. // The `web_login` flow gate rejects an OIDC-minted code as `not_found`. const result = await consumeNativeHandoff(code, codeVerifier, "web_login"); if (result.status !== "ok") { switch (result.status) { case "not_found": annotate({ action: { name: "auth.native.token.not_found" } }); break; case "replayed": annotate({ action: { name: "auth.native.handoff_replay" }, meta: { revoked_issued_pair: result.revokedIssuedPair }, }); break; case "expired": annotate({ action: { name: "auth.native.token.expired" } }); break; case "pkce_mismatch": annotate({ action: { name: "auth.native.pkce_failed" } }); break; case "race_lost": annotate({ action: { name: "auth.native.token.race_lost" } }); break; } return invalidCode(); } // (5) Load the user (may have been deleted mid-flight). const user = await prisma.user.findUnique({ where: { id: result.userId }, select: { id: true, username: true, onboardingCompletedAt: true, deletionRequestedAt: true, deletionPurgeAt: true, }, }); if (!user) { annotate({ action: { name: "auth.native.token.user_missing" } }); return invalidCode(); } // (6) Mint the native bundle. Step (2) guarantees the cookie-less-native // branch of `finishLogin` (24h access + 60d rotating refresh). `mfaVerified` // is deliberately unset — the handoff login can never satisfy step-up. const ua = request.headers.get("user-agent"); const response = await finishLogin({ user, request, ip, userAgent: ua, source: "login.web_handoff", }); // (7) Stamp the issued refresh token's hash for replay-containment reach-back, // then audit. Best-effort — the code is already single-use-consumed. try { const bundle = (await response.clone().json()) as { data?: { refreshToken?: unknown }; }; const refreshToken = bundle.data?.refreshToken; if (typeof refreshToken === "string") { await stampIssuedRefreshToken(result.handoffId, refreshToken); } } catch { // Non-fatal — see comment above. } await auditLog("auth.login", { userId: user.id, ipAddress: ip, details: { transport: "native", method: "web_handoff" }, }); return response; });