Răsfoiți Sursa

RHL-020 feat(authRedirect): implement login redirect helpers and corresponding tests

Code_Uwe 1 lună în urmă
părinte
comite
00b42fbd0b
2 a modificat fișierele cu 280 adăugiri și 0 ștergeri
  1. 148 0
      lib/frontend/authRedirect.js
  2. 132 0
      lib/frontend/authRedirect.test.js

+ 148 - 0
lib/frontend/authRedirect.js

@@ -0,0 +1,148 @@
+/**
+ * Auth redirect helpers (RHL-020).
+ *
+ * Why this file exists:
+ * - We want a clean, testable place for all "login redirect" rules.
+ * - UI components should not re-implement URL parsing or "next" sanitization.
+ * - This prevents bugs, keeps behavior consistent, and avoids open-redirect issues.
+ *
+ * This module contains only pure functions (no DOM, no Next runtime required).
+ */
+
+import { loginPath } from "@/lib/frontend/routes";
+
+/**
+ * Allowed reasons for /login messages.
+ * Anything else is treated as "unknown" and ignored.
+ *
+ * Keep this list small and explicit.
+ */
+export const LOGIN_REASONS = Object.freeze({
+	EXPIRED: "expired",
+	LOGGED_OUT: "logged-out",
+});
+
+/**
+ * Check if a given string is one of our known login reasons.
+ *
+ * @param {unknown} value
+ * @returns {value is string}
+ */
+export function isKnownLoginReason(value) {
+	return value === LOGIN_REASONS.EXPIRED || value === LOGIN_REASONS.LOGGED_OUT;
+}
+
+/**
+ * Sanitize a "next" path to prevent open redirects.
+ *
+ * Security goal:
+ * - Only allow internal paths like "/NL01/2025/12/31?x=1".
+ * - Reject absolute URLs ("https://evil.com") and protocol-relative URLs ("//evil.com").
+ *
+ * Rule set (intentionally strict):
+ * - Must be a non-empty string after trimming.
+ * - Must start with exactly one "/" (i.e. "/" is ok, but "//" is not).
+ * - Must not contain backslashes (avoid weird path interpretations on some platforms).
+ *
+ * Note:
+ * - URLSearchParams returns *decoded* strings.
+ *   Example: next=%2F%2Fevil.com becomes "//evil.com" -> rejected by our rules.
+ *
+ * @param {unknown} nextValue
+ * @returns {string|null} safe internal path or null when invalid/unsafe
+ */
+export function sanitizeNext(nextValue) {
+	if (typeof nextValue !== "string") return null;
+
+	const trimmed = nextValue.trim();
+	if (!trimmed) return null;
+
+	// Must be an internal path.
+	if (!trimmed.startsWith("/")) return null;
+
+	// Reject protocol-relative URLs.
+	if (trimmed.startsWith("//")) return null;
+
+	// Reject backslashes (avoid ambiguous interpretations).
+	if (trimmed.includes("\\")) return null;
+
+	return trimmed;
+}
+
+/**
+ * Build a /login URL with optional reason + next.
+ *
+ * Design:
+ * - We only add parameters if they are present and valid.
+ * - We keep a stable parameter ordering for predictable tests and debugging:
+ *   - reason first, then next.
+ *
+ * @param {{ reason?: string|null, next?: string|null }} input
+ * @returns {string} e.g. "/login?reason=expired&next=%2FNL01"
+ */
+export function buildLoginUrl({ reason, next } = {}) {
+	const base = loginPath();
+
+	const safeReason = isKnownLoginReason(reason) ? reason : null;
+	const safeNext = sanitizeNext(next);
+
+	const params = new URLSearchParams();
+
+	// Stable param insertion order (important for tests and log readability).
+	if (safeReason) params.set("reason", safeReason);
+	if (safeNext) params.set("next", safeNext);
+
+	const qs = params.toString();
+	return qs ? `${base}?${qs}` : base;
+}
+
+/**
+ * Read a query parameter from either:
+ * - URLSearchParams (client-side `useSearchParams()`)
+ * - Next.js server `searchParams` object (plain object with string or string[])
+ *
+ * This keeps our parsing logic independent from where it is called.
+ *
+ * @param {any} searchParams
+ * @param {string} key
+ * @returns {string|null}
+ */
+function readParam(searchParams, key) {
+	if (!searchParams) return null;
+
+	// URLSearchParams-like object: has .get()
+	if (typeof searchParams.get === "function") {
+		const value = searchParams.get(key);
+		return typeof value === "string" ? value : null;
+	}
+
+	// Next.js server searchParams: plain object
+	const raw = searchParams[key];
+
+	if (Array.isArray(raw)) {
+		// If multiple values exist, we take the first one.
+		return typeof raw[0] === "string" ? raw[0] : null;
+	}
+
+	return typeof raw === "string" ? raw : null;
+}
+
+/**
+ * Parse /login params (reason + next) in a safe and normalized way.
+ *
+ * Output contract:
+ * - reason: "expired" | "logged-out" | null
+ * - next: sanitized internal path or null
+ *
+ * @param {URLSearchParams|Record<string, any>|any} searchParams
+ * @returns {{ reason: string|null, next: string|null }}
+ */
+export function parseLoginParams(searchParams) {
+	const rawReason = readParam(searchParams, "reason");
+	const rawNext = readParam(searchParams, "next");
+
+	const reason = isKnownLoginReason(rawReason) ? rawReason : null;
+	const next = sanitizeNext(rawNext);
+
+	return { reason, next };
+}

