diff --git a/changelog.d/fixes/oidc-callback-security-hardening.md b/changelog.d/fixes/oidc-callback-security-hardening.md new file mode 100644 index 0000000000..bc719f2e9f --- /dev/null +++ b/changelog.d/fixes/oidc-callback-security-hardening.md @@ -0,0 +1 @@ +- fix(security): OIDC login gate now requires `email_verified` before honoring the email claim against the allowlist (an unverified IdP email matching an allowlisted address no longer bypasses the gate), and error redirects resolve against the parsed request URL instead of the raw Host header diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index 58f1289255..15054f0fef 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(`${originEarly}/login?oidc_error=missing_code`); + return NextResponse.redirect(new URL("/login?oidc_error=missing_code", reqUrlEarly)); } // 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(`${originEarly}/login?oidc_error=invalid_state`); + return NextResponse.redirect(new URL("/login?oidc_error=invalid_state", reqUrlEarly)); } // 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(`${originEarly}/login?oidc_error=not_configured`); + return NextResponse.redirect(new URL("/login?oidc_error=not_configured", reqUrlEarly)); } // 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(`${originEarly}/login?oidc_error=token_exchange`); + return NextResponse.redirect(new URL("/login?oidc_error=token_exchange", reqUrlEarly)); } if (!tokenResp.ok) { - return NextResponse.redirect(`${originEarly}/login?oidc_error=token_exchange`); + return NextResponse.redirect(new URL("/login?oidc_error=token_exchange", reqUrlEarly)); } let tokenData: unknown; try { tokenData = await tokenResp.json(); } catch { - return NextResponse.redirect(`${originEarly}/login?oidc_error=token_response`); + return NextResponse.redirect(new URL("/login?oidc_error=token_response", reqUrlEarly)); } if (!tokenData || typeof tokenData !== "object") { - return NextResponse.redirect(`${originEarly}/login?oidc_error=token_response`); + return NextResponse.redirect(new URL("/login?oidc_error=token_response", reqUrlEarly)); } const td = tokenData as Record; const idToken = typeof td.id_token === "string" ? td.id_token : undefined; if (!idToken) { - return NextResponse.redirect(`${originEarly}/login?oidc_error=no_id_token`); + return NextResponse.redirect(new URL("/login?oidc_error=no_id_token", reqUrlEarly)); } // Validate ID token @@ -167,23 +167,24 @@ export async function GET(request: Request) { const allowed = Array.isArray(settings.oidcAllowedSubjects) ? settings.oidcAllowedSubjects : []; if (allowed.length > 0) { const sub = typeof payload.sub === "string" ? payload.sub : ""; + const emailVerified = (payload as Record).email_verified === true; const email = - typeof (payload as Record).email === "string" - ? ((payload as Record).email as string) + emailVerified && typeof (payload as Record).email === "string" + ? ((payload as Record).email as string).toLowerCase() : ""; const ok = allowed.some((v: unknown) => { if (typeof v !== "string") return false; if (v === sub) return true; - const vLower = v.toLowerCase(); - const emailLower = email ? email.toLowerCase() : ""; - return vLower === emailLower; + return email !== "" && v.toLowerCase() === email; }); if (!ok) { - return NextResponse.redirect(`${originEarly}/login?oidc_error=subject_not_allowed`); + return NextResponse.redirect( + new URL("/login?oidc_error=subject_not_allowed", reqUrlEarly) + ); } } } catch { - return NextResponse.redirect(`${originEarly}/login?oidc_error=id_token_invalid`); + return NextResponse.redirect(new URL("/login?oidc_error=id_token_invalid", reqUrlEarly)); } // First successful OIDC login marks setupComplete (like password bootstrap). try { @@ -193,7 +194,9 @@ export async function GET(request: Request) { } // Mint the exact same dashboard session JWT as password login if (!process.env.JWT_SECRET) { - return NextResponse.redirect(`${originEarly}/login?oidc_error=server_misconfigured`); + return NextResponse.redirect( + new URL("/login?oidc_error=server_misconfigured", reqUrlEarly) + ); } const forceSecureCookie = process.env.AUTH_COOKIE_SECURE === "true"; diff --git a/tests/unit/oidc-callback.test.ts b/tests/unit/oidc-callback.test.ts index 7d01bdfb61..310fed37cb 100644 --- a/tests/unit/oidc-callback.test.ts +++ b/tests/unit/oidc-callback.test.ts @@ -246,6 +246,65 @@ test("OIDC callback rejects subject not in allowed list (subject_not_allowed)", } }); +test("OIDC callback rejects allowlisted email when email_verified is not asserted", async () => { + 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: ["admin@example.com"], + }); + + // sub is NOT allowlisted; email matches the allowlist but the IdP did not assert + // email_verified — the gate must not honor the email claim (security regression guard). + const { idToken, jwks } = await createSignedIdToken({ + iss: "https://idp.test", + aud: "client-oidc-test", + sub: "attacker-sub", + email: "admin@example.com", + // email_verified intentionally omitted + }); + + const testState = "state-for-unverified-email"; + capturedCookies["oidc_state"] = { value: testState }; + + 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")) { + return new Response( + JSON.stringify({ + token_endpoint: "https://idp.test/token", + jwks_uri: "https://idp.test/jwks", + }), + { status: 200 } + ); + } + if (url.includes("/token")) { + return new Response(JSON.stringify({ id_token: idToken }), { status: 200 }); + } + if (url.includes("/jwks")) { + return new Response(JSON.stringify(jwks), { status: 200 }); + } + return new Response("not mocked", { status: 404 }); + }) as unknown as typeof fetch; + + try { + const reqUrl = `http://localhost/api/auth/oidc/callback?code=code-unverified&state=${testState}`; + const response = await callbackRoute.GET( + new Request(reqUrl, { headers: { "x-forwarded-proto": "http" } }) + ); + assert.equal(response.status, 307); + const loc = response.headers.get("location") || ""; + assert.ok(loc.includes("subject_not_allowed")); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("OIDC callback rejects partial/misconfigured OIDC settings (not_configured)", async () => { // Partial config (enabled but missing issuer/clientId/secret) should hit the config guard. // We must set a matching oidc_state cookie first, otherwise we hit invalid_state.