feat(login): simplify login client auth and support PKCS#1 keys (#11888)

This commit is contained in:
Mridang Agarwalla
2026-04-02 13:10:57 +05:30
committed by GitHub
parent c97ffd6eb7
commit 140f07e60b
7 changed files with 180 additions and 99 deletions
+4 -10
View File
@@ -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
+120
View File
@@ -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<typeof vi.fn>;
let mockNewSystemToken: ReturnType<typeof vi.fn>;
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"');
});
});
+21 -20
View File
@@ -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<string> {
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,
});
}
+11 -46
View File
@@ -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);
});
});
+4 -16
View File
@@ -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;
}
/**
+6 -6
View File
@@ -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<T extends ServiceClass>(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",
);
}
+14 -1
View File
@@ -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"));
}
/**