diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index 702cd39deb..28e88fe054 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -48,14 +48,14 @@ export async function GET(request: Request) { const originEarly = `${schemeEarly}://${hostEarly}`; if (!code || !returnedState) { - return NextResponse.redirect(new URL("/login?oidc_error=missing_code", reqUrlEarly)); + return NextResponse.redirect(new URL("/login?oidc_error=missing_code", originEarly)); } // Validate state from cookie (via seam so tests can capture) const cookieStore = await oidcCallbackInternals.getCookieStore(); const storedState = cookieStore.get("oidc_state")?.value; if (!storedState || storedState !== returnedState) { - return NextResponse.redirect(new URL("/login?oidc_error=invalid_state", reqUrlEarly)); + return NextResponse.redirect(new URL("/login?oidc_error=invalid_state", originEarly)); } // Clear state cookie @@ -80,7 +80,7 @@ export async function GET(request: Request) { : "/api/auth/oidc/callback"; if (!enabled || !issuer || !clientId || !clientSecret) { - return NextResponse.redirect(new URL("/login?oidc_error=not_configured", reqUrlEarly)); + return NextResponse.redirect(new URL("/login?oidc_error=not_configured", originEarly)); } // Compute absolute redirect_uri matching what we sent @@ -131,28 +131,28 @@ export async function GET(request: Request) { signal: AbortSignal.timeout(10000), }); } catch { - return NextResponse.redirect(new URL("/login?oidc_error=token_exchange", reqUrlEarly)); + return NextResponse.redirect(new URL("/login?oidc_error=token_exchange", originEarly)); } if (!tokenResp.ok) { - return NextResponse.redirect(new URL("/login?oidc_error=token_exchange", reqUrlEarly)); + return NextResponse.redirect(new URL("/login?oidc_error=token_exchange", originEarly)); } let tokenData: unknown; try { tokenData = await tokenResp.json(); } catch { - return NextResponse.redirect(new URL("/login?oidc_error=token_response", reqUrlEarly)); + return NextResponse.redirect(new URL("/login?oidc_error=token_response", originEarly)); } if (!tokenData || typeof tokenData !== "object") { - return NextResponse.redirect(new URL("/login?oidc_error=token_response", reqUrlEarly)); + return NextResponse.redirect(new URL("/login?oidc_error=token_response", originEarly)); } const td = tokenData as Record; const idToken = typeof td.id_token === "string" ? td.id_token : undefined; if (!idToken) { - return NextResponse.redirect(new URL("/login?oidc_error=no_id_token", reqUrlEarly)); + return NextResponse.redirect(new URL("/login?oidc_error=no_id_token", originEarly)); } // Validate ID token @@ -178,13 +178,11 @@ export async function GET(request: Request) { return email !== "" && v.toLowerCase() === email; }); if (!ok) { - return NextResponse.redirect( - new URL("/login?oidc_error=subject_not_allowed", reqUrlEarly) - ); + return NextResponse.redirect(new URL("/login?oidc_error=subject_not_allowed", originEarly)); } } } catch { - return NextResponse.redirect(new URL("/login?oidc_error=id_token_invalid", reqUrlEarly)); + return NextResponse.redirect(new URL("/login?oidc_error=id_token_invalid", originEarly)); } // First successful OIDC login marks setupComplete (like password bootstrap). try { @@ -194,9 +192,7 @@ export async function GET(request: Request) { } // Mint the exact same dashboard session JWT as password login if (!process.env.JWT_SECRET) { - return NextResponse.redirect( - new URL("/login?oidc_error=server_misconfigured", reqUrlEarly) - ); + return NextResponse.redirect(new URL("/login?oidc_error=server_misconfigured", originEarly)); } const forceSecureCookie = process.env.AUTH_COOKIE_SECURE === "true"; diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts index 0c9eb5f619..a49dbbe431 100644 --- a/src/app/api/auth/oidc/login/route.ts +++ b/src/app/api/auth/oidc/login/route.ts @@ -73,6 +73,7 @@ export async function GET(request: Request) { url.searchParams.set("client_id", clientId); url.searchParams.set("redirect_uri", redirectUri); url.searchParams.set("scope", scope); + url.searchParams.set("state", state); const isHttpsRequest = scheme === "https"; const useSecureCookie = process.env.AUTH_COOKIE_SECURE === "true" || isHttpsRequest; diff --git a/tests/unit/oidc-callback.test.ts b/tests/unit/oidc-callback.test.ts index 310fed37cb..5d41701e42 100644 --- a/tests/unit/oidc-callback.test.ts +++ b/tests/unit/oidc-callback.test.ts @@ -485,3 +485,24 @@ test("OIDC callback rejects missing JWT_SECRET at mint time (server_misconfigure globalThis.fetch = originalFetch; } }); + +test("OIDC callback error redirect respects proxy headers (#10224)", async () => { + await setupFullOidcSettings(); + + // Omit code/state to force an immediate error redirect + // Use a bind-address style URL like when behind an internal proxy + const request = new Request("http://127.0.0.1:20128/api/auth/oidc/callback", { + headers: { + "x-forwarded-proto": "https", + host: "auth.pubg-sell.ir", + }, + }); + + const response = await callbackRoute.GET(request); + assert.equal(response.status, 307); + + const loc = response.headers.get("location") || ""; + // Without the fix, this would be http://127.0.0.1:20128/login?oidc_error=missing_code + // With the fix, it correctly uses originEarly + assert.equal(loc, "https://auth.pubg-sell.ir/login?oidc_error=missing_code"); +}); diff --git a/tests/unit/oidc-login-state.test.ts b/tests/unit/oidc-login-state.test.ts new file mode 100644 index 0000000000..ea9c4e56fb --- /dev/null +++ b/tests/unit/oidc-login-state.test.ts @@ -0,0 +1,120 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// NOTE: Dynamic imports below are used (with comment) solely because the modules read process.env at evaluation time. +// The specifiers are literals. This is the established pattern in this repo's auth tests for env-controlled DB setup. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-oidc-login-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-for-oidc-login"; + +// @ts-ignore - intentional for test harness timing (see note at top) +const core = await import("../../src/lib/db/core.ts"); +// @ts-ignore - intentional for test harness timing +const localDb = await import("../../src/lib/localDb.ts"); +// @ts-ignore - intentional for test harness timing +const loginRoute = await import("../../src/app/api/auth/oidc/login/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.JWT_SECRET; +}); + +async function setupFullOidcSettings() { + await localDb.updateSettings({ + requireLogin: true, + password: "", + oidcEnabled: true, + oidcIssuer: "https://idp.test", + oidcClientId: "client-oidc-test", + oidcClientSecret: "secret-oidc-test", + oidcRedirectPath: "/api/auth/oidc/callback", + oidcAllowedSubjects: [], + }); +} + +function extractCookieValue(setCookieHeader: string | null, name: string): string | undefined { + if (!setCookieHeader) return undefined; + const match = setCookieHeader + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name}=`)); + return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined; +} + +test("OIDC login redirect includes a state parameter matching the oidc_state cookie", async () => { + await setupFullOidcSettings(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + if (url.includes("/.well-known/openid-configuration")) { + // Force the deterministic fallback authEndpoint path (no live network dependency). + return new Response("not found", { status: 404 }); + } + return new Response("not mocked", { status: 404 }); + }) as unknown as typeof fetch; + + try { + const response = await loginRoute.GET( + new Request("http://localhost/api/auth/oidc/login", { + headers: { "x-forwarded-proto": "http" }, + }) + ); + + assert.equal(response.status, 307); + + const location = response.headers.get("location"); + assert.ok(location, "redirect must include a Location header"); + + const redirectUrl = new URL(location as string); + const stateInUrl = redirectUrl.searchParams.get("state"); + assert.ok(stateInUrl, "authorization URL must include a state query parameter"); + + const setCookieHeader = response.headers.get("set-cookie"); + assert.ok(setCookieHeader, "response must set the oidc_state cookie"); + + const stateInCookie = extractCookieValue(setCookieHeader, "oidc_state"); + assert.ok(stateInCookie, "oidc_state cookie must carry a value"); + + assert.equal( + stateInUrl, + stateInCookie, + "state parameter in the authorization URL must match the oidc_state cookie value" + ); + + // Basic cookie hygiene (mirrors the callback route's expectations) + assert.match(setCookieHeader as string, /HttpOnly/i); + assert.match(setCookieHeader as string, /SameSite=lax/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("OIDC login returns 400 without redirecting when OIDC is not configured", async () => { + await localDb.updateSettings({ + requireLogin: true, + password: "", + oidcEnabled: false, + }); + + const response = await loginRoute.GET(new Request("http://localhost/api/auth/oidc/login")); + + assert.equal(response.status, 400); + assert.equal(response.headers.get("location"), null); + assert.equal(response.headers.get("set-cookie"), null); +});