mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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 { prisma } from "@/lib/db";
|
||||||
import { loginPasswordSchema } from "@/lib/validations/auth";
|
import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||||
import { verifyPassword } from "@/lib/auth/password";
|
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 { auditLog } from "@/lib/auth/audit";
|
||||||
import {
|
import {
|
||||||
apiSuccess,
|
apiSuccess,
|
||||||
@@ -76,10 +76,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ua = request.headers.get("user-agent");
|
const ua = request.headers.get("user-agent");
|
||||||
await createSession(user.id, ip, ua);
|
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` now anchors the
|
||||||
// v1.4.22 C4 — mirror the user's onboarding status into a cookie so
|
// `hl_onboarding` cookie itself; pass the user's onboarding state
|
||||||
// the proxy can short-circuit the redirect before the page hydrates.
|
// through so the proxy can short-circuit the redirect before the
|
||||||
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
|
// page hydrates.
|
||||||
|
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
|
||||||
|
|
||||||
await auditLog("auth.login.password", {
|
await auditLog("auth.login.password", {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ export const dynamic = "force-dynamic";
|
|||||||
export const GET = apiHandler(async () => {
|
export const GET = apiHandler(async () => {
|
||||||
const { user } = await requireAuth();
|
const { user } = await requireAuth();
|
||||||
|
|
||||||
// v1.4.22 C4 — keep the proxy-readable onboarding cookie in sync
|
// v1.4.22 W5 reconcile (Sr-H1) — fall-back resync for legacy
|
||||||
// with the DB state on every /me roundtrip. Old sessions that
|
// sessions that predate the cookie. New sessions anchor the cookie
|
||||||
// predate the cookie pick it up on first dashboard load; the
|
// inside `createSession` itself, so this is no longer the primary
|
||||||
// /onboarding/complete handler always wins on the cleared edge.
|
// 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);
|
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
|
||||||
|
|
||||||
annotate({ action: { name: "auth.me" } });
|
annotate({ action: { name: "auth.me" } });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
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 { auditLog } from "@/lib/auth/audit";
|
||||||
import {
|
import {
|
||||||
apiSuccess,
|
apiSuccess,
|
||||||
@@ -65,11 +65,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
|||||||
return apiError("User not found", 404);
|
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");
|
const ua = request.headers.get("user-agent");
|
||||||
await createSession(user.id, ip, ua);
|
await createSession(user.id, user.onboardingCompletedAt == null, 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 auditLog("auth.login.passkey", {
|
await auditLog("auth.login.passkey", {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -16,11 +16,7 @@ import {
|
|||||||
import { checkRateLimit } from "@/lib/rate-limit";
|
import { checkRateLimit } from "@/lib/rate-limit";
|
||||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||||
import { annotate } from "@/lib/logging/context";
|
import { annotate } from "@/lib/logging/context";
|
||||||
import {
|
import { destroyAllSessions, createSession } from "@/lib/auth/session";
|
||||||
destroyAllSessions,
|
|
||||||
createSession,
|
|
||||||
setOnboardingPendingCookie,
|
|
||||||
} from "@/lib/auth/session";
|
|
||||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||||
|
|
||||||
export const POST = apiHandler(async (request: NextRequest) => {
|
export const POST = apiHandler(async (request: NextRequest) => {
|
||||||
@@ -79,16 +75,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
|||||||
data: { passwordHash: newHash },
|
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 destroyAllSessions(user.id);
|
||||||
await createSession(
|
await createSession(
|
||||||
user.id,
|
user.id,
|
||||||
|
user.onboardingCompletedAt == null,
|
||||||
getClientIp(request),
|
getClientIp(request),
|
||||||
request.headers.get("user-agent"),
|
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", {
|
await auditLog("auth.password.change", {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { registerSchema } from "@/lib/validations/auth";
|
import { registerSchema } from "@/lib/validations/auth";
|
||||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
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 { auditLog } from "@/lib/auth/audit";
|
||||||
import {
|
import {
|
||||||
apiSuccess,
|
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");
|
const ua = request.headers.get("user-agent");
|
||||||
await createSession(user.id, ip, ua);
|
await createSession(user.id, true, 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 auditLog("auth.register", {
|
await auditLog("auth.register", {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
+21
-1
@@ -29,7 +29,12 @@ export async function setOnboardingPendingCookie(
|
|||||||
cookieStore.set(ONBOARDING_COOKIE, "pending", {
|
cookieStore.set(ONBOARDING_COOKIE, "pending", {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
secure: process.env.NODE_ENV === "production",
|
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,
|
maxAge: SESSION_MAX_AGE_MS / 1000,
|
||||||
path: "/",
|
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(
|
export async function createSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
onboardingPending: boolean,
|
||||||
ipAddress?: string | null,
|
ipAddress?: string | null,
|
||||||
userAgent?: string | null,
|
userAgent?: string | null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
@@ -63,6 +78,11 @@ export async function createSession(
|
|||||||
path: "/",
|
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;
|
return session.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ describe("session lifecycle (real Postgres)", () => {
|
|||||||
data: { username: "session-test", email: "session@example.test" },
|
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({
|
const row = await getPrismaClient().session.findUnique({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
@@ -80,7 +85,7 @@ describe("session lifecycle (real Postgres)", () => {
|
|||||||
data: { username: "expired-session", email: "expired@example.test" },
|
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.
|
// Force the row past its expiry — getSession must purge it.
|
||||||
await getPrismaClient().session.update({
|
await getPrismaClient().session.update({
|
||||||
@@ -101,4 +106,43 @@ describe("session lifecycle (real Postgres)", () => {
|
|||||||
const { getSession } = await import("@/lib/auth/session");
|
const { getSession } = await import("@/lib/auth/session");
|
||||||
expect(await getSession()).toBeNull();
|
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