Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 6 additions & 4 deletions lib/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import {
CertificateCredential,
type ClusterCredential,
Credential,
JwtCredential,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.'
)
}
}
Expand Down
107 changes: 106 additions & 1 deletion lib/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
anirudhlakhotia marked this conversation as resolved.
this.passphrase = options.passphrase
}
}

/**
* Credential variants accepted by an Analytics cluster.
*
* @category Authentication
*/
export type ClusterCredential = Credential | JwtCredential
export type ClusterCredential = Credential | JwtCredential | CertificateCredential
40 changes: 32 additions & 8 deletions lib/httpclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class HttpClient {
private _credential: ClusterCredential
private _hostname: string
private _port: string
private _securityOptions: SecurityOptions

constructor(
url: URL,
Expand All @@ -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:') {
Expand All @@ -59,7 +66,7 @@ export class HttpClient {
)
}

this._agent = this._buildAgent(securityOptions)
this._agent = this._buildAgent(credential)
}

/**
Expand All @@ -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
}

/**
Expand All @@ -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()
}
}

/**
Expand All @@ -108,24 +125,31 @@ 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,
...tlsOptions,
})
}

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.
Expand Down
Loading
Loading