Files
HealthLog/src/lib/auth/session.ts
T
Marc-André BombeckandClaude Opus 4.7 b1ce6ebe81 refactor(auth): fold onboarding-cookie write into createSession + destroySession
The `setOnboardingPendingCookie` helper was called from six different
auth routes (login, passkey-verify, register, password, /me,
/onboarding/complete) by convention. A future auth surface (SSO,
magic link, social login) added without remembering the helper would
silently re-introduce the dashboard flash that v1.4.22 C4 was
designed to fix.

Make `onboardingPending` a required parameter on `createSession`
(not optional) so issuing a session without anchoring the cookie is
type-impossible. The five primary auth surfaces now thread the
user's `onboardingCompletedAt == null` value through; the
`/api/auth/me` mirror remains as a fall-back for legacy sessions
that predate the cookie. The `/api/onboarding/complete` clear stays
where it is — it is a value flip after a DB write, not a session
issuance.

Three new integration tests pin: createSession sets the cookie when
pending, clears it when completed, and destroySession clears it
alongside the session cookie.

Closes Sr-H1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:11:07 +02:00

171 lines
5.4 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";
const SESSION_COOKIE = "healthlog_session";
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
/**
* 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: process.env.NODE_ENV === "production",
// 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,
): 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,
},
});
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
// 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.delete({ where: { id: sessionId } });
}
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: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SESSION_MAX_AGE_MS / 1000,
path: "/",
});
}
return {
session: { id: session.id, expiresAt: session.expiresAt },
user: session.user,
};
}
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
if (sessionId) {
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {});
}
cookieStore.delete(SESSION_COOKIE);
cookieStore.delete(ONBOARDING_COOKIE);
}
export async function destroyAllSessions(userId: string): Promise<void> {
await prisma.session.deleteMany({ where: { userId } });
}
/**
* Require an authenticated admin user.
* Returns user if admin, null otherwise.
*/
export async function requireAdmin(): Promise<User | null> {
const sessionData = await getSession();
if (!sessionData) return null;
if (sessionData.user.role !== "ADMIN") return null;
return sessionData.user;
}