+ 132 - 0
lib/frontend/authRedirect.test.js

@@ -0,0 +1,132 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	LOGIN_REASONS,
+	isKnownLoginReason,
+	sanitizeNext,
+	buildLoginUrl,
+	parseLoginParams,
+} from "./authRedirect.js";
+
+describe("lib/frontend/authRedirect", () => {
+	describe("isKnownLoginReason", () => {
+		it("accepts only the explicit known reasons", () => {
+			expect(isKnownLoginReason(LOGIN_REASONS.EXPIRED)).toBe(true);
+			expect(isKnownLoginReason(LOGIN_REASONS.LOGGED_OUT)).toBe(true);
+
+			expect(isKnownLoginReason("something-else")).toBe(false);
+			expect(isKnownLoginReason("")).toBe(false);
+			expect(isKnownLoginReason(null)).toBe(false);
+			expect(isKnownLoginReason(undefined)).toBe(false);
+		});
+	});
+
+	describe("sanitizeNext", () => {
+		it("accepts internal paths that start with a single slash", () => {
+			expect(sanitizeNext("/")).toBe("/");
+			expect(sanitizeNext("/NL01")).toBe("/NL01");
+			expect(sanitizeNext("/NL01/2025/12/31")).toBe("/NL01/2025/12/31");
+			expect(sanitizeNext("/NL01?x=1#hash")).toBe("/NL01?x=1#hash");
+		});
+
+		it("trims whitespace", () => {
+			expect(sanitizeNext("   /NL01   ")).toBe("/NL01");
+		});
+
+		it("rejects empty or non-string input", () => {
+			expect(sanitizeNext("")).toBe(null);
+			expect(sanitizeNext("   ")).toBe(null);
+			expect(sanitizeNext(null)).toBe(null);
+			expect(sanitizeNext(undefined)).toBe(null);
+			expect(sanitizeNext(123)).toBe(null);
+		});
+
+		it("rejects paths that do not start with '/'", () => {
+			expect(sanitizeNext("NL01")).toBe(null);
+			expect(sanitizeNext("login")).toBe(null);
+			expect(sanitizeNext("http://evil.com")).toBe(null);
+		});
+
+		it("rejects protocol-relative URLs and backslashes", () => {
+			expect(sanitizeNext("//evil.com")).toBe(null);
+
+			// Backslashes can lead to confusing path interpretations.
+			expect(sanitizeNext("/\\evil")).toBe(null);
+			expect(sanitizeNext("/NL01\\2025")).toBe(null);
+		});
+	});
+
+	describe("buildLoginUrl", () => {
+		it("returns plain /login when no params are provided", () => {
+			expect(buildLoginUrl()).toBe("/login");
+			expect(buildLoginUrl({})).toBe("/login");
+		});
+
+		it("adds reason when valid", () => {
+			expect(buildLoginUrl({ reason: "expired" })).toBe(
+				"/login?reason=expired"
+			);
+		});
+
+		it("adds next when valid", () => {
+			expect(buildLoginUrl({ next: "/NL01" })).toBe("/login?next=%2FNL01");
+		});
+
+		it("adds reason first, then next (stable ordering)", () => {
+			expect(buildLoginUrl({ reason: "expired", next: "/NL01" })).toBe(
+				"/login?reason=expired&next=%2FNL01"
+			);
+		});
+
+		it("ignores invalid reason and unsafe next", () => {
+			// Invalid reason is dropped; unsafe next is dropped.
+			expect(buildLoginUrl({ reason: "nope", next: "//evil.com" })).toBe(
+				"/login"
+			);
+		});
+	});
+
+	describe("parseLoginParams", () => {
+		it("parses from URLSearchParams (client-side style)", () => {
+			const sp = new URLSearchParams({
+				reason: "expired",
+				next: "/NL01/2025",
+			});
+
+			expect(parseLoginParams(sp)).toEqual({
+				reason: "expired",
+				next: "/NL01/2025",
+			});
+		});
+
+		it("parses from plain object (Next.js server searchParams style)", () => {
+			const sp = {
+				reason: "logged-out",
+				next: "/NL02",
+			};
+
+			expect(parseLoginParams(sp)).toEqual({
+				reason: "logged-out",
+				next: "/NL02",
+			});
+		});
+
+		it("normalizes unknown reason to null and sanitizes next", () => {
+			const sp = new URLSearchParams({
+				reason: "unknown",
+				next: "//evil.com",
+			});
+
+			expect(parseLoginParams(sp)).toEqual({
+				reason: null,
+				next: null,
+			});
+		});
+
+		it("handles missing params", () => {
+			const sp = new URLSearchParams();
+			expect(parseLoginParams(sp)).toEqual({ reason: null, next: null });
+		});
+	});
+});