client-certificate-auth 1.3.5 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,11 +8,12 @@ Comprehensive toolkit for client SSL certificate authentication (mTLS) in Node.j
8
8
  [![stryker mutation testing](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Ftgies%2Fclient-certificate-auth%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/tgies/client-certificate-auth/master)
9
9
 
10
10
  [**Full Documentation**](https://tgies.github.io/client-certificate-auth/) - guides, API reference, and runnable examples
11
+
11
12
  [**Commercial Support**](#commercial-support) - consulting, custom features, and priority support for production deployments
12
13
 
13
14
  **Recommended by AWS** - Featured in the [AWS API Gateway documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/getting-started-client-side-ssl-authentication.html#certificate-validation).
14
15
 
15
- **Fanatically Tested** - 100% line/branch/function/statement coverage, plus mutation testing and E2E tests against real nginx/Envoy/Traefik containers. ~4,776 lines of test code for ~711 lines of source (measured by [cloc](https://github.com/AlDanial/cloc)).
16
+ **Fanatically Tested** - 100% line/branch/function/statement coverage, plus mutation testing and E2E tests against real nginx/Envoy/Traefik containers. ~5,224 lines of test code for ~788 lines of source (measured by [cloc](https://github.com/AlDanial/cloc)).
16
17
 
17
18
  ## Installation
18
19
 
@@ -171,7 +172,7 @@ Returns Express middleware.
171
172
 
172
173
  | Name | Type | Description |
173
174
  |------|------|-------------|
174
- | `callback` | `(cert, req?) => boolean \| Promise<boolean>` | Receives the client certificate and request, returns `true` to allow access |
175
+ | `callback` | `(cert, req?) => boolean \| PromiseLike<boolean>` | Receives the client certificate and request, returns `true` to allow access |
175
176
  | `options.certificateSource` | `string` | Use a preset for a known proxy: `'aws-alb'`, `'envoy'`, `'cloudflare'`, `'traefik'` |
176
177
  | `options.certificateHeader` | `string` | Custom header name to read certificate from |
177
178
  | `options.headerEncoding` | `string` | Encoding format: `'url-pem'`, `'url-pem-aws'`, `'xfcc'`, `'base64-der'`, `'rfc9440'` |
@@ -814,6 +815,15 @@ The E2E tests spin up real reverse proxies, generate fresh certificates, and ver
814
815
  - **When using header-based auth**, ensure your proxy strips certificate headers from external requests
815
816
  - Use `verifyHeader`/`verifyValue` as defense-in-depth when using header-based authentication
816
817
 
818
+ ## Upgrading from 1.x
819
+
820
+ v2.0.0 introduced two behavior changes:
821
+
822
+ - **Validation callbacks must return exactly `true`.** Previously any truthy value (`'admin'`, `1`, an object) authorized; now only `true` (or a `Promise`/thenable resolving to `true`) does. Callbacks that returned ad-hoc truthy values (e.g., `return cert.subject.CN`) must be rewritten to explicit `return true` / `return false`.
823
+ - **Header options are validated at construction.** Typos like `certificateSource: 'aws-alp'` now throw at app startup instead of failing at request time.
824
+
825
+ See the [CHANGELOG](./CHANGELOG.md) for the full v2.0.0 entry.
826
+
817
827
  ## Troubleshooting
818
828
 
819
829
  ### `DEPTH_ZERO_SELF_SIGNED_CERT` error
@@ -19,6 +19,19 @@ async function loadModule() {
19
19
  return _default;
20
20
  }
21
21
 
22
+ /**
23
+ * Duck-typed thenable check per Promises/A+: an object or function with
24
+ * a callable `.then` method. Matches the set of values `Promise.resolve`
25
+ * natively adopts as thenables.
26
+ * @param {unknown} value
27
+ * @returns {boolean}
28
+ */
29
+ function isThenable(value) {
30
+ return value !== null
31
+ && (typeof value === 'object' || typeof value === 'function')
32
+ && typeof value.then === 'function';
33
+ }
34
+
22
35
  /**
23
36
  * Options not supported by the sync CJS wrapper.
24
37
  * These require the ESM module's async header parsing.
@@ -90,6 +103,18 @@ function clientCertificateAuth(callback, options = {}) {
90
103
  return next(e);
91
104
  }
92
105
 
106
+ // Socket may report authorized: true without exposing TLS methods (mocks,
107
+ // misconfigured non-TLS path that nonetheless sets authorized). Mirrors the
108
+ // guard in the ESM extractor (lib/extractor.js).
109
+ if (typeof req.socket.getPeerCertificate !== 'function') {
110
+ safeCallHook(onRejected, null, req, 'certificate_not_retrievable');
111
+ const e = new Error(
112
+ 'Client certificate was authenticated but certificate information could not be retrieved.'
113
+ );
114
+ e.status = 500;
115
+ return next(e);
116
+ }
117
+
93
118
  // Obtain certificate details
94
119
  const cert = req.socket.getPeerCertificate(includeChain);
95
120
  if (!cert || Object.keys(cert).length === 0) {
@@ -105,7 +130,7 @@ function clientCertificateAuth(callback, options = {}) {
105
130
  req.clientCertificate = cert;
106
131
 
107
132
  function doneAuthorizing(authorized) {
108
- if (authorized) {
133
+ if (authorized === true) {
109
134
  safeCallHook(onAuthenticated, cert, req);
110
135
  return next();
111
136
  } else {
@@ -118,8 +143,8 @@ function clientCertificateAuth(callback, options = {}) {
118
143
 
119
144
  try {
120
145
  const result = callback(cert, req);
121
- if (result instanceof Promise) {
122
- result.then(doneAuthorizing).catch((err) => {
146
+ if (isThenable(result)) {
147
+ Promise.resolve(result).then(doneAuthorizing).catch((err) => {
123
148
  safeCallHook(onRejected, cert, req, err.message || 'callback_threw');
124
149
  if (err.status === undefined) {
125
150
  err.status = 401;
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'http';
2
- import type { TLSSocket, PeerCertificate, DetailedPeerCertificate } from 'tls';
2
+ import type { Socket } from 'net';
3
+ import type { PeerCertificate, DetailedPeerCertificate } from 'tls';
3
4
  import type {
4
5
  ClientCertificateAuthOptions as EsmOptions,
5
6
  ValidationCallback as EsmValidationCallback,
@@ -24,17 +25,31 @@ export interface HttpError extends Error {
24
25
  }
25
26
 
26
27
  /**
27
- * Extended request object with TLS socket and optional Express properties.
28
+ * Extended request object compatible with Node's `http.IncomingMessage`,
29
+ * Express's `Request`, and Connect-style request objects.
30
+ *
31
+ * The socket is typed as the broader `net.Socket` with TLS-specific fields
32
+ * marked optional so the middleware accepts requests from any of those
33
+ * frameworks without a framework-specific type dependency. The runtime
34
+ * guards in the middleware check for `getPeerCertificate` before calling it.
28
35
  */
29
36
  export interface ClientCertRequest extends IncomingMessage {
30
- /** True if connection is over HTTPS (Express-specific) */
37
+ /** True if connection is over HTTPS (Express-specific, optional). */
31
38
  secure?: boolean;
32
- /** TLS socket with authorization properties */
33
- socket: TLSSocket & {
34
- /** Whether the client certificate was authorized at the TLS layer */
39
+ /**
40
+ * Underlying socket. Typed as the broader `net.Socket` so this interface
41
+ * is satisfied by both Node's `IncomingMessage.socket` and Express's
42
+ * `Request.socket`. TLS-specific fields (`authorized`, `authorizationError`,
43
+ * `getPeerCertificate`) are present at runtime when the request arrived
44
+ * over TLS and are detected by the middleware via runtime feature checks.
45
+ */
46
+ socket: Socket & {
47
+ /** Whether the client certificate was authorized at the TLS layer. */
35
48
  authorized?: boolean;
36
- /** Error message if authorization failed */
37
- authorizationError?: string;
49
+ /** Error from TLS authorization, if any. */
50
+ authorizationError?: Error | string;
51
+ /** TLS getPeerCertificate, present on TLSSocket only. */
52
+ getPeerCertificate?: (detailed?: boolean) => PeerCertificate | DetailedPeerCertificate;
38
53
  };
39
54
  /**
40
55
  * Client certificate attached by clientCertificateAuth middleware.
@@ -94,7 +109,7 @@ export interface ClientCertificateAuthOptions {
94
109
  export type ValidationCallback = (
95
110
  cert: PeerCertificate | DetailedPeerCertificate,
96
111
  req?: ClientCertRequest
97
- ) => boolean | Promise<boolean>;
112
+ ) => boolean | PromiseLike<boolean>;
98
113
 
99
114
  export type Middleware = (
100
115
  req: ClientCertRequest,
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'http';
2
- import type { TLSSocket, PeerCertificate, DetailedPeerCertificate } from 'tls';
2
+ import type { Socket } from 'net';
3
+ import type { PeerCertificate, DetailedPeerCertificate } from 'tls';
3
4
  import type { CertificateSource, HeaderEncoding } from './parsers.js';
4
5
 
5
6
  export type { CertificateSource, HeaderEncoding };
@@ -22,17 +23,31 @@ export interface HttpError extends Error {
22
23
  }
23
24
 
24
25
  /**
25
- * Extended request object with TLS socket and optional Express properties.
26
+ * Extended request object compatible with Node's `http.IncomingMessage`,
27
+ * Express's `Request`, and Connect-style request objects.
28
+ *
29
+ * The socket is typed as the broader `net.Socket` with TLS-specific fields
30
+ * marked optional so the middleware accepts requests from any of those
31
+ * frameworks without a framework-specific type dependency. The runtime
32
+ * guards in the middleware check for `getPeerCertificate` before calling it.
26
33
  */
27
34
  export interface ClientCertRequest extends IncomingMessage {
28
- /** True if connection is over HTTPS (Express-specific) */
35
+ /** True if connection is over HTTPS (Express-specific, optional). */
29
36
  secure?: boolean;
30
- /** TLS socket with authorization properties */
31
- socket: TLSSocket & {
32
- /** Whether the client certificate was authorized at the TLS layer */
37
+ /**
38
+ * Underlying socket. Typed as the broader `net.Socket` so this interface
39
+ * is satisfied by both Node's `IncomingMessage.socket` and Express's
40
+ * `Request.socket`. TLS-specific fields (`authorized`, `authorizationError`,
41
+ * `getPeerCertificate`) are present at runtime when the request arrived
42
+ * over TLS and are detected by the middleware via runtime feature checks.
43
+ */
44
+ socket: Socket & {
45
+ /** Whether the client certificate was authorized at the TLS layer. */
33
46
  authorized?: boolean;
34
- /** Error message if authorization failed */
35
- authorizationError?: string;
47
+ /** Error from TLS authorization, if any. */
48
+ authorizationError?: Error | string;
49
+ /** TLS getPeerCertificate, present on TLSSocket only. */
50
+ getPeerCertificate?: (detailed?: boolean) => PeerCertificate | DetailedPeerCertificate;
36
51
  };
37
52
  /**
38
53
  * Client certificate attached by clientCertificateAuth middleware.
@@ -91,6 +106,7 @@ export interface ClientCertificateAuthOptions {
91
106
  /**
92
107
  * Expected value indicating successful certificate verification.
93
108
  * If verifyHeader is set, requests are rejected unless the header matches this value.
109
+ * Comparison is exact (case-sensitive, no whitespace trimming).
94
110
  * Example: 'SUCCESS' for nginx.
95
111
  */
96
112
  verifyValue?: string;
@@ -123,7 +139,7 @@ export interface ClientCertificateAuthOptions {
123
139
  export type ValidationCallback = (
124
140
  cert: PeerCertificate | DetailedPeerCertificate,
125
141
  req?: ClientCertRequest
126
- ) => boolean | Promise<boolean>;
142
+ ) => boolean | PromiseLike<boolean>;
127
143
 
128
144
  export type Middleware = (
129
145
  req: ClientCertRequest,
@@ -135,7 +151,8 @@ export type Middleware = (
135
151
  * Express/Connect middleware for client SSL certificate authentication.
136
152
  *
137
153
  * @param callback - Validation function that receives the client certificate
138
- * and returns true/false (sync) or `Promise<boolean>` (async).
154
+ * and returns true/false (sync) or `PromiseLike<boolean>` (async,
155
+ * including native Promises and any thenable resolving to a boolean).
139
156
  * @param options - Configuration options
140
157
  * @returns Express middleware function
141
158
  *
@@ -5,10 +5,23 @@
5
5
  * @license MIT
6
6
  */
7
7
 
8
- import { extractClientCertificate } from './extractor.js';
8
+ import { extractClientCertificate, validateExtractorOptions } from './extractor.js';
9
9
 
10
10
  /**
11
- * @typedef {import('http').IncomingMessage & { secure?: boolean; socket: import('tls').TLSSocket & { authorized?: boolean; authorizationError?: string }; clientCertificate?: import('tls').PeerCertificate }} ClientCertRequest
11
+ * Duck-typed thenable check per Promises/A+: an object or function with
12
+ * a callable `.then` method. Matches the set of values `Promise.resolve`
13
+ * natively adopts as thenables.
14
+ * @param {unknown} value
15
+ * @returns {boolean}
16
+ */
17
+ function isThenable(value) {
18
+ return value !== null
19
+ && (typeof value === 'object' || typeof value === 'function')
20
+ && typeof /** @type {{then?: unknown}} */ (value).then === 'function';
21
+ }
22
+
23
+ /**
24
+ * @typedef {import('http').IncomingMessage & { secure?: boolean; socket: import('net').Socket & { authorized?: boolean; authorizationError?: Error | string; getPeerCertificate?: (detailed?: boolean) => import('tls').PeerCertificate | import('tls').DetailedPeerCertificate }; clientCertificate?: import('tls').PeerCertificate }} ClientCertRequest
12
25
  * @typedef {import('http').ServerResponse & { redirect: (statusOrUrl: number | string, url?: string) => void }} ClientCertResponse
13
26
  * @typedef {(req: ClientCertRequest, res: ClientCertResponse, next: (err?: Error) => void) => void} Middleware
14
27
  */
@@ -33,6 +46,7 @@ import { extractClientCertificate } from './extractor.js';
33
46
  * upstream proxy (e.g., 'X-SSL-Client-Verify'). Must be used with verifyValue.
34
47
  * @property {string} [verifyValue] - Expected value indicating successful verification (e.g., 'SUCCESS').
35
48
  * If verifyHeader is set, requests are rejected unless the header matches this value.
49
+ * Comparison is exact (case-sensitive, no whitespace trimming); set this to the exact string your proxy emits.
36
50
  * @property {(cert: import('tls').PeerCertificate, req: ClientCertRequest) => void | Promise<void>} [onAuthenticated] -
37
51
  * Called when a client is successfully authenticated. Fire-and-forget.
38
52
  * @property {(cert: import('tls').PeerCertificate | null, req: ClientCertRequest, reason: string) => void | Promise<void>} [onRejected] -
@@ -44,12 +58,13 @@ import { extractClientCertificate } from './extractor.js';
44
58
  * passed the client certificate information for additional validation.
45
59
  *
46
60
  * The callback receives the certificate (as obtained through
47
- * `req.socket.getPeerCertificate()` or extracted from headers) and must
48
- * return `true` (or a Promise resolving to `true`) for the request to proceed.
61
+ * `req.socket.getPeerCertificate()` or extracted from headers) and must
62
+ * return `true` (or a thenable resolving to `true`) for the request to proceed.
49
63
  *
50
- * @param {(cert: import('tls').PeerCertificate, req: ClientCertRequest) => boolean | Promise<boolean>} callback
64
+ * @param {(cert: import('tls').PeerCertificate, req: ClientCertRequest) => boolean | PromiseLike<boolean>} callback
51
65
  * Validation function that receives the client certificate and the request
52
- * object. Returns true/false (sync) or a `Promise<boolean>` (async) to
66
+ * object. Returns true/false (sync) or a `PromiseLike<boolean>` (async,
67
+ * including native Promises and any thenable resolving to a boolean) to
53
68
  * allow/deny access.
54
69
  * @param {ClientCertificateAuthOptions} [options={}]
55
70
  * @returns {Middleware}
@@ -76,6 +91,8 @@ export default function clientCertificateAuth(callback, options = {}) {
76
91
  throw new TypeError('client-certificate-auth: callback must be a function');
77
92
  }
78
93
 
94
+ validateExtractorOptions(options);
95
+
79
96
  const {
80
97
  certificateSource,
81
98
  certificateHeader,
@@ -88,12 +105,6 @@ export default function clientCertificateAuth(callback, options = {}) {
88
105
  onRejected,
89
106
  } = options;
90
107
 
91
- if ((verifyHeader && !verifyValue) || (!verifyHeader && verifyValue)) {
92
- throw new Error(
93
- 'client-certificate-auth: verifyHeader and verifyValue must both be provided together, or both omitted'
94
- );
95
- }
96
-
97
108
  /**
98
109
  * Safely call a hook function without blocking or throwing.
99
110
  * Deferred via queueMicrotask to ensure truly non-blocking behavior.
@@ -161,10 +172,10 @@ export default function clientCertificateAuth(callback, options = {}) {
161
172
  req.clientCertificate = cert;
162
173
 
163
174
  /**
164
- * @param {boolean} authorized
175
+ * @param {unknown} authorized
165
176
  */
166
177
  function doneAuthorizing(authorized) {
167
- if (authorized) {
178
+ if (authorized === true) {
168
179
  safeCallHook(onAuthenticated, cert, req);
169
180
  return next();
170
181
  } else {
@@ -177,8 +188,8 @@ export default function clientCertificateAuth(callback, options = {}) {
177
188
 
178
189
  try {
179
190
  const callbackResult = callback(cert, req);
180
- if (callbackResult instanceof Promise) {
181
- callbackResult.then(doneAuthorizing).catch((err) => {
191
+ if (isThenable(callbackResult)) {
192
+ Promise.resolve(callbackResult).then(doneAuthorizing).catch((err) => {
182
193
  safeCallHook(onRejected, cert, req, err.message || 'callback_threw');
183
194
  if (err.status === undefined) {
184
195
  err.status = 401;
@@ -58,6 +58,13 @@ export function extractClientCertificate(req: {
58
58
  getPeerCertificate?: (detailed: boolean) => import("tls").PeerCertificate;
59
59
  };
60
60
  }, options?: ExtractorOptions): ExtractionResult;
61
+ /**
62
+ * Validate options shared by `extractClientCertificate` and the middleware constructor.
63
+ * Throws on unknown `certificateSource`, unknown `headerEncoding`,
64
+ * `certificateHeader` without an encoding source, or only one of `verifyHeader`/`verifyValue`.
65
+ * Omitted or `undefined` options are treated as the empty object; explicit `null` throws.
66
+ */
67
+ export function validateExtractorOptions(options?: ExtractorOptions): void;
61
68
  export type ExtractionResult = {
62
69
  /**
63
70
  * - Whether extraction succeeded
package/lib/extractor.js CHANGED
@@ -4,7 +4,47 @@
4
4
  * @license MIT
5
5
  */
6
6
 
7
- import { getCertificateFromHeaders } from './parsers.js';
7
+ import { getCertificateFromHeaders, PRESETS } from './parsers.js';
8
+
9
+ const VALID_ENCODINGS = ['url-pem', 'url-pem-aws', 'xfcc', 'base64-der', 'rfc9440'];
10
+
11
+ /**
12
+ * Validate options shared by `extractClientCertificate` and the middleware
13
+ * constructor. Throws on misconfiguration (unknown preset, unknown encoding,
14
+ * `certificateHeader` without an encoding source, or only one of
15
+ * `verifyHeader`/`verifyValue`). Omitted or `undefined` options are treated
16
+ * as the empty object; explicit `null` will throw a `TypeError`.
17
+ *
18
+ * @param {ExtractorOptions} [options]
19
+ * @throws {Error} when options are malformed
20
+ */
21
+ export function validateExtractorOptions(options = {}) {
22
+ const { certificateSource, certificateHeader, headerEncoding, verifyHeader, verifyValue } = options;
23
+
24
+ if ((verifyHeader && !verifyValue) || (!verifyHeader && verifyValue)) {
25
+ throw new Error(
26
+ 'client-certificate-auth: verifyHeader and verifyValue must both be provided together, or both omitted'
27
+ );
28
+ }
29
+
30
+ if (certificateSource && !Object.hasOwn(PRESETS, certificateSource)) {
31
+ throw new Error(
32
+ `client-certificate-auth: unknown certificateSource '${certificateSource}'. Valid values: ${Object.keys(PRESETS).join(', ')}`
33
+ );
34
+ }
35
+
36
+ if (headerEncoding && !VALID_ENCODINGS.includes(headerEncoding)) {
37
+ throw new Error(
38
+ `client-certificate-auth: unknown headerEncoding '${headerEncoding}'. Valid values: ${VALID_ENCODINGS.join(', ')}`
39
+ );
40
+ }
41
+
42
+ if (certificateHeader && !certificateSource && !headerEncoding) {
43
+ throw new Error(
44
+ 'client-certificate-auth: certificateHeader requires headerEncoding (or a certificateSource preset that supplies one)'
45
+ );
46
+ }
47
+ }
8
48
 
9
49
  /**
10
50
  * @typedef {Object} ExtractionResult
@@ -66,6 +106,8 @@ import { getCertificateFromHeaders } from './parsers.js';
66
106
  * });
67
107
  */
68
108
  export function extractClientCertificate(req, options = {}) {
109
+ validateExtractorOptions(options);
110
+
69
111
  const {
70
112
  certificateSource,
71
113
  certificateHeader,
@@ -76,13 +118,6 @@ export function extractClientCertificate(req, options = {}) {
76
118
  verifyValue,
77
119
  } = options;
78
120
 
79
- // Validate verifyHeader/verifyValue pairing
80
- if ((verifyHeader && !verifyValue) || (!verifyHeader && verifyValue)) {
81
- throw new Error(
82
- 'extractClientCertificate: verifyHeader and verifyValue must both be provided together, or both omitted'
83
- );
84
- }
85
-
86
121
  const useHeaders = Boolean(certificateSource || certificateHeader);
87
122
  let cert = null;
88
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "client-certificate-auth",
3
- "version": "1.3.5",
3
+ "version": "2.0.1",
4
4
  "description": "Express/Connect middleware for mTLS client certificate authentication with reverse proxy support (AWS ALB, Envoy, Cloudflare, Traefik)",
5
5
  "homepage": "https://github.com/tgies/client-certificate-auth",
6
6
  "bugs": {
@@ -75,12 +75,13 @@
75
75
  "@arethetypeswrong/cli": "^0.18.2",
76
76
  "@commitlint/cli": "^20.4.3",
77
77
  "@commitlint/config-conventional": "^20.5.0",
78
+ "@eslint/js": "^10.0.1",
78
79
  "@stryker-mutator/core": "^9.6.0",
79
80
  "@stryker-mutator/jest-runner": "^9.6.1",
80
81
  "@types/express": "^5.0.6",
81
82
  "@types/node": "^25.6.0",
82
83
  "c8": "^11.0.0",
83
- "eslint": "^9.39.4",
84
+ "eslint": "^10.2.1",
84
85
  "globals": "^17.5.0",
85
86
  "husky": "^9.1.7",
86
87
  "jest": "^30.3.0",