mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
ef597bfbbd
commit
b1ce6ebe81
7 files changed
+95
-33
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user