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