fix(login): ClassifiedConnectError breaking ConnectError instanceof checks (#12022)

# Which Problems Are Solved

1. SSL/TLS handshake failures on Cloud Run (middleware self-loopback)
PR #11903 changed the middleware (proxy.ts) to fetch security settings
via fetch(origin + "/security") on every request, including non-proxy
routes like /login. On Cloud Run, this self-loopback goes through the
Google Front End (GFE) load balancer, causing intermittent SSL
routines::record layer failure errors that were tenant-consistent.

2. The ClassifiedConnectError introduced in #11926 set this.name =
"ClassifiedConnectError", which broke ConnectError's custom
Symbol.hasInstance duck-typing check (v.name === "ConnectError"). This
caused ConnectError.from() inside the connectRPC transport's abort
handler to re-wrap classified errors as new ConnectError instances with
Code.Unknown — losing the original gRPC error code and all
classification metadata.

# How the Problems Are Solved

Middleware: eliminate self-loopback fetch:

- Replaced the fetch(origin + "/security") self-loopback with a direct
fetch to ZITADEL_API_URL using the Connect protocol (POST + JSON),
bypassing the load balancer entirely
- Security settings are cached in-memory with a 1-hour TTL per instance
host
- Extracted the fetching/caching logic into
src/lib/server/security-settings.ts
Removed the now-unused /security API route (src/app/security/route.ts)
- CSP headers with iframe origins are now applied to all routes without
any loopback

Error classification interceptor: fixed
- Keep this.name = "ConnectError" in ClassifiedConnectError so the
duck-typing Symbol.hasInstance check passes
- The branded Symbol.for check via isClassifiedError() still correctly
distinguishes the subclass
- Remove redundant Object.setPrototypeOf call (the super constructor
already handles it via new.target)
- Replace remaining instanceof ConnectError checks with
isClassifiedError() in setUserPassword and checkSessionAndSetPassword
This commit is contained in:
Max Peintner
2026-04-23 09:11:13 +02:00
committed by GitHub
parent dd9dcf9f72
commit de98c6edb8
12 changed files with 196 additions and 129 deletions
+2 -13
View File
@@ -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 });
-22
View File
@@ -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<typeof SettingsService> = 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;
}
+35
View File
@@ -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 });
}
});
}
@@ -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", () => {
@@ -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;
@@ -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, {
+3 -21
View File
@@ -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<NextResponse> {
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<NextResponse> {
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 });
+8 -2
View File
@@ -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",
+3 -2
View File
@@ -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" };
@@ -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<string, number> = {};
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<string> {
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<string[] | undefined> {
const cacheKey = instanceHost || "__default__";
return cache.getOrFetch<string[] | undefined>(cacheKey, () => fetchIframeOrigins(baseUrl, instanceHost), CACHE_TTL_MS);
}
async function fetchIframeOrigins(baseUrl: string, instanceHost?: string): Promise<string[] | undefined> {
const token = await resolveAuthToken();
const reqHeaders: Record<string, string> = {
"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;
}
+8 -22
View File
@@ -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<string, number> = {};
@@ -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);
};
+11 -41
View File
@@ -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<SecuritySettings | null> {
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));