Files
HealthLog/src/app/api/auth/native/token/route.ts
T
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

161 lines
5.7 KiB
TypeScript

/**
* 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;
});