diff --git a/lib/analytics.ts b/lib/analytics.ts index 76e46dd..6920946 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -40,7 +40,7 @@ export * from './database.js' export * from './deserializers.js' export * from './certificates.js' export * from './cluster.js' -export { Credential, JwtCredential } from './credential.js' +export { CertificateCredential, Credential, JwtCredential } from './credential.js' export type { ClusterCredential, ICredential } from './credential.js' export * from './errors.js' export * from './scope.js' diff --git a/lib/cluster.ts b/lib/cluster.ts index 055063d..8ff1cfe 100644 --- a/lib/cluster.ts +++ b/lib/cluster.ts @@ -16,6 +16,7 @@ */ import { + CertificateCredential, type ClusterCredential, Credential, JwtCredential, @@ -366,8 +367,8 @@ export class Cluster { /** * Replace the credential used for subsequent HTTP requests, for example to - * refresh a JWT before it expires. The new credential must be the same - * kind as the current one and takes effect on the next request. + * refresh a JWT or rotate a client certificate. The new credential must be + * the same kind as the current one and takes effect on the next request. * * @param credential The new credential to use. * @throws {InvalidArgumentError} If `credential` is null/undefined or is a @@ -395,10 +396,11 @@ export class Cluster { } if ( !(credential instanceof Credential) && - !(credential instanceof JwtCredential) + !(credential instanceof JwtCredential) && + !(credential instanceof CertificateCredential) ) { throw new InvalidArgumentError( - 'credential must be a Credential or JwtCredential.' + 'credential must be a Credential, JwtCredential, or CertificateCredential.' ) } } diff --git a/lib/credential.ts b/lib/credential.ts index d283be9..9a4d1bf 100644 --- a/lib/credential.ts +++ b/lib/credential.ts @@ -110,9 +110,114 @@ export class JwtCredential { } } +/** + * Material to construct a {@link CertificateCredential}. Provide either + * `pfx` (PKCS#12 Buffer), or both `cert` and `key` (PEM strings or Buffers). + * + * @category Authentication + */ +export interface CertificateCredentialOptions { + /** PKCS#12 keystore bytes (mutually exclusive with cert/key). */ + pfx?: Buffer + /** PEM-encoded certificate (paired with `key`). */ + cert?: string | Buffer + /** PEM-encoded private key (paired with `cert`). */ + key?: string | Buffer + /** Passphrase for the keystore or encrypted private key, if any. */ + passphrase?: string +} + +/** + * A client certificate (mTLS) for authenticating to an Analytics cluster. + * The certificate is presented during the TLS handshake; no + * `Authorization` header is sent. Requires an `https://` endpoint. + * + * To load from disk, read the file(s) yourself and pass the bytes. + * + * ```ts + * // PKCS#12 keystore + * import * as fs from 'node:fs' + * new CertificateCredential({ + * pfx: fs.readFileSync('/path/to/client.p12'), + * passphrase: 'keystore-pass', + * }) + * + * // PEM certificate + private key + * new CertificateCredential({ + * cert: fs.readFileSync('/path/to/client.pem'), + * key: fs.readFileSync('/path/to/client.key'), + * }) + * ``` + * + * @category Authentication + */ +export class CertificateCredential { + /** @internal */ + readonly type = 'certificate' as const + + /** @internal */ + readonly pfx?: Buffer + /** @internal */ + readonly cert?: string | Buffer + /** @internal */ + readonly key?: string | Buffer + /** @internal */ + readonly passphrase?: string + + /** + * Constructs a {@link CertificateCredential}. Provide either `pfx` + * (PKCS#12) or both `cert` and `key` (PEM); supplying both forms, or + * neither, throws. + * + * @param options Certificate material. + */ + constructor(options: CertificateCredentialOptions) { + const hasPfx = options.pfx !== undefined + const hasCert = options.cert !== undefined + const hasKey = options.key !== undefined + if (hasPfx && (hasCert || hasKey)) { + throw new InvalidArgumentError( + 'Provide either `pfx` or `cert`+`key`, not both.' + ) + } + if (!hasPfx && !(hasCert && hasKey)) { + throw new InvalidArgumentError( + 'Provide either `pfx` or both `cert` and `key`.' + ) + } + if (hasPfx && !Buffer.isBuffer(options.pfx)) { + throw new InvalidArgumentError('`pfx` must be a Buffer.') + } + if ( + hasCert && + typeof options.cert !== 'string' && + !Buffer.isBuffer(options.cert) + ) { + throw new InvalidArgumentError('`cert` must be a string or Buffer.') + } + if ( + hasKey && + typeof options.key !== 'string' && + !Buffer.isBuffer(options.key) + ) { + throw new InvalidArgumentError('`key` must be a string or Buffer.') + } + if ( + options.passphrase !== undefined && + typeof options.passphrase !== 'string' + ) { + throw new InvalidArgumentError('`passphrase` must be a string.') + } + this.pfx = options.pfx + this.cert = options.cert + this.key = options.key + this.passphrase = options.passphrase + } +} + /** * Credential variants accepted by an Analytics cluster. * * @category Authentication */ -export type ClusterCredential = Credential | JwtCredential +export type ClusterCredential = Credential | JwtCredential | CertificateCredential diff --git a/lib/httpclient.ts b/lib/httpclient.ts index c55e64b..c0177cf 100644 --- a/lib/httpclient.ts +++ b/lib/httpclient.ts @@ -37,6 +37,7 @@ export class HttpClient { private _credential: ClusterCredential private _hostname: string private _port: string + private _securityOptions: SecurityOptions constructor( url: URL, @@ -45,9 +46,15 @@ export class HttpClient { ) { this._hostname = url.hostname this._credential = credential + this._securityOptions = securityOptions this.randomLookup = this.randomLookup.bind(this) if (url.protocol === 'http:') { + if (credential.type === 'certificate') { + throw new InvalidArgumentError( + 'Client-certificate authentication requires an https:// endpoint.' + ) + } this._port = url.port ?? '80' this._module = http } else if (url.protocol === 'https:') { @@ -59,7 +66,7 @@ export class HttpClient { ) } - this._agent = this._buildAgent(securityOptions) + this._agent = this._buildAgent(credential) } /** @@ -76,12 +83,15 @@ export class HttpClient { * @internal */ genericRequestOptions(): http.RequestOptions { - return { + const opts: http.RequestOptions = { agent: this._agent, hostname: this._hostname, port: this._port, - headers: { Authorization: this._credential.authorizationHeader }, } + if (this._credential.type !== 'certificate') { + opts.headers = { Authorization: this._credential.authorizationHeader } + } + return opts } /** @@ -97,6 +107,13 @@ export class HttpClient { ) } this._credential = credential + if (credential.type === 'certificate') { + // Cert/key are baked into the agent's TLS context, so rotation needs + // a fresh agent. Pooled keep-alive sockets on the old agent are dropped. + const oldAgent = this._agent + this._agent = this._buildAgent(credential) + oldAgent.destroy() + } } /** @@ -108,14 +125,22 @@ export class HttpClient { } } - private _buildAgent(securityOptions: SecurityOptions): HttpAgent | HttpsAgent { + private _buildAgent(credential: ClusterCredential): HttpAgent | HttpsAgent { if (this._module === http) { return new HttpAgent({ keepAlive: true, lookup: this.randomLookup, }) } - const tlsOptions = this._buildTlsOptions(securityOptions) + const tlsOptions = this._buildTlsOptions() + if (credential.type === 'certificate') { + if (credential.pfx !== undefined) tlsOptions.pfx = credential.pfx + if (credential.cert !== undefined) tlsOptions.cert = credential.cert + if (credential.key !== undefined) tlsOptions.key = credential.key + if (credential.passphrase !== undefined) { + tlsOptions.passphrase = credential.passphrase + } + } return new HttpsAgent({ keepAlive: true, lookup: this.randomLookup, @@ -123,9 +148,8 @@ export class HttpClient { }) } - private _buildTlsOptions( - securityOptions: SecurityOptions - ): tls.ConnectionOptions { + private _buildTlsOptions(): tls.ConnectionOptions { + const securityOptions = this._securityOptions const tlsOptions: tls.ConnectionOptions = {} // Override the servername to use the hostname rather than the DNS record. diff --git a/test/credential.test.ts b/test/credential.test.ts index 2174d57..1c87e5f 100644 --- a/test/credential.test.ts +++ b/test/credential.test.ts @@ -19,6 +19,7 @@ import { assert } from 'chai' import * as http from 'node:http' import { AddressInfo } from 'node:net' import { + CertificateCredential, Credential, JwtCredential, createInstance, @@ -28,6 +29,9 @@ import { InvalidArgumentError } from '../lib/errors.js' const SAMPLE_JWT = 'header.payload.signature' +const DUMMY_PFX = Buffer.from('dummy-pkcs12-bytes') +const DUMMY_PASSPHRASE = 'test' + describe('#Credential', function () { describe('Password', function () { it('constructor builds a Basic header', function () { @@ -71,6 +75,118 @@ describe('#Credential', function () { }) + describe('Certificate (mTLS)', function () { + it('constructor accepts a PKCS#12 Buffer', function () { + const cred = new CertificateCredential({ + pfx: DUMMY_PFX, + passphrase: DUMMY_PASSPHRASE, + }) + assert.strictEqual(cred.type, 'certificate') + assert.strictEqual(cred.pfx, DUMMY_PFX) + assert.strictEqual(cred.passphrase, DUMMY_PASSPHRASE) + assert.isUndefined(cred.cert) + assert.isUndefined(cred.key) + }) + + it('constructor accepts PEM cert and key', function () { + const cert = 'CERT-PEM' + const key = 'KEY-PEM' + const cred = new CertificateCredential({ cert, key }) + assert.strictEqual(cred.type, 'certificate') + assert.strictEqual(cred.cert, cert) + assert.strictEqual(cred.key, key) + assert.isUndefined(cred.pfx) + }) + + it('rejects supplying both pfx and cert+key', function () { + assert.throws( + () => + new CertificateCredential({ + pfx: DUMMY_PFX, + cert: 'C', + key: 'K', + }), + InvalidArgumentError + ) + }) + + it('rejects supplying neither pfx nor cert+key', function () { + assert.throws(() => new CertificateCredential({}), InvalidArgumentError) + }) + + it('rejects cert without key', function () { + assert.throws( + () => new CertificateCredential({ cert: 'C' }), + InvalidArgumentError + ) + }) + + it('rejects key without cert', function () { + assert.throws( + () => new CertificateCredential({ key: 'K' }), + InvalidArgumentError + ) + }) + + it('rejects pfx alongside cert', function () { + assert.throws( + () => new CertificateCredential({ pfx: DUMMY_PFX, cert: 'C' }), + InvalidArgumentError + ) + }) + + it('rejects pfx alongside key', function () { + assert.throws( + () => new CertificateCredential({ pfx: DUMMY_PFX, key: 'K' }), + InvalidArgumentError + ) + }) + + it('rejects a non-Buffer pfx', function () { + assert.throws( + () => + new CertificateCredential({ + pfx: 'not a buffer' as unknown as Buffer, + }), + InvalidArgumentError + ) + }) + + it('rejects a non-string/non-Buffer cert', function () { + assert.throws( + () => + new CertificateCredential({ + cert: 42 as unknown as string, + key: 'K', + }), + InvalidArgumentError + ) + }) + + it('rejects a non-string/non-Buffer key', function () { + assert.throws( + () => + new CertificateCredential({ + cert: 'C', + key: {} as unknown as string, + }), + InvalidArgumentError + ) + }) + + it('rejects a non-string passphrase', function () { + assert.throws( + () => + new CertificateCredential({ + pfx: DUMMY_PFX, + passphrase: 42 as unknown as string, + }), + InvalidArgumentError + ) + }) + + }) + describe('Cluster.setCredential', function () { it('rejects a null/undefined initial credential', function () { assert.throws( @@ -175,6 +291,61 @@ describe('#Credential', function () { } }) + it('rejects mTLS over http://', function () { + assert.throws( + () => + createInstance( + 'http://localhost:8095', + new CertificateCredential({ + pfx: DUMMY_PFX, + passphrase: DUMMY_PASSPHRASE, + }) + ), + InvalidArgumentError + ) + }) + + it('rotates a certificate by rebuilding the agent', function () { + const cluster = createInstance( + 'https://localhost:18095', + new CertificateCredential({ + pfx: DUMMY_PFX, + passphrase: DUMMY_PASSPHRASE, + }), + { securityOptions: { disableServerCertificateVerification: true } } + ) + try { + const before = cluster.httpClient.genericRequestOptions().agent + cluster.setCredential( + new CertificateCredential({ + pfx: DUMMY_PFX, + passphrase: DUMMY_PASSPHRASE, + }) + ) + const after = cluster.httpClient.genericRequestOptions().agent + assert.notStrictEqual(after, before) + } finally { + cluster.close() + } + }) + + it('certificate credentials do not set an Authorization header', function () { + const cluster = createInstance( + 'https://localhost:18095', + new CertificateCredential({ + pfx: DUMMY_PFX, + passphrase: DUMMY_PASSPHRASE, + }), + { securityOptions: { disableServerCertificateVerification: true } } + ) + try { + const opts = cluster.httpClient.genericRequestOptions() + assert.isUndefined(opts.headers) + } finally { + cluster.close() + } + }) + it('rejects a null/undefined credential', function () { const cluster = createInstance( 'http://localhost:8095',