mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
feat(login): added support for otel push-based logs, metrics, and traces (#11429)
This commit is contained in:
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -13,4 +13,5 @@ dist-ssr
|
||||
*.local
|
||||
.vscode
|
||||
/blob-report/
|
||||
# Dockerized test output (generated by integration tests)
|
||||
dockerized/*/output/
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 $@
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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") };
|
||||
}
|
||||
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
Generated
+1649
-4
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user