From b1ce6ebe812c1c81b24c67d9a2edd0124d92853e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 22:11:07 +0200 Subject: [PATCH] refactor(auth): fold onboarding-cookie write into createSession + destroySession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/auth/login/route.ts | 11 +++-- src/app/api/auth/me/route.ts | 9 ++-- .../api/auth/passkey/login-verify/route.ts | 11 +++-- src/app/api/auth/password/route.ts | 14 ++---- src/app/api/auth/register/route.ts | 13 +++-- src/lib/auth/session.ts | 22 ++++++++- tests/integration/auth-flow.test.ts | 48 ++++++++++++++++++- 7 files changed, 95 insertions(+), 33 deletions(-) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index ba8c969e6..1cac71665 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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, diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 0a09736dd..4b71f2530 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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" } }); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 9e3783041..bac870ed7 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -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, diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 53ea9a206..2896bc960 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -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, diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 78a1c4dd5..8847952e8 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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 - // `` to hydrate and trigger a useEffect bounce. - await setOnboardingPendingCookie(true); + await createSession(user.id, true, ip, ua); await auditLog("auth.register", { userId: user.id, diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 6b433d5dc..658d8b17f 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -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 { @@ -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; } diff --git a/tests/integration/auth-flow.test.ts b/tests/integration/auth-flow.test.ts index 756ee9e30..cd18fdf4c 100644 --- a/tests/integration/auth-flow.test.ts +++ b/tests/integration/auth-flow.test.ts @@ -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(); + }); });