mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
fix(login): lru-cache compatibility, improve session error handling (#12099)
# Which Problems Are Solved - Logo and heading overlap on login pages due to a -mb-4 negative margin on the logo container - The /login route returns a 500 when `listSessions` fails (e.g. stale session cookies), because loadSessions has no error handling. - The proxy logs `"fetch() returned undefined"` because `fetchIframeOrigins` returns undefined, which lru-cache's forceFetch treats as a fetch failure. # How the Problems Are Solved - Remove `-mb-4` from the logo container in DynamicTheme. - Wrap `loadSessions` in a try/catch — failures fall through as empty sessions instead of crashing. - Return `null` instead of `undefined` from `fetchIframeOrigins` so lru-cache caches it normally. --------- Co-authored-by: Livio Spring <[email protected]> Co-authored-by: conblem <[email protected]>
This commit is contained in:
co-authored by
Livio Spring
conblem
parent
13f8ba4215
commit
b1c8781516
Vendored
+7
@@ -96,6 +96,13 @@ declare namespace NodeJS {
|
||||
*/
|
||||
API_CACHE_CONFIG?: string;
|
||||
|
||||
/**
|
||||
* Optional: Disable the proxy's runtime CSP fetch from the ZITADEL API.
|
||||
* Defaults to true (enabled). Set to "false" to skip fetching iframe origins
|
||||
* and use a restrictive default CSP instead.
|
||||
*/
|
||||
CSP_FETCH_ENABLED?: string;
|
||||
|
||||
/**
|
||||
* Optional: Disable OpenTelemetry instrumentation.
|
||||
* Set to "true" to bypass OTEL initialization.
|
||||
|
||||
@@ -30,7 +30,6 @@ const nextConfig = {
|
||||
experimental: {
|
||||
// Add React 19 compatibility optimizations
|
||||
optimizePackageImports: ["@radix-ui/react-tooltip", "@heroicons/react"],
|
||||
useCache: true,
|
||||
serverActions: {
|
||||
...(process.env.SERVER_ACTION_ALLOWED_ORIGINS
|
||||
? { allowedOrigins: process.env.SERVER_ACTION_ALLOWED_ORIGINS.split(",").map((o) => o.trim()) }
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { isRSCRequest, validateAuthRequest } from "@/lib/auth-utils";
|
||||
import { getAllSessions } from "@/lib/cookies";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { FlowInitiationParams, handleOIDCFlowInitiation, handleSAMLFlowInitiation } from "@/lib/server/flow-initiation";
|
||||
import { getServiceConfig } from "@/lib/service-url";
|
||||
import { listSessions, ServiceConfig } from "@/lib/zitadel";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = false;
|
||||
export const fetchCache = "default-no-store";
|
||||
|
||||
const logger = createLogger("login-route");
|
||||
|
||||
async function loadSessions({ serviceConfig, ids }: { serviceConfig: ServiceConfig; ids: string[] }): Promise<Session[]> {
|
||||
const response = await listSessions({ serviceConfig, ids: ids.filter((id: string | undefined) => !!id) });
|
||||
|
||||
@@ -18,8 +21,7 @@ async function loadSessions({ serviceConfig, ids }: { serviceConfig: ServiceConf
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const _headers = await headers();
|
||||
const { serviceConfig } = getServiceConfig(_headers);
|
||||
const { serviceConfig } = getServiceConfig(request.headers);
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
@@ -38,20 +40,33 @@ export async function GET(request: NextRequest) {
|
||||
const ids = sessionCookies.map((s) => s.id);
|
||||
let sessions: Session[] = [];
|
||||
if (ids && ids.length) {
|
||||
try {
|
||||
sessions = await loadSessions({ serviceConfig, ids });
|
||||
} catch (error) {
|
||||
logger.warn("Failed to load sessions", { error });
|
||||
// listSessions can fail for various reasons (stale/expired session IDs
|
||||
// still in cookies, API errors, etc.). Treat any failure as "no valid
|
||||
// sessions" so the user is redirected to loginname instead of a 500.
|
||||
sessions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Flow initiation - delegate to appropriate handler
|
||||
const flowParams: FlowInitiationParams = { serviceConfig, requestId, sessions, sessionCookies, request };
|
||||
|
||||
try {
|
||||
if (requestId.startsWith("oidc_")) {
|
||||
return handleOIDCFlowInitiation(flowParams);
|
||||
return await handleOIDCFlowInitiation(flowParams);
|
||||
} else if (requestId.startsWith("saml_")) {
|
||||
return handleSAMLFlowInitiation(flowParams);
|
||||
return await handleSAMLFlowInitiation(flowParams);
|
||||
} else if (requestId.startsWith("device_")) {
|
||||
// Device Authorization does not need to start here as it is handled on the /device endpoint
|
||||
return NextResponse.json({ error: "Device authorization should use /device endpoint" }, { status: 400 });
|
||||
} else {
|
||||
return NextResponse.json({ error: "Invalid request ID format" }, { status: 400 });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error("Flow initiation failed", { requestId, error });
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function DynamicTheme({
|
||||
<div className="relative mx-auto w-full max-w-[440px] px-4 py-4">
|
||||
<Card>
|
||||
<div className="mx-auto flex flex-col items-center space-y-8">
|
||||
<div className="relative -mb-4 flex flex-row items-center justify-center">
|
||||
<div className="relative flex flex-row items-center justify-center">
|
||||
{branding && (
|
||||
<Logo
|
||||
lightSrc={branding.lightTheme?.logoUrl}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function RegisterForm({
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
firstName: firstname ?? "",
|
||||
firstname: firstname ?? "",
|
||||
lastname: lastname ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ const BASE_DIRECTIVES: Record<string, string[]> = {
|
||||
|
||||
export interface CSPOptions {
|
||||
serviceUrl?: string;
|
||||
iframeOrigins?: string[];
|
||||
iframeOrigins?: string[] | null;
|
||||
}
|
||||
|
||||
export function buildCSP(options: CSPOptions = {}): string {
|
||||
|
||||
@@ -267,8 +267,14 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
|
||||
}
|
||||
return NextResponse.redirect(loginNameUrl);
|
||||
} else if (authRequest.prompt.includes(Prompt.NONE)) {
|
||||
const securitySettings = await getSecuritySettings({ serviceConfig });
|
||||
|
||||
let securitySettings: SecuritySettings | undefined;
|
||||
try {
|
||||
securitySettings = await getSecuritySettings({ serviceConfig });
|
||||
} catch (err) {
|
||||
logger.error("Failed to load security settings for CSP in prompt=none flow", {
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
const selectedSession = await findValidSession({ serviceConfig, sessions, authRequest, organization });
|
||||
|
||||
const noSessionResponse = NextResponse.json({ error: "No active session found" }, { status: 400 });
|
||||
@@ -302,7 +308,6 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
|
||||
|
||||
const callbackResponse = NextResponse.redirect(callbackUrl);
|
||||
setCSPHeaders(callbackResponse, serviceConfig, securitySettings);
|
||||
|
||||
return callbackResponse;
|
||||
} else {
|
||||
let selectedSession = await findValidSession({ serviceConfig, sessions, authRequest, organization });
|
||||
|
||||
@@ -39,23 +39,6 @@ describe("Host utility functions", () => {
|
||||
expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-instance-host");
|
||||
expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-forward-host");
|
||||
});
|
||||
|
||||
test("should return null when neither x-zitadel-instance-host nor x-zitadel-forward-host are available", () => {
|
||||
const mockHeaders = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "x-zitadel-instance-host") return null;
|
||||
if (key === "x-zitadel-forward-host") return null;
|
||||
if (key === "x-forwarded-host") return "accounts.mycompany.com";
|
||||
if (key === "host") return "internal.server";
|
||||
return null;
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const result = getInstanceHost(mockHeaders);
|
||||
expect(result).toBeNull();
|
||||
expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-instance-host");
|
||||
expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-forward-host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPublicHostWithProtocol", () => {
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/he
|
||||
*/
|
||||
export function getInstanceHost(headers: ReadonlyHeaders): string | null {
|
||||
// use standard proxy headers (x-forwarded-host → host) for both multi-tenant and self-hosted, do not use x-zitadel-instance-host
|
||||
const instanceHost = headers.get("x-zitadel-instance-host") || headers.get("x-zitadel-forward-host");
|
||||
const instanceHost =
|
||||
headers.get("x-zitadel-instance-host") || headers.get("x-zitadel-forward-host") || headers.get("host");
|
||||
|
||||
return instanceHost;
|
||||
}
|
||||
|
||||
@@ -54,30 +54,50 @@ async function resolveAuthToken(): Promise<string> {
|
||||
* via the Connect protocol (POST + JSON). This uses raw fetch (no connectRPC
|
||||
* node transport) so it stays compatible with the Next.js proxy runtime.
|
||||
*
|
||||
* Uses the same header pattern as createServerTransport in zitadel.ts:
|
||||
* - baseUrl (ZITADEL_API_URL) as the fetch URL
|
||||
* - x-zitadel-instance-host and x-zitadel-public-host as headers
|
||||
* - CUSTOM_REQUEST_HEADERS applied
|
||||
*
|
||||
* Results are cached in-memory for 1 hour per instance host using a bounded
|
||||
* LRU cache. Concurrent requests for the same key share a single in-flight
|
||||
* promise to prevent thundering-herd stampedes on the backend.
|
||||
*
|
||||
* @param baseUrl - The ZITADEL API base URL (ZITADEL_API_URL)
|
||||
* @param instanceHost - Optional instance host for multi-tenant deployments
|
||||
* @returns An array of allowed iframe origins, or undefined if not configured
|
||||
* @param instanceHost - Optional instance host for multi-tenant routing
|
||||
* @param publicHost - Optional public host for multi-tenant routing
|
||||
* @returns An array of allowed iframe origins, or null if not configured
|
||||
*/
|
||||
export async function getIframeOrigins(baseUrl: string, instanceHost?: string): Promise<string[] | undefined> {
|
||||
export async function getIframeOrigins(
|
||||
baseUrl: string,
|
||||
instanceHost?: string,
|
||||
publicHost?: string,
|
||||
): Promise<string[] | null> {
|
||||
const cacheKey = instanceHost || "__default__";
|
||||
|
||||
return cache.getOrFetch<string[] | undefined>(cacheKey, () => fetchIframeOrigins(baseUrl, instanceHost), CACHE_TTL_MS);
|
||||
// The fetcher returns null (not undefined) because lru-cache treats
|
||||
// undefined as a fetch failure.
|
||||
return cache.getOrFetch<string[] | null>(
|
||||
cacheKey,
|
||||
() => fetchIframeOrigins(baseUrl, instanceHost, publicHost),
|
||||
CACHE_TTL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchIframeOrigins(baseUrl: string, instanceHost?: string): Promise<string[] | undefined> {
|
||||
async function fetchIframeOrigins(baseUrl: string, instanceHost?: string, publicHost?: string): Promise<string[] | null> {
|
||||
const token = await resolveAuthToken();
|
||||
const reqHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
// Apply instance/public host headers — same pattern as createServerTransport
|
||||
if (instanceHost) {
|
||||
reqHeaders["x-zitadel-instance-host"] = instanceHost;
|
||||
}
|
||||
if (publicHost) {
|
||||
reqHeaders["x-zitadel-public-host"] = publicHost;
|
||||
}
|
||||
|
||||
// Apply custom headers from environment
|
||||
applyCustomHeaders({
|
||||
@@ -93,6 +113,7 @@ async function fetchIframeOrigins(baseUrl: string, instanceHost?: string): Promi
|
||||
method: "POST",
|
||||
headers: reqHeaders,
|
||||
body: "{}",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -100,13 +121,15 @@ async function fetchIframeOrigins(baseUrl: string, instanceHost?: string): Promi
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return undefined;
|
||||
// Return null instead of undefined — lru-cache treats undefined as a
|
||||
// fetch failure and throws "fetch() returned undefined".
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: GetSecuritySettingsResponseJson = await response.json();
|
||||
const settings = data.settings;
|
||||
|
||||
const origins = settings?.embeddedIframe?.enabled ? settings.embeddedIframe.allowedOrigins : undefined;
|
||||
const origins = settings?.embeddedIframe?.enabled ? settings.embeddedIframe.allowedOrigins : null;
|
||||
|
||||
return origins && origins.length > 0 ? origins : undefined;
|
||||
return origins && origins.length > 0 ? origins : null;
|
||||
}
|
||||
|
||||
@@ -24,24 +24,6 @@ describe("Service URL utilities", () => {
|
||||
expect(() => getServiceConfig(mockHeaders)).toThrow("ZITADEL_API_URL is not set");
|
||||
});
|
||||
|
||||
test("should return only baseUrl when x-zitadel-forward-host is not present (self-hosted)", () => {
|
||||
process.env.ZITADEL_API_URL = "https://zitadel.mycompany.com";
|
||||
|
||||
const mockHeaders = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "x-zitadel-forward-host") return null;
|
||||
if (key === "host") return "mycompany.com";
|
||||
return null;
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const result = getServiceConfig(mockHeaders);
|
||||
|
||||
expect(result.serviceConfig.baseUrl).toBe("https://zitadel.mycompany.com");
|
||||
expect(result.serviceConfig.instanceHost).toBeUndefined();
|
||||
expect(result.serviceConfig.publicHost).toBe("mycompany.com");
|
||||
});
|
||||
|
||||
test("should use x-zitadel-forward-host when present (multi-tenant)", () => {
|
||||
process.env.ZITADEL_API_URL = "https://api.zitadel.cloud";
|
||||
|
||||
|
||||
+10
-4
@@ -1,4 +1,3 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildCSP } from "./lib/csp";
|
||||
import { createLogger } from "./lib/logger";
|
||||
@@ -6,6 +5,7 @@ import { getIframeOrigins } from "./lib/server/security-settings";
|
||||
import { getServiceConfig } from "./lib/service-url";
|
||||
|
||||
const logger = createLogger("middleware");
|
||||
|
||||
export const config = {
|
||||
matcher: ["/.well-known/:path*", "/oauth/:path*", "/oidc/:path*", "/idps/callback/:path*", "/saml/:path*", "/:path*"],
|
||||
};
|
||||
@@ -28,16 +28,18 @@ export async function proxy(request: NextRequest) {
|
||||
return NextResponse.next({ request: { headers: requestHeaders } });
|
||||
}
|
||||
|
||||
const _headers = await headers();
|
||||
const { serviceConfig } = getServiceConfig(_headers);
|
||||
const { serviceConfig } = getServiceConfig(request.headers);
|
||||
const { baseUrl, publicHost, instanceHost } = serviceConfig;
|
||||
|
||||
// Build CSP headers using security settings fetched directly from the
|
||||
// ZITADEL API (no self-loopback through the load balancer).
|
||||
const responseHeaders = new Headers();
|
||||
|
||||
const cspFetchEnabled = process.env.CSP_FETCH_ENABLED !== "false";
|
||||
|
||||
if (cspFetchEnabled) {
|
||||
try {
|
||||
const iframeOrigins = await getIframeOrigins(baseUrl, instanceHost);
|
||||
const iframeOrigins = await getIframeOrigins(baseUrl, instanceHost, publicHost);
|
||||
|
||||
responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl, iframeOrigins }));
|
||||
|
||||
@@ -51,6 +53,10 @@ export async function proxy(request: NextRequest) {
|
||||
responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl }));
|
||||
responseHeaders.set("X-Frame-Options", "deny");
|
||||
}
|
||||
} else {
|
||||
responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl }));
|
||||
responseHeaders.set("X-Frame-Options", "deny");
|
||||
}
|
||||
|
||||
// Only proxy paths need to be rewritten to the ZITADEL backend
|
||||
const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/"];
|
||||
|
||||
Reference in New Issue
Block a user