Skip to content

Commit 5c42ac9

Browse files
zeevdrclaude
andauthored
feat(tls): expose custom CA and mTLS options on ClientOptions (#87)
Add TlsOptions interface with rootCerts, privateKey, certChain fields. Wire them through createChannel into credentials.createSsl. Export TlsOptions from the public index. Update docs with custom-CA and mTLS examples. Add three new TLS tests. Closes #56 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5b93ca commit 5c42ac9

5 files changed

Lines changed: 108 additions & 7 deletions

File tree

docs/configuration.md

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ const client = new ConfigClient('localhost:9090', {
2323
| `role` | `string` | `"superadmin"` | Role for `x-role` metadata header |
2424
| `tenantId` | `string` || Default tenant for `x-tenant-id` metadata header |
2525
| `token` | `string` || Bearer token. When set, metadata headers are not sent |
26-
| `insecure` | `boolean` | `true` | Use plaintext (no TLS) |
26+
| `insecure` | `boolean` | `false` | Use plaintext (no TLS) |
27+
| `tls` | `TlsOptions` || Custom CA or client cert/key for mTLS. Ignored when `insecure` is true |
2728
| `timeout` | `number` | `10000` | Per-RPC timeout in milliseconds |
2829
| `retry` | `RetryConfig \| false` | See below | Retry configuration. Set to `false` to disable |
2930

@@ -77,17 +78,55 @@ in the `authorization` metadata header. The `subject`, `role`, and
7778

7879
## TLS
7980

80-
By default, the SDK connects with plaintext (`insecure: true`). For
81-
production, disable insecure mode to use TLS:
81+
By default, the SDK connects with TLS using the system certificate store. To
82+
use plaintext (local/dev only), set `insecure: true`:
8283

8384
```typescript
85+
const client = new ConfigClient('localhost:9090', {
86+
insecure: true,
87+
});
88+
```
89+
90+
### Custom CA
91+
92+
To connect to a server with a private CA (self-signed or internal PKI):
93+
94+
```typescript
95+
import { readFileSync } from 'node:fs';
96+
8497
const client = new ConfigClient('production:9090', {
85-
insecure: false,
98+
tls: {
99+
rootCerts: readFileSync('/path/to/ca.pem'),
100+
},
86101
});
87102
```
88103

89-
This uses `@grpc/grpc-js` default TLS credentials, which trust the system
90-
certificate store.
104+
### mTLS (Mutual TLS)
105+
106+
To present a client certificate for mTLS authentication:
107+
108+
```typescript
109+
import { readFileSync } from 'node:fs';
110+
111+
const client = new ConfigClient('production:9090', {
112+
tls: {
113+
rootCerts: readFileSync('/path/to/ca.pem'),
114+
privateKey: readFileSync('/path/to/client.key'),
115+
certChain: readFileSync('/path/to/client.crt'),
116+
},
117+
});
118+
```
119+
120+
`rootCerts`, `privateKey`, and `certChain` are all optional. Omit
121+
`rootCerts` to use the system store while still sending a client cert.
122+
123+
### TlsOptions
124+
125+
| Option | Type | Description |
126+
|--------|------|-------------|
127+
| `rootCerts` | `Buffer` | PEM-encoded root CA certificate(s). Overrides the system store |
128+
| `privateKey` | `Buffer` | PEM-encoded client private key for mTLS |
129+
| `certChain` | `Buffer` | PEM-encoded client certificate chain for mTLS |
91130

92131
## Retry
93132

src/channel.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,10 @@ export function createChannel(options: ClientOptions): ChannelCredentials {
2828
}
2929
return credentials.createInsecure();
3030
}
31-
return credentials.createSsl();
31+
const tls = options.tls;
32+
return credentials.createSsl(
33+
tls?.rootCerts ?? null,
34+
tls?.privateKey ?? null,
35+
tls?.certChain ?? null,
36+
);
3237
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export type {
4444
RetryConfig,
4545
ServerInfo,
4646
ServerVersion,
47+
TlsOptions,
4748
} from "./types.js";
4849
// Watcher
4950
export { ConfigWatcher, WatchedField } from "./watcher.js";

src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ export interface RetryConfig {
4949
readonly retryableCodes?: (typeof GrpcStatus)[keyof typeof GrpcStatus][];
5050
}
5151

52+
/** TLS/mTLS credential material for a ConfigClient. */
53+
export interface TlsOptions {
54+
/** PEM-encoded root CA certificate(s). Overrides the system certificate store. */
55+
readonly rootCerts?: Buffer;
56+
/** PEM-encoded client private key for mTLS. Required when certChain is set. */
57+
readonly privateKey?: Buffer;
58+
/** PEM-encoded client certificate chain for mTLS. Required when privateKey is set. */
59+
readonly certChain?: Buffer;
60+
}
61+
5262
/** Options for configuring a ConfigClient. */
5363
export interface ClientOptions {
5464
/** Identity for x-subject metadata header. */
@@ -61,6 +71,8 @@ export interface ClientOptions {
6171
readonly token?: string;
6272
/** Use plaintext (no TLS). Default: false. Set to true only for local/dev servers without TLS. */
6373
readonly insecure?: boolean;
74+
/** TLS credential overrides: custom CA, or client cert/key for mTLS. Ignored when insecure is true. */
75+
readonly tls?: TlsOptions;
6476
/** Default per-RPC timeout in milliseconds. Default: 10000. */
6577
readonly timeout?: number;
6678
/** Retry configuration. Set to false to disable retry. Default: RetryConfig defaults. */

test/client.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,5 +903,49 @@ describe("ConfigClient", () => {
903903
expect(warn.mock.calls[0][0]).toContain("cleartext");
904904
warn.mockRestore();
905905
});
906+
907+
it("creates TLS channel with custom root CA", () => {
908+
const rootCerts = Buffer.from(
909+
"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n",
910+
);
911+
const c = new ConfigClient("localhost:9090", {
912+
tls: { rootCerts },
913+
retry: false,
914+
});
915+
c.close();
916+
});
917+
918+
it("creates TLS channel with mTLS client cert", async () => {
919+
const grpcCredentials = await import("@grpc/grpc-js");
920+
const createSsl = vi
921+
.spyOn(grpcCredentials.credentials, "createSsl")
922+
.mockReturnValue(
923+
grpcCredentials.credentials.createInsecure() as ReturnType<
924+
typeof grpcCredentials.credentials.createSsl
925+
>,
926+
);
927+
const rootCerts = Buffer.from("fake-ca");
928+
const privateKey = Buffer.from("fake-key");
929+
const certChain = Buffer.from("fake-cert");
930+
const c = new ConfigClient("localhost:9090", {
931+
tls: { rootCerts, privateKey, certChain },
932+
retry: false,
933+
});
934+
c.close();
935+
expect(createSsl).toHaveBeenCalledWith(rootCerts, privateKey, certChain);
936+
createSsl.mockRestore();
937+
});
938+
939+
it("ignores tls option when insecure is true", () => {
940+
const rootCerts = Buffer.from(
941+
"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n",
942+
);
943+
const c = new ConfigClient("localhost:9090", {
944+
insecure: true,
945+
tls: { rootCerts },
946+
retry: false,
947+
});
948+
c.close();
949+
});
906950
});
907951
});

0 commit comments

Comments
 (0)