mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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.
161 lines
5.7 KiB
TypeScript
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;
|
|
});
|