Files
HealthLog/src/lib/auth/login-response.ts
T
Marc-André Bombeck 50031911c2 feat(auth): warn on a Secure-cookie / plain-HTTP transport mismatch at login
The most common self-host login loop is a Secure session cookie issued
over plain HTTP: the browser drops it and bounces the user back to the
sign-in page with no error. Detect it at cookie-mint time
(detectInsecureCookieTransport) and emit a clear warn-log plus a
session_cookie_secure_transport_mismatch wide-event, pointing at
SESSION_COOKIE_SECURE=false / X-Forwarded-Proto. Non-fatal; login still
completes.
2026-07-08 23:36:48 +02:00

147 lines
5.4 KiB
TypeScript

/**
* Shared "issue the authenticated response" tail used by both the password
* login route and the `/api/auth/mfa/verify` route.
*
* Keeping one implementation guarantees the transport contract is identical
* whether or not a second factor sat in the middle: a web caller gets a
* session cookie, a cookie-less native caller gets the access + refresh
* bundle, and a legacy `X-Client-Type: native` caller gets a plain access
* token. The only behavioural difference the second factor introduces is the
* `mfaVerifiedAt` stamp threaded onto the issued session (cookie path) so
* step-up freshness can read it.
*
* Bearer-issuing branches do not carry an `mfaVerifiedAt` equivalent — the
* step-up primitive (`requireFreshMfa`) is cookie-only by construction, the
* same structural boundary as `requireAdmin`, so a token transport can never
* satisfy step-up regardless.
*/
import type { NextRequest } from "next/server";
import { apiSuccess } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
import {
resolveTokenPolicy,
shouldIssueBearerToken,
isCookielessNativeCaller,
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { detectInsecureCookieTransport } from "@/lib/auth/secure-cookie";
import { annotate } from "@/lib/logging/context";
import type { User } from "@/generated/prisma/client";
export interface FinishLoginParams {
user: Pick<User, "id" | "username" | "onboardingCompletedAt">;
request: NextRequest;
ip: string;
userAgent: string | null;
/** Audit `source` discriminator, e.g. `"login.password"` / `"mfa.verify"`. */
source: string;
/**
* When true the issued session is stamped `mfaVerifiedAt = now()` so
* step-up gates treat it as freshly second-factor-verified. Only the
* cookie path carries the stamp.
*/
mfaVerified?: boolean;
}
/**
* Mint the session/token for an already-authenticated user and return the
* client response. Caller is responsible for the prior factor checks and for
* its own `auth.login.*` / `auth.mfa.*` audit entries; this writes only the
* token-issuance audit rows.
*/
export async function finishLogin(
params: FinishLoginParams,
): Promise<Response> {
const { user, request, ip, userAgent, source, mfaVerified } = params;
// v1.23 — new-device / new-location alert. Fire-and-forget: never let the
// device ledger or the dispatcher add latency to — or fail — a sign-in. Runs
// for every transport (web cookie + native bundle) since the factor checks
// are already done by the time finishLogin is reached.
void recordSignInDevice({ userId: user.id, ip, userAgent });
if (
shouldIssueBearerToken(request.headers) ||
isNativeClientRequest(request.headers)
) {
const policy = resolveTokenPolicy(request.headers);
const deviceId = request.headers.get("x-device-id");
// A 60-day refresh token is only ever delivered to a genuinely
// cookie-less native caller — a browser spoofing `X-Client-Type: native`
// falls through to the plain access-token path below.
if (
policy.refreshTokenDays !== null &&
isCookielessNativeCaller(request.headers)
) {
const bundle = await issueAccessAndRefresh({
userId: user.id,
policy,
deviceId,
userAgent,
ipAddress: ip,
source,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { source, policy: "native" },
});
return apiSuccess({
user: { id: user.id, username: user.username },
token: bundle.accessToken,
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
refreshToken: bundle.refreshToken,
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
});
}
// Web policy with explicit X-Client-Type:native (legacy iOS auto-login).
const issued = await issueApiToken({
userId: user.id,
name: `web auto-login ${new Date().toISOString()}`,
permissions: ["*"],
expiresInDays: policy.accessTokenDays,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { tokenId: issued.tokenId, source, policy: "web" },
});
return apiSuccess({
user: { id: user.id, username: user.username },
token: issued.token,
tokenExpiresAt: issued.expiresAt.toISOString(),
});
}
// Browser path — session cookie. Before minting it, surface the single
// most common self-host snag: a Secure cookie about to be set on a plain-HTTP
// request drops silently and loops the login. Warn-log + wide-event so an
// operator sees the cause; the login still completes.
const transportWarning = detectInsecureCookieTransport(request);
if (transportWarning) {
console.warn(
`[auth] session-cookie transport mismatch: ${transportWarning}`,
);
annotate({ meta: { session_cookie_secure_transport_mismatch: true } });
}
// `createSession` anchors the onboarding cookie itself; thread the user's
// onboarding state through.
await createSession(
user.id,
user.onboardingCompletedAt == null,
ip,
userAgent,
mfaVerified ? new Date() : null,
);
return apiSuccess({
user: { id: user.id, username: user.username },
});
}