mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-01 03:52:14 +00:00
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:
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user