Jelajahi Sumber

RHL-044 feat(auth): expose mustChangePassword in session and me

codeUWE 1 bulan lalu
induk
melakukan
7ece02a6ca

+ 1 - 0
app/api/auth/login/route.js

@@ -105,6 +105,7 @@ export const POST = withErrorHandling(
 			role: user.role,
 			branchId: user.branchId ?? null,
 			email: user.email ?? null,
+			mustChangePassword: Boolean(user.mustChangePassword),
 		});
 
 		// Happy path response stays unchanged:

+ 2 - 0
app/api/auth/login/route.test.js

@@ -58,6 +58,7 @@ describe("POST /api/auth/login", () => {
 			passwordHash: "hashed-password",
 			role: "branch",
 			branchId: "NL01",
+			mustChangePassword: true,
 		};
 
 		User.findOne.mockReturnValue({
@@ -90,6 +91,7 @@ describe("POST /api/auth/login", () => {
 			role: "branch",
 			branchId: "NL01",
 			email: "nl01@example.com",
+			mustChangePassword: true,
 		});
 	});
 

+ 2 - 1
app/api/auth/me/route.js

@@ -17,7 +17,7 @@ export const dynamic = "force-dynamic";
  *
  * Semantics (frontend-friendly):
  * - 200 with { user: null } when unauthenticated
- * - 200 with { user: { userId, role, branchId } } when authenticated
+ * - 200 with { user: { userId, role, branchId, email, mustChangePassword } } when authenticated
  *
  * This avoids using 401 as control-flow for basic "am I logged in?" checks.
  */
@@ -36,6 +36,7 @@ export const GET = withErrorHandling(
 					role: session.role,
 					branchId: session.branchId ?? null,
 					email: session.email ?? null,
+					mustChangePassword: session.mustChangePassword === true,
 				},
 			},
 			200,

+ 5 - 1
app/api/auth/me/route.test.js

@@ -32,6 +32,7 @@ describe("GET /api/auth/me", () => {
 			role: "branch",
 			branchId: "NL01",
 			email: "nl01@example.com",
+			mustChangePassword: true,
 		});
 
 		const res = await GET();
@@ -42,16 +43,18 @@ describe("GET /api/auth/me", () => {
 				role: "branch",
 				branchId: "NL01",
 				email: "nl01@example.com",
+				mustChangePassword: true,
 			},
 		});
 	});
 
-	it("returns email=null when session has no email", async () => {
+	it("returns email=null and mustChangePassword=false when missing", async () => {
 		getSession.mockResolvedValue({
 			userId: "u2",
 			role: "admin",
 			branchId: null,
 			email: null,
+			mustChangePassword: undefined,
 		});
 
 		const res = await GET();
@@ -62,6 +65,7 @@ describe("GET /api/auth/me", () => {
 				role: "admin",
 				branchId: null,
 				email: null,
+				mustChangePassword: false,
 			},
 		});
 	});

+ 14 - 2
lib/auth/session.js

@@ -30,10 +30,20 @@ function normalizeEmailOrNull(value) {
 	return s.toLowerCase();
 }
 
+function normalizeMustChangePassword(value) {
+	return value === true;
+}
+
 /**
  * Create a signed session JWT and store it in a HTTP-only cookie.
  */
-export async function createSession({ userId, role, branchId, email }) {
+export async function createSession({
+	userId,
+	role,
+	branchId,
+	email,
+	mustChangePassword,
+}) {
 	if (!userId || !role) {
 		throw new Error("createSession requires userId and role");
 	}
@@ -43,6 +53,7 @@ export async function createSession({ userId, role, branchId, email }) {
 		role,
 		branchId: branchId ?? null,
 		email: normalizeEmailOrNull(email),
+		mustChangePassword: normalizeMustChangePassword(mustChangePassword),
 	};
 
 	const jwt = await new SignJWT(payload)
@@ -80,7 +91,7 @@ export async function getSession() {
 	try {
 		const { payload } = await jwtVerify(cookie.value, secretKey);
 
-		const { userId, role, branchId, email } = payload;
+		const { userId, role, branchId, email, mustChangePassword } = payload;
 
 		if (typeof userId !== "string" || typeof role !== "string") {
 			return null;
@@ -91,6 +102,7 @@ export async function getSession() {
 			role,
 			branchId: typeof branchId === "string" ? branchId : null,
 			email: typeof email === "string" ? email : null,
+			mustChangePassword: normalizeMustChangePassword(mustChangePassword),
 		};
 	} catch {
 		const store = await cookies();

+ 57 - 0
lib/auth/session.test.js

@@ -1,6 +1,7 @@
 /* @vitest-environment node */
 
 import { describe, it, expect, vi, beforeEach } from "vitest";
+import { SignJWT } from "jose";
 
 // Mock next/headers to provide a simple in-memory cookie store
 vi.mock("next/headers", () => {
@@ -87,6 +88,7 @@ describe("auth session utilities", () => {
 			role: "admin",
 			branchId: null,
 			email: null,
+			mustChangePassword: false,
 		});
 	});
 
@@ -105,6 +107,61 @@ describe("auth session utilities", () => {
 			role: "branch",
 			branchId: "NL01",
 			email: "user@example.com",
+			mustChangePassword: false,
+		});
+	});
+
+	it("includes mustChangePassword=true only when explicitly set to true", async () => {
+		await createSession({
+			userId: "user-must",
+			role: "branch",
+			branchId: "NL01",
+			mustChangePassword: true,
+		});
+
+		const session = await getSession();
+
+		expect(session).toEqual({
+			userId: "user-must",
+			role: "branch",
+			branchId: "NL01",
+			email: null,
+			mustChangePassword: true,
+		});
+	});
+
+	it("defaults mustChangePassword=false for legacy payloads without the field", async () => {
+		const jwt = await new SignJWT({
+			userId: "legacy-user",
+			role: "admin",
+			branchId: null,
+			email: "legacy@example.com",
+		})
+			.setProtectedHeader({ alg: "HS256", typ: "JWT" })
+			.setIssuedAt()
+			.setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`)
+			.sign(new TextEncoder().encode(process.env.SESSION_SECRET));
+
+		const store = __cookieStore.dump();
+		store.set(SESSION_COOKIE_NAME, {
+			value: jwt,
+			options: {
+				httpOnly: true,
+				secure: false,
+				sameSite: "lax",
+				path: "/",
+				maxAge: SESSION_MAX_AGE_SECONDS,
+			},
+		});
+
+		const session = await getSession();
+
+		expect(session).toEqual({
+			userId: "legacy-user",
+			role: "admin",
+			branchId: null,
+			email: "legacy@example.com",
+			mustChangePassword: false,
 		});
 	});