From 351cf7dd85515081dd8204dfd9057665f4f667c1 Mon Sep 17 00:00:00 2001 From: Balaji Vijayakumar Date: Fri, 17 Jul 2026 19:58:13 +0530 Subject: [PATCH] fix(auth): build OIDC redirects from NEXT_PUBLIC_APP_URL, not the request URL Every browser-facing redirect in the OIDC login/callback routes was built from req.url. Behind a reverse proxy that doesn't forward the original host, that reflects the address the app process was addressed as inside the container rather than the public domain, sending users to an unreachable internal URL after login. Route every redirect through the trusted, operator-set NEXT_PUBLIC_APP_URL instead, matching the convention the Withings integration already uses. --- .../oidc/callback/__tests__/route.test.ts | 1 + src/app/api/auth/oidc/callback/route.ts | 33 ++++++++++--------- .../auth/oidc/login/__tests__/route.test.ts | 2 ++ src/app/api/auth/oidc/login/route.ts | 11 +++---- src/lib/auth/oidc.ts | 16 +++++++++ 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/app/api/auth/oidc/callback/__tests__/route.test.ts b/src/app/api/auth/oidc/callback/__tests__/route.test.ts index b221295ce..d547e64e3 100644 --- a/src/app/api/auth/oidc/callback/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/callback/__tests__/route.test.ts @@ -64,6 +64,7 @@ vi.mock("@/lib/auth/oidc", () => ({ fetchUserinfoEmail: vi.fn(), getOidcRedirectUri: () => "https://healthlog.example.com/api/auth/oidc/callback", + oidcAppUrl: (path: string) => new URL(path, "https://healthlog.example.com"), deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]), })); diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index 9e125e173..f772eb858 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -15,6 +15,7 @@ import { fetchUserinfoEmail, getOidcConfig, getOidcRedirectUri, + oidcAppUrl, verifyIdToken, } from "@/lib/auth/oidc"; import { @@ -31,9 +32,9 @@ import { resolveServerDefaultTimezone } from "@/lib/tz/resolver"; const LOGIN_ERROR_URL = "/auth/login"; -function errorRedirect(req: NextRequest, reason: string): NextResponse { +function errorRedirect(reason: string): NextResponse { const response = NextResponse.redirect( - new URL(`${LOGIN_ERROR_URL}?error=${reason}`, req.url), + oidcAppUrl(`${LOGIN_ERROR_URL}?error=${reason}`), ); deleteStateCookie(response); return response; @@ -106,7 +107,7 @@ export const GET = apiHandler(async (req: NextRequest) => { annotate({ action: { name: "auth.oidc.callback" } }); const config = getOidcConfig(); - if (!config) return errorRedirect(req, "oidc_disabled"); + if (!config) return errorRedirect("oidc_disabled"); const rl = await checkAuthSurfaceRateLimit( req, @@ -115,7 +116,7 @@ export const GET = apiHandler(async (req: NextRequest) => { 15 * 60 * 1000, ); const ip = rl.ip ?? "unknown"; - if (!rl.allowed) return errorRedirect(req, "oidc_rate_limited"); + if (!rl.allowed) return errorRedirect("oidc_rate_limited"); const { searchParams } = req.nextUrl; const idpError = searchParams.get("error"); @@ -126,12 +127,12 @@ export const GET = apiHandler(async (req: NextRequest) => { idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other", }, }); - return errorRedirect(req, "oidc_denied"); + return errorRedirect("oidc_denied"); } const code = searchParams.get("code"); const state = searchParams.get("state"); - if (!code || !state) return errorRedirect(req, "oidc_state"); + if (!code || !state) return errorRedirect("oidc_state"); const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value; let stored: StoredOidcState | null = null; @@ -143,7 +144,7 @@ export const GET = apiHandler(async (req: NextRequest) => { stored = null; } } - if (!stored) return errorRedirect(req, "oidc_state"); + if (!stored) return errorRedirect("oidc_state"); // CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback. if ( @@ -151,7 +152,7 @@ export const GET = apiHandler(async (req: NextRequest) => { !timingSafeEqual(Buffer.from(state), Buffer.from(stored.state)) ) { annotate({ meta: { reason: "csrf" } }); - return errorRedirect(req, "oidc_state"); + return errorRedirect("oidc_state"); } try { @@ -165,7 +166,7 @@ export const GET = apiHandler(async (req: NextRequest) => { codeVerifier: stored.codeVerifier, redirectUri, }); - if (!tokens.id_token) return errorRedirect(req, "oidc_failed"); + if (!tokens.id_token) return errorRedirect("oidc_failed"); const identity = await verifyIdToken({ metadata, @@ -228,11 +229,11 @@ export const GET = apiHandler(async (req: NextRequest) => { // health record. if (!email) { annotate({ meta: { reason: "no_email" } }); - return errorRedirect(req, "oidc_no_email"); + return errorRedirect("oidc_no_email"); } if (emailVerified !== true) { annotate({ meta: { reason: "email_unverified" } }); - return errorRedirect(req, "oidc_email_unverified"); + return errorRedirect("oidc_email_unverified"); } const byEmail = await prisma.user.findFirst({ @@ -250,7 +251,7 @@ export const GET = apiHandler(async (req: NextRequest) => { ipAddress: ip, }); annotate({ meta: { reason: "identity_conflict" } }); - return errorRedirect(req, "oidc_identity_conflict"); + return errorRedirect("oidc_identity_conflict"); } user = await prisma.user.update({ where: { id: byEmail.id }, @@ -276,7 +277,7 @@ export const GET = apiHandler(async (req: NextRequest) => { } if (!registrationEnabled) { annotate({ meta: { reason: "registration_disabled" } }); - return errorRedirect(req, "oidc_registration_disabled"); + return errorRedirect("oidc_registration_disabled"); } const username = await deriveUniqueUsername(email, (candidate) => @@ -361,7 +362,7 @@ export const GET = apiHandler(async (req: NextRequest) => { action: { name: "auth.mfa.challenge" }, meta: { mfa_required: true }, }); - const loginUrl = new URL(LOGIN_ERROR_URL, req.url); + const loginUrl = oidcAppUrl(LOGIN_ERROR_URL); if (stored.next !== "/") { loginUrl.searchParams.set("next", stored.next); } @@ -407,12 +408,12 @@ export const GET = apiHandler(async (req: NextRequest) => { await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip }); - const response = NextResponse.redirect(new URL(stored.next, req.url)); + const response = NextResponse.redirect(oidcAppUrl(stored.next)); deleteStateCookie(response); return response; } catch (err) { getEvent()?.setError(err); annotate({ meta: { reason: "exchange_or_verify_failed" } }); - return errorRedirect(req, "oidc_failed"); + return errorRedirect("oidc_failed"); } }); diff --git a/src/app/api/auth/oidc/login/__tests__/route.test.ts b/src/app/api/auth/oidc/login/__tests__/route.test.ts index 1789d23fa..52b15b8eb 100644 --- a/src/app/api/auth/oidc/login/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/login/__tests__/route.test.ts @@ -34,6 +34,8 @@ vi.mock("@/lib/auth/oidc", async () => { buildAuthorizationUrl: vi.fn(() => "https://idp.example.com/authorize?x=1"), getOidcRedirectUri: () => "https://healthlog.example.com/api/auth/oidc/callback", + oidcAppUrl: (path: string) => + new URL(path, "https://healthlog.example.com"), sanitizeOidcNextPath: actual.sanitizeOidcNextPath, }; }); diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts index 2c4987ade..6ae2ffe67 100644 --- a/src/app/api/auth/oidc/login/route.ts +++ b/src/app/api/auth/oidc/login/route.ts @@ -11,6 +11,7 @@ import { discoverOidcMetadata, getOidcConfig, getOidcRedirectUri, + oidcAppUrl, sanitizeOidcNextPath, } from "@/lib/auth/oidc"; import { @@ -30,9 +31,7 @@ export const GET = apiHandler(async (req: NextRequest) => { const config = getOidcConfig(); if (!config) { - return NextResponse.redirect( - new URL("/auth/login?error=oidc_disabled", req.url), - ); + return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_disabled")); } const rl = await checkAuthSurfaceRateLimit( @@ -43,7 +42,7 @@ export const GET = apiHandler(async (req: NextRequest) => { ); if (!rl.allowed) { return NextResponse.redirect( - new URL("/auth/login?error=oidc_rate_limited", req.url), + oidcAppUrl("/auth/login?error=oidc_rate_limited"), ); } @@ -52,9 +51,7 @@ export const GET = apiHandler(async (req: NextRequest) => { metadata = await discoverOidcMetadata(config); } catch (err) { getEvent()?.setError(err); - return NextResponse.redirect( - new URL("/auth/login?error=oidc_failed", req.url), - ); + return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_failed")); } const state = randomBytes(32).toString("base64url"); diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts index 4a0b011c3..23987ffc2 100644 --- a/src/lib/auth/oidc.ts +++ b/src/lib/auth/oidc.ts @@ -73,6 +73,22 @@ export function getOidcRedirectUri(): string { return `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback`; } +/** + * Build a browser-facing redirect target from the trusted, operator-set + * `NEXT_PUBLIC_APP_URL` — never from the incoming request's own URL/Host. + * Behind a reverse proxy that doesn't forward the original host (Coolify, + * Traefik, etc. proxying to the container's bind address), `req.url` + * reflects what the Next.js process itself was addressed as (e.g. + * `http://0.0.0.0:3000/...`), not the public-facing URL — sending that + * back to the browser in a `Location` header redirects real users to an + * address only reachable from inside the deployment. Every OIDC redirect + * back to the app must be built from this, mirroring the convention + * `src/app/api/withings/callback/route.ts` already uses. + */ +export function oidcAppUrl(path: string): URL { + return new URL(path, process.env.NEXT_PUBLIC_APP_URL); +} + /** * Resolve a caller-supplied post-login `next` path against the request's own * origin and only accept it if it stays there — see