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.
This commit is contained in:
Balaji Vijayakumar committed 2026-07-17 20:30:41 +05:30
1 parent bef3b9dd3f
commit 351cf7dd85
5 files changed
+40 -23

No files matched your search

@@ -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]),
}));
+17 -16
View File
@@ -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");
}
});
@@ -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,
};
});
+4 -7
View File
@@ -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");
+16
View File
@@ -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