fix(auth): force the native-bundle branch on the OIDC token exchange

Adversarial review found the transport gate (isCookielessNativeCaller) was
broader than finishLogin's bearer predicate: a blank-UA caller with no
X-Client-Type could pass it and fall into the cookie branch, receiving a
Set-Cookie session instead of the token bundle (inert, not exploitable, but
the endpoint's own invariant was false). Also require isNativeClientRequest
so the cookie path is structurally unreachable here, and report the actual
revoke outcome on a replay so the wide event never overclaims.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 17:02:45 +02:00
1 parent ada147522f
commit 16fd4966d0
3 files changed
+32 -10

No files matched your search

@@ -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),
});
}
+16 -7
View File
@@ -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":
+9 -2
View File
@@ -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