/** * 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|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 }; }