authRedirect.js 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. /**
  2. * Auth redirect helpers (RHL-020).
  3. *
  4. * Why this file exists:
  5. * - We want a clean, testable place for all "login redirect" rules.
  6. * - UI components should not re-implement URL parsing or "next" sanitization.
  7. * - This prevents bugs, keeps behavior consistent, and avoids open-redirect issues.
  8. *
  9. * This module contains only pure functions (no DOM, no Next runtime required).
  10. */
  11. import { loginPath } from "@/lib/frontend/routes";
  12. /**
  13. * Allowed reasons for /login messages.
  14. * Anything else is treated as "unknown" and ignored.
  15. *
  16. * Keep this list small and explicit.
  17. */
  18. export const LOGIN_REASONS = Object.freeze({
  19. EXPIRED: "expired",
  20. LOGGED_OUT: "logged-out",
  21. });
  22. /**
  23. * Check if a given string is one of our known login reasons.
  24. *
  25. * @param {unknown} value
  26. * @returns {value is string}
  27. */
  28. export function isKnownLoginReason(value) {
  29. return value === LOGIN_REASONS.EXPIRED || value === LOGIN_REASONS.LOGGED_OUT;
  30. }
  31. /**
  32. * Sanitize a "next" path to prevent open redirects.
  33. *
  34. * Security goal:
  35. * - Only allow internal paths like "/NL01/2025/12/31?x=1".
  36. * - Reject absolute URLs ("https://evil.com") and protocol-relative URLs ("//evil.com").
  37. *
  38. * Rule set (intentionally strict):
  39. * - Must be a non-empty string after trimming.
  40. * - Must start with exactly one "/" (i.e. "/" is ok, but "//" is not).
  41. * - Must not contain backslashes (avoid weird path interpretations on some platforms).
  42. *
  43. * Note:
  44. * - URLSearchParams returns *decoded* strings.
  45. * Example: next=%2F%2Fevil.com becomes "//evil.com" -> rejected by our rules.
  46. *
  47. * @param {unknown} nextValue
  48. * @returns {string|null} safe internal path or null when invalid/unsafe
  49. */
  50. export function sanitizeNext(nextValue) {
  51. if (typeof nextValue !== "string") return null;
  52. const trimmed = nextValue.trim();
  53. if (!trimmed) return null;
  54. // Must be an internal path.
  55. if (!trimmed.startsWith("/")) return null;
  56. // Reject protocol-relative URLs.
  57. if (trimmed.startsWith("//")) return null;
  58. // Reject backslashes (avoid ambiguous interpretations).
  59. if (trimmed.includes("\\")) return null;
  60. return trimmed;
  61. }
  62. /**
  63. * Build a /login URL with optional reason + next.
  64. *
  65. * Design:
  66. * - We only add parameters if they are present and valid.
  67. * - We keep a stable parameter ordering for predictable tests and debugging:
  68. * - reason first, then next.
  69. *
  70. * @param {{ reason?: string|null, next?: string|null }} input
  71. * @returns {string} e.g. "/login?reason=expired&next=%2FNL01"
  72. */
  73. export function buildLoginUrl({ reason, next } = {}) {
  74. const base = loginPath();
  75. const safeReason = isKnownLoginReason(reason) ? reason : null;
  76. const safeNext = sanitizeNext(next);
  77. const params = new URLSearchParams();
  78. // Stable param insertion order (important for tests and log readability).
  79. if (safeReason) params.set("reason", safeReason);
  80. if (safeNext) params.set("next", safeNext);
  81. const qs = params.toString();
  82. return qs ? `${base}?${qs}` : base;
  83. }
  84. /**
  85. * Read a query parameter from either:
  86. * - URLSearchParams (client-side `useSearchParams()`)
  87. * - Next.js server `searchParams` object (plain object with string or string[])
  88. *
  89. * This keeps our parsing logic independent from where it is called.
  90. *
  91. * @param {any} searchParams
  92. * @param {string} key
  93. * @returns {string|null}
  94. */
  95. function readParam(searchParams, key) {
  96. if (!searchParams) return null;
  97. // URLSearchParams-like object: has .get()
  98. if (typeof searchParams.get === "function") {
  99. const value = searchParams.get(key);
  100. return typeof value === "string" ? value : null;
  101. }
  102. // Next.js server searchParams: plain object
  103. const raw = searchParams[key];
  104. if (Array.isArray(raw)) {
  105. // If multiple values exist, we take the first one.
  106. return typeof raw[0] === "string" ? raw[0] : null;
  107. }
  108. return typeof raw === "string" ? raw : null;
  109. }
  110. /**
  111. * Parse /login params (reason + next) in a safe and normalized way.
  112. *
  113. * Output contract:
  114. * - reason: "expired" | "logged-out" | null
  115. * - next: sanitized internal path or null
  116. *
  117. * @param {URLSearchParams|Record<string, any>|any} searchParams
  118. * @returns {{ reason: string|null, next: string|null }}
  119. */
  120. export function parseLoginParams(searchParams) {
  121. const rawReason = readParam(searchParams, "reason");
  122. const rawNext = readParam(searchParams, "next");
  123. const reason = isKnownLoginReason(rawReason) ? rawReason : null;
  124. const next = sanitizeNext(rawNext);
  125. return { reason, next };
  126. }