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