From 6c6ed15c313613a0feaa75943de2952758851b5e Mon Sep 17 00:00:00 2001 From: Wira Basalamah Date: Tue, 21 Apr 2026 13:18:13 +0700 Subject: [PATCH] fix: use forwarded host for auth redirects --- app/auth/login/route.ts | 10 ++++++---- app/auth/logout/route.ts | 3 ++- lib/request-url.ts | 19 +++++++++++++++++++ middleware.ts | 8 +++++--- 4 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 lib/request-url.ts diff --git a/app/auth/login/route.ts b/app/auth/login/route.ts index 98bb90a..368c4b8 100644 --- a/app/auth/login/route.ts +++ b/app/auth/login/route.ts @@ -4,6 +4,7 @@ import { SESSION_COOKIE, UserRole, authenticateUser, getDefaultPathForRole, seri import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit"; import { prisma } from "@/lib/prisma"; +import { getRequestBaseUrl } from "@/lib/request-url"; function getSafePath(value: string | null) { if (!value) { @@ -28,6 +29,7 @@ function resolveNumber(raw: string | undefined, fallback: number) { export async function POST(request: NextRequest) { const { ipAddress, userAgent } = await getRequestAuditContext(); + const baseUrl = getRequestBaseUrl(request); const retryControl = consumeRateLimit(ipAddress || "unknown", { scope: "auth_login", limit: resolveNumber(process.env.LOGIN_RATE_LIMIT_ATTEMPTS, 10), @@ -35,7 +37,7 @@ export async function POST(request: NextRequest) { }); if (!retryControl.allowed) { - const loginUrl = new URL("/login", request.url); + const loginUrl = new URL("/login", baseUrl); loginUrl.searchParams.set("error", "rate_limited"); const response = NextResponse.redirect(loginUrl); const headers = getRateLimitHeaders(retryControl); @@ -55,7 +57,7 @@ export async function POST(request: NextRequest) { const password = typeof rawPassword === "string" ? rawPassword : ""; if (!email || !password) { - const loginUrl = new URL("/login", request.url); + const loginUrl = new URL("/login", baseUrl); loginUrl.searchParams.set("error", "credentials_required"); if (next) { loginUrl.searchParams.set("next", next); @@ -88,7 +90,7 @@ export async function POST(request: NextRequest) { }); } - const loginUrl = new URL("/login", request.url); + const loginUrl = new URL("/login", baseUrl); loginUrl.searchParams.set("error", "invalid_credentials"); if (next) { loginUrl.searchParams.set("next", next); @@ -116,7 +118,7 @@ export async function POST(request: NextRequest) { }); const destination = next ?? getDefaultPathForRole(session.role as UserRole); - const response = NextResponse.redirect(new URL(destination, request.url)); + const response = NextResponse.redirect(new URL(destination, baseUrl)); response.cookies.set(SESSION_COOKIE, await serializeSession(session), { httpOnly: true, sameSite: "lax", diff --git a/app/auth/logout/route.ts b/app/auth/logout/route.ts index 410d87f..a403263 100644 --- a/app/auth/logout/route.ts +++ b/app/auth/logout/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; import { getSession, SESSION_COOKIE } from "@/lib/auth"; +import { getRequestBaseUrl } from "@/lib/request-url"; export async function GET(request: NextRequest) { const session = await getSession(); @@ -20,7 +21,7 @@ export async function GET(request: NextRequest) { }); } - const response = NextResponse.redirect(new URL("/login", request.url)); + const response = NextResponse.redirect(new URL("/login", getRequestBaseUrl(request))); response.cookies.delete(SESSION_COOKIE); return response; } diff --git a/lib/request-url.ts b/lib/request-url.ts new file mode 100644 index 0000000..0deadf7 --- /dev/null +++ b/lib/request-url.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +export function getRequestBaseUrl(request: NextRequest) { + const configured = process.env.APP_URL?.trim(); + if (configured) { + return new URL(configured); + } + + const forwardedHost = request.headers.get("x-forwarded-host"); + const forwardedProto = request.headers.get("x-forwarded-proto"); + const host = forwardedHost?.split(",")[0]?.trim() || request.headers.get("host") || request.nextUrl.host; + const proto = (forwardedProto?.split(",")[0]?.trim() || request.nextUrl.protocol || "http").replace(":", ""); + + if (!host) { + return request.nextUrl; + } + + return new URL(`${proto}://${host}`); +} diff --git a/middleware.ts b/middleware.ts index c390b75..55278f0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { canAccessPath, getDefaultPathForRole, parseSessionCookie, SESSION_COOKIE, type UserRole } from "@/lib/auth"; import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/i18n"; +import { getRequestBaseUrl } from "@/lib/request-url"; const publicPaths = ["/login", "/forgot-password", "/reset-password", "/unauthorized", "/invite", "/auth"]; @@ -15,6 +16,7 @@ async function decodeSessionCookie(value: string) { export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; + const baseUrl = getRequestBaseUrl(request); const response = NextResponse.next(); if (pathname.startsWith("/_next") || pathname.includes(".")) { @@ -44,17 +46,17 @@ export async function middleware(request: NextRequest) { } if (!session && !isPublicPath(pathname) && pathname !== "/") { - const loginUrl = new URL("/login", request.url); + const loginUrl = new URL("/login", baseUrl); loginUrl.searchParams.set("next", pathname); return NextResponse.redirect(loginUrl); } if (session && (pathname === "/" || pathname === "/login")) { - return NextResponse.redirect(new URL(getDefaultPathForRole(session.role), request.url)); + return NextResponse.redirect(new URL(getDefaultPathForRole(session.role), baseUrl)); } if (session && !isPublicPath(pathname) && !canAccessPath(session.role, pathname)) { - return NextResponse.redirect(new URL("/unauthorized", request.url)); + return NextResponse.redirect(new URL("/unauthorized", baseUrl)); } return response;