mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user