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>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 22:11:07 +02:00
1 parent ef597bfbbd
commit b1ce6ebe81
7 files changed
+95 -33

No files matched your search

+6 -5
View File
@@ -1,7 +1,7 @@
import { prisma } from "@/lib/db";
import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setOnboardingPendingCookie } from "@/lib/auth/session";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
@@ -76,10 +76,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const ua = request.headers.get("user-agent");
await createSession(user.id, ip, ua);
// v1.4.22 C4 — mirror the user's onboarding status into a cookie so
// the proxy can short-circuit the redirect before the page hydrates.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` now anchors the
// `hl_onboarding` cookie itself; pass the user's onboarding state
// through so the proxy can short-circuit the redirect before the
// page hydrates.
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
await auditLog("auth.login.password", {
userId: user.id,
+5 -4
View File
@@ -9,10 +9,11 @@ export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.4.22 C4 — keep the proxy-readable onboarding cookie in sync
// with the DB state on every /me roundtrip. Old sessions that
// predate the cookie pick it up on first dashboard load; the
// /onboarding/complete handler always wins on the cleared edge.
// v1.4.22 W5 reconcile (Sr-H1) — fall-back resync for legacy
// sessions that predate the cookie. New sessions anchor the cookie
// inside `createSession` itself, so this is no longer the primary
// write path; it just makes sure pre-v1.4.22 sessions get their
// cookie set on the first /me roundtrip after the upgrade.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
annotate({ action: { name: "auth.me" } });
@@ -1,5 +1,5 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import { createSession, setOnboardingPendingCookie } from "@/lib/auth/session";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
@@ -65,11 +65,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("User not found", 404);
}
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` anchors the
// `hl_onboarding` cookie itself; pass the user's onboarding state
// through so the proxy short-circuits the redirect before
// hydration.
const ua = request.headers.get("user-agent");
await createSession(user.id, ip, ua);
// v1.4.22 C4 — mirror onboarding status into the proxy-readable cookie
// so the redirect short-circuits before the page hydrates.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
await auditLog("auth.login.passkey", {
userId: user.id,
+5 -9
View File
@@ -16,11 +16,7 @@ import {
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
destroyAllSessions,
createSession,
setOnboardingPendingCookie,
} from "@/lib/auth/session";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -79,16 +75,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: { passwordHash: newHash },
});
// Invalidate all existing sessions and create a fresh one
// Invalidate all existing sessions and create a fresh one.
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` re-anchors the
// `hl_onboarding` cookie itself.
await destroyAllSessions(user.id);
await createSession(
user.id,
user.onboardingCompletedAt == null,
getClientIp(request),
request.headers.get("user-agent"),
);
// v1.4.22 C4 — re-anchor the onboarding cookie to the user's real
// status after the session rebuild.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
await auditLog("auth.password.change", {
userId: user.id,
+6 -7
View File
@@ -1,7 +1,7 @@
import { prisma } from "@/lib/db";
import { registerSchema } from "@/lib/validations/auth";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession, setOnboardingPendingCookie } from "@/lib/auth/session";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
@@ -94,13 +94,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// Create session immediately
// Create session immediately. v1.4.22 W5 reconcile (Sr-H1) —
// `createSession` anchors the `hl_onboarding` cookie itself; fresh
// users are always pending so the proxy redirects on first
// navigation instead of waiting for hydration.
const ua = request.headers.get("user-agent");
await createSession(user.id, ip, ua);
// v1.4.22 C4 — fresh users are always onboarding-pending. The cookie
// lets the proxy redirect on first navigation instead of waiting for
// `<AuthShell>` to hydrate and trigger a useEffect bounce.
await setOnboardingPendingCookie(true);
await createSession(user.id, true, ip, ua);
await auditLog("auth.register", {
userId: user.id,
+21 -1
View File
@@ -29,7 +29,12 @@ export async function setOnboardingPendingCookie(
cookieStore.set(ONBOARDING_COOKIE, "pending", {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
// 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: "/",
});
@@ -38,8 +43,18 @@ export async function setOnboardingPendingCookie(
}
}
/**
* 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> {
@@ -63,6 +78,11 @@ export async function createSession(
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;
}
+46 -2
View File
@@ -57,7 +57,12 @@ describe("session lifecycle (real Postgres)", () => {
data: { username: "session-test", email: "session@example.test" },
});
const sessionId = await createSession(user.id, "127.0.0.1", "vitest/1.0");
const sessionId = await createSession(
user.id,
true,
"127.0.0.1",
"vitest/1.0",
);
const row = await getPrismaClient().session.findUnique({
where: { id: sessionId },
@@ -80,7 +85,7 @@ describe("session lifecycle (real Postgres)", () => {
data: { username: "expired-session", email: "expired@example.test" },
});
const sessionId = await createSession(user.id);
const sessionId = await createSession(user.id, false);
// Force the row past its expiry — getSession must purge it.
await getPrismaClient().session.update({
@@ -101,4 +106,43 @@ describe("session lifecycle (real Postgres)", () => {
const { getSession } = await import("@/lib/auth/session");
expect(await getSession()).toBeNull();
});
/**
* v1.4.22 W5 reconcile (Sr-H1) — `createSession` now anchors the
* `hl_onboarding` cookie itself so issuing a session without it is
* type-impossible. Pin the contract so a future auth surface added
* without the helper can never reintroduce the dashboard flash.
*/
it("createSession sets the hl_onboarding cookie when onboardingPending=true", async () => {
const { createSession } = await import("@/lib/auth/session");
const user = await getPrismaClient().user.create({
data: { username: "onb-pending", email: "pending@example.test" },
});
await createSession(user.id, true);
expect(cookieJar.get("hl_onboarding")).toBe("pending");
});
it("createSession clears the hl_onboarding cookie when onboardingPending=false", async () => {
const { createSession } = await import("@/lib/auth/session");
const user = await getPrismaClient().user.create({
data: { username: "onb-done", email: "done@example.test" },
});
cookieJar.set("hl_onboarding", "pending"); // simulate stale value
await createSession(user.id, false);
expect(cookieJar.get("hl_onboarding")).toBeUndefined();
});
it("destroySession clears the hl_onboarding cookie alongside the session cookie", async () => {
const { createSession, destroySession } = await import(
"@/lib/auth/session"
);
const user = await getPrismaClient().user.create({
data: { username: "dest", email: "dest@example.test" },
});
await createSession(user.id, true);
expect(cookieJar.get("hl_onboarding")).toBe("pending");
await destroySession();
expect(cookieJar.get("hl_onboarding")).toBeUndefined();
expect(cookieJar.get("healthlog_session")).toBeUndefined();
});
});