feat(login): added support for otel push-based logs, metrics, and traces (#11429)

This commit is contained in:
Mridang Agarwalla
2026-03-04 15:58:56 +05:30
committed by GitHub
parent 685be24dfe
commit 3f3413a230
41 changed files with 4182 additions and 350 deletions
+50 -1
View File
@@ -1,8 +1,9 @@
module.exports = {
parser: "@typescript-eslint/parser",
extends: ["next", "prettier"],
plugins: ["@typescript-eslint"],
plugins: ["@typescript-eslint", "import"],
rules: {
"import/first": "error",
"@next/next/no-html-link-for-pages": "off",
"@next/next/no-img-element": "off",
"react/no-unescaped-entities": "off",
@@ -27,4 +28,52 @@ module.exports = {
},
project: "./tsconfig.json",
},
overrides: [
{
// Server components and server actions: must use logger, not console.*
// Files in src/lib/server/*, src/app/**/route.ts, src/middleware.ts, and files with "use server"
files: [
"src/lib/server/**/*.ts",
"src/app/**/route.ts",
"src/middleware.ts",
"src/lib/cookies.ts",
],
rules: {
"no-console": "warn",
},
},
{
// instrumentation.ts uses console.log during OTEL initialization (before logger is available)
files: ["src/instrumentation.ts"],
rules: {
"no-console": "off",
},
},
{
// Client components: logger import is forbidden (enforced by server-only at build time)
// This override documents the expectation for client components
files: ["src/components/**/*.tsx"],
rules: {
"no-restricted-imports": ["error", {
"paths": [
{
"name": "next/image",
"message": "Use of next/image is forbidden. Use regular <img> elements instead."
},
{
"name": "@/lib/logger",
"message": "Cannot import logger in client components. Use console.* instead. The logger uses 'server-only' and will fail at build time."
}
]
}],
},
},
{
// Test files: allow console.*
files: ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/tests/**/*"],
rules: {
"no-console": "off",
},
},
],
};
+1
View File
@@ -13,4 +13,5 @@ dist-ssr
*.local
.vscode
/blob-report/
# Dockerized test output (generated by integration tests)
dockerized/*/output/
+3 -1
View File
@@ -14,7 +14,9 @@ ENV HOSTNAME="::" \
NODE_OPTIONS="--use-openssl-ca" \
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt" \
SSL_CERT_DIR="/etc/ssl/certs" \
ZITADEL_TLS_ENABLED="false"
ZITADEL_TLS_ENABLED="false" \
OTEL_SERVICE_NAME="zitadel-login" \
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
# TODO: Check healthy, not ready
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+71 -134
View File
@@ -1,165 +1,117 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { GenericContainer, type StartedTestContainer, Network, Wait } from "testcontainers";
import {
GenericContainer,
type StartedTestContainer,
Network,
Wait,
} from "testcontainers";
import type { StartedNetwork } from "testcontainers";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as esbuild from "esbuild";
import { generateCertificates } from "./utils/tls.ts";
const TEST_DIR = path.dirname(new URL(import.meta.url).pathname);
const OUTPUT_DIR = path.join(TEST_DIR, "output");
const CERTS_DIR = path.join(OUTPUT_DIR, "certs");
const LOGIN_APP_DIR = path.join(TEST_DIR, "../..");
async function bundleMockServer(): Promise<string> {
const bundleDir = path.join(OUTPUT_DIR, "bundle");
fs.mkdirSync(bundleDir, { recursive: true });
const outfile = path.join(bundleDir, "mock-server.js");
await esbuild.build({
entryPoints: [path.join(TEST_DIR, "mock-server.ts")],
bundle: true,
platform: "node",
target: "node22",
outfile,
format: "cjs",
});
return outfile;
}
function readCapturedRequests(): Array<{ method: string; url: string; tlsConnected: boolean }> {
const filePath = path.join(OUTPUT_DIR, "requests.json");
if (!fs.existsSync(filePath)) {
return [];
}
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
const SMOCKER_MOCK_PORT = 443;
const SMOCKER_ADMIN_PORT = 8081;
/**
* Integration tests for custom CA certificate support in the login application.
*
* These tests verify that the Dockerfile correctly configures Node.js to trust custom
* Certificate Authorities via the SSL_CERT_FILE environment variable and the
* NODE_OPTIONS=--use-openssl-ca flag. This is essential for deployments where the
* ZITADEL API is served behind a TLS-terminating proxy using internal or self-signed
* certificates.
* These tests verify that the Dockerfile correctly configures Node.js to trust
* custom Certificate Authorities via the SSL_CERT_FILE environment variable and
* the NODE_OPTIONS=--use-openssl-ca flag.
*
* The test suite spins up a mock TLS server with a self-signed certificate inside a
* Docker network. The login application container connects to this mock server, and
* the tests verify that TLS connections succeed when the custom CA is mounted and
* fail when it is not. The mock server records all incoming requests to a shared
* volume, allowing the tests to verify that TLS handshakes completed successfully.
*
* The Docker network ensures container-to-container communication uses internal DNS
* resolution, avoiding port conflicts on the host machine and ensuring reliable
* execution in CI environments.
* We use Smocker as a TLS server and test CA loading by making curl requests
* from inside the container - this verifies TLS at the transport layer without
* needing gRPC/HTTP2 support.
*/
const LOGIN_IMAGE_TAG = `zitadel-login-test:${process.env.GITHUB_SHA || "local"}`;
async function testTlsConnection(
container: StartedTestContainer,
url: string,
): Promise<{ exitCode: number; output: string }> {
return container.exec([
"node",
"-e",
`fetch('${url}').then(() => process.exit(0)).catch(() => process.exit(1))`,
]);
}
describe("Custom CA Certificate Integration", () => {
let certs: ReturnType<typeof generateCertificates>;
let network: StartedNetwork;
let mockServer: StartedTestContainer;
let mockServerBundle: string;
let loginImage: GenericContainer;
let certsDir: string;
beforeAll(async () => {
if (fs.existsSync(OUTPUT_DIR)) {
fs.rmSync(OUTPUT_DIR, { recursive: true });
}
fs.mkdirSync(CERTS_DIR, { recursive: true });
certsDir = fs.mkdtempSync(path.join(os.tmpdir(), "ca-test-certs-"));
certs = generateCertificates();
fs.writeFileSync(path.join(CERTS_DIR, "ca.crt"), certs.ca.cert);
fs.writeFileSync(path.join(CERTS_DIR, "server.key"), certs.server.key);
fs.writeFileSync(path.join(CERTS_DIR, "server.crt"), certs.server.cert);
mockServerBundle = await bundleMockServer();
const certs = generateCertificates();
fs.writeFileSync(path.join(certsDir, "ca.crt"), certs.ca.cert);
fs.writeFileSync(path.join(certsDir, "server.key"), certs.server.key);
fs.writeFileSync(path.join(certsDir, "server.crt"), certs.server.cert);
network = await new Network().start();
mockServer = await new GenericContainer("node:22-alpine")
mockServer = await new GenericContainer("ghcr.io/smocker-dev/smocker")
.withNetwork(network)
.withNetworkAliases("mock-zitadel")
.withCopyFilesToContainer([
{ source: mockServerBundle, target: "/app/server.js" },
{ source: path.join(CERTS_DIR, "server.key"), target: "/certs/server.key" },
{ source: path.join(CERTS_DIR, "server.crt"), target: "/certs/server.crt" },
])
.withBindMounts([{ source: OUTPUT_DIR, target: "/output" }])
.withCommand(["node", "/app/server.js"])
.withExposedPorts(443)
.withWaitStrategy(Wait.forLogMessage("Mock TLS server listening"))
.withBindMounts([{ source: certsDir, target: "/certs", mode: "ro" }])
.withEnvironment({
SMOCKER_MOCK_SERVER_LISTEN_PORT: "443",
SMOCKER_CONFIG_LISTEN_PORT: "8081",
SMOCKER_TLS_ENABLE: "true",
SMOCKER_TLS_CERT_FILE: "/certs/server.crt",
SMOCKER_TLS_PRIVATE_KEY_FILE: "/certs/server.key",
})
.withExposedPorts(SMOCKER_MOCK_PORT, SMOCKER_ADMIN_PORT)
.withStartupTimeout(120_000)
.withWaitStrategy(Wait.forLogMessage(/Starting mock server/))
.start();
loginImage = await GenericContainer.fromDockerfile(LOGIN_APP_DIR).build();
});
try {
await GenericContainer.fromDockerfile(LOGIN_APP_DIR).build(LOGIN_IMAGE_TAG);
} catch (err) {
throw new Error(`Failed to build login Docker image: ${err instanceof Error ? err.message : err}`);
}
}, 180_000);
afterAll(async () => {
await mockServer?.stop();
await network?.stop();
await mockServer?.stop().catch(() => {});
await network?.stop().catch(() => {});
});
describe("when custom CA certificate is provided", () => {
let container: StartedTestContainer;
let appUrl: string;
beforeAll(async () => {
if (fs.existsSync(path.join(OUTPUT_DIR, "requests.json"))) {
fs.unlinkSync(path.join(OUTPUT_DIR, "requests.json"));
}
container = await loginImage
container = await new GenericContainer(LOGIN_IMAGE_TAG)
.withNetwork(network)
.withExposedPorts(3000)
.withEnvironment({
ZITADEL_API_URL: "https://mock-zitadel",
ZITADEL_SERVICE_USER_TOKEN: "test-token",
SSL_CERT_FILE: "/etc/ssl/certs/custom-ca.crt",
})
.withCopyFilesToContainer([
{
source: path.join(CERTS_DIR, "ca.crt"),
source: path.join(certsDir, "ca.crt"),
target: "/etc/ssl/certs/custom-ca.crt",
},
])
.withStartupTimeout(180_000)
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
.start();
appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`;
await fetch(`${appUrl}/ui/v2/login/loginname`, { redirect: "manual" });
for (let i = 0; i < 30; i++) {
const requests = readCapturedRequests();
if (requests.length > 0) break;
await new Promise((r) => setTimeout(r, 1000));
}
});
}, 180_000);
afterAll(async () => {
await container?.stop();
await container?.stop().catch(() => {});
});
it("connects to mock server over TLS", () => {
const requests = readCapturedRequests();
expect(requests.length).toBeGreaterThan(0);
});
it("marks requests as TLS connected", () => {
const requests = readCapturedRequests();
const tlsRequests = requests.filter((r) => r.tlsConnected === true);
expect(tlsRequests.length).toBeGreaterThan(0);
});
it("returns 200 from healthy endpoint", async () => {
const response = await fetch(`${appUrl}/ui/v2/login/healthy`);
expect(response.status).toBe(200);
});
it("serves the login page", async () => {
const response = await fetch(`${appUrl}/ui/v2/login/loginname`, { redirect: "manual" });
expect([200, 302, 303, 307, 308]).toContain(response.status);
it("can establish TLS connection to server with custom CA", async () => {
const result = await testTlsConnection(container, "https://mock-zitadel/");
expect(result.exitCode).toBe(0);
});
it("has NODE_OPTIONS set with --use-openssl-ca", async () => {
@@ -184,33 +136,23 @@ describe("Custom CA Certificate Integration", () => {
describe("when custom CA certificate is not provided", () => {
let container: StartedTestContainer;
let appUrl: string;
let requestsBefore: number;
beforeAll(async () => {
requestsBefore = readCapturedRequests().length;
container = await loginImage
container = await new GenericContainer(LOGIN_IMAGE_TAG)
.withNetwork(network)
.withExposedPorts(3000)
.withEnvironment({
ZITADEL_API_URL: "https://mock-zitadel",
ZITADEL_SERVICE_USER_TOKEN: "test-token",
})
.withStartupTimeout(180_000)
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
.start();
appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`;
await fetch(`${appUrl}/ui/v2/login/loginname`, { redirect: "manual" });
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 500));
}
});
}, 180_000);
afterAll(async () => {
await container?.stop();
await container?.stop().catch(() => {});
});
it("cannot establish TLS connection without custom CA", async () => {
const result = await testTlsConnection(container, "https://mock-zitadel/");
expect(result.exitCode).not.toBe(0);
});
it("uses system CA store by default", async () => {
@@ -226,10 +168,5 @@ describe("Custom CA Certificate Integration", () => {
]);
expect(result.output.trim()).toContain("missing");
});
it("cannot establish TLS connection to mock server", () => {
const requestsAfter = readCapturedRequests().length;
expect(requestsAfter).toBe(requestsBefore);
});
});
});
-38
View File
@@ -1,38 +0,0 @@
import * as http2 from "node:http2";
import * as fs from "node:fs";
const OUTPUT_DIR = process.env.OUTPUT_DIR || "/output";
const server = http2.createSecureServer({
key: fs.readFileSync("/certs/server.key"),
cert: fs.readFileSync("/certs/server.crt"),
allowHTTP1: true,
});
interface CapturedRequest {
method: string;
url: string;
tlsConnected: boolean;
}
const requests: CapturedRequest[] = [];
server.on("stream", (stream, headers) => {
requests.push({
method: (headers[":method"] as string) || "UNKNOWN",
url: (headers[":path"] as string) || "/",
tlsConnected: true,
});
fs.writeFileSync(`${OUTPUT_DIR}/requests.json`, JSON.stringify(requests, null, 2));
stream.respond({
":status": 200,
"content-type": "application/json",
"grpc-status": "0",
});
stream.end(JSON.stringify({ status: "ok" }));
});
server.listen(443, "0.0.0.0", () => {
console.log("Mock TLS server listening on port 443");
});
@@ -0,0 +1,155 @@
/**
* Mock Zitadel Connect/gRPC server for testing trace propagation.
* Captures incoming headers and writes them to a file for test assertions.
*
* Uses HTTP/2 with cleartext (h2c) to support gRPC protocol.
*/
const http2 = require("http2");
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = process.env.PORT || 8080;
const OUTPUT_DIR = process.env.OUTPUT_DIR || "/tmp/otel";
const HEADERS_FILE = path.join(OUTPUT_DIR, "captured-headers.json");
/**
* Safely stringify a value to JSON with XSS-preventing character escapes.
*
* Escapes `<`, `>`, `&`, and `/` to their Unicode equivalents to prevent
* script injection when the output is reflected in HTTP responses or
* embedded in HTML contexts.
*
* @param {unknown} value - The value to stringify
* @returns {string} JSON string with dangerous characters escaped
*/
function safeJsonStringify(value) {
return JSON.stringify(value, null, 2)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\//g, "\\/");
}
// Store captured requests
const capturedRequests = [];
// Handle HTTP/2 request
function handleRequest(stream, headers, isHttp2 = true) {
const method = isHttp2 ? headers[":method"] : headers.method;
const url = isHttp2 ? headers[":path"] : headers.url;
// Capture trace headers
const traceHeaders = {
timestamp: new Date().toISOString(),
method: method,
url: url,
traceparent: headers["traceparent"] || null,
tracestate: headers["tracestate"] || null,
baggage: headers["baggage"] || null,
grpcAcceptEncoding: headers["grpc-accept-encoding"] || null,
contentType: headers["content-type"] || null,
};
capturedRequests.push(traceHeaders);
// Write to file for test assertions
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(HEADERS_FILE, JSON.stringify(capturedRequests, null, 2));
console.log(`[${traceHeaders.timestamp}] ${method} ${url}`);
console.log(` traceparent: ${traceHeaders.traceparent || "(none)"}`);
// Helper to send response
const sendResponse = (statusCode, contentType, body) => {
if (isHttp2) {
stream.respond({
":status": statusCode,
"content-type": contentType,
"grpc-status": "0",
"grpc-message": "OK",
});
stream.end(body);
} else {
stream.writeHead(statusCode, {
"Content-Type": contentType,
"grpc-status": "0",
"grpc-message": "OK",
});
stream.end(body);
}
};
// Health check
if (url.includes("/health") || url === "/") {
sendResponse(200, "application/json", safeJsonStringify({ status: "ok" }));
return;
}
// Return captured headers (for debugging)
if (url === "/captured-headers") {
sendResponse(200, "application/json", safeJsonStringify(capturedRequests));
return;
}
// Mock Connect/gRPC responses
// These are minimal responses to prevent the login app from crashing
if (url.includes("GetLoginSettings") || url.includes("SettingsService")) {
sendResponse(200, "application/json", safeJsonStringify({ settings: {} }));
} else if (url.includes("GetBrandingSettings")) {
sendResponse(200, "application/json", safeJsonStringify({ settings: {} }));
} else if (url.includes("SessionService") || url.includes("Session")) {
sendResponse(200, "application/json", safeJsonStringify({ session: {} }));
} else if (url.includes("UserService") || url.includes("User")) {
sendResponse(200, "application/json", safeJsonStringify({ user: {} }));
} else if (url.includes("OrganizationService") || url.includes("Organization")) {
sendResponse(200, "application/json", safeJsonStringify({ result: [] }));
} else if (url.includes("IdentityProviderService") || url.includes("IdentityProvider")) {
sendResponse(200, "application/json", safeJsonStringify({ identityProviders: [] }));
} else {
sendResponse(200, "application/json", safeJsonStringify({}));
}
}
// Create HTTP/2 server (cleartext for testing)
const http2Server = http2.createServer();
http2Server.on("stream", (stream, headers) => {
handleRequest(stream, headers, true);
});
http2Server.on("error", (err) => console.error("HTTP/2 error:", err));
// Also create HTTP/1.1 server for health checks from wget
const httpServer = http.createServer((req, res) => {
// Create a headers object compatible with handleRequest
const headers = {
method: req.method,
url: req.url,
...req.headers,
};
handleRequest(res, headers, false);
});
// Start HTTP/2 server on main port
http2Server.listen(PORT, "0.0.0.0", () => {
console.log(`Mock Zitadel HTTP/2 server listening on port ${PORT}`);
console.log(`Captured headers will be written to: ${HEADERS_FILE}`);
});
// Start HTTP/1.1 server on a different port for health checks
const HEALTH_PORT = 7432;
httpServer.listen(HEALTH_PORT, "0.0.0.0", () => {
console.log(`Mock Zitadel HTTP/1.1 health server listening on port ${HEALTH_PORT}`);
});
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("Shutting down mock servers...");
http2Server.close(() => {
httpServer.close(() => {
console.log("Mock servers stopped");
process.exit(0);
});
});
});
@@ -0,0 +1,43 @@
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 1s
send_batch_size: 1024
exporters:
debug:
verbosity: detailed
file/traces:
path: /tmp/otel/traces.json
flush_interval: 1s
file/metrics:
path: /tmp/otel/metrics.json
flush_interval: 1s
file/logs:
path: /tmp/otel/logs.json
flush_interval: 1s
extensions:
health_check:
endpoint: 0.0.0.0:13133
service:
extensions: [health_check]
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug, file/traces]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [debug, file/metrics]
logs:
receivers: [otlp]
processors: [batch]
exporters: [debug, file/logs]
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
const OUTPUT_DIR = path.join(TEST_DIR, "output");
const CERTS_DIR = path.join(OUTPUT_DIR, "certs");
const LOGIN_APP_DIR = path.join(TEST_DIR, "../..");
const LOGIN_IMAGE_TAG = `zitadel-login-test:${process.env.GITHUB_SHA || "local"}`;
/**
* Integration tests for TLS support in the login application.
@@ -44,7 +45,8 @@ describe("Login Container TLS Support", () => {
fs.writeFileSync(path.join(CERTS_DIR, "server.crt"), certs.server.cert);
fs.writeFileSync(path.join(CERTS_DIR, "server.key"), certs.server.key);
loginImage = await GenericContainer.fromDockerfile(LOGIN_APP_DIR).build();
await GenericContainer.fromDockerfile(LOGIN_APP_DIR).build(LOGIN_IMAGE_TAG);
loginImage = new GenericContainer(LOGIN_IMAGE_TAG);
});
describe("when TLS is not enabled", () => {
+21 -1
View File
@@ -42,7 +42,27 @@ const nextConfig = {
: {}),
},
},
// Packages that must not be bundled by webpack and should remain as external
// requires at runtime. These packages use native modules or have bundling
// incompatibilities. Keep this list in sync with package.json dependencies
// when adding new OpenTelemetry or logging packages.
serverExternalPackages: [
'winston',
'@opentelemetry/api',
'@opentelemetry/api-logs',
'@opentelemetry/sdk-node',
'@opentelemetry/sdk-metrics',
'@opentelemetry/sdk-logs',
'@opentelemetry/exporter-metrics-otlp-http',
'@opentelemetry/exporter-logs-otlp-http',
'@opentelemetry/exporter-prometheus',
'@opentelemetry/resources',
'@opentelemetry/semantic-conventions',
'@opentelemetry/auto-instrumentations-node',
'@opentelemetry/winston-transport',
'@opentelemetry/resource-detector-container',
'@opentelemetry/resource-detector-gcp',
],
// Improve SSR stability - not actually needed for React 19 SSR issues
// onDemandEntries: {
// maxInactiveAge: 25 * 1000,
+16 -2
View File
@@ -12,6 +12,7 @@
"lint-check-prettier": "prettier --check .",
"lint-fix": "prettier --write .",
"test-unit": "vitest --run",
"test-dockerized": "vitest --run --config vitest.config.dockerized.mts",
"lint-staged": "lint-staged",
"clean": "rm -rf node_modules .next cypress"
},
@@ -24,6 +25,14 @@
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.211.0",
"@opentelemetry/auto-instrumentations-node": "^0.69.0",
"@opentelemetry/resource-detector-container": "^0.8.2",
"@opentelemetry/resource-detector-gcp": "^0.46.0",
"@opentelemetry/resources": "^2.5.0",
"@opentelemetry/sdk-node": "^0.211.0",
"@opentelemetry/winston-transport": "^0.21.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/forms": "^0.5.11",
"clsx": "^2.1.1",
@@ -40,21 +49,25 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.71.1",
"server-only": "^0.0.1",
"tinycolor2": "^1.6.0",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"winston": "^3.17.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.28.6",
"@faker-js/faker": "^9.9.0",
"@opentelemetry/sdk-trace-base": "^2.5.0",
"@opentelemetry/sdk-trace-node": "^2.5.0",
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1",
"@playwright/test": "^1.58.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/escape-html": "^1.0.4",
"@types/node": "^22.19.7",
"@types/node-forge": "^1.3.14",
"@types/escape-html": "^1.0.4",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/tinycolor2": "^1.4.6",
@@ -74,6 +87,7 @@
"eslint": "^8.57.1",
"eslint-config-next": "15.5.9",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-import": "^2.32.0",
"gaxios": "^7.1.3",
"grpc-tools": "^1.13.1",
"jsdom": "^26.1.0",
+8 -6
View File
@@ -99,10 +99,11 @@
]
},
"test": {
"description": "Runs all tests (unit and integration)",
"description": "Runs all tests (unit, integration, and dockerized)",
"dependsOn": [
"test-unit",
"test-integration"
"test-integration",
"test-dockerized"
]
},
"test-unit": {
@@ -111,10 +112,11 @@
"^build"
]
},
"test-ca": {
"description": "Runs custom CA certificate integration tests using testcontainers",
"test-dockerized": {
"description": "Runs dockerized integration tests (OpenTelemetry and custom CA certificate tests)",
"dependsOn": [
"build"
"build",
"test-integration"
],
"executor": "nx:run-commands",
"cache": true,
@@ -127,7 +129,7 @@
"options": {
"cwd": "{projectRoot}",
"commands": [
"pnpm vitest --run --config vitest.config.ca.mts"
"pnpm vitest --run --config vitest.config.dockerized.mts"
]
}
},
+5
View File
@@ -15,4 +15,9 @@ if [ -n "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ]; then
export ZITADEL_SERVICE_USER_TOKEN=$(cat "${ZITADEL_SERVICE_USER_TOKEN_FILE}")
fi
# Log OTEL configuration on startup (if enabled)
if [ -n "${OTEL_EXPORTER_OTLP_ENDPOINT}" ]; then
echo "OTEL enabled: service=${OTEL_SERVICE_NAME:-zitadel-login}, endpoint=${OTEL_EXPORTER_OTLP_ENDPOINT}"
fi
exec $@
+2 -1
View File
@@ -154,7 +154,8 @@ if (process.env.ZITADEL_TLS_ENABLED !== "true") {
}
httpsServer.listen(currentPort, hostname, () => {
const readyUrl = new URL(`https://${hostname}:${currentPort}`);
const formattedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
const readyUrl = new URL(`https://${formattedHost}:${currentPort}`);
console.log(`> Ready on ${readyUrl}`);
});
}
+85
View File
@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { trace, metrics, SpanStatusCode } from "@opentelemetry/api";
import { createLogger } from "@/lib/logger";
import {
recordAuthAttempt,
recordAuthSuccess,
recordAuthFailure,
recordRequestStart,
recordRequestEnd,
recordSessionCreationDuration,
} from "@/lib/metrics";
export const runtime = "nodejs";
const logger = createLogger("otel-test");
export async function GET() {
const startTime = Date.now();
recordRequestStart();
recordAuthAttempt("test", "test-org");
const tracer = trace.getTracer("otel-test");
const span = tracer.startSpan("test-span");
try {
logger.info("OTEL test endpoint called", { test: true });
const meter = metrics.getMeter("otel-test");
const counter = meter.createCounter("otel_test_requests_total", {
description: "Total number of OTEL test requests",
});
counter.add(1, { test: "true" });
const histogram = meter.createHistogram("otel_test_duration_seconds", {
description: "Duration of OTEL test requests",
unit: "s",
});
span.setAttribute("test.attribute", "test-value");
span.setAttribute("test.timestamp", new Date().toISOString());
await new Promise((resolve) => setTimeout(resolve, 10));
const duration = (Date.now() - startTime) / 1000;
histogram.record(duration, { test: "true" });
recordAuthSuccess("test", "test-org");
recordRequestEnd(startTime, 200, "/otel-test");
recordSessionCreationDuration(startTime, true);
span.setStatus({ code: SpanStatusCode.OK });
span.end();
logger.info("OTEL test completed successfully", {
duration,
traceId: span.spanContext().traceId,
});
return NextResponse.json({
status: "ok",
message: "OTEL test completed",
traceId: span.spanContext().traceId,
spanId: span.spanContext().spanId,
duration: `${duration}s`,
timestamp: new Date().toISOString(),
});
} catch (error) {
recordAuthFailure("test", "error", "test-org");
recordRequestEnd(startTime, 500, "/otel-test");
recordSessionCreationDuration(startTime, false);
logger.error("OTEL test failed", { error });
span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
span.end();
return NextResponse.json(
{
status: "error",
message: String(error),
traceId: span.spanContext().traceId,
},
{ status: 500 },
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import type { LoggerProvider } from "@opentelemetry/api-logs";
/**
* @module instrumentation
*
* OpenTelemetry instrumentation for Next.js applications using NodeSDK.
* Set OTEL_SDK_DISABLED=true to disable instrumentation entirely.
*
* Configuration via standard OTEL environment variables:
*
* ### Exporters (all default to "otlp")
* - OTEL_TRACES_EXPORTER: trace exporter (otlp, console, none)
* - OTEL_METRICS_EXPORTER: metric exporter (otlp, prometheus, console, none)
* - OTEL_LOGS_EXPORTER: log exporter (otlp, console, none)
* - OTEL_EXPORTER_OTLP_ENDPOINT: base endpoint for OTLP signals
* - OTEL_EXPORTER_PROMETHEUS_PORT: Prometheus scrape port (default 9464)
*
* ### Batch Processors
* - OTEL_BSP_SCHEDULE_DELAY: span export delay (default 5000ms)
* - OTEL_METRIC_EXPORT_INTERVAL: metric push interval (default 60000ms)
*
* ### Resource
* - OTEL_SERVICE_NAME: service.name attribute
* - OTEL_RESOURCE_ATTRIBUTES: additional attributes (key=value,key=value)
*
* @see https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/
*/
let _loggerProvider: LoggerProvider | null = null;
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
const [
{ NodeSDK },
{ envDetector, processDetector, hostDetector, resourceFromAttributes },
{ containerDetector },
{ gcpDetector },
{ logs },
{ getNodeAutoInstrumentations },
] = await Promise.all([
import("@opentelemetry/sdk-node"),
import("@opentelemetry/resources"),
import("@opentelemetry/resource-detector-container"),
import("@opentelemetry/resource-detector-gcp"),
import("@opentelemetry/api-logs"),
import("@opentelemetry/auto-instrumentations-node"),
]);
const sdk = new NodeSDK({
resource: resourceFromAttributes({
"deployment.environment.name": process.env.NODE_ENV || "development",
}),
resourceDetectors: [
envDetector,
processDetector,
hostDetector,
containerDetector,
gcpDetector,
],
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-http": {
ignoreIncomingRequestHook: (req) =>
["/healthy", "/_next/", "/favicon.ico", "/__nextjs", "/metrics"].some((p) =>
req.url?.includes(p),
),
},
"@opentelemetry/instrumentation-fs": { enabled: false },
"@opentelemetry/instrumentation-winston": { disableLogSending: false },
}),
],
});
sdk.start();
_loggerProvider = logs.getLoggerProvider();
// Use console during shutdown as the logger may be unavailable
const shutdown = () => sdk.shutdown().catch(console.error);
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
}
export function getLoggerProvider(): LoggerProvider | null {
return _loggerProvider;
}
+17 -12
View File
@@ -13,19 +13,31 @@ import {
setLanguageCookie,
} from "./cookies";
import { cookies } from "next/headers";
import { timestampDate, timestampFromMs } from "@zitadel/client";
// Mock logger - use vi.hoisted to ensure it's defined before vi.mock runs
const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}));
// Mock dependencies
vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
vi.mock("./logger", () => ({
createLogger: () => mockLogger,
}));
vi.mock("@zitadel/client", () => ({
timestampDate: vi.fn((ts: any) => new Date(Number(ts.seconds) * 1000)),
timestampFromMs: vi.fn((ms: number) => ({ seconds: BigInt(Math.floor(ms / 1000)) }) as any),
}));
import { cookies } from "next/headers";
import { timestampDate, timestampFromMs } from "@zitadel/client";
describe("cookies", () => {
let mockCookies: any;
@@ -148,11 +160,9 @@ describe("cookies", () => {
value: JSON.stringify(manySessions),
});
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await addSessionToCookie({ session: mockSession });
expect(consoleSpy).toHaveBeenCalledWith("WARNING COOKIE OVERFLOW");
expect(mockLogger.warn).toHaveBeenCalledWith("WARNING COOKIE OVERFLOW");
const setCall = mockCookies.set.mock.calls[0][0];
const sessions = JSON.parse(setCall.value);
@@ -160,8 +170,6 @@ describe("cookies", () => {
// Should have new session and all but the first old session
expect(sessions[0]).toEqual(mockSession);
expect(sessions).not.toContain(manySessions[0]);
consoleSpy.mockRestore();
});
it("should cleanup expired sessions when cleanup is true", async () => {
@@ -763,14 +771,11 @@ describe("cookies", () => {
it("should return empty array when no sessions exist", async () => {
mockCookies.get.mockReturnValue(undefined);
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const result = await getAllSessions();
expect(result).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith("getAllSessions: No session cookie found, returning empty array");
consoleSpy.mockRestore();
expect(mockLogger.info).toHaveBeenCalledWith("getAllSessions: No session cookie found, returning empty array");
});
});
+5 -2
View File
@@ -3,6 +3,9 @@
import { timestampDate, timestampFromMs } from "@zitadel/client";
import { cookies } from "next/headers";
import { LANGUAGE_COOKIE_NAME } from "./i18n";
import { createLogger } from "./logger";
const logger = createLogger("cookies");
// TODO: improve this to handle overflow
const MAX_COOKIE_SIZE = 2048;
@@ -77,7 +80,7 @@ export async function addSessionToCookie<T>({
const temp = [...currentSessions, session];
if (JSON.stringify(temp).length >= MAX_COOKIE_SIZE) {
console.log("WARNING COOKIE OVERFLOW");
logger.warn("WARNING COOKIE OVERFLOW");
// TODO: improve cookie handling
// this replaces the first session (oldest) with the new one
currentSessions = [session].concat(currentSessions.slice(1));
@@ -257,7 +260,7 @@ export async function getAllSessions<T>(cleanup: boolean = false): Promise<Sessi
const stringifiedCookie = cookiesList.get("sessions");
if (!stringifiedCookie?.value) {
console.log("getAllSessions: No session cookie found, returning empty array");
logger.info("getAllSessions: No session cookie found, returning empty array");
return [];
}
+4 -4
View File
@@ -8,6 +8,10 @@ import {
getUserAgent,
} from "./fingerprint";
import { cookies, headers } from "next/headers";
import { userAgent } from "next/server";
import { create } from "@zitadel/client";
// Mock dependencies
vi.mock("uuid");
vi.mock("next/headers", () => ({
@@ -21,10 +25,6 @@ vi.mock("@zitadel/client", () => ({
create: vi.fn(),
}));
import { cookies, headers } from "next/headers";
import { userAgent } from "next/server";
import { create } from "@zitadel/client";
describe("fingerprint", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -0,0 +1,209 @@
import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
import { resourceFromAttributes } from "@opentelemetry/resources";
import {
InMemorySpanExporter,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { otelGrpcInterceptor } from "./otel";
const exporter = new InMemorySpanExporter();
const provider = new NodeTracerProvider({
resource: resourceFromAttributes({ "service.name": "test-zitadel-client" }),
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
provider.register();
afterAll(() => provider.shutdown());
describe("otelGrpcInterceptor", () => {
beforeEach(() => exporter.reset());
it("injects traceparent header into gRPC requests", async () => {
const capturedHeaders: Record<string, string> = {};
await otelGrpcInterceptor(async () => ({ success: true }))({
service: { typeName: "zitadel.session.v2.SessionService" },
method: { name: "CreateSession" },
url: "https://api.zitadel.example.com/zitadel.session.v2/CreateSession",
header: {
set: (key: string, value: string) => {
capturedHeaders[key.toLowerCase()] = value;
},
},
});
expect(capturedHeaders["traceparent"]).toMatch(
/^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/,
);
});
it("creates client spans with RPC attributes", async () => {
await otelGrpcInterceptor(async () => ({ user: { id: "123" } }))({
service: { typeName: "zitadel.user.v2.UserService" },
method: { name: "GetUserByID" },
url: "https://api.zitadel.example.com/zitadel.user.v2/GetUserByID",
header: { set: () => {} },
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0]).toMatchObject({
name: "zitadel.user.v2.UserService/GetUserByID",
kind: SpanKind.CLIENT,
attributes: {
"rpc.system": "grpc",
"rpc.service": "zitadel.user.v2.UserService",
"rpc.method": "GetUserByID",
"server.address": "api.zitadel.example.com",
},
});
});
it("records error spans with gRPC status codes", async () => {
const error = Object.assign(new Error("Session not found"), { code: 5 });
await expect(
otelGrpcInterceptor(async () => {
throw error;
})({
service: { typeName: "zitadel.session.v2.SessionService" },
method: { name: "DeleteSession" },
url: "https://api.zitadel.example.com",
header: { set: () => {} },
}),
).rejects.toThrow("Session not found");
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0]).toMatchObject({
name: "zitadel.session.v2.SessionService/DeleteSession",
status: { code: SpanStatusCode.ERROR, message: "Session not found" },
attributes: { "rpc.grpc.status_code": 5 },
});
expect(spans[0].events).toHaveLength(1);
expect(spans[0].events[0].name).toBe("exception");
});
it("handles missing service/method gracefully", async () => {
await otelGrpcInterceptor(async () => ({ ok: true }))({
url: "https://api.zitadel.example.com",
header: { set: () => {} },
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0]).toMatchObject({
name: "unknown/unknown",
attributes: { "rpc.service": "unknown", "rpc.method": "unknown" },
});
});
it("returns the response from the next handler", async () => {
const expectedResponse = { data: "test-data", items: [1, 2, 3] };
const result = await otelGrpcInterceptor(async () => expectedResponse)({
service: { typeName: "TestService" },
method: { name: "TestMethod" },
url: "https://test.example.com",
header: { set: () => {} },
});
expect(result).toEqual(expectedResponse);
});
it("records error spans without gRPC status code", async () => {
const error = new Error("Network failure");
await expect(
otelGrpcInterceptor(async () => {
throw error;
})({
service: { typeName: "zitadel.user.v2.UserService" },
method: { name: "ListUsers" },
url: "https://api.zitadel.example.com",
header: { set: () => {} },
}),
).rejects.toThrow("Network failure");
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0]).toMatchObject({
name: "zitadel.user.v2.UserService/ListUsers",
status: { code: SpanStatusCode.ERROR, message: "Network failure" },
});
expect(spans[0].attributes).not.toHaveProperty("rpc.grpc.status_code");
});
it("handles malformed URL gracefully", async () => {
await otelGrpcInterceptor(async () => ({ ok: true }))({
service: { typeName: "TestService" },
method: { name: "TestMethod" },
url: "not-a-valid-url",
header: { set: () => {} },
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].attributes["server.address"]).toBe("unknown");
});
it("handles missing URL gracefully", async () => {
await otelGrpcInterceptor(async () => ({ ok: true }))({
service: { typeName: "TestService" },
method: { name: "TestMethod" },
header: { set: () => {} },
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].attributes["server.address"]).toBe("unknown");
});
it("handles empty string URL gracefully", async () => {
await otelGrpcInterceptor(async () => ({ ok: true }))({
service: { typeName: "TestService" },
method: { name: "TestMethod" },
url: "",
header: { set: () => {} },
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].attributes["server.address"]).toBe("unknown");
});
it("uses empty strings as-is for service/method names", async () => {
await otelGrpcInterceptor(async () => ({ ok: true }))({
service: { typeName: "" },
method: { name: "" },
url: "https://api.example.com",
header: { set: () => {} },
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0]).toMatchObject({
name: "/",
attributes: { "rpc.service": "", "rpc.method": "" },
});
});
it("handles non-Error exceptions", async () => {
await expect(
otelGrpcInterceptor(async () => {
throw "string error";
})({
service: { typeName: "TestService" },
method: { name: "TestMethod" },
url: "https://api.example.com",
header: { set: () => {} },
}),
).rejects.toBe("string error");
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].status.code).toBe(SpanStatusCode.ERROR);
});
});
@@ -0,0 +1,98 @@
/**
* OpenTelemetry gRPC/Connect Interceptor
*
* Provides distributed tracing for Connect/gRPC calls. Creates client spans
* with RPC semantic conventions, propagates W3C trace context headers
* (traceparent/tracestate) and baggage, and records errors with gRPC status
* codes.
*
* @see https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/
*
* @example
* ```typescript
* import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel";
*
* const transport = createTransport({
* baseUrl: "https://api.example.com",
* interceptors: [otelGrpcInterceptor],
* });
* ```
*/
import {
context,
propagation,
SpanKind,
SpanStatusCode,
trace,
} from "@opentelemetry/api";
import { GrpcError, Interceptor } from "./types";
const TRACER_NAME = "zitadel-login-grpc" as const;
/**
* Extracts the hostname from a URL string. Returns "unknown" if the URL is
* undefined or cannot be parsed.
*/
function parseHostname(url: string | undefined): string {
if (!url) {
return "unknown";
}
try {
return new URL(url).hostname;
} catch {
return "unknown";
}
}
/**
* OpenTelemetry interceptor for Connect/gRPC calls. Wraps each RPC call in a
* client span, injects trace context headers for distributed tracing, and
* records any errors that occur during the call.
*/
export function otelGrpcInterceptor(next: Parameters<Interceptor>[0]): ReturnType<Interceptor> {
return async function tracedCall(req) {
const serviceName: string = req.service?.typeName ?? "unknown";
const methodName: string = req.method?.name ?? "unknown";
return trace.getTracer(TRACER_NAME).startActiveSpan(
`${serviceName}/${methodName}`,
{
kind: SpanKind.CLIENT,
attributes: {
"rpc.system": "grpc",
"rpc.service": serviceName,
"rpc.method": methodName,
"server.address": parseHostname(req.url),
},
},
async (span) => {
const ctx = trace.setSpan(context.active(), span);
propagation.inject(ctx, req.header, {
set: (carrier, key, value) => carrier.set(key, value),
});
try {
const response = await next(req);
span.setStatus({ code: SpanStatusCode.OK });
return response;
} catch (err: unknown) {
const exception =
err instanceof Error ? err : new Error(String(err));
span.recordException(exception);
const grpcError = err as GrpcError;
if (grpcError.code !== undefined) {
span.setAttribute("rpc.grpc.status_code", grpcError.code);
}
span.setStatus({
code: SpanStatusCode.ERROR,
message: exception.message,
});
throw err;
} finally {
span.end();
}
},
);
};
}
@@ -0,0 +1,18 @@
export interface GrpcRequestHeader {
readonly set: (key: string, value: string) => void;
}
export interface GrpcRequest {
readonly service?: { readonly typeName: string };
readonly method?: { readonly name: string };
readonly url?: string;
readonly header: GrpcRequestHeader;
}
export interface GrpcError extends Error {
readonly code?: number;
}
export type Interceptor<T = unknown> = (
next: (req: GrpcRequest) => Promise<T>,
) => (req: GrpcRequest) => Promise<T>;
+71
View File
@@ -0,0 +1,71 @@
import "server-only";
import winston from "winston";
const isProduction = process.env.NODE_ENV === "production";
/**
* Winston logger configuration for the login application.
*
* OpenTelemetry integration:
* - @opentelemetry/instrumentation-winston automatically injects trace context
* (trace_id, span_id, trace_flags) into every log entry
* - @opentelemetry/winston-transport automatically sends logs to OTEL LoggerProvider
* when the SDK is initialized with a LoggerProvider
*
* The trace context injection and OTEL log sending are handled by the instrumentation
* registered in instrumentation.ts - no manual setup required here.
*/
// Custom format for development - colorized and readable
const devFormat = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.printf(({ level, message, timestamp, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : "";
return `${timestamp} ${level}: ${message}${metaStr}`;
}),
);
// Production format - JSON for structured logging
const prodFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
);
/**
* The main logger instance.
* In production, outputs JSON for structured logging.
* In development, outputs colorized human-readable format.
*/
export const logger = winston.createLogger({
level: process.env.OTEL_LOG_LEVEL || process.env.LOG_LEVEL || "info",
format: isProduction ? prodFormat : devFormat,
defaultMeta: {
service: process.env.OTEL_SERVICE_NAME || "zitadel-login",
},
transports: [
new winston.transports.Console({
// In production, use stderr for error level, stdout for others
stderrLevels: isProduction ? ["error"] : [],
}),
],
});
/**
* Create a child logger with a specific context.
* The context is added as metadata to all log entries from this logger.
*
* @param context - The context name for this logger (e.g., 'auth-flow', 'session')
* @param metadata - Optional additional metadata to include in all log messages
* @returns A child logger instance
*/
export function createLogger(
context: string,
metadata?: Record<string, unknown>,
): winston.Logger {
return logger.child({ context, ...metadata });
}
// Re-export winston types for convenience
export type { Logger } from "winston";
+146
View File
@@ -0,0 +1,146 @@
import "server-only";
import { metrics } from "@opentelemetry/api";
const serviceName = process.env.OTEL_SERVICE_NAME || "zitadel-login";
const meter = metrics.getMeter(serviceName);
const requestCounter = meter.createCounter("http_server_requests_total", {
description: "Total number of HTTP requests",
});
const requestDuration = meter.createHistogram(
"http_server_request_duration_seconds",
{
description: "HTTP request duration in seconds",
unit: "s",
},
);
const requestSize = meter.createHistogram("http_server_request_size_bytes", {
description: "Size of HTTP request bodies in bytes",
unit: "By",
});
const responseSize = meter.createHistogram("http_server_response_size_bytes", {
description: "Size of HTTP response bodies in bytes",
unit: "By",
});
const responseStatusCounter = meter.createCounter(
"http_server_response_status_total",
{
description: "Count of HTTP responses per status code",
},
);
const activeRequests = meter.createUpDownCounter("http_server_active_requests", {
description: "Number of active HTTP requests",
});
const authAttempts = meter.createCounter("login_auth_attempts_total", {
description: "Total authentication attempts",
});
const authSuccesses = meter.createCounter("login_auth_successes_total", {
description: "Total successful authentications",
});
const authFailures = meter.createCounter("login_auth_failures_total", {
description: "Total failed authentications",
});
const sessionCreationDuration = meter.createHistogram(
"login_session_creation_duration_seconds",
{
description: "Session creation duration in seconds",
unit: "s",
},
);
/**
* Record an authentication attempt.
*/
export function recordAuthAttempt(method: string, organization?: string): void {
authAttempts.add(1, {
method,
organization: organization || "unknown",
});
}
/**
* Record a successful authentication.
*/
export function recordAuthSuccess(method: string, organization?: string): void {
authSuccesses.add(1, {
method,
organization: organization || "unknown",
});
}
/**
* Record a failed authentication.
*/
export function recordAuthFailure(
method: string,
reason: string,
organization?: string,
): void {
authFailures.add(1, {
method,
reason,
organization: organization || "unknown",
});
}
/**
* Record the start of an HTTP request.
*/
export function recordRequestStart(): void {
activeRequests.add(1);
requestCounter.add(1);
}
/**
* Record the end of an HTTP request.
*/
export function recordRequestEnd(
startTime: number,
statusCode: number,
route: string,
requestBytes?: number,
responseBytes?: number,
): void {
activeRequests.add(-1);
const labels = {
status_code: statusCode.toString(),
route,
};
const duration = (Date.now() - startTime) / 1000;
requestDuration.record(duration, labels);
responseStatusCounter.add(1, {
status_code: statusCode.toString(),
});
if (requestBytes !== undefined) {
requestSize.record(requestBytes, labels);
}
if (responseBytes !== undefined) {
responseSize.record(responseBytes, labels);
}
}
/**
* Record session creation duration.
*/
export function recordSessionCreationDuration(
startTime: number,
success: boolean,
): void {
const duration = (Date.now() - startTime) / 1000;
sessionCreationDuration.record(duration, {
success: success.toString(),
});
}
+5 -2
View File
@@ -1,6 +1,7 @@
"use server";
import { getAllSessions } from "@/lib/cookies";
import { createLogger } from "@/lib/logger";
import { loginWithOIDCAndSession } from "@/lib/oidc";
import { loginWithSAMLAndSession } from "@/lib/saml";
import { getServiceConfig } from "@/lib/service-url";
@@ -8,6 +9,8 @@ import { listSessions, ServiceConfig } from "@/lib/zitadel";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { headers } from "next/headers";
const logger = createLogger("auth-flow");
export interface AuthFlowParams {
sessionId: string;
requestId: string;
@@ -54,7 +57,7 @@ export async function completeAuthFlow(
// Safety net - ensure we always return a valid object
if (!result || typeof result !== "object" || (!("redirect" in result) && !("error" in result))) {
console.error("Auth flow: Invalid result from loginWithOIDCAndSession:", result);
logger.error("Auth flow: Invalid result from loginWithOIDCAndSession:", { result });
return { error: "Authentication completed but navigation failed" };
}
@@ -75,7 +78,7 @@ export async function completeAuthFlow(
typeof result !== "object" ||
(!("redirect" in result) && !("error" in result) && !("samlData" in result))
) {
console.error("Auth flow: Invalid result from loginWithSAMLAndSession:", result);
logger.error("Auth flow: Invalid result from loginWithSAMLAndSession:", { result });
return { error: "Authentication completed but navigation failed" };
}
+6 -3
View File
@@ -1,6 +1,7 @@
"use server";
import { addSessionToCookie, updateSessionCookie } from "@/lib/cookies";
import { createLogger } from "@/lib/logger";
import {
createSessionForUserIdAndIdpIntent,
createSessionFromChecksAndChallenges,
@@ -16,6 +17,8 @@ import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { headers } from "next/headers";
import { getServiceConfig } from "../service-url";
const logger = createLogger("cookie");
type CustomCookieData = {
id: string;
token: string;
@@ -52,7 +55,7 @@ export async function createSessionAndUpdateCookie(command: {
let sessionLifetime = command.lifetime;
if (!sessionLifetime || !sessionLifetime.seconds) {
console.warn("No session lifetime provided, using default of 24 hours.");
logger.warn("No session lifetime provided, using default of 24 hours");
sessionLifetime = {
seconds: BigInt(24 * 60 * 60), // 24 hours
@@ -126,7 +129,7 @@ export async function createSessionForIdpAndUpdateCookie({
let sessionLifetime = lifetime;
if (!sessionLifetime || !sessionLifetime.seconds) {
console.warn("No IDP session lifetime provided, using default of 24 hours.");
logger.warn("No IDP session lifetime provided, using default of 24 hours");
sessionLifetime = {
seconds: BigInt(24 * 60 * 60), // 24 hours
@@ -140,7 +143,7 @@ export async function createSessionForIdpAndUpdateCookie({
idpIntent,
lifetime: sessionLifetime,
}).catch((error: ErrorDetail | CredentialsCheckError) => {
console.error("Could not set session", error);
logger.error("Could not set session", { error });
if ("failedAttempts" in error && error.failedAttempts) {
throw {
error: `Failed to authenticate: You had ${error.failedAttempts} password attempts.`,
+8 -5
View File
@@ -1,4 +1,5 @@
import { idpTypeToSlug } from "@/lib/idp";
import { createLogger } from "@/lib/logger";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import { constructUrl } from "@/lib/service-url";
import { findValidSession } from "@/lib/session";
@@ -24,6 +25,8 @@ import { NextRequest, NextResponse } from "next/server";
import { buildCSP } from "../csp";
import escapeHtml from "escape-html";
const logger = createLogger("flow-initiation");
const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/;
const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/;
const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/;
@@ -103,7 +106,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
const matched = ORG_DOMAIN_SCOPE_REGEX.exec(orgDomainScope);
const orgDomain = matched?.[1] ?? "";
console.log("Extracted Organization Domain:", orgDomain);
logger.info("Extracted org domain:", { orgDomain });
if (orgDomain) {
const orgs = await getOrgsByDomain({ serviceConfig, domain: orgDomain });
@@ -238,7 +241,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
return NextResponse.redirect(absoluteUrl.toString());
}
} catch (error) {
console.error("Failed to execute sendLoginname:", error);
logger.error("Failed to execute sendLoginname:", { error });
}
}
@@ -333,7 +336,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
if (callbackUrl) {
return NextResponse.redirect(callbackUrl);
} else {
console.log("could not create callback, redirect user to choose other account");
logger.info("could not create callback, redirect user to choose other account");
return gotoAccounts({
request,
organization,
@@ -341,7 +344,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
});
}
} catch (error) {
console.error(error);
logger.error("Error creating callback:", { error });
return gotoAccounts({
request,
requestId,
@@ -451,7 +454,7 @@ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Pr
});
}
} catch (error) {
console.error("SAML createResponse failed:", error);
logger.error("SAML createResponse failed:", { error });
}
// Final fallback: SAML response creation failed - show account selection
+42 -39
View File
@@ -1,6 +1,7 @@
"use server";
import { getSessionCookieById } from "@/lib/cookies";
import { createLogger } from "@/lib/logger";
import { getServiceConfig } from "@/lib/service-url";
import {
addHuman,
@@ -31,6 +32,8 @@ import { headers } from "next/headers";
import { getFingerprintIdCookie } from "../fingerprint";
import { createNewSessionFromIdpIntent } from "./idp";
const logger = createLogger("idp-intent");
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
async function resolveOrganizationForUser({
@@ -139,7 +142,7 @@ async function resolveUserIdFromSession({
try {
const sessionCookie = await getSessionCookieById({ sessionId });
if (!sessionCookie) {
console.warn("[IDP Process] Session for linking not found or invalid");
logger.warn("Session for linking not found or invalid");
return { redirect: `/idp/${provider}/linking-failed?error=session_invalid` };
}
@@ -151,13 +154,13 @@ async function resolveUserIdFromSession({
const session = sessionResp.session;
if (!session?.factors?.user?.id) {
console.warn("[IDP Process] Session found but no userId associated for linking.");
logger.warn("Session found but no userId associated for linking");
return { redirect: `/idp/${provider}/linking-failed?error=session_invalid` };
}
return { userId: session.factors.user.id };
} catch (error) {
console.warn("[IDP Process] Error retrieving session for linking:", error);
logger.warn("Error retrieving session for linking", { error });
return { redirect: `/idp/${provider}/linking-failed?error=session_invalid` };
}
}
@@ -173,7 +176,7 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
const fingerprintCookie = await getFingerprintIdCookie();
if (!linkFingerprint || !fingerprintCookie?.value) {
console.warn("[IDP Process] Missing fingerprint information for linking verification");
logger.warn("Missing fingerprint information for linking verification");
return { redirect: `/idp/${provider}/linking-failed?error=session_mismatch` };
}
@@ -183,7 +186,7 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
.digest("hex");
if (linkFingerprint !== expectedHash) {
console.warn("[IDP Process] Session linking fingerprint mismatch");
logger.warn("Session linking fingerprint mismatch");
return { redirect: `/idp/${provider}/linking-failed?error=session_mismatch` };
}
@@ -198,11 +201,11 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
return { redirect: sessionRedirect || `/idp/${provider}/linking-failed?error=session_invalid` };
}
console.log("[IDP Process] Resolved userId from session link:", resolvedUserId);
logger.debug("Resolved userId from session link", { userId: resolvedUserId });
// 3. Perform Linking Logic
if (!options?.isLinkingAllowed) {
console.error("[IDP Process] Linking not allowed by IDP configuration");
logger.error("Linking not allowed by IDP configuration");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=linking_not_allowed` };
}
@@ -211,7 +214,7 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
const targetUser = await getUserByID({ serviceConfig, userId: resolvedUserId });
if (!targetUser || !targetUser.details?.resourceOwner) {
console.error("[IDP Process] User not found or missing organization");
logger.error("User not found or missing organization");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=user_not_found` };
}
@@ -223,7 +226,7 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
});
if (!isAllowed) {
console.error("[IDP Process] IDP linking validation failed");
logger.error("IDP linking validation failed");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=validation_failed` };
}
@@ -237,7 +240,7 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
},
userId: resolvedUserId,
});
console.log("[IDP Process] IDP linked successfully, creating session");
logger.info("IDP linked successfully, creating session");
const sessionResult = await createNewSessionFromIdpIntent({
userId: resolvedUserId,
@@ -250,23 +253,23 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
logger.error("Error creating session", { error: sessionResult.error });
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
logger.debug("Session created, redirecting", { redirect: sessionResult.redirect });
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
console.log("[IDP Process] Session created, returning samlData");
logger.info("Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
console.error("[IDP Process] Error linking IDP:", error);
logger.error("Error linking IDP", { error });
const errorMessage = error instanceof Error ? error.message : t("errors.unknownError");
let params = buildRedirectParams({ error: errorMessage });
if (error instanceof ConnectError && error.code === Code.AlreadyExists) {
@@ -291,7 +294,7 @@ async function handleUserExists(ctx: IDPHandlerContext): Promise<IDPHandlerResul
// Auto-update user if enabled
if (options?.isAutoUpdate && updateHumanUser) {
try {
console.log("[IDP Process] Auto-updating user profile");
logger.debug("Auto-updating user profile");
await updateHuman({
serviceConfig,
request: create(UpdateHumanUserRequestSchema, {
@@ -302,13 +305,13 @@ async function handleUserExists(ctx: IDPHandlerContext): Promise<IDPHandlerResul
}),
});
} catch (error) {
console.warn("[IDP Process] Failed to auto-update user:", error);
logger.warn("Failed to auto-update user", { error });
// Continue with login even if update fails
}
}
// Create session and handle redirect
console.log("[IDP Process] Creating session for existing user");
logger.debug("Creating session for existing user");
const sessionResult = await createNewSessionFromIdpIntent({
userId,
idpIntent: {
@@ -320,17 +323,17 @@ async function handleUserExists(ctx: IDPHandlerContext): Promise<IDPHandlerResul
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
logger.error("Error creating session", { error: sessionResult.error });
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
logger.debug("Session created, redirecting", { redirect: sessionResult.redirect });
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
console.log("[IDP Process] Session created, returning samlData");
logger.info("Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
@@ -378,7 +381,7 @@ async function handleAutoLinking(ctx: IDPHandlerContext): Promise<IDPHandlerResu
if (foundUser) {
try {
if (!foundUser.details?.resourceOwner) {
console.error("[IDP Process] Found user missing organization information");
logger.error("Found user missing organization information");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=missing_organization` };
}
@@ -391,7 +394,7 @@ async function handleAutoLinking(ctx: IDPHandlerContext): Promise<IDPHandlerResu
});
if (!isAllowed) {
console.error("[IDP Process] Auto-linking validation failed");
logger.error("Auto-linking validation failed");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=validation_failed` };
}
@@ -405,7 +408,7 @@ async function handleAutoLinking(ctx: IDPHandlerContext): Promise<IDPHandlerResu
},
userId: foundUser.userId,
});
console.log("[IDP Process] User auto-linked successfully, creating session");
logger.info("User auto-linked successfully, creating session");
// Create session after auto-linking
const sessionResult = await createNewSessionFromIdpIntent({
@@ -419,23 +422,23 @@ async function handleAutoLinking(ctx: IDPHandlerContext): Promise<IDPHandlerResu
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
logger.error("Error creating session", { error: sessionResult.error });
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
logger.debug("Session created, redirecting", { redirect: sessionResult.redirect });
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
console.log("[IDP Process] Session created, returning samlData");
logger.info("Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
console.error("[IDP Process] Error auto-linking user:", error);
logger.error("Error auto-linking user", { error });
const errorMessage = error instanceof Error ? error.message : t("errors.unknownError");
const params = buildRedirectParams({ error: errorMessage });
return { redirect: `/idp/${provider}/linking-failed?${params}` };
@@ -462,7 +465,7 @@ async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerRes
});
if (!orgToRegisterOn) {
console.error("[IDP Process] Could not determine organization for auto-creation (no default org available)");
logger.error("Could not determine organization for auto-creation (no default org available)");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=no_organization_context` };
}
@@ -478,7 +481,7 @@ async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerRes
try {
const newUser = await addHuman({ serviceConfig, request: addHumanUserWithOrganization });
console.log("[IDP Process] User auto-created successfully, creating session");
logger.info("User auto-created successfully, creating session");
// Create session for newly created user
const sessionResult = await createNewSessionFromIdpIntent({
@@ -492,23 +495,23 @@ async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerRes
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
logger.error("Error creating session", { error: sessionResult.error });
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
logger.debug("Session created, redirecting", { redirect: sessionResult.redirect });
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
console.log("[IDP Process] Session created, returning samlData");
logger.info("Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error: unknown) {
console.error("[IDP Process] Error auto-creating user:", error);
logger.error("Error auto-creating user", { error });
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=user_creation_failed` };
}
@@ -533,7 +536,7 @@ async function handleManualCreation(ctx: IDPHandlerContext): Promise<IDPHandlerR
});
if (!orgToRegisterOn) {
console.error("[IDP Process] Could not determine organization for registration (no default org available)");
logger.error("Could not determine organization for registration (no default org available)");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/registration-failed?${params}` };
}
@@ -565,7 +568,7 @@ async function handleManualCreation(ctx: IDPHandlerContext): Promise<IDPHandlerR
*/
async function handleNoUserFound(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { buildRedirectParams } = ctx;
console.log("[IDP Process] No matching user and creation not allowed");
logger.debug("No matching user and creation not allowed");
const params = buildRedirectParams();
return { redirect: `/idp/${ctx.params.provider}/account-not-found?${params}` };
}
@@ -604,7 +607,7 @@ export async function processIDPCallback({
// Validate required parameters
if (!provider || !id || !token) {
console.error("[IDP Process] Missing required parameters:", { provider, id, hasToken: !!token });
logger.error("Missing required parameters", { provider, id, hasToken: !!token });
const errorParams = new URLSearchParams();
if (requestId) errorParams.set("requestId", requestId);
if (organization) errorParams.set("organization", organization);
@@ -617,13 +620,13 @@ export async function processIDPCallback({
// Consume the single-use token ONCE
const intent = await retrieveIDPIntent({ serviceConfig, id, token });
console.log("[IDP Process] Intent retrieved successfully, processing business logic");
logger.debug("Intent retrieved successfully, processing business logic");
const { idpInformation } = intent;
// Verify we have IDP info early on
if (!idpInformation) {
console.error("[IDP Process] IDP information missing");
logger.error("IDP information missing");
return { redirect: `/idp/${provider}/failure?error=missing_idp_info` };
}
@@ -693,7 +696,7 @@ export async function processIDPCallback({
// Should theoretically be unreachable if handleNoUserFound covers the rest
return { error: t("errors.unknown") };
} catch (error: unknown) {
console.error("[IDP Process] Error processing intent:", error);
logger.error("Error processing intent", { error });
const errorParams = new URLSearchParams();
if (requestId) errorParams.set("requestId", requestId);
+21 -16
View File
@@ -1,5 +1,6 @@
"use server";
import { createLogger } from "@/lib/logger";
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
@@ -25,6 +26,8 @@ import {
import { createSessionAndUpdateCookie } from "./cookie";
import { getPublicHost } from "./host";
const logger = createLogger("loginname");
export type SendLoginnameCommand = {
loginName: string;
requestId?: string;
@@ -60,17 +63,17 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// Safety check: ensure searchResult is defined
if (!searchResult) {
console.error("searchUsers returned undefined or null");
logger.error("searchUsers returned undefined or null");
return { error: t("errors.couldNotSearchUsers") };
}
if ("error" in searchResult && searchResult.error) {
console.log("searchUsers returned error, returning early:", searchResult.error);
logger.debug("searchUsers returned error, returning early", { error: searchResult.error });
return searchResult;
}
if (!("result" in searchResult)) {
console.log("searchUsers has no result field");
logger.debug("searchUsers has no result field");
return { error: t("errors.couldNotSearchUsers") };
}
@@ -80,12 +83,12 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const users = potentialUsers ?? [];
if (users.length === 0) {
console.log("No users found, will proceed with org discovery");
logger.debug("No users found, will proceed with org discovery");
}
const preventUserEnumeration = (organization: string | undefined) => {
if (command.ignoreUnknownUsernames) {
console.log("ignoreUnknownUsernames is true, redirecting to password");
logger.debug("ignoreUnknownUsernames is true, redirecting to password");
const paramsPasswordDefault = new URLSearchParams({
loginName: command.loginName,
});
@@ -229,7 +232,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
};
if (users.length > 1) {
console.log("multiple users found, returning error");
logger.debug("multiple users found, returning error");
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
@@ -331,7 +334,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// always resend invite or setup email if user has no primary auth method set
if (!hasPrimaryMethod) {
console.log("humanUser.email?.isVerified", humanUser?.email?.isVerified);
logger.debug("humanUser.email?.isVerified", {
isVerified: humanUser?.email?.isVerified,
});
const params = new URLSearchParams({
loginName: (session?.factors?.user?.loginName ?? user.preferredLoginName) as string,
send: "true", // set this to true to request a new code immediately
@@ -482,7 +487,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}
}
console.log("user not found (0 potential users), checking registration options");
logger.debug("User not found (0 potential users), checking registration options");
// user not found, perform organization discovery if no org context provided
let discoveredOrganization = command.organization;
@@ -508,33 +513,33 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery });
if (orgLoginSettings?.allowDomainDiscovery) {
console.log("org discovery successful, using org:", orgToCheckForDiscovery);
logger.debug("Org discovery successful", { organization: orgToCheckForDiscovery });
discoveredOrganization = orgToCheckForDiscovery;
// Use the discovered organization's login settings for subsequent checks
effectiveLoginSettings = orgLoginSettings;
} else {
console.log("org does not allow domain discovery");
logger.debug("Org does not allow domain discovery");
}
} else {
console.log("no single org found for discovery");
logger.debug("No single org found for discovery");
}
}
// user not found, check if register is enabled on instance / organization context
if (effectiveLoginSettings?.allowRegister && !effectiveLoginSettings?.allowLocalAuthentication) {
console.log("redirecting to IDP (register allowed, password not allowed)");
logger.debug("redirecting to IDP (register allowed, password not allowed)");
const resp = await redirectUserToIDP(undefined, discoveredOrganization);
if (resp) {
return resp;
}
console.log("IDP redirect failed, returning user not found");
logger.debug("IDP redirect failed, returning user not found");
return preventUserEnumeration(discoveredOrganization);
} else if (effectiveLoginSettings?.allowRegister && effectiveLoginSettings?.allowLocalAuthentication) {
console.log("register and password both allowed");
logger.debug("register and password both allowed");
// do not register user if ignoreUnknownUsernames is set
if (discoveredOrganization && !effectiveLoginSettings?.ignoreUnknownUsernames) {
console.log("redirecting to registration page with org:", discoveredOrganization);
logger.debug("Redirecting to registration page", { organization: discoveredOrganization });
const params = new URLSearchParams({ organization: discoveredOrganization });
if (command.requestId) {
@@ -547,7 +552,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
return { redirect: "/register?" + params };
} else {
console.log("not redirecting to register:", {
logger.debug("Not redirecting to register", {
hasDiscoveredOrg: !!discoveredOrganization,
ignoreUnknownUsernames: effectiveLoginSettings?.ignoreUnknownUsernames,
});
+5 -2
View File
@@ -1,5 +1,6 @@
"use server";
import { createLogger } from "@/lib/logger";
import {
createPasskeyRegistrationLink,
getLoginSettings,
@@ -27,6 +28,8 @@ import { updateOrCreateSession } from "./session";
import { completeFlowOrGetUrl } from "../client";
import { createSessionAndUpdateCookie } from "./cookie";
const logger = createLogger("passkeys");
type VerifyPasskeyCommand = {
passkeyId: string;
passkeyName?: string;
@@ -103,7 +106,7 @@ export async function registerPasskeyLink(
// check if a verification was done earlier
const hasValidUserVerificationCheck = await checkUserVerification(currentUserId);
console.log("hasValidUserVerificationCheck", hasValidUserVerificationCheck);
logger.info("hasValidUserVerificationCheck", { hasValidUserVerificationCheck });
if (!hasValidUserVerificationCheck) {
return { error: "User Verification Check has to be done" };
}
@@ -295,7 +298,7 @@ export async function sendPasskey(command: SendPasskeyCommand) {
try {
userResponse = await getUserByID({ serviceConfig, userId });
} catch (error) {
console.error("Error fetching user by ID:", error);
logger.error("Error fetching user by ID:", { error });
return { error: t("verify.errors.couldNotGetUser") };
}
+29 -5
View File
@@ -1,5 +1,7 @@
"use server";
import { createLogger } from "@/lib/logger";
import { recordAuthAttempt, recordAuthSuccess, recordAuthFailure } from "@/lib/metrics";
import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie";
import {
getLockoutSettings,
@@ -31,6 +33,8 @@ import {
} from "../verify-helper";
import { getPublicHostWithProtocol } from "./host";
const logger = createLogger("password");
type ResetPasswordCommand = {
loginName: string;
organization?: string;
@@ -134,6 +138,8 @@ export async function sendPassword(
const { serviceConfig } = getServiceConfig(_headers);
const t = await getTranslations("password");
recordAuthAttempt("password", command.organization);
let sessionCookie = await getSessionCookieByLoginName({
loginName: command.loginName,
organization: command.organization,
@@ -152,7 +158,7 @@ export async function sendPassword(
let lifetime = loginSettingsByUser.passwordCheckLifetime;
if (!lifetime || !lifetime.seconds) {
console.warn("No password lifetime provided, defaulting to 24 hours");
logger.warn("No password lifetime provided, defaulting to 24 hours");
lifetime = {
seconds: BigInt(60 * 60 * 24), // default to 24 hours
nanos: 0,
@@ -170,7 +176,7 @@ export async function sendPassword(
throw new Error("Could not load login settings");
}
} catch {
console.warn("[Password] Could not update session");
logger.warn("[Password] Could not update session");
// If the session was terminated or any other error occurred during update,
// we fall back to creating a new session.
sessionCookie = undefined;
@@ -189,6 +195,7 @@ export async function sendPassword(
// Force fallback if settings can't be loaded
if (!loginSettingsByContext) {
// this is a fake error message to hide that the user does not even exist
recordAuthFailure("password", "settings_unavailable", command.organization);
return { error: t("errors.couldNotVerifyPassword") };
}
@@ -215,6 +222,7 @@ export async function sendPassword(
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName) {
// emulate user not found to prevent enumeration (use context settings not user settings)
recordAuthFailure("password", "login_name_mismatch", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
@@ -222,6 +230,7 @@ export async function sendPassword(
}
} else if (userLoginSettings?.disableLoginWithEmail) {
if (user.preferredLoginName !== command.loginName && humanUser?.phone?.phone !== command.loginName) {
recordAuthFailure("password", "login_name_mismatch", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
@@ -229,6 +238,7 @@ export async function sendPassword(
}
} else if (userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName && humanUser?.email?.email !== command.loginName) {
recordAuthFailure("password", "login_name_mismatch", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
@@ -251,6 +261,7 @@ export async function sendPassword(
sessionCookie = result.sessionCookie;
} catch (error: any) {
if ("failedAttempts" in error && error.failedAttempts) {
recordAuthFailure("password", "invalid_password", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
@@ -269,6 +280,7 @@ export async function sendPassword(
}),
};
}
recordAuthFailure("password", "session_creation_failed", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
@@ -276,6 +288,7 @@ export async function sendPassword(
}
} else {
// this is a fake error message to hide that the user does not even exist
recordAuthFailure("password", "user_not_found", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
@@ -284,6 +297,7 @@ export async function sendPassword(
}
if (!session?.factors?.user?.id) {
recordAuthFailure("password", "session_invalid", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
@@ -293,12 +307,14 @@ export async function sendPassword(
if (!user) {
const userResponse = await getUserByID({ serviceConfig, userId: session?.factors?.user?.id });
if (!userResponse.user) {
recordAuthFailure("password", "user_not_found", command.organization);
return { error: t("errors.userNotFound") };
}
user = userResponse.user;
}
if (!session?.factors?.user?.id || !sessionCookie) {
recordAuthFailure("password", "session_invalid", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
@@ -334,6 +350,7 @@ export async function sendPassword(
// throw error if user is in initial state here and do not continue
if (user.state === UserState.INITIAL) {
recordAuthFailure("password", "user_initial_state", command.organization);
return { error: t("errors.initialUserNotSupported") };
}
@@ -354,6 +371,7 @@ export async function sendPassword(
}
if (!authMethods) {
recordAuthFailure("password", "no_auth_methods", command.organization);
return { error: t("errors.couldNotVerifyPassword") };
}
@@ -374,7 +392,7 @@ export async function sendPassword(
if (command.requestId && session.id) {
// OIDC/SAML flow
console.log("Password auth: OIDC/SAML flow with requestId:", command.requestId, "sessionId:", session.id);
logger.info("Password auth: OIDC/SAML flow with requestId:", { requestId: command.requestId, sessionId: session.id });
result = await completeFlowOrGetUrl(
{
sessionId: session.id,
@@ -385,7 +403,7 @@ export async function sendPassword(
);
} else {
// Regular flow (no requestId)
console.log("Password auth: Regular flow with loginName:", session.factors.user.loginName);
logger.info("Password auth: Regular flow with loginName:", { loginName: session.factors.user.loginName });
result = await completeFlowOrGetUrl(
{
loginName: session.factors.user.loginName,
@@ -396,9 +414,15 @@ export async function sendPassword(
}
if (result && typeof result === "object") {
if ("redirect" in result) {
recordAuthSuccess("password", command.organization);
} else if ("error" in result) {
recordAuthFailure("password", "flow_error", command.organization);
}
return result;
}
recordAuthFailure("password", "navigation_failed", command.organization);
return { error: "Authentication completed but navigation failed" };
}
@@ -476,7 +500,7 @@ export async function checkSessionAndSetPassword({
});
session = sessionResponse.session;
} catch (error) {
console.error("Error getting session:", error);
logger.error("Error getting session:", { error });
return { error: "Could not load session" };
}
+5 -2
View File
@@ -1,5 +1,6 @@
"use server";
import { createLogger } from "@/lib/logger";
import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie";
import {
deleteSession,
@@ -25,6 +26,8 @@ import {
import { getServiceConfig } from "../service-url";
import { getPublicHost } from "./host";
const logger = createLogger("session");
export async function skipMFAAndContinueWithNextUrl({
userId,
requestId,
@@ -149,7 +152,7 @@ export async function updateOrCreateSession(options: UpdateSessionCommand) {
challenges,
requestId,
}).catch((error) => {
console.error("Could not create session", error);
logger.error("Could not create session", { error });
return undefined;
});
@@ -175,7 +178,7 @@ export async function updateOrCreateSession(options: UpdateSessionCommand) {
}
if (!lifetime || !lifetime.seconds) {
console.warn("No lifetime provided for session, defaulting to 24 hours");
logger.warn("No lifetime provided for session, defaulting to 24 hours");
lifetime = {
seconds: BigInt(60 * 60 * 24), // default to 24 hours
nanos: 0,
+5 -5
View File
@@ -1,6 +1,11 @@
import { vi, describe, expect, test, beforeEach } from "vitest";
import { sendVerification } from "./verify";
import { verifyEmail, getUserByID, getSession, listAuthenticationMethodTypes } from "@/lib/zitadel";
import { getSessionCookieByLoginName } from "../cookies";
import { createSessionAndUpdateCookie } from "./cookie";
import { cookies } from "next/headers";
// Mock dependencies
vi.mock("@/lib/zitadel", () => ({
verifyEmail: vi.fn(),
@@ -36,11 +41,6 @@ vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(() => (key: string) => key),
}));
import { verifyEmail, getUserByID, getSession, listAuthenticationMethodTypes } from "@/lib/zitadel";
import { getSessionCookieByLoginName } from "../cookies";
import { createSessionAndUpdateCookie } from "./cookie";
import { cookies } from "next/headers";
describe("sendVerification", () => {
let mockVerifyEmail: any;
let mockGetUserByID: any;
+6 -3
View File
@@ -1,5 +1,6 @@
"use server";
import { createLogger } from "@/lib/logger";
import {
createInviteCode,
getLoginSettings,
@@ -28,6 +29,8 @@ import { createSessionAndUpdateCookie } from "./cookie";
import { getPublicHostWithProtocol } from "./host";
import { getTranslations } from "next-intl/server";
const logger = createLogger("verify");
export async function verifyTOTP(code: string, loginName?: string, organization?: string) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
@@ -63,11 +66,11 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
const verifyResponse = command.isInvite
? await verifyInviteCode({ serviceConfig, userId: command.userId, verificationCode: command.code }).catch((error) => {
console.warn(error);
logger.warn("Could not verify invite:", { error });
return { error: t("errors.couldNotVerifyInvite") };
})
: await verifyEmail({ serviceConfig, userId: command.userId, verificationCode: command.code }).catch((error) => {
console.warn(error);
logger.warn("Could not verify email:", { error });
return { error: t("errors.couldNotVerifyEmail") };
});
@@ -102,7 +105,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
})
.catch((error) => {
// user session is not found, so we create a new one
console.warn("[verify] user session is not found, so we create a new one", error);
logger.warn("[verify] user session is not found, so we create a new one", { error });
return undefined;
});
}
+9 -9
View File
@@ -4,6 +4,15 @@ import { cookies } from "next/headers";
import { getFingerprintIdCookie } from "./fingerprint";
import crypto from "crypto";
import {
checkPasswordChangeRequired,
checkEmailVerified,
checkEmailVerification,
checkMFAFactors,
checkUserVerification,
} from "./verify-helper";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
// Mock function to create timestamps - following the same pattern as session.test.ts
function createMockTimestamp(offsetMs = 3600000): any {
return {
@@ -324,15 +333,6 @@ describe("shouldEnforceMFA", () => {
});
});
import {
checkPasswordChangeRequired,
checkEmailVerified,
checkEmailVerification,
checkMFAFactors,
checkUserVerification,
} from "./verify-helper";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
// Mock dependencies
vi.mock("@zitadel/client", () => ({
timestampDate: vi.fn((ts: any) => new Date(Number(ts.seconds) * 1000)),
+21 -11
View File
@@ -1,6 +1,8 @@
import { Client, create, Duration } from "@zitadel/client";
import { createServerTransport as libCreateServerTransport } from "@zitadel/client/node";
import { makeReqCtx } from "@zitadel/client/v2";
import { otelGrpcInterceptor } from "./grpc/interceptors/otel";
import { createLogger } from "./logger";
import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb";
import { OrganizationSchema, TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb";
import { CreateCallbackRequest, OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
@@ -35,6 +37,8 @@ import { getUserAgent } from "./fingerprint";
import { createServiceForHost } from "./service";
const logger = createLogger("zitadel");
const useCache = process.env.DEBUG !== "true";
async function cacheWrapper<T>(callback: Promise<T>) {
@@ -1238,14 +1242,16 @@ export type WithServiceConfig<T = {}> = T & {
};
export function createServerTransport(token: string, serviceConfig: ServiceConfig) {
return libCreateServerTransport(token, {
baseUrl: serviceConfig.baseUrl,
interceptors:
!process.env.CUSTOM_REQUEST_HEADERS && !serviceConfig.instanceHost && !serviceConfig.publicHost
? undefined
: [
(next) => {
return (req) => {
// Build interceptors list - OTEL interceptor is always included for trace propagation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const interceptors: any[] = [otelGrpcInterceptor];
// Add custom headers interceptor if needed
if (process.env.CUSTOM_REQUEST_HEADERS || serviceConfig.instanceHost || serviceConfig.publicHost) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interceptors.push((next: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (req: any) => {
// Apply headers from serviceConfig
if (serviceConfig.instanceHost) {
req.header.set("x-zitadel-instance-host", serviceConfig.instanceHost);
@@ -1267,14 +1273,18 @@ export function createServerTransport(token: string, serviceConfig: ServiceConfi
req.header.delete(key);
}
} else {
console.warn(`Skipping malformed header: ${header}`);
logger.warn("Skipping malformed header", { header });
}
});
}
return next(req);
};
},
],
});
}
return libCreateServerTransport(token, {
baseUrl: serviceConfig.baseUrl,
interceptors,
});
}
+7 -2
View File
@@ -3,7 +3,12 @@ import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_se
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { buildCSP } from "./lib/csp";
import { createLogger } from "./lib/logger";
import { getServiceConfig } from "./lib/service-url";
export const runtime = "nodejs";
const logger = createLogger("middleware");
export const config = {
matcher: ["/.well-known/:path*", "/oauth/:path*", "/oidc/:path*", "/idps/callback/:path*", "/saml/:path*", "/:path*"],
};
@@ -12,14 +17,14 @@ async function loadSecuritySettings(request: NextRequest): Promise<SecuritySetti
const securityResponse = await fetch(`${request.nextUrl.origin}/security`);
if (!securityResponse.ok) {
console.error("Failed to fetch security settings:", securityResponse.statusText);
logger.error("Failed to fetch security settings:", { status: securityResponse.statusText });
return null;
}
const response = await securityResponse.json();
if (!response || !response.settings) {
console.error("No security settings found in the response.");
logger.error("No security settings found in the response.");
return null;
}
+4
View File
@@ -0,0 +1,4 @@
// Mock for server-only package in tests
// The real package throws an error when imported outside of a React Server Component context
// This empty mock allows tests to run without that restriction
export {};
-11
View File
@@ -1,11 +0,0 @@
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
include: ["dockerized/ca/**/*.test.ts"],
testTimeout: 30000,
hookTimeout: 180000,
},
});
+10 -1
View File
@@ -1,11 +1,20 @@
import { fileURLToPath } from "url";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [tsconfigPaths(), react()],
resolve: {
alias: {
"server-only": fileURLToPath(
new URL("./test-mocks/server-only.ts", import.meta.url),
),
},
},
test: {
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"],
exclude: ["**/*.integration.test.ts"],
environment: "jsdom",
setupFiles: ["./test-setup.ts"],
},
+1649 -4
View File
File diff suppressed because it is too large Load Diff