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.
This commit is contained in:
yusufipk
2026-07-25 14:57:57 +07:00
parent 0faa4b4e2a
commit 5871d4d87d
3 changed files with 33 additions and 6 deletions
+11 -4
View File
@@ -2,11 +2,18 @@ import { NextRequest, NextResponse } from 'next/server';
import { consumeVerificationToken } from '@/lib/email-verification'; import { consumeVerificationToken } from '@/lib/email-verification';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { getPublicOrigin } from '@/lib/request-origin';
// A raw 32-byte hex token is exactly 64 characters. // A raw 32-byte hex token is exactly 64 characters.
const TOKEN_REGEX = /^[0-9a-f]{64}$/; const TOKEN_REGEX = /^[0-9a-f]{64}$/;
export async function GET(request: NextRequest) { 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 { try {
// Rate-limit by IP to prevent token enumeration attacks. // Rate-limit by IP to prevent token enumeration attacks.
const limited = await rateLimit(request, 'verify-email'); const limited = await rateLimit(request, 'verify-email');
@@ -15,18 +22,18 @@ export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token'); const token = request.nextUrl.searchParams.get('token');
if (!token || !TOKEN_REGEX.test(token.trim())) { 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()); const email = await consumeVerificationToken(token.trim());
if (!email) { 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) { } catch (err) {
logError('Email verification error:', err); logError('Email verification error:', err);
return NextResponse.redirect(new URL('/login?error=VerificationFailed', request.url)); return redirectTo('/login?error=VerificationFailed');
} }
} }
+2 -2
View File
@@ -2,7 +2,7 @@ import { createHash } from 'crypto';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { checkRateLimit, getClientIp, rateLimit, rateLimitHeaders } from '@/lib/rate-limit'; 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 { MAX_SHARE_PASSWORD_LENGTH, validateShareLinkAccess } from '@/lib/share-links';
import { import {
createPendingShareValue, createPendingShareValue,
@@ -48,7 +48,7 @@ function validateSameOriginRequest(request: NextRequest): NextResponse | null {
export async function GET(request: NextRequest, { params }: RouteParams) { export async function GET(request: NextRequest, { params }: RouteParams) {
const { videoId } = await params; 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'); const legacyShareToken = request.nextUrl.searchParams.get('shareToken');
// Keep GET route for backwards compatibility, but never establish session from GET. // Keep GET route for backwards compatibility, but never establish session from GET.
+20
View File
@@ -16,6 +16,26 @@ function getConfiguredOrigins(): string[] {
.filter((value): value is string => value !== null); .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<string> { export function getAllowedRequestOrigins(request: NextRequest): Set<string> {
const origins = new Set<string>(); const origins = new Set<string>();