diff --git a/docs/configuration.md b/docs/configuration.md index ed39c4d..6e8038b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,7 +23,8 @@ const client = new ConfigClient('localhost:9090', { | `role` | `string` | `"superadmin"` | Role for `x-role` metadata header | | `tenantId` | `string` | — | Default tenant for `x-tenant-id` metadata header | | `token` | `string` | — | Bearer token. When set, metadata headers are not sent | -| `insecure` | `boolean` | `true` | Use plaintext (no TLS) | +| `insecure` | `boolean` | `false` | Use plaintext (no TLS) | +| `tls` | `TlsOptions` | — | Custom CA or client cert/key for mTLS. Ignored when `insecure` is true | | `timeout` | `number` | `10000` | Per-RPC timeout in milliseconds | | `retry` | `RetryConfig \| false` | See below | Retry configuration. Set to `false` to disable | @@ -77,17 +78,55 @@ in the `authorization` metadata header. The `subject`, `role`, and ## TLS -By default, the SDK connects with plaintext (`insecure: true`). For -production, disable insecure mode to use TLS: +By default, the SDK connects with TLS using the system certificate store. To +use plaintext (local/dev only), set `insecure: true`: ```typescript +const client = new ConfigClient('localhost:9090', { + insecure: true, +}); +``` + +### Custom CA + +To connect to a server with a private CA (self-signed or internal PKI): + +```typescript +import { readFileSync } from 'node:fs'; + const client = new ConfigClient('production:9090', { - insecure: false, + tls: { + rootCerts: readFileSync('/path/to/ca.pem'), + }, }); ``` -This uses `@grpc/grpc-js` default TLS credentials, which trust the system -certificate store. +### mTLS (Mutual TLS) + +To present a client certificate for mTLS authentication: + +```typescript +import { readFileSync } from 'node:fs'; + +const client = new ConfigClient('production:9090', { + tls: { + rootCerts: readFileSync('/path/to/ca.pem'), + privateKey: readFileSync('/path/to/client.key'), + certChain: readFileSync('/path/to/client.crt'), + }, +}); +``` + +`rootCerts`, `privateKey`, and `certChain` are all optional. Omit +`rootCerts` to use the system store while still sending a client cert. + +### TlsOptions + +| Option | Type | Description | +|--------|------|-------------| +| `rootCerts` | `Buffer` | PEM-encoded root CA certificate(s). Overrides the system store | +| `privateKey` | `Buffer` | PEM-encoded client private key for mTLS | +| `certChain` | `Buffer` | PEM-encoded client certificate chain for mTLS | ## Retry diff --git a/src/channel.ts b/src/channel.ts index b724e40..5718f6e 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -28,5 +28,10 @@ export function createChannel(options: ClientOptions): ChannelCredentials { } return credentials.createInsecure(); } - return credentials.createSsl(); + const tls = options.tls; + return credentials.createSsl( + tls?.rootCerts ?? null, + tls?.privateKey ?? null, + tls?.certChain ?? null, + ); } diff --git a/src/index.ts b/src/index.ts index e080e85..e610a1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export type { RetryConfig, ServerInfo, ServerVersion, + TlsOptions, } from "./types.js"; // Watcher export { ConfigWatcher, WatchedField } from "./watcher.js"; diff --git a/src/types.ts b/src/types.ts index 946b632..524c364 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,6 +49,16 @@ export interface RetryConfig { readonly retryableCodes?: (typeof GrpcStatus)[keyof typeof GrpcStatus][]; } +/** TLS/mTLS credential material for a ConfigClient. */ +export interface TlsOptions { + /** PEM-encoded root CA certificate(s). Overrides the system certificate store. */ + readonly rootCerts?: Buffer; + /** PEM-encoded client private key for mTLS. Required when certChain is set. */ + readonly privateKey?: Buffer; + /** PEM-encoded client certificate chain for mTLS. Required when privateKey is set. */ + readonly certChain?: Buffer; +} + /** Options for configuring a ConfigClient. */ export interface ClientOptions { /** Identity for x-subject metadata header. */ @@ -61,6 +71,8 @@ export interface ClientOptions { readonly token?: string; /** Use plaintext (no TLS). Default: false. Set to true only for local/dev servers without TLS. */ readonly insecure?: boolean; + /** TLS credential overrides: custom CA, or client cert/key for mTLS. Ignored when insecure is true. */ + readonly tls?: TlsOptions; /** Default per-RPC timeout in milliseconds. Default: 10000. */ readonly timeout?: number; /** Retry configuration. Set to false to disable retry. Default: RetryConfig defaults. */ diff --git a/test/client.test.ts b/test/client.test.ts index 8b270c5..677313a 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -903,5 +903,49 @@ describe("ConfigClient", () => { expect(warn.mock.calls[0][0]).toContain("cleartext"); warn.mockRestore(); }); + + it("creates TLS channel with custom root CA", () => { + const rootCerts = Buffer.from( + "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n", + ); + const c = new ConfigClient("localhost:9090", { + tls: { rootCerts }, + retry: false, + }); + c.close(); + }); + + it("creates TLS channel with mTLS client cert", async () => { + const grpcCredentials = await import("@grpc/grpc-js"); + const createSsl = vi + .spyOn(grpcCredentials.credentials, "createSsl") + .mockReturnValue( + grpcCredentials.credentials.createInsecure() as ReturnType< + typeof grpcCredentials.credentials.createSsl + >, + ); + const rootCerts = Buffer.from("fake-ca"); + const privateKey = Buffer.from("fake-key"); + const certChain = Buffer.from("fake-cert"); + const c = new ConfigClient("localhost:9090", { + tls: { rootCerts, privateKey, certChain }, + retry: false, + }); + c.close(); + expect(createSsl).toHaveBeenCalledWith(rootCerts, privateKey, certChain); + createSsl.mockRestore(); + }); + + it("ignores tls option when insecure is true", () => { + const rootCerts = Buffer.from( + "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n", + ); + const c = new ConfigClient("localhost:9090", { + insecure: true, + tls: { rootCerts }, + retry: false, + }); + c.close(); + }); }); });