diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index 48f83382f..a68fe86d6 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -1,6 +1,5 @@ import { isRSCRequest, validateAuthRequest } from "@/lib/auth-utils"; import { getAllSessions } from "@/lib/cookies"; -import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification"; import { FlowInitiationParams, handleOIDCFlowInitiation, handleSAMLFlowInitiation } from "@/lib/server/flow-initiation"; import { getServiceConfig } from "@/lib/service-url"; import { listSessions, ServiceConfig } from "@/lib/zitadel"; @@ -46,19 +45,9 @@ export async function GET(request: NextRequest) { const flowParams: FlowInitiationParams = { serviceConfig, requestId, sessions, sessionCookies, request }; if (requestId.startsWith("oidc_")) { - try { - return await handleOIDCFlowInitiation(flowParams); - } catch (error) { - const status = isClassifiedError(error) ? error.httpStatus : 500; - return NextResponse.json({ error: "Authentication flow failed" }, { status }); - } + return handleOIDCFlowInitiation(flowParams); } else if (requestId.startsWith("saml_")) { - try { - return await handleSAMLFlowInitiation(flowParams); - } catch (error) { - const status = isClassifiedError(error) ? error.httpStatus : 500; - return NextResponse.json({ error: "SAML flow failed" }, { status }); - } + return 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 }); diff --git a/apps/login/src/app/security/route.ts b/apps/login/src/app/security/route.ts deleted file mode 100644 index c1e02af47..000000000 --- a/apps/login/src/app/security/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createServiceForHost } from "@/lib/service"; -import { getServiceConfig } from "@/lib/service-url"; -import { Client } from "@zitadel/client"; -import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; -import { headers } from "next/headers"; -import { NextResponse } from "next/server"; - -export async function GET() { - const _headers = await headers(); - const { serviceConfig } = getServiceConfig(_headers); - - const settingsService: Client = await createServiceForHost(SettingsService, serviceConfig); - - const settings = await settingsService.getSecuritySettings({}).then((resp) => (resp.settings ? resp.settings : undefined)); - - const response = NextResponse.json({ settings }, { status: 200 }); - - // Add Cache-Control header to cache the response for up to 1 hour - response.headers.set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400"); - - return response; -} diff --git a/apps/login/src/lib/custom-headers.ts b/apps/login/src/lib/custom-headers.ts new file mode 100644 index 000000000..0eff91dbe --- /dev/null +++ b/apps/login/src/lib/custom-headers.ts @@ -0,0 +1,35 @@ +import { createLogger } from "@/lib/logger"; + +const logger = createLogger("custom-headers"); + +/** + * Parses the CUSTOM_REQUEST_HEADERS environment variable and applies the + * headers to a mutable target via the provided `set` and `remove` callbacks. + * + * Format: comma-separated "key:value" pairs. + * - Non-empty values call `set(key, value)`. + * - Empty values (e.g., "X-Remove-Me:") call `remove(key)` to delete the header. + * - Malformed entries (no colon) are logged and skipped. + */ +export function applyCustomHeaders(actions: { + set: (key: string, value: string) => void; + remove: (key: string) => void; +}): void { + const raw = process.env.CUSTOM_REQUEST_HEADERS; + if (!raw) return; + + raw.split(",").forEach((header) => { + const kv = header.indexOf(":"); + if (kv > 0) { + const key = header.slice(0, kv).trim(); + const value = header.slice(kv + 1).trim(); + if (value) { + actions.set(key, value); + } else { + actions.remove(key); + } + } else { + logger.warn("Skipping malformed CUSTOM_REQUEST_HEADERS entry", { header }); + } + }); +} diff --git a/apps/login/src/lib/grpc/interceptors/error-classification.test.ts b/apps/login/src/lib/grpc/interceptors/error-classification.test.ts index 891adf2ae..eac1c88c7 100644 --- a/apps/login/src/lib/grpc/interceptors/error-classification.test.ts +++ b/apps/login/src/lib/grpc/interceptors/error-classification.test.ts @@ -38,7 +38,15 @@ describe("ClassifiedConnectError", () => { expect(classified.message).toContain("not found"); expect(classified.code).toBe(Code.NotFound); - expect(classified.name).toBe("ClassifiedConnectError"); + // name must remain "ConnectError" so ConnectError's custom Symbol.hasInstance passes + expect(classified.name).toBe("ConnectError"); + }); + + it("is instanceof ConnectError (required for ConnectError.from compatibility)", () => { + const source = new ConnectError("test", Code.FailedPrecondition); + const classified = new ClassifiedConnectError(source); + + expect(classified instanceof ConnectError).toBe(true); }); it("sets httpStatus from gRPC code", () => { diff --git a/apps/login/src/lib/grpc/interceptors/error-classification.ts b/apps/login/src/lib/grpc/interceptors/error-classification.ts index beb7fffcb..2e8df066b 100644 --- a/apps/login/src/lib/grpc/interceptors/error-classification.ts +++ b/apps/login/src/lib/grpc/interceptors/error-classification.ts @@ -70,10 +70,10 @@ export class ClassifiedConnectError extends ConnectError { constructor(source: ConnectError) { super(source.rawMessage, source.code, source.metadata, undefined, source.cause); - // ConnectError's constructor resets the prototype chain via Object.setPrototypeOf. - // We must restore it so instanceof ClassifiedConnectError works correctly. - Object.setPrototypeOf(this, ClassifiedConnectError.prototype); - this.name = "ClassifiedConnectError"; + // Keep name as "ConnectError" so that ConnectError's custom Symbol.hasInstance + // duck-typing check (v.name === "ConnectError") passes. The branded Symbol.for + // check via isClassifiedError() is used to distinguish this subclass instead. + this.name = "ConnectError"; // Preserve the original stack trace so debugging/alert triage can see the RPC call site. if (source.stack) { this.stack = source.stack; diff --git a/apps/login/src/lib/grpc/interceptors/otel.test.ts b/apps/login/src/lib/grpc/interceptors/otel.test.ts index e61ce357b..9ed62bc55 100644 --- a/apps/login/src/lib/grpc/interceptors/otel.test.ts +++ b/apps/login/src/lib/grpc/interceptors/otel.test.ts @@ -67,7 +67,7 @@ describe("otelGrpcInterceptor", () => { }); }); - it("records error spans with gRPC status codes", async () => { + it("records error spans with gRPC status codes and classification", async () => { const mockTransport = createRouterTransport( ({ service }) => { service(SessionService, { diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index 893da6418..b17462b54 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -1,6 +1,6 @@ import { getValidLocaleFromUILocales } from "@/lib/auth-utils"; import { getLanguageCookie, setLanguageCookie } from "@/lib/cookies"; -import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification"; + import { shouldUILocalesOverrideCookie } from "@/lib/i18n"; import { idpTypeToSlug } from "@/lib/idp"; import { createLogger } from "@/lib/logger"; @@ -87,16 +87,7 @@ export interface FlowInitiationParams { export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Promise { const { serviceConfig, requestId, sessions, sessionCookies, request } = params; - let authRequest; - try { - ({ authRequest } = await getAuthRequest({ serviceConfig, authRequestId: requestId.replace("oidc_", "") })); - } catch (error) { - if (isClassifiedError(error) && error.isUserError) { - logger.warn("Auth request failed (client error)", { grpcCode: error.code, httpStatus: error.httpStatus }); - return NextResponse.json({ error: error.message }, { status: error.httpStatus }); - } - throw error; - } + const { authRequest } = await getAuthRequest({ serviceConfig, authRequestId: requestId.replace("oidc_", "") }); const locale = getValidLocaleFromUILocales(authRequest?.uiLocales); if (locale) { @@ -396,16 +387,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Promise { const { serviceConfig, requestId, sessions, sessionCookies, request } = params; - let samlRequest; - try { - ({ samlRequest } = await getSAMLRequest({ serviceConfig, samlRequestId: requestId.replace("saml_", "") })); - } catch (error) { - if (isClassifiedError(error) && error.isUserError) { - logger.warn("SAML request failed (client error)", { grpcCode: error.code, httpStatus: error.httpStatus }); - return NextResponse.json({ error: error.message }, { status: error.httpStatus }); - } - throw error; - } + const { samlRequest } = await getSAMLRequest({ serviceConfig, samlRequestId: requestId.replace("saml_", "") }); if (!samlRequest) { return NextResponse.json({ error: "No samlRequest found" }, { status: 400 }); diff --git a/apps/login/src/lib/server/password.test.ts b/apps/login/src/lib/server/password.test.ts index 9efb98cab..fa39fc671 100644 --- a/apps/login/src/lib/server/password.test.ts +++ b/apps/login/src/lib/server/password.test.ts @@ -1,5 +1,7 @@ +import { Code, ConnectError } from "@connectrpc/connect"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ClassifiedConnectError } from "../grpc/interceptors/error-classification"; import { changePassword, checkSessionAndSetPassword, resetPassword, sendPassword } from "./password"; // Mock dependencies @@ -273,8 +275,12 @@ describe("checkSessionAndSetPassword", () => { }); test("should handle setPassword failure with failed precondition", async () => { - const { ConnectError } = await import("@zitadel/client"); - mockSetPassword.mockRejectedValue(new ConnectError("User is not yet initialized", 9)); + // Use real ConnectError + ClassifiedConnectError to match runtime behavior + // where the error-classification interceptor wraps transport errors. + const classifiedError = new ClassifiedConnectError( + new ConnectError("User is not yet initialized", Code.FailedPrecondition), + ); + mockSetPassword.mockRejectedValue(classifiedError); const result = await checkSessionAndSetPassword({ sessionId: "session123", diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 56118a897..d790456df 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -1,5 +1,6 @@ "use server"; +import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification"; import { createLogger } from "@/lib/logger"; import { recordAuthAttempt, recordAuthFailure, recordAuthSuccess } from "@/lib/metrics"; import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie"; @@ -15,7 +16,7 @@ import { setPassword, setUserPassword, } from "@/lib/zitadel"; -import { Code, ConnectError, create, Duration } from "@zitadel/client"; +import { Code, create, Duration } from "@zitadel/client"; import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; @@ -585,7 +586,7 @@ export async function checkSessionAndSetPassword({ return setPassword({ serviceConfig, payload }).catch((error) => { // throw error if failed precondition (ex. User is not yet initialized) - if (error instanceof ConnectError && error.code === Code.FailedPrecondition && error.message) { + if (isClassifiedError(error) && error.code === Code.FailedPrecondition && error.message) { return { error: t("errors.failedPrecondition") }; } return { error: "Could not set password" }; diff --git a/apps/login/src/lib/server/security-settings.ts b/apps/login/src/lib/server/security-settings.ts new file mode 100644 index 000000000..566969d99 --- /dev/null +++ b/apps/login/src/lib/server/security-settings.ts @@ -0,0 +1,112 @@ +import { PromiseCache } from "@/lib/cache"; +import { applyCustomHeaders } from "@/lib/custom-headers"; +import { createLogger } from "@/lib/logger"; +import type { GetSecuritySettingsResponseJson } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; + +const logger = createLogger("security-settings"); + +let cacheConfig: Record = {}; +try { + if (process.env.API_CACHE_CONFIG) { + cacheConfig = JSON.parse(process.env.API_CACHE_CONFIG); + } +} catch (e) { + console.error("Failed to parse API_CACHE_CONFIG", e); +} + +/** Cache TTL: defaults to longMinutes (1 hour). Security settings rarely change. */ +const CACHE_TTL_MS = (cacheConfig.longMinutes ?? 60) * 60 * 1000; + +/** + * Bounded LRU cache for security settings per instance host. + * + * Uses the shared PromiseCache which provides: + * - LRU eviction (capped by API_CACHE_CONFIG maxSize, default 10 000) + * - Automatic TTL expiry (API_CACHE_CONFIG longMinutes, default 60 min) + * - Stale-while-revalidate (serves stale data while refreshing in background) + * - Built-in in-flight request deduplication + */ +const cache = new PromiseCache(Number(cacheConfig.maxSize) || 10_000); + +/** + * Resolves an authentication token from available credential sources. + * Priority: system user JWT > login client key > service account token. + */ +async function resolveAuthToken(): Promise { + const { hasSystemUserCredentials, hasLoginClientKey, hasServiceUserToken } = await import("@/lib/deployment"); + + if (hasSystemUserCredentials()) { + const { systemAPIToken } = await import("@/lib/api"); + return systemAPIToken(); + } + if (hasLoginClientKey()) { + const { loginClientKeyToken } = await import("@/lib/api"); + return loginClientKeyToken(); + } + if (hasServiceUserToken()) { + return process.env.ZITADEL_SERVICE_USER_TOKEN!; + } + throw new Error("No authentication credentials found"); +} + +/** + * Fetches iframe origins from security settings using the ZITADEL API directly + * via the Connect protocol (POST + JSON). This uses raw fetch (no connectRPC + * node transport) so it stays compatible with the Next.js proxy runtime. + * + * 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 + */ +export async function getIframeOrigins(baseUrl: string, instanceHost?: string): Promise { + const cacheKey = instanceHost || "__default__"; + + return cache.getOrFetch(cacheKey, () => fetchIframeOrigins(baseUrl, instanceHost), CACHE_TTL_MS); +} + +async function fetchIframeOrigins(baseUrl: string, instanceHost?: string): Promise { + const token = await resolveAuthToken(); + const reqHeaders: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }; + + if (instanceHost) { + reqHeaders["x-zitadel-instance-host"] = instanceHost; + } + + // Apply custom headers from environment + applyCustomHeaders({ + set: (key, value) => { + reqHeaders[key] = value; + }, + remove: (key) => { + delete reqHeaders[key]; + }, + }); + + const response = await fetch(`${baseUrl}/zitadel.settings.v2.SettingsService/GetSecuritySettings`, { + method: "POST", + headers: reqHeaders, + body: "{}", + }); + + if (!response.ok) { + logger.error("Failed to fetch security settings from API", { + status: response.status, + statusText: response.statusText, + }); + return undefined; + } + + const data: GetSecuritySettingsResponseJson = await response.json(); + const settings = data.settings; + + const origins = settings?.embeddedIframe?.enabled ? settings.embeddedIframe.allowedOrigins : undefined; + + return origins && origins.length > 0 ? origins : undefined; +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index ec0cb05a7..753de1b19 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -32,16 +32,14 @@ import { import { getTranslations } from "next-intl/server"; import { getUserAgent } from "./fingerprint"; -import { createLogger } from "./logger"; -import { errorClassificationInterceptor } from "@/lib/grpc/interceptors/error-classification"; +import { applyCustomHeaders } from "@/lib/custom-headers"; +import { errorClassificationInterceptor, isClassifiedError } from "@/lib/grpc/interceptors/error-classification"; import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel"; -import { Code, ConnectError, Interceptor } from "@connectrpc/connect"; +import { Code, Interceptor } from "@connectrpc/connect"; import { PromiseCache } from "./cache"; import { createServiceForHost } from "./service"; -const logger = createLogger("zitadel"); - const useCache = process.env.API_CACHE_ENABLED !== "false"; let cacheConfig: Record = {}; @@ -1169,7 +1167,7 @@ export async function setUserPassword({ return userService.setPassword(payload, {}).catch((error) => { // throw error if failed precondition (ex. User is not yet initialized) - if (error instanceof ConnectError && error.code === Code.FailedPrecondition && error.message) { + if (isClassifiedError(error) && error.code === Code.FailedPrecondition && error.message) { return { error: error.message }; } else { throw error; @@ -1370,22 +1368,10 @@ export function createServerTransport(token: string, serviceConfig: ServiceConfi } // Apply headers from CUSTOM_REQUEST_HEADERS environment variable - if (process.env.CUSTOM_REQUEST_HEADERS) { - process.env.CUSTOM_REQUEST_HEADERS.split(",").forEach((header) => { - const kv = header.indexOf(":"); - if (kv > 0) { - const key = header.slice(0, kv).trim(); - const value = header.slice(kv + 1).trim(); - if (value) { - req.header.set(key, value); - } else { - req.header.delete(key); - } - } else { - logger.warn("Skipping malformed header", { header }); - } - }); - } + applyCustomHeaders({ + set: (key, value) => req.header.set(key, value), + remove: (key) => req.header.delete(key), + }); return next(req); }; diff --git a/apps/login/src/proxy.ts b/apps/login/src/proxy.ts index 4e4ab3215..9ce0dbb19 100644 --- a/apps/login/src/proxy.ts +++ b/apps/login/src/proxy.ts @@ -1,9 +1,8 @@ -import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_settings_pb"; - import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { buildCSP } from "./lib/csp"; import { createLogger } from "./lib/logger"; +import { getIframeOrigins } from "./lib/server/security-settings"; import { getServiceConfig } from "./lib/service-url"; const logger = createLogger("middleware"); @@ -11,24 +10,6 @@ export const config = { matcher: ["/.well-known/:path*", "/oauth/:path*", "/oidc/:path*", "/idps/callback/:path*", "/saml/:path*", "/:path*"], }; -async function loadSecuritySettings(request: NextRequest): Promise { - const securityResponse = await fetch(`${request.nextUrl.origin}/security`); - - if (!securityResponse.ok) { - logger.error("Failed to fetch security settings:", { status: securityResponse.statusText }); - return null; - } - - const response = await securityResponse.json(); - - if (!response || !response.settings) { - logger.error("No security settings found in the response."); - return null; - } - - return response.settings; -} - export async function proxy(request: NextRequest) { // Add the original URL as a header to all requests const requestHeaders = new Headers(request.headers); @@ -40,34 +21,23 @@ export async function proxy(request: NextRequest) { } // Internal infrastructure routes — skip middleware entirely. - // /security is the internal API this middleware fetches (loop prevention). // /healthy and /ready are Kubernetes/Docker health probes that must respond // without depending on a ZITADEL backend. - const skipPaths = ["/security", "/healthy", "/ready"]; + const skipPaths = ["/healthy", "/ready"]; if (skipPaths.includes(request.nextUrl.pathname)) { return NextResponse.next({ request: { headers: requestHeaders } }); } - // Build security response headers (shared by all routes). - // Wrapped in try/catch so the middleware gracefully degrades with a default - // CSP when the ZITADEL backend is unavailable (e.g. during container startup). + const _headers = await headers(); + const { serviceConfig } = getServiceConfig(_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(); - let publicHost: string | undefined; - let instanceHost: string | undefined; - let baseUrl: string | undefined; try { - const _headers = await headers(); - const { serviceConfig } = getServiceConfig(_headers); - publicHost = serviceConfig.publicHost; - instanceHost = serviceConfig.instanceHost; - baseUrl = serviceConfig.baseUrl; - - const securitySettings = await loadSecuritySettings(request); - const iframeOrigins = - securitySettings?.embeddedIframe?.enabled && securitySettings.embeddedIframe.allowedOrigins.length > 0 - ? securitySettings.embeddedIframe.allowedOrigins - : undefined; + const iframeOrigins = await getIframeOrigins(baseUrl, instanceHost); responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl, iframeOrigins })); @@ -78,11 +48,11 @@ export async function proxy(request: NextRequest) { logger.error("Failed to load security settings for CSP, using default CSP", { error: err instanceof Error ? err.message : String(err), }); - responseHeaders.set("Content-Security-Policy", buildCSP()); + responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl })); responseHeaders.set("X-Frame-Options", "deny"); } - // Only run the rest of the logic for the original matcher paths + // Only proxy paths need to be rewritten to the ZITADEL backend const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/"]; const isMatched = proxyPaths.some((prefix) => request.nextUrl.pathname.startsWith(prefix));