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 index 774627b3d..6fc48da0a 100644 --- a/src/app/api/auth/oidc/native/token/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/native/token/__tests__/route.test.ts @@ -61,7 +61,13 @@ function makeRequest( ): NextRequest { return new NextRequest("http://localhost/api/auth/oidc/native/token", { method: "POST", - headers: { "content-type": "application/json", ...headers }, + // The genuine iOS client always marks itself native; the transport gate + // requires it. A gate-rejection test overrides `x-client-type` explicitly. + headers: { + "content-type": "application/json", + "x-client-type": "native", + ...headers, + }, body: typeof body === "string" ? body : JSON.stringify(body), }); } diff --git a/src/app/api/auth/oidc/native/token/route.ts b/src/app/api/auth/oidc/native/token/route.ts index 24e6ed179..d0bd4674e 100644 --- a/src/app/api/auth/oidc/native/token/route.ts +++ b/src/app/api/auth/oidc/native/token/route.ts @@ -31,6 +31,7 @@ import { prisma } from "@/lib/db"; import { ensureDbCompatibility } from "@/lib/db-compat"; 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, @@ -63,10 +64,17 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - // (2) Transport gate — this endpoint can only ever hand a bundle to a - // genuine cookie-less native caller. A browser (Mozilla UA or an inbound - // session cookie) is rejected before any state is touched. - if (!isCookielessNativeCaller(request.headers)) { + // (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 (`isNativeClientRequest`, the + // SAME predicate `finishLogin` uses to pick its bearer branch) — otherwise a + // blank-UA caller could slip past `isCookielessNativeCaller` into the cookie + // branch and be handed a `Set-Cookie` session instead of the bundle. Both + // together make the cookie path structurally unreachable here. + if ( + !isCookielessNativeCaller(request.headers) || + !isNativeClientRequest(request.headers) + ) { annotate({ action: { name: "auth.oidc.native.token.wrong_transport" } }); return invalidCode(); } @@ -93,11 +101,12 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "auth.oidc.native.token.not_found" } }); break; case "replayed": - // Containment (revoke the issued pair) + the audit row already ran - // inside `consumeNativeHandoff`. + // Containment + the audit row already ran inside `consumeNativeHandoff`; + // report whether the issued pair was actually revoked (false only in the + // sub-ms pre-stamp window). annotate({ action: { name: "auth.oidc.native.handoff_replay" }, - meta: { revoked_issued_pair: true }, + meta: { revoked_issued_pair: result.revokedIssuedPair }, }); break; case "expired": diff --git a/src/lib/auth/oidc-native-handoff.ts b/src/lib/auth/oidc-native-handoff.ts index bd37a8f64..b14c7a6e6 100644 --- a/src/lib/auth/oidc-native-handoff.ts +++ b/src/lib/auth/oidc-native-handoff.ts @@ -114,7 +114,7 @@ export async function mintNativeHandoff(input: { export type ConsumeHandoffResult = | { status: "ok"; userId: string; handoffId: string } | { status: "not_found" } - | { status: "replayed"; userId: string } + | { status: "replayed"; userId: string; revokedIssuedPair: boolean } | { status: "expired" } | { status: "pkce_mismatch" } | { status: "race_lost" }; @@ -145,14 +145,21 @@ export async function consumeNativeHandoff( // Revoke exactly the pair the first exchange issued and audit; the code // itself is already spent, so no further consume is needed. if (row.consumedAt !== null) { + // `issuedRefreshTokenHash` is null only in the sub-millisecond window + // between the consume and the post-`finishLogin` stamp; a replay there + // finds no pair to revoke (the legitimate pair stays live — a later replay + // once stamped triggers containment). Report what actually happened so the + // wide event never claims a revoke that did not occur. + let revokedIssuedPair = false; if (row.issuedRefreshTokenHash) { await revokeRefreshTokenByHash(row.issuedRefreshTokenHash); + revokedIssuedPair = true; } await auditLog("auth.oidc.native.handoff_replay", { userId: row.userId, ipAddress: row.ipAddress, }); - return { status: "replayed", userId: row.userId }; + return { status: "replayed", userId: row.userId, revokedIssuedPair }; } // Expiry: enforced at read. Burn the row so a lingering expired code has no