mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The first-paint ladder consulted only the healthlog-locale cookie and Accept-Language — never User.locale. The cookie is written client-side via document.cookie, which Safari ITP caps at 7 days, so a chosen language silently reverted to the browser language once a week. Three seams close it: - resolveInitialLocale (extracted to src/lib/i18n/resolve-initial-locale.ts) now reads User.locale on a cookie miss via a lightweight session read (no sliding-expiry write, no cookie mutation, fail-soft) before the Accept-Language fallback. - PUT /api/auth/me/locale mirrors the accepted locale into the cookie via Set-Cookie (365 days, SameSite=Lax, not HttpOnly, Secure via shouldEmitSecureCookie) — server-set cookies are exempt from the ITP cap, and the mount-time backfill hitting the route doubles as the weekly refresh. - getSession re-emits the cookie when the user has a persisted locale but the cookie is absent (absent-only, so a mid-switch cookie is never reverted before the PUT lands). Users who never chose a language stay on Accept-Language.
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { cookies } from "next/headers";
|
|
import type { User } from "@/generated/prisma/client";
|
|
import { ensureDbCompatibility } from "@/lib/db-compat";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
|
import { isP2025 } from "@/lib/prisma-errors";
|
|
import { locales, type Locale } from "@/lib/i18n/config";
|
|
import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie";
|
|
|
|
const SESSION_COOKIE = "healthlog_session";
|
|
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
|
|
// v1.23 — throttle window for the user-facing active-session list's "last
|
|
// seen" stamp. A write only happens when the prior stamp is older than this,
|
|
// so the high-churn `getSession` path costs at most one extra UPDATE per
|
|
// session per window rather than one per request.
|
|
const LAST_ACTIVE_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
|
/**
|
|
* v1.4.22 C4 — flag cookie mirroring the user's `onboardingCompletedAt`
|
|
* status. Set to `"pending"` while the field is null and cleared once
|
|
* onboarding completes. The proxy reads this cookie to short-circuit
|
|
* the post-hydration redirect that previously caused a dashboard flash
|
|
* before the client-side `<AuthShell>` effect could fire.
|
|
*
|
|
* NOT httpOnly: the cookie is a UX hint, not a security signal — the
|
|
* real gate stays the server-side onboarding-complete check in
|
|
* `/api/onboarding/complete`. A user editing the cookie locally just
|
|
* skips the dashboard flash; they still can't bypass any data check.
|
|
*/
|
|
const ONBOARDING_COOKIE = "hl_onboarding";
|
|
|
|
export async function setOnboardingPendingCookie(
|
|
pending: boolean,
|
|
): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
if (pending) {
|
|
cookieStore.set(ONBOARDING_COOKIE, "pending", {
|
|
httpOnly: false,
|
|
secure: shouldEmitSecureCookie(),
|
|
// v1.4.22 W5 reconcile (Sec-MED-1) — Strict (not Lax) because
|
|
// no cross-site redirect flow ever depends on this cookie. The
|
|
// sibling `healthlog_session` cookie stays Lax because the
|
|
// Withings OAuth callback arrives via top-level cross-site
|
|
// redirect; the onboarding hint has no equivalent flow.
|
|
sameSite: "strict",
|
|
maxAge: SESSION_MAX_AGE_MS / 1000,
|
|
path: "/",
|
|
});
|
|
} else {
|
|
cookieStore.delete(ONBOARDING_COOKIE);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* v1.4.22 W5 reconcile (Sr-H1) — `onboardingPending` is required
|
|
* (not optional) so issuing a session without anchoring the
|
|
* onboarding cookie is type-impossible. Every auth surface (login,
|
|
* passkey-verify, register, password-reset) must thread the user's
|
|
* `onboardingCompletedAt == null` value through. The onboarding
|
|
* surface itself flips the cookie via `setOnboardingPendingCookie`
|
|
* directly when the user hands in the form.
|
|
*/
|
|
export async function createSession(
|
|
userId: string,
|
|
onboardingPending: boolean,
|
|
ipAddress?: string | null,
|
|
userAgent?: string | null,
|
|
// v1.23 — set when this session was minted behind a completed second
|
|
// factor (the `/api/auth/mfa/verify` path) or a fresh factor re-confirm.
|
|
// `requireFreshMfa` reads it for step-up freshness; a null value means
|
|
// "single-factor session" and never satisfies step-up.
|
|
mfaVerifiedAt?: Date | null,
|
|
): Promise<string> {
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId,
|
|
expiresAt: new Date(Date.now() + SESSION_MAX_AGE_MS),
|
|
ipAddress: ipAddress ?? null,
|
|
userAgent: userAgent ?? null,
|
|
mfaVerifiedAt: mfaVerifiedAt ?? null,
|
|
},
|
|
});
|
|
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(SESSION_COOKIE, session.id, {
|
|
httpOnly: true,
|
|
secure: shouldEmitSecureCookie(),
|
|
// OAuth callbacks (e.g. Withings) arrive via top-level cross-site redirect.
|
|
// Lax keeps CSRF protection for unsafe methods while allowing this flow.
|
|
sameSite: "lax",
|
|
maxAge: SESSION_MAX_AGE_MS / 1000,
|
|
path: "/",
|
|
});
|
|
|
|
// Anchor the onboarding cookie alongside the session cookie so a
|
|
// future auth surface added without remembering the helper can never
|
|
// reintroduce the dashboard flash.
|
|
await setOnboardingPendingCookie(onboardingPending);
|
|
|
|
return session.id;
|
|
}
|
|
|
|
export async function getSession(): Promise<{
|
|
session: { id: string; expiresAt: Date };
|
|
user: User;
|
|
} | null> {
|
|
try {
|
|
await ensureDbCompatibility();
|
|
} catch (error) {
|
|
getEvent()?.setError(
|
|
error instanceof Error
|
|
? error
|
|
: new Error("DB compatibility check failed"),
|
|
);
|
|
}
|
|
|
|
const cookieStore = await cookies();
|
|
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
|
if (!sessionId) return null;
|
|
|
|
const session = await prisma.session.findUnique({
|
|
where: { id: sessionId },
|
|
include: { user: true },
|
|
});
|
|
|
|
if (!session || session.expiresAt < new Date()) {
|
|
if (session) {
|
|
await prisma.session
|
|
.deleteMany({ where: { id: sessionId } })
|
|
.catch(() => {});
|
|
}
|
|
cookieStore.delete(SESSION_COOKIE);
|
|
cookieStore.delete(ONBOARDING_COOKIE);
|
|
return null;
|
|
}
|
|
|
|
// Sliding expiry: refresh if more than 1 day old
|
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
|
if (
|
|
session.expiresAt.getTime() - Date.now() <
|
|
SESSION_MAX_AGE_MS - oneDayMs
|
|
) {
|
|
const newExpiry = new Date(Date.now() + SESSION_MAX_AGE_MS);
|
|
await prisma.session.update({
|
|
where: { id: session.id },
|
|
data: { expiresAt: newExpiry },
|
|
});
|
|
cookieStore.set(SESSION_COOKIE, session.id, {
|
|
httpOnly: true,
|
|
secure: shouldEmitSecureCookie(),
|
|
sameSite: "lax",
|
|
maxAge: SESSION_MAX_AGE_MS / 1000,
|
|
path: "/",
|
|
});
|
|
}
|
|
|
|
// v1.23 — sliding "last seen" stamp for the active-session list. Throttled
|
|
// (only when the prior stamp is stale) and fire-and-forget so it never adds
|
|
// latency or a failure mode to the request-resolution path.
|
|
const lastActive = session.lastActiveAt;
|
|
if (
|
|
!lastActive ||
|
|
lastActive.getTime() < Date.now() - LAST_ACTIVE_THROTTLE_MS
|
|
) {
|
|
void prisma.session
|
|
.update({
|
|
where: { id: session.id },
|
|
data: { lastActiveAt: new Date() },
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// Locale-cookie refresh: Safari's ITP expires the script-written
|
|
// `healthlog-locale` cookie after 7 days, so a returning user's first
|
|
// paint fell back to the browser language once a week. When the user
|
|
// has a persisted locale but the cookie is gone, re-emit it here —
|
|
// Set-Cookie is exempt from the ITP cap. Absent-only on purpose: a
|
|
// locale switch writes the cookie client-side before the PUT persists
|
|
// the column, and overwriting a *differing* cookie here would revert
|
|
// that fresh choice mid-flight. Fail-soft: in a server-component
|
|
// render the cookie store is read-only and `set` throws — the next
|
|
// route-handler request re-attempts.
|
|
if (
|
|
session.user.locale &&
|
|
(locales as readonly string[]).includes(session.user.locale) &&
|
|
!cookieStore.get(LOCALE_COOKIE)
|
|
) {
|
|
try {
|
|
setLocaleCookie(cookieStore, session.user.locale as Locale);
|
|
} catch {
|
|
// Read-only cookie context — skip; nothing depends on this write.
|
|
}
|
|
}
|
|
|
|
return {
|
|
session: { id: session.id, expiresAt: session.expiresAt },
|
|
user: session.user,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Lightweight read of the signed-in user's persisted locale for the root
|
|
* layout's first-paint language resolution. Deliberately NOT
|
|
* `getSession()`: no db-compat check, no sliding-expiry write, no cookie
|
|
* mutation — the root layout renders on every request in a context where
|
|
* the cookie store is read-only, so the full session touch is both dead
|
|
* weight and a throw hazard there. Returns null for missing/expired
|
|
* sessions and users without a persisted locale.
|
|
*/
|
|
export async function getSessionUserLocale(): Promise<string | null> {
|
|
const cookieStore = await cookies();
|
|
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
|
if (!sessionId) return null;
|
|
|
|
const session = await prisma.session.findUnique({
|
|
where: { id: sessionId },
|
|
select: { expiresAt: true, user: { select: { locale: true } } },
|
|
});
|
|
if (!session || session.expiresAt < new Date()) return null;
|
|
return session.user.locale;
|
|
}
|
|
|
|
export async function destroySession(): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
|
if (sessionId) {
|
|
// Logout is idempotent: a session row that no longer exists (P2025) is a
|
|
// no-op. Any other failure is a real delete error — record it on the wide
|
|
// event rather than swallowing it silently, but never block the cookie
|
|
// clear below: a transient DB fault must not leave the client logged in
|
|
// with the cookie intact.
|
|
await prisma.session.delete({ where: { id: sessionId } }).catch((err) => {
|
|
if (!isP2025(err)) {
|
|
getEvent()?.addWarning(
|
|
`destroySession delete failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
cookieStore.delete(SESSION_COOKIE);
|
|
cookieStore.delete(ONBOARDING_COOKIE);
|
|
}
|
|
|
|
/**
|
|
* Revoke every authenticated surface a user owns: web sessions, API
|
|
* tokens (long-lived Bearer credentials issued via `/settings/api-tokens`),
|
|
* and native-client refresh tokens. Called from every credential-rotation
|
|
* path (`/api/auth/password`, `/api/admin/users/[id]/reset-password`,
|
|
* `DELETE /api/settings/account`) so a remediated user wipes a stolen
|
|
* credential across every transport, not just the browser cookie.
|
|
*
|
|
* `ApiToken` flips `revoked=true` (the token-hash lookup at
|
|
* `src/lib/auth/api-token.ts` filters on this column). `RefreshToken`
|
|
* sets `revokedAt` (the rotation lookup at
|
|
* `src/lib/auth/refresh-token.ts` short-circuits on a non-null value).
|
|
* Both run as `updateMany` rather than `deleteMany` so the audit trail
|
|
* survives — the rows linger as evidence of the rotation event.
|
|
*/
|
|
export async function destroyAllSessions(userId: string): Promise<void> {
|
|
await prisma.$transaction([
|
|
prisma.session.deleteMany({ where: { userId } }),
|
|
prisma.apiToken.updateMany({
|
|
where: { userId, revoked: false },
|
|
data: { revoked: true },
|
|
}),
|
|
prisma.refreshToken.updateMany({
|
|
where: { userId, revokedAt: null },
|
|
data: { revokedAt: new Date() },
|
|
}),
|
|
// v1.23 — a credential rotation also kills every "remember this device"
|
|
// trusted-device token: a stolen device cookie must not survive a
|
|
// password change or account-credential remediation.
|
|
prisma.trustedDevice.deleteMany({ where: { userId } }),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* v1.23 — "sign out everywhere" for the user-facing active-session surface
|
|
* (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's
|
|
* CURRENT cookie session alive so clicking the button doesn't log the user out
|
|
* of the device they pressed it on, and it does NOT revoke `ApiToken`s — those
|
|
* are long-lived programmatic credentials the user manages separately under
|
|
* /settings/api-tokens, not "sessions" in the device-list sense. It DOES revoke
|
|
* every native-client `RefreshToken` (each is a device login) so a signed-in
|
|
* phone/tablet is dropped too, matching the "everywhere" promise.
|
|
*
|
|
* Returns the number of OTHER web sessions removed so the surface can confirm.
|
|
*/
|
|
export async function destroyOtherSessions(
|
|
userId: string,
|
|
currentSessionId: string,
|
|
): Promise<{ sessionsRevoked: number }> {
|
|
const [deleted] = await prisma.$transaction([
|
|
prisma.session.deleteMany({
|
|
where: { userId, id: { not: currentSessionId } },
|
|
}),
|
|
prisma.refreshToken.updateMany({
|
|
where: { userId, revokedAt: null },
|
|
data: { revokedAt: new Date() },
|
|
}),
|
|
// v1.23 — "sign out everywhere" also drops every trusted device so a
|
|
// remembered browser can no longer skip the second factor (§1.7: a device
|
|
// cookie is killed on sign-out-everywhere).
|
|
prisma.trustedDevice.deleteMany({ where: { userId } }),
|
|
]);
|
|
return { sessionsRevoked: deleted.count };
|
|
}
|
|
|
|
/**
|
|
* v1.23 — revoke a single web session by id, scoped to the owning user so a
|
|
* caller can never delete another user's session row. Returns whether a row
|
|
* was actually removed (false → not found or not owned).
|
|
*/
|
|
export async function destroySessionById(
|
|
userId: string,
|
|
sessionId: string,
|
|
): Promise<boolean> {
|
|
const result = await prisma.session.deleteMany({
|
|
where: { id: sessionId, userId },
|
|
});
|
|
return result.count > 0;
|
|
}
|