From 140f07e60b01c302b0135bb082198b6bf58ea7f6 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 2 Apr 2026 14:40:57 +0700 Subject: [PATCH] feat(login): simplify login client auth and support PKCS#1 keys (#11888) --- apps/login/next-env-vars.d.ts | 14 +-- apps/login/src/lib/api.test.ts | 120 ++++++++++++++++++++++++++ apps/login/src/lib/api.ts | 41 ++++----- apps/login/src/lib/deployment.test.ts | 57 +++--------- apps/login/src/lib/deployment.ts | 20 +---- apps/login/src/lib/service.ts | 12 +-- packages/zitadel-client/src/node.ts | 15 +++- 7 files changed, 180 insertions(+), 99 deletions(-) create mode 100644 apps/login/src/lib/api.test.ts diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts index 153aba1fe2..c3da60ae88 100644 --- a/apps/login/next-env-vars.d.ts +++ b/apps/login/next-env-vars.d.ts @@ -25,18 +25,12 @@ declare namespace NodeJS { ZITADEL_SERVICE_USER_TOKEN: string; /** - * Path to a private key file for JWT authentication. - * When set, the login service will read the key and sign JWTs for API authentication. - * Requires ZITADEL_LOGIN_SYSTEM_USER_ID or SYSTEM_USER_ID to be set. + * Path to a private key file for login client JWT authentication. + * When set, the login service reads this key and signs JWTs with a + * hardcoded subject of "login-client". * AUDIENCE defaults to ZITADEL_API_URL if not explicitly set. */ - ZITADEL_LOGIN_SERVICE_KEY_FILE?: string; - - /** - * The system user ID for login service key authentication. - * Falls back to SYSTEM_USER_ID if not set. - */ - ZITADEL_LOGIN_SYSTEM_USER_ID?: string; + ZITADEL_LOGINCLIENT_KEYFILE?: string; /** * Optional: wheter a user must have verified email diff --git a/apps/login/src/lib/api.test.ts b/apps/login/src/lib/api.test.ts new file mode 100644 index 0000000000..ca147606da --- /dev/null +++ b/apps/login/src/lib/api.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment node +import { newSystemToken } from "@zitadel/client/node"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const execAsync = promisify(exec); + +const { stdout: pkcs1Key } = await execAsync( + "openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null | openssl rsa -traditional 2>/dev/null", +); + +const { stdout: pkcs8Key } = await execAsync("openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null"); + +describe("newSystemToken key format support", () => { + test("should sign a JWT with a PKCS#8 key (BEGIN PRIVATE KEY)", async () => { + expect(pkcs8Key).toContain("BEGIN PRIVATE KEY"); + + const token = await newSystemToken({ + audience: "https://example.com", + subject: "login-client", + key: pkcs8Key, + }); + + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.split(".")).toHaveLength(3); + }); + + test("should sign a JWT with a PKCS#1 key (BEGIN RSA PRIVATE KEY)", async () => { + expect(pkcs1Key).toContain("BEGIN RSA PRIVATE KEY"); + + const token = await newSystemToken({ + audience: "https://example.com", + subject: "login-client", + key: pkcs1Key, + }); + + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.split(".")).toHaveLength(3); + }); +}); + +describe("loginClientKeyToken", () => { + const originalEnv = process.env; + let mockReadFile: ReturnType; + let mockNewSystemToken: ReturnType; + + beforeEach(() => { + process.env = { ...originalEnv }; + vi.resetModules(); + + mockReadFile = vi.fn(); + mockNewSystemToken = vi.fn(); + + vi.doMock("fs/promises", () => ({ readFile: mockReadFile })); + vi.doMock("@zitadel/client/node", () => ({ newSystemToken: mockNewSystemToken })); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test("should read key file and create token with hardcoded subject", async () => { + process.env.ZITADEL_LOGINCLIENT_KEYFILE = "/path/to/key.pem"; + process.env.AUDIENCE = "https://api.zitadel.cloud"; + + mockReadFile.mockResolvedValue("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"); + mockNewSystemToken.mockResolvedValue("signed-jwt-token"); + + const { loginClientKeyToken } = await import("./api"); + const token = await loginClientKeyToken(); + + expect(token).toBe("signed-jwt-token"); + expect(mockReadFile).toHaveBeenCalledWith("/path/to/key.pem", "utf-8"); + expect(mockNewSystemToken).toHaveBeenCalledWith({ + audience: "https://api.zitadel.cloud", + subject: "login-client", + key: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + }); + }); + + test("should cache key and not re-read file on subsequent calls", async () => { + process.env.ZITADEL_LOGINCLIENT_KEYFILE = "/path/to/key.pem"; + process.env.AUDIENCE = "https://api.zitadel.cloud"; + + mockReadFile.mockResolvedValue("cached-key"); + mockNewSystemToken.mockResolvedValue("token"); + + const { loginClientKeyToken } = await import("./api"); + await loginClientKeyToken(); + await loginClientKeyToken(); + + expect(mockReadFile).toHaveBeenCalledTimes(1); + }); + + test("should fall back to ZITADEL_API_URL when AUDIENCE is not set", async () => { + process.env.ZITADEL_LOGINCLIENT_KEYFILE = "/path/to/key.pem"; + process.env.AUDIENCE = undefined as any; + process.env.ZITADEL_API_URL = "https://zitadel.example.com"; + + mockReadFile.mockResolvedValue("key-content"); + mockNewSystemToken.mockResolvedValue("token"); + + const { loginClientKeyToken } = await import("./api"); + await loginClientKeyToken(); + + expect(mockNewSystemToken).toHaveBeenCalledWith(expect.objectContaining({ audience: "https://zitadel.example.com" })); + }); + + test("should throw a clear error when key file cannot be read", async () => { + process.env.ZITADEL_LOGINCLIENT_KEYFILE = "/nonexistent/key.pem"; + + mockReadFile.mockRejectedValue(new Error("ENOENT: no such file or directory")); + + const { loginClientKeyToken } = await import("./api"); + await expect(loginClientKeyToken()).rejects.toThrow('Failed to read login client key file "/nonexistent/key.pem"'); + }); +}); diff --git a/apps/login/src/lib/api.ts b/apps/login/src/lib/api.ts index 7a97579065..2fbe387a5f 100644 --- a/apps/login/src/lib/api.ts +++ b/apps/login/src/lib/api.ts @@ -1,10 +1,10 @@ import { newSystemToken } from "@zitadel/client/node"; import { readFile } from "fs/promises"; -import { getLoginSystemUserId } from "./deployment"; -// The key token is only loaded once from disk per process. -// If the file was loaded you need to restart the process to switch the key. +// Keys are only loaded once from disk per process. +// If a file changes you need to restart the process to pick up the new key. let keyToken: string | undefined; +let loginClientKeyCache: string | undefined; async function getTokenFromFile(): Promise { if (keyToken) { @@ -38,27 +38,28 @@ export async function systemAPIToken() { /** * Creates a signed JWT token by reading a private key from the file path - * specified in ZITADEL_LOGIN_SERVICE_KEY_FILE. The audience is resolved from - * AUDIENCE or falls back to ZITADEL_API_URL, and the subject is resolved via - * {@link getLoginSystemUserId}. + * specified in ZITADEL_LOGINCLIENT_KEYFILE. Uses a hardcoded subject of + * "login-client". The audience is resolved from AUDIENCE or ZITADEL_API_URL. * * @returns A signed JWT token string for authenticating API requests. * @throws If the key file cannot be read or the token signing fails. */ -export async function loginServiceKeyToken() { - const keyFile = process.env.ZITADEL_LOGIN_SERVICE_KEY_FILE!; +export async function loginClientKeyToken() { + const keyFile = process.env.ZITADEL_LOGINCLIENT_KEYFILE!; - try { - const key = await readFile(keyFile, "utf-8"); - - return newSystemToken({ - audience: process.env.AUDIENCE || process.env.ZITADEL_API_URL, - subject: getLoginSystemUserId()!, - key: key, - }); - } catch (err) { - throw new Error(`Failed to read login service key file "${keyFile}": ${err instanceof Error ? err.message : err}`, { - cause: err, - }); + if (!loginClientKeyCache) { + try { + loginClientKeyCache = await readFile(keyFile, "utf-8"); + } catch (err) { + throw new Error(`Failed to read login client key file "${keyFile}": ${err instanceof Error ? err.message : err}`, { + cause: err, + }); + } } + + return newSystemToken({ + audience: process.env.AUDIENCE || process.env.ZITADEL_API_URL, + subject: "login-client", + key: loginClientKeyCache, + }); } diff --git a/apps/login/src/lib/deployment.test.ts b/apps/login/src/lib/deployment.test.ts index e155dd7624..53ee54bcc7 100644 --- a/apps/login/src/lib/deployment.test.ts +++ b/apps/login/src/lib/deployment.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { getLoginSystemUserId, hasLoginServiceKey, hasServiceUserToken, hasSystemUserCredentials } from "./deployment"; +import { hasLoginClientKey, hasServiceUserToken, hasSystemUserCredentials } from "./deployment"; describe("Deployment utilities", () => { const originalEnv = process.env; @@ -55,58 +55,23 @@ describe("Deployment utilities", () => { }); }); - describe("hasLoginServiceKey", () => { - test("should return true with ZITADEL_LOGIN_SERVICE_KEY_FILE and ZITADEL_LOGIN_SYSTEM_USER_ID", () => { - process.env.ZITADEL_LOGIN_SERVICE_KEY_FILE = "/path/to/key.pem"; - process.env.ZITADEL_LOGIN_SYSTEM_USER_ID = "login-user"; + describe("hasLoginClientKey", () => { + test("should return true when ZITADEL_LOGINCLIENT_KEYFILE is set", () => { + process.env.ZITADEL_LOGINCLIENT_KEYFILE = "/path/to/key.pem"; - expect(hasLoginServiceKey()).toBe(true); + expect(hasLoginClientKey()).toBe(true); }); - test("should return true with ZITADEL_LOGIN_SERVICE_KEY_FILE and SYSTEM_USER_ID fallback", () => { - process.env.ZITADEL_LOGIN_SERVICE_KEY_FILE = "/path/to/key.pem"; - process.env.ZITADEL_LOGIN_SYSTEM_USER_ID = undefined as any; - process.env.SYSTEM_USER_ID = "system-user"; + test("should return false when ZITADEL_LOGINCLIENT_KEYFILE is missing", () => { + process.env.ZITADEL_LOGINCLIENT_KEYFILE = undefined as any; - expect(hasLoginServiceKey()).toBe(true); + expect(hasLoginClientKey()).toBe(false); }); - test("should return false when ZITADEL_LOGIN_SERVICE_KEY_FILE is missing", () => { - process.env.ZITADEL_LOGIN_SERVICE_KEY_FILE = undefined as any; - process.env.ZITADEL_LOGIN_SYSTEM_USER_ID = "login-user"; + test("should return false when ZITADEL_LOGINCLIENT_KEYFILE is empty string", () => { + process.env.ZITADEL_LOGINCLIENT_KEYFILE = ""; - expect(hasLoginServiceKey()).toBe(false); - }); - - test("should return false when no user ID is set", () => { - process.env.ZITADEL_LOGIN_SERVICE_KEY_FILE = "/path/to/key.pem"; - process.env.ZITADEL_LOGIN_SYSTEM_USER_ID = undefined as any; - process.env.SYSTEM_USER_ID = undefined as any; - - expect(hasLoginServiceKey()).toBe(false); - }); - }); - - describe("getLoginSystemUserId", () => { - test("should return ZITADEL_LOGIN_SYSTEM_USER_ID when set", () => { - process.env.ZITADEL_LOGIN_SYSTEM_USER_ID = "login-user"; - process.env.SYSTEM_USER_ID = "system-user"; - - expect(getLoginSystemUserId()).toBe("login-user"); - }); - - test("should return SYSTEM_USER_ID as fallback", () => { - process.env.ZITADEL_LOGIN_SYSTEM_USER_ID = undefined as any; - process.env.SYSTEM_USER_ID = "system-user"; - - expect(getLoginSystemUserId()).toBe("system-user"); - }); - - test("should return undefined when neither is set", () => { - process.env.ZITADEL_LOGIN_SYSTEM_USER_ID = undefined as any; - process.env.SYSTEM_USER_ID = undefined as any; - - expect(getLoginSystemUserId()).toBeUndefined(); + expect(hasLoginClientKey()).toBe(false); }); }); diff --git a/apps/login/src/lib/deployment.ts b/apps/login/src/lib/deployment.ts index add7e4e0ec..32d02cb3f4 100644 --- a/apps/login/src/lib/deployment.ts +++ b/apps/login/src/lib/deployment.ts @@ -20,24 +20,12 @@ export function hasSystemUserCredentials(): boolean { } /** - * Checks if login service key file is available for JWT authentication. + * Checks if login client key file is available for JWT authentication. * - * @returns true if ZITADEL_LOGIN_SERVICE_KEY_FILE and a user ID are present, false otherwise + * @returns true if ZITADEL_LOGINCLIENT_KEYFILE is present, false otherwise */ -export function hasLoginServiceKey(): boolean { - return ( - !!process.env.ZITADEL_LOGIN_SERVICE_KEY_FILE && - !!(process.env.ZITADEL_LOGIN_SYSTEM_USER_ID || process.env.SYSTEM_USER_ID) - ); -} - -/** - * Gets the system user ID for login service key authentication. - * - * @returns ZITADEL_LOGIN_SYSTEM_USER_ID if set, otherwise SYSTEM_USER_ID - */ -export function getLoginSystemUserId(): string | undefined { - return process.env.ZITADEL_LOGIN_SYSTEM_USER_ID || process.env.SYSTEM_USER_ID; +export function hasLoginClientKey(): boolean { + return !!process.env.ZITADEL_LOGINCLIENT_KEYFILE; } /** diff --git a/apps/login/src/lib/service.ts b/apps/login/src/lib/service.ts index 8d26dd66b3..31d7f045c4 100644 --- a/apps/login/src/lib/service.ts +++ b/apps/login/src/lib/service.ts @@ -6,8 +6,8 @@ import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { loginServiceKeyToken, systemAPIToken } from "./api"; -import { hasLoginServiceKey, hasServiceUserToken, hasSystemUserCredentials } from "./deployment"; +import { loginClientKeyToken, systemAPIToken } from "./api"; +import { hasLoginClientKey, hasServiceUserToken, hasSystemUserCredentials } from "./deployment"; import { createServerTransport, ServiceConfig } from "./zitadel"; type ServiceClass = @@ -23,17 +23,17 @@ export async function createServiceForHost(service: T, s let token; // Determine authentication method based on available credentials - // Priority: system user JWT > login service key > service account token + // Priority: system user JWT > login client key > service account token if (hasSystemUserCredentials()) { token = await systemAPIToken(); - } else if (hasLoginServiceKey()) { - token = await loginServiceKeyToken(); + } else if (hasLoginClientKey()) { + token = await loginClientKeyToken(); } else if (hasServiceUserToken()) { // Use service account token authentication (self-hosted) token = process.env.ZITADEL_SERVICE_USER_TOKEN; } else { throw new Error( - "No authentication credentials found. Set either system user credentials (AUDIENCE, SYSTEM_USER_ID, SYSTEM_USER_PRIVATE_KEY), login service key (ZITADEL_LOGIN_SERVICE_KEY_FILE with ZITADEL_LOGIN_SYSTEM_USER_ID or SYSTEM_USER_ID), or ZITADEL_SERVICE_USER_TOKEN", + "No authentication credentials found. Set ZITADEL_LOGINCLIENT_KEYFILE, or system user credentials (AUDIENCE, SYSTEM_USER_ID, SYSTEM_USER_PRIVATE_KEY or SYSTEM_USER_PRIVATE_KEY_FILE), or ZITADEL_SERVICE_USER_TOKEN", ); } diff --git a/packages/zitadel-client/src/node.ts b/packages/zitadel-client/src/node.ts index c5b942a30b..854af1cffd 100644 --- a/packages/zitadel-client/src/node.ts +++ b/packages/zitadel-client/src/node.ts @@ -1,3 +1,4 @@ +import { createPrivateKey } from "crypto"; import { createGrpcTransport, GrpcTransportOptions, @@ -29,6 +30,18 @@ export function createServerTransport( }); } +/** + * Normalize a PEM private key to PKCS#8 format. Accepts both PKCS#1 + * (BEGIN RSA PRIVATE KEY) and PKCS#8 (BEGIN PRIVATE KEY) inputs. + * Returns the key as a PKCS#8 PEM string. + */ +function toPKCS8(pem: string): string { + if (pem.includes("BEGIN PRIVATE KEY")) { + return pem; + } + return createPrivateKey(pem).export({ type: "pkcs8", format: "pem" }) as string; +} + export async function newSystemToken({ audience, subject, @@ -47,7 +60,7 @@ export async function newSystemToken({ .setIssuer(subject) .setSubject(subject) .setAudience(audience) - .sign(await importPKCS8(key, "RS256")); + .sign(await importPKCS8(toPKCS8(key), "RS256")); } /**