|
| 1 | +import { getPublicAppBaseUrl, isLocalhostBaseUrl } from "@wse/core/lib/app-base-url" |
| 2 | + |
1 | 3 | /** Build login path preserving return URL after Google sign-in. */ |
2 | 4 | export function authLoginPath(callbackUrl: string): string { |
3 | | - const params = new URLSearchParams({ callbackUrl }); |
4 | | - return `/auth/login?${params.toString()}`; |
| 5 | + const params = new URLSearchParams({ callbackUrl }) |
| 6 | + return `/auth/login?${params.toString()}` |
| 7 | +} |
| 8 | + |
| 9 | +/** |
| 10 | + * Hosts that must never appear in browser redirects (Docker bind address, loopback). |
| 11 | + * Auth.js + `HOSTNAME=0.0.0.0` commonly produces `https://0.0.0.0:3000/...` after OAuth. |
| 12 | + */ |
| 13 | +export function isUnusableRedirectHost(hostname: string): boolean { |
| 14 | + const host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "") |
| 15 | + return ( |
| 16 | + host === "0.0.0.0" || |
| 17 | + host === "::" || |
| 18 | + host === "localhost" || |
| 19 | + host === "127.0.0.1" || |
| 20 | + host === "::1" |
| 21 | + ) |
| 22 | +} |
| 23 | + |
| 24 | +function tryPublicBaseUrl(): string | null { |
| 25 | + try { |
| 26 | + return getPublicAppBaseUrl().replace(/\/+$/, "") |
| 27 | + } catch { |
| 28 | + return null |
| 29 | + } |
| 30 | +} |
| 31 | + |
| 32 | +function resolveBase(fallbackBaseUrl?: string): URL { |
| 33 | + const configured = tryPublicBaseUrl() |
| 34 | + const candidates = [configured, fallbackBaseUrl, "http://localhost:3000"].filter( |
| 35 | + (v): v is string => Boolean(v?.trim()) |
| 36 | + ) |
| 37 | + |
| 38 | + for (const raw of candidates) { |
| 39 | + try { |
| 40 | + const base = new URL(raw.includes("://") ? raw.replace(/\/+$/, "") : `https://${raw}`) |
| 41 | + if (isUnusableRedirectHost(base.hostname)) continue |
| 42 | + if (process.env.NODE_ENV === "production" && isLocalhostBaseUrl(base.origin) && configured) { |
| 43 | + continue |
| 44 | + } |
| 45 | + return base |
| 46 | + } catch { |
| 47 | + /* try next */ |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + return new URL("http://localhost:3000") |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * Resolve Auth.js / route-handler redirects onto the configured public origin. |
| 56 | + * Rewrites absolute URLs that landed on `0.0.0.0` / loopback (Docker HOSTNAME bind). |
| 57 | + */ |
| 58 | +export function resolveAuthRedirectUrl(url: string, fallbackBaseUrl?: string): string { |
| 59 | + const base = resolveBase(fallbackBaseUrl) |
| 60 | + const trimmed = url.trim() |
| 61 | + if (!trimmed) return base.origin |
| 62 | + |
| 63 | + if (trimmed.startsWith("/") && !trimmed.startsWith("//")) { |
| 64 | + return `${base.origin}${trimmed}` |
| 65 | + } |
| 66 | + |
| 67 | + try { |
| 68 | + const parsed = new URL(trimmed) |
| 69 | + if (isUnusableRedirectHost(parsed.hostname)) { |
| 70 | + return `${base.origin}${parsed.pathname}${parsed.search}${parsed.hash}` |
| 71 | + } |
| 72 | + if (parsed.origin === base.origin) { |
| 73 | + return parsed.toString() |
| 74 | + } |
| 75 | + // Auth.js may pass its inferred baseUrl origin (wrong behind Docker). If the path is |
| 76 | + // on that inferred origin, move it onto the public origin. |
| 77 | + if (fallbackBaseUrl) { |
| 78 | + try { |
| 79 | + const inferred = new URL(fallbackBaseUrl) |
| 80 | + if (parsed.origin === inferred.origin) { |
| 81 | + return `${base.origin}${parsed.pathname}${parsed.search}${parsed.hash}` |
| 82 | + } |
| 83 | + } catch { |
| 84 | + /* ignore */ |
| 85 | + } |
| 86 | + } |
| 87 | + // Foreign absolute URL — do not open-redirect; send home. |
| 88 | + return base.origin |
| 89 | + } catch { |
| 90 | + return base.origin |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +/** Absolute URL for an app path, preferring NEXT_PUBLIC_APP_URL / AUTH_URL. */ |
| 95 | +export function absoluteAppUrl(path: string, requestUrl?: string): string { |
| 96 | + const normalized = path.startsWith("/") ? path : `/${path}` |
| 97 | + return resolveAuthRedirectUrl(normalized, requestUrl) |
5 | 98 | } |
0 commit comments