|
|
@@ -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 };
|
|
|
+}
|