From 5871d4d87df315766f137e861273b5d1f69bb6c3 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 25 Jul 2026 14:57:57 +0700 Subject: [PATCH] fix(auth): build verify-email redirects from the configured public origin Redirects were built relative to `request.url`, which behind a reverse proxy resolves to the container-internal address. Verification succeeded but the browser was sent to localhost:3000, so users saw a connection error instead of the "email verified" confirmation. Add getPublicOrigin() (NEXTAUTH_URL, then NEXT_PUBLIC_APP_URL, falling back to the request origin for local development) and use it for every verify-email redirect. The legacy GET redirect in the watch session route had the same defect and is fixed alongside it. --- app/api/auth/verify-email/route.ts | 15 +++++++++++---- app/watch/[videoId]/session/route.ts | 4 ++-- lib/request-origin.ts | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/app/api/auth/verify-email/route.ts b/app/api/auth/verify-email/route.ts index 6a56b1c..4838fe6 100644 --- a/app/api/auth/verify-email/route.ts +++ b/app/api/auth/verify-email/route.ts @@ -2,11 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { consumeVerificationToken } from '@/lib/email-verification'; import { rateLimit } from '@/lib/rate-limit'; import { logError } from '@/lib/logger'; +import { getPublicOrigin } from '@/lib/request-origin'; // A raw 32-byte hex token is exactly 64 characters. const TOKEN_REGEX = /^[0-9a-f]{64}$/; export async function GET(request: NextRequest) { + // Redirect targets must be built from the public origin, not `request.url`: + // behind a reverse proxy the latter is the container-internal address and the + // user lands on a dead host even though verification succeeded. + const origin = getPublicOrigin(request); + const redirectTo = (path: string) => NextResponse.redirect(new URL(path, origin)); + try { // Rate-limit by IP to prevent token enumeration attacks. const limited = await rateLimit(request, 'verify-email'); @@ -15,18 +22,18 @@ export async function GET(request: NextRequest) { const token = request.nextUrl.searchParams.get('token'); if (!token || !TOKEN_REGEX.test(token.trim())) { - return NextResponse.redirect(new URL('/login?error=InvalidVerificationToken', request.url)); + return redirectTo('/login?error=InvalidVerificationToken'); } const email = await consumeVerificationToken(token.trim()); if (!email) { - return NextResponse.redirect(new URL('/login?error=InvalidVerificationToken', request.url)); + return redirectTo('/login?error=InvalidVerificationToken'); } - return NextResponse.redirect(new URL('/login?verified=true', request.url)); + return redirectTo('/login?verified=true'); } catch (err) { logError('Email verification error:', err); - return NextResponse.redirect(new URL('/login?error=VerificationFailed', request.url)); + return redirectTo('/login?error=VerificationFailed'); } } diff --git a/app/watch/[videoId]/session/route.ts b/app/watch/[videoId]/session/route.ts index 12d3bb6..0ed8209 100644 --- a/app/watch/[videoId]/session/route.ts +++ b/app/watch/[videoId]/session/route.ts @@ -2,7 +2,7 @@ import { createHash } from 'crypto'; import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { checkRateLimit, getClientIp, rateLimit, rateLimitHeaders } from '@/lib/rate-limit'; -import { isTrustedSameOriginRequest } from '@/lib/request-origin'; +import { getPublicOrigin, isTrustedSameOriginRequest } from '@/lib/request-origin'; import { MAX_SHARE_PASSWORD_LENGTH, validateShareLinkAccess } from '@/lib/share-links'; import { createPendingShareValue, @@ -48,7 +48,7 @@ function validateSameOriginRequest(request: NextRequest): NextResponse | null { export async function GET(request: NextRequest, { params }: RouteParams) { const { videoId } = await params; - const cleanWatchUrl = new URL(`/watch/${videoId}`, request.nextUrl.origin); + const cleanWatchUrl = new URL(`/watch/${videoId}`, getPublicOrigin(request)); const legacyShareToken = request.nextUrl.searchParams.get('shareToken'); // Keep GET route for backwards compatibility, but never establish session from GET. diff --git a/lib/request-origin.ts b/lib/request-origin.ts index edd9708..212d90f 100644 --- a/lib/request-origin.ts +++ b/lib/request-origin.ts @@ -16,6 +16,26 @@ function getConfiguredOrigins(): string[] { .filter((value): value is string => value !== null); } +/** + * Origin to build user-facing redirects from. + * + * Behind a reverse proxy (Docker deployments) `request.nextUrl.origin` is the + * container-internal address (`localhost:3000`), so redirecting relative to the + * request URL sends the browser to a dead host. Prefer the operator-configured + * public origin and fall back to the request origin for local development. + */ +export function getPublicOrigin(request: NextRequest): string { + const configured = [process.env.NEXTAUTH_URL, process.env.NEXT_PUBLIC_APP_URL]; + + for (const value of configured) { + if (typeof value !== 'string' || value.trim().length === 0) continue; + const origin = normalizeOrigin(/^https?:\/\//i.test(value) ? value : `https://${value}`); + if (origin) return origin; + } + + return request.nextUrl.origin; +} + export function getAllowedRequestOrigins(request: NextRequest): Set { const origins = new Set();