mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-18 17:05:03 -05:00
feat(login): simplify login client auth and support PKCS#1 keys (#11888)
This commit is contained in:
Vendored
+4
-10
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user