From 42384838897ed965096264d5aa0d97c8ffdcf6e8 Mon Sep 17 00:00:00 2001 From: Michael Zetterberg Date: Thu, 17 Oct 2024 09:08:04 +0200 Subject: [PATCH] feat: add utility for getting internal next url --- middleware.ts | 6 +++ middlewares/authRequired.ts | 13 +++--- server/utils.ts | 89 ++++++++++++++++++++++++++++++++----- 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/middleware.ts b/middleware.ts index 8aaff393e..489d30503 100644 --- a/middleware.ts +++ b/middleware.ts @@ -15,6 +15,10 @@ import * as webView from "@/middlewares/webView" import { findLang } from "@/utils/languages" export const middleware: NextMiddleware = async (request, event) => { + // auth() overrides the request origin, we need the original for internal rewrites + // @see getInternalNextURL() + request.headers.set("x-sh-origin", request.nextUrl.origin) + const headers = getDefaultRequestHeaders(request) const lang = findLang(request.nextUrl.pathname) @@ -60,6 +64,8 @@ export const middleware: NextMiddleware = async (request, event) => { if (_continue) { continue } + // Clean up internal headers + result?.headers.delete("x-sh-origin") return result } } diff --git a/middlewares/authRequired.ts b/middlewares/authRequired.ts index 09781ac56..c0948cd6c 100644 --- a/middlewares/authRequired.ts +++ b/middlewares/authRequired.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server" import { authRequired, mfaRequired } from "@/constants/routes/authRequired" import { login } from "@/constants/routes/handleAuth" -import { getPublicNextURL } from "@/server/utils" +import { getInternalNextURL, getPublicNextURL } from "@/server/utils" import { auth } from "@/auth" import { findLang } from "@/utils/languages" @@ -37,12 +37,15 @@ import type { MiddlewareMatcher } from "@/types/middleware" * https://authjs.dev/reference/nextjs */ export const middleware = auth(async (request) => { - const { nextUrl } = request - const lang = findLang(nextUrl.pathname)! + const lang = findLang(request.nextUrl.pathname)! const isLoggedIn = !!request.auth const hasError = request.auth?.error + // Inside auth() we need an internal request for rewrites. + // @see getInternalNextURL() + const nextUrlInternal = getInternalNextURL(request) + const nextUrlPublic = getPublicNextURL(request) /** @@ -55,13 +58,13 @@ export const middleware = auth(async (request) => { : false return !(request.auth?.token.mfa_scope && isMFATokenValid) } - const isMFAPath = mfaRequired.includes(nextUrl.pathname) + const isMFAPath = mfaRequired.includes(request.nextUrl.pathname) if (isLoggedIn && isMFAPath && isMFAInvalid()) { const headers = new Headers(request.headers) headers.set("x-returnurl", nextUrlPublic.href) headers.set("x-login-source", "mfa") - return NextResponse.rewrite(new URL(`/${lang}/login`, request.nextUrl), { + return NextResponse.rewrite(new URL(`/${lang}/login`, nextUrlInternal), { request: { headers, }, diff --git a/server/utils.ts b/server/utils.ts index 0a727c118..6d6251763 100644 --- a/server/utils.ts +++ b/server/utils.ts @@ -1,10 +1,9 @@ +import { NextRequest } from "next/server" import { z } from "zod" import { Lang } from "@/constants/languages" import { env } from "@/env/server" -import type { NextRequest } from "next/server" - export const langInput = z.object({ lang: z.nativeEnum(Lang), }) @@ -37,16 +36,20 @@ export function toLang(lang: string): Lang | undefined { return Object.values(Lang).find((l) => l === lowerCaseLang) } -export function getPublicURL(request: NextRequest) { - if (env.PUBLIC_URL) { - return env.PUBLIC_URL - } - - const host = request.nextUrl.host - const proto = request.nextUrl.protocol - return `${proto}//${host}` -} - +/** + * Use this function when you want to create URLs that are public facing, for + * example for redirects or redirectTo query parameters. + * Dedicated environments are behind Akamai (test, stage, production). They have + * env.PUBLIC_URL set. + * All other environment like deploy previews and branch deployments are not + * behind Akamai and therefore do not have env.PUBLIC_URL set. + * We need this approach because Netlify uses x-forwarded-host internally and + * strips it from ever reaching our code. + * TODO: Replace this approach with custom header in Akamai that mirrors the + * value in x-forwarded-host which would not get stripped by Netlify. + * @param request The incoming request. + * @returns NextURL The public facing URL instance for the given request. + */ export function getPublicNextURL(request: NextRequest) { if (env.PUBLIC_URL) { const publicNextURL = request.nextUrl.clone() @@ -60,3 +63,65 @@ export function getPublicNextURL(request: NextRequest) { } return request.nextUrl } + +/** + * Use this function when you want the public facing URL for the given request. + * Read about the motivation in getPublicNextURL above. + * @see getPublicNextURL + * @param request The incoming request. + * @returns string The public facing origin for the given request. + */ +export function getPublicURL(request: NextRequest) { + if (env.PUBLIC_URL) { + return env.PUBLIC_URL + } + return request.nextUrl.origin +} + +/** + * Use this function when you want to create URLs that are internal (behind Akamai), + * for example for rewrites. Mainly used for middleware wrapped in auth(). + * The auth() function overrides the origin of the incoming request. It sets it + * to the origin of AUTH_URL/NEXTAUTH_URL. This means we cannot use the augmented + * request from auth() for rewrites, as those will point to auth url origin + * (in front of Akamai) and not the origin of the incoming request (behind Akamai). + * This results in rewrites going over the internet instead of going through the + * internal routing at Netlify. + * For dedicated environments (test, stage and production) we are behind Akamai. + * For those we have set a value for AUTH_URL/NEXTAUTH_URL, they point to the + * PUBLIC_URL. For rewrites we need the internal origin inside Netlify. + * In middleware.ts we copy the incoming origin to a header 'x-sh-origin'. We try + * and use that first, if not present we assume the internal origin is the value + * of the host header, as that is what Netlify used for routing to this deployment. + * @param request The incoming request. + * @returns NextURL The internal request, in Netlify behind Akamai. + */ +export function getInternalNextURL(request: NextRequest) { + const { href, origin } = request.nextUrl + + const originHeader = request.headers.get("x-sh-origin") + if (originHeader) { + console.log(`[internalNextUrl] using x-sh-origin header`, { + origin, + originHeader, + newOrigin: href.replace(origin, originHeader), + }) + return new NextRequest(href.replace(origin, originHeader), request).nextUrl + } + + const hostHeader = request.headers.get("host") + if (hostHeader) { + const inputHostOrigin = `${request.nextUrl.protocol}//${hostHeader}` + console.log(`[internalNextUrl] using host header`, { + origin, + hostHeader, + hostOrigin: inputHostOrigin, + newOrigin: href.replace(origin, inputHostOrigin), + }) + const { origin: hostOrigin } = new URL(inputHostOrigin) + return new NextRequest(href.replace(origin, hostOrigin), request).nextUrl + } + + console.log(`[internalNextUrl] falling back to incoming request`) + return request.nextUrl +}