client-certificate-auth 1.3.2 → 1.3.4

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
@@ -1,13 +1,18 @@
1
1
  # client-certificate-auth
2
2
 
3
- Express/Connect middleware for client SSL certificate authentication (mTLS).
3
+ Comprehensive toolkit for client SSL certificate authentication (mTLS) in Node.js. Includes Express/Connect middleware, framework-agnostic certificate extraction for reverse proxies (AWS ALB, Envoy, Cloudflare, Traefik, and more), and pre-built authorization helpers.
4
4
 
5
5
  [![CI](https://github.com/tgies/client-certificate-auth/actions/workflows/ci.yml/badge.svg)](https://github.com/tgies/client-certificate-auth/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/client-certificate-auth.svg)](https://www.npmjs.com/package/client-certificate-auth)
7
7
  [![codecov](https://codecov.io/gh/tgies/client-certificate-auth/graph/badge.svg)](https://codecov.io/gh/tgies/client-certificate-auth)
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
- 100% line/branch/function/statement coverage, plus mutation testing and E2E tests against real nginx/Envoy/Traefik containers. ~4,742 lines of test code for ~709 lines of source (measured by [cloc](https://github.com/AlDanial/cloc)).
10
+ [**Full Documentation**](https://tgies.github.io/client-certificate-auth/) - guides, API reference, and runnable examples
11
+ [**Commercial Support**](#commercial-support) - consulting, custom features, and priority support for production deployments
12
+
13
+ **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
+ **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)).
11
16
 
12
17
  ## Installation
13
18
 
@@ -19,9 +24,11 @@ npm install client-certificate-auth
19
24
 
20
25
  ## Synopsis
21
26
 
22
- This middleware requires clients to present a valid, verifiable SSL certificate (mutual TLS / mTLS). The certificate is validated at the TLS layer, then passed to your callback for additional authorization logic.
27
+ This library provides everything you need to implement mutual TLS (mTLS) authentication in Node.js. It extracts client certificates from direct TLS connections (`req.socket`) or from HTTP headers forwarded by reverse proxies (AWS ALB, Envoy, Cloudflare, Traefik, nginx, HAProxy).
23
28
 
24
- Compatible with Express, Connect, and any Node.js HTTP server framework that uses standard `req.socket` and `req.headers`.
29
+ The certificate is parsed into a standard `tls.PeerCertificate` object and passed to your callback for authorization logic.
30
+
31
+ Compatible with Express, Connect, or any Node.js HTTP server framework by using the framework-agnostic `extractClientCertificate` function.
25
32
 
26
33
  ## Usage
27
34
 
@@ -292,6 +299,8 @@ This package provides everything you need to build mTLS authentication for any N
292
299
 
293
300
  If you build an adapter for another framework (Koa, Fastify, Hapi, NestJS, etc.), please open an issue or PR to get it listed here!
294
301
 
302
+ > For complete API documentation with all types, parameters, and examples, see the [API Reference](https://tgies.github.io/client-certificate-auth/api/).
303
+
295
304
  ### Accessing the Certificate
296
305
 
297
306
  After authentication, the certificate is attached to `req.clientCertificate` for downstream handlers:
@@ -903,6 +912,30 @@ The sync CJS wrapper does not support reverse proxy options (`certificateSource`
903
912
 
904
913
  The `/helpers`, `/parsers`, and `/extractor` subpath exports each provide a `load()` function in CJS. See [Subpath Exports in CJS](#subpath-exports-in-cjs) for details.
905
914
 
915
+ ## Commercial Support
916
+
917
+ `client-certificate-auth` is built and maintained by [Tony Gies](https://github.com/tgies). For organizations running it in production, commercial support is available through his consultancy, Crash United, LLC.
918
+
919
+ ### Support Offerings
920
+
921
+ | Service | Description |
922
+ |---------|-------------|
923
+ | **Priority bug fixes** | Reported issues triaged and patched ahead of the public queue |
924
+ | **Custom features & integrations** | Adapters for new reverse proxies, encoding formats, or framework wrappers |
925
+ | **mTLS architecture consulting** | Review of your certificate issuance, rotation, and trust-chain design |
926
+ | **Deployment security review** | Threat modeling for your specific proxy + middleware + auth flow |
927
+ | **Private security advisories** | Coordinated disclosure for vulnerabilities affecting your deployment |
928
+
929
+ For pricing, scoping, or anything not listed above, email **[support@crashunited.com](mailto:support@crashunited.com)** to discuss your needs.
930
+
931
+ ### Sponsorship
932
+
933
+ To support ongoing development without a formal contract, [GitHub Sponsors](https://github.com/sponsors/tgies) is the simplest path.
934
+
935
+ ### Enterprise Procurement
936
+
937
+ This package is enrolled in [Tidelift](https://tidelift.com/) (now part of SonarQube Advanced Security). If your organization already subscribes, `client-certificate-auth` is included in your coverage for security disclosures, license compliance, and version metadata.
938
+
906
939
  ## License
907
940
 
908
941
  MIT © Tony Gies
@@ -135,7 +135,7 @@ export type Middleware = (
135
135
  * Express/Connect middleware for client SSL certificate authentication.
136
136
  *
137
137
  * @param callback - Validation function that receives the client certificate
138
- * and returns true/false (sync) or Promise<boolean> (async).
138
+ * and returns true/false (sync) or `Promise<boolean>` (async).
139
139
  * @param options - Configuration options
140
140
  * @returns Express middleware function
141
141
  *
@@ -15,11 +15,14 @@ import { extractClientCertificate } from './extractor.js';
15
15
 
16
16
  /**
17
17
  * @typedef {Object} ClientCertificateAuthOptions
18
- * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Use a preset
18
+ * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Use a preset
19
19
  * configuration for a known reverse proxy. Header-based certs are only checked if this or
20
- * certificateHeader is set.
20
+ * certificateHeader is set. Trust boundary: the proxy must strip the preset's header from
21
+ * external requests; any source that can set it is trusted to assert client identity.
21
22
  * @property {string} [certificateHeader] - Custom header name to read certificate from.
22
- * Overrides preset header name if also using certificateSource.
23
+ * Overrides preset header name if also using certificateSource. Trust boundary: the proxy
24
+ * must strip this header from external requests; any source that can set it is trusted to
25
+ * assert client identity.
23
26
  * @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] -
24
27
  * How to decode the header value. Required when using certificateHeader without certificateSource.
25
28
  * @property {boolean} [fallbackToSocket=false] - If header-based extraction is configured but
@@ -46,7 +49,7 @@ import { extractClientCertificate } from './extractor.js';
46
49
  *
47
50
  * @param {(cert: import('tls').PeerCertificate, req: ClientCertRequest) => boolean | Promise<boolean>} callback
48
51
  * Validation function that receives the client certificate and the request
49
- * object. Returns true/false (sync) or a Promise<boolean> (async) to
52
+ * object. Returns true/false (sync) or a `Promise<boolean>` (async) to
50
53
  * allow/deny access.
51
54
  * @param {ClientCertificateAuthOptions} [options={}]
52
55
  * @returns {Middleware}
package/lib/extractor.js CHANGED
@@ -21,8 +21,12 @@ import { getCertificateFromHeaders } from './parsers.js';
21
21
 
22
22
  /**
23
23
  * @typedef {Object} ExtractorOptions
24
- * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Preset configuration
25
- * @property {string} [certificateHeader] - Custom header name
24
+ * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Preset
25
+ * configuration. Trust boundary: the proxy must strip the preset's header from external
26
+ * requests; any source that can set it is trusted to assert client identity.
27
+ * @property {string} [certificateHeader] - Custom header name. Trust boundary: the proxy must
28
+ * strip this header from external requests; any source that can set it is trusted to assert
29
+ * client identity.
26
30
  * @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] - Header encoding
27
31
  * @property {boolean} [fallbackToSocket=false] - Try socket if header extraction fails
28
32
  * @property {boolean} [includeChain=false] - Include issuerCertificate chain
package/lib/helpers.js CHANGED
@@ -245,6 +245,9 @@ export function allowEmail(emails) {
245
245
  * )));
246
246
  */
247
247
  export function allOf(...callbacks) {
248
+ if (callbacks.length === 0) {
249
+ return () => false;
250
+ }
248
251
  return async (cert, req) => {
249
252
  const results = await Promise.all(callbacks.map((cb) => cb(cert, req)));
250
253
  return results.every((r) => r === true);
package/lib/parsers.js CHANGED
@@ -60,7 +60,7 @@ export const PRESETS = {
60
60
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
61
61
  */
62
62
  export function parseUrlPem(headerValue) {
63
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
63
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
64
64
  if (!headerValue) {
65
65
  return null;
66
66
  }
@@ -73,17 +73,88 @@ export function parseUrlPem(headerValue) {
73
73
  }
74
74
  }
75
75
 
76
+ /**
77
+ * Split a string of concatenated PEM CERTIFICATE blocks into an array of
78
+ * individual PEM strings. Uses indexOf scanning (O(N) in input length) rather
79
+ * than regex to avoid polynomial-time backtracking on adversarial input
80
+ * (e.g., many unterminated BEGIN markers).
81
+ *
82
+ * @param {string} pem - Concatenated PEM blocks
83
+ * @returns {string[]} Individual PEM blocks (empty array if none found)
84
+ */
85
+ function splitPemBlocks(pem) {
86
+ // Stryker disable next-line ArrayDeclaration: sentinel-array mutant pushes a non-PEM string that pemToCertificate rejects and .filter(Boolean) drops downstream — same chain output
87
+ const blocks = [];
88
+ // Stryker disable next-line StringLiteral: empty beginMarker → indexOf returns scanPos every iteration; END markers still bracket the same blocks correctly
89
+ const beginMarker = '-----BEGIN CERTIFICATE-----';
90
+ const endMarker = '-----END CERTIFICATE-----';
91
+ let scanPos = 0;
92
+ while (true) {
93
+ const begin = pem.indexOf(beginMarker, scanPos);
94
+ if (begin === -1) {break;}
95
+ const end = pem.indexOf(endMarker, begin + beginMarker.length);
96
+ if (end === -1) {break;}
97
+ blocks.push(pem.substring(begin, end + endMarker.length));
98
+ // Stryker disable next-line ArithmeticOperator: end-endMarker.length backs up before the END marker, but the next BEGIN is past it so indexOf still advances correctly
99
+ scanPos = end + endMarker.length;
100
+ }
101
+ return blocks;
102
+ }
103
+
104
+ /**
105
+ * Parse a multi-block PEM blob into a chained PeerCertificate. Splits the
106
+ * input into individual blocks, parses each, drops blocks that fail to
107
+ * parse, and links the remainder via issuerCertificate.
108
+ *
109
+ * Used by parsers whose proxies forward the full chain in a single field
110
+ * (parseUrlPemAws, parseXfcc Chain). Without explicit chain linking, calling
111
+ * X509Certificate() on a multi-block PEM returns just the leaf with no
112
+ * issuerCertificate (toLegacyObject drops the property), silently losing
113
+ * chain information for `includeChain: true` consumers.
114
+ *
115
+ * @param {string} pem - Concatenated PEM blocks
116
+ * @returns {PeerCertificate | null} Leaf cert with chain via issuerCertificate, or null if no blocks parse
117
+ */
118
+ function chainFromMultiBlockPem(pem) {
119
+ const pemBlocks = splitPemBlocks(pem);
120
+ // Stryker disable next-line BlockStatement,ConditionalExpression: short-circuit; falling through hits the next certs.length===0 check with the same null result
121
+ if (pemBlocks.length === 0) {
122
+ return null;
123
+ }
124
+
125
+ const certs = pemBlocks.map(block => {
126
+ try {
127
+ return pemToCertificate(block);
128
+ } catch {
129
+ // Stryker disable next-line BlockStatement: empty catch returns undefined which .filter(Boolean) drops alongside null — same chain output
130
+ return null;
131
+ }
132
+ }).filter(Boolean);
133
+
134
+ if (certs.length === 0) {
135
+ return null;
136
+ }
137
+
138
+ // Stryker disable next-line EqualityOperator: setting issuerCertificate = undefined on last cert is same as not setting it
139
+ for (let i = 0; i < certs.length - 1; i++) {
140
+ certs[i].issuerCertificate = certs[i + 1];
141
+ }
142
+
143
+ return certs[0];
144
+ }
145
+
76
146
  /**
77
147
  * Parse URL-encoded PEM certificate with AWS ALB safe character handling.
78
148
  * AWS ALB uses +, =, / as safe characters (not encoded), but decodeURIComponent
79
- * interprets + as space, so we must escape it first.
80
- *
149
+ * interprets + as space, so we must escape it first. AWS sends the full chain
150
+ * as concatenated PEM blocks, which we split and link via issuerCertificate.
151
+ *
81
152
  * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/mutual-authentication.html
82
153
  * @param {string} headerValue - AWS ALB URL-encoded PEM certificate
83
154
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
84
155
  */
85
156
  export function parseUrlPemAws(headerValue) {
86
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
157
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
87
158
  if (!headerValue) {
88
159
  return null;
89
160
  }
@@ -93,7 +164,7 @@ export function parseUrlPemAws(headerValue) {
93
164
  // Must escape before decodeURIComponent or + becomes space
94
165
  const escaped = headerValue.replace(/\+/g, '%2B');
95
166
  const pem = decodeURIComponent(escaped);
96
- return pemToCertificate(pem);
167
+ return chainFromMultiBlockPem(pem);
97
168
  } catch {
98
169
  return null;
99
170
  }
@@ -101,14 +172,17 @@ export function parseUrlPemAws(headerValue) {
101
172
 
102
173
  /**
103
174
  * Parse Envoy XFCC (X-Forwarded-Client-Cert) structured header format.
104
- * Format: Key=Value;Key=Value;... where Cert or Chain contains URL-encoded PEM.
105
- *
175
+ * Format: Key=Value;Key=Value;... where Cert (single URL-encoded PEM) or
176
+ * Chain (URL-encoded multi-block PEM with the full chain incl. leaf) carry
177
+ * the certificate. When both are present we prefer Chain because it carries
178
+ * more information, and link the chain via issuerCertificate.
179
+ *
106
180
  * @see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
107
181
  * @param {string} headerValue - XFCC formatted header value
108
182
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
109
183
  */
110
184
  export function parseXfcc(headerValue) {
111
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
185
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
112
186
  if (!headerValue) {
113
187
  return null;
114
188
  }
@@ -132,8 +206,11 @@ export function parseXfcc(headerValue) {
132
206
  }
133
207
  const firstElement = headerValue.substring(0, endOfFirst);
134
208
 
135
- // Parse key=value pairs separated by semicolons
209
+ // Parse key=value pairs separated by semicolons. Collect Cert and
210
+ // Chain values; prefer Chain when both are present.
136
211
  const pairs = firstElement.split(';');
212
+ let certValue = null;
213
+ let chainValue = null;
137
214
 
138
215
  for (const pair of pairs) {
139
216
  const eqIndex = pair.indexOf('=');
@@ -147,19 +224,32 @@ export function parseXfcc(headerValue) {
147
224
  // Stryker disable next-line MethodExpression: defensive normalization, no real proxy sends whitespace
148
225
  let value = pair.substring(eqIndex + 1).trim();
149
226
 
150
- // Cert or Chain contain the certificate
151
227
  if (key === 'Cert' || key === 'Chain') {
152
- // Remove surrounding quotes if present
228
+ // Stryker disable next-line LogicalOperator,MethodExpression,StringLiteral,BlockStatement: quote-stripping mutations are equivalent given the secondary unbalanced-quote reject below and lenient PEM marker scan downstream — broken quotes either trigger reject or produce a stripped value that fails to parse, both yielding null
153
229
  if (value.startsWith('"') && value.endsWith('"')) {
230
+ // Stryker disable next-line MethodExpression: skipping the slice leaves quotes around the value; PEM marker scan still finds BEGIN/END inside them and parses the cert content correctly — equivalent for tests
154
231
  value = value.slice(1, -1);
232
+ } else if (value.startsWith('"') || value.endsWith('"')) {
233
+ // Unbalanced quote indicates malformed XFCC. Reject rather
234
+ // than parse the cert content out of the broken wrapping.
235
+ return null;
236
+ }
237
+ if (key === 'Chain') {
238
+ chainValue = value;
239
+ } else {
240
+ certValue = value;
155
241
  }
156
-
157
- const pem = decodeURIComponent(value);
158
- return pemToCertificate(pem);
159
242
  }
160
243
  }
161
244
 
162
- return null;
245
+ const value = chainValue ?? certValue;
246
+ // Stryker disable next-line BlockStatement,ConditionalExpression: short-circuit; falling through with null hands "null" string to chainFromMultiBlockPem which finds no PEM markers and returns null
247
+ if (!value) {
248
+ return null;
249
+ }
250
+
251
+ const pem = decodeURIComponent(value);
252
+ return chainFromMultiBlockPem(pem);
163
253
  } catch {
164
254
  return null;
165
255
  }
@@ -175,7 +265,7 @@ export function parseXfcc(headerValue) {
175
265
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
176
266
  */
177
267
  export function parseBase64Der(headerValue) {
178
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
268
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
179
269
  if (!headerValue) {
180
270
  return null;
181
271
  }
@@ -222,7 +312,7 @@ export function parseBase64Der(headerValue) {
222
312
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
223
313
  */
224
314
  export function parseRfc9440(headerValue) {
225
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
315
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
226
316
  if (!headerValue) {
227
317
  return null;
228
318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "client-certificate-auth",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
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": {
@@ -72,21 +72,24 @@
72
72
  "node": ">= 20"
73
73
  },
74
74
  "devDependencies": {
75
- "@commitlint/cli": "^20.4.1",
76
- "@commitlint/config-conventional": "^20.4.1",
77
- "@stryker-mutator/core": "^9.5.1",
78
- "@stryker-mutator/jest-runner": "^9.5.1",
75
+ "@commitlint/cli": "^20.4.3",
76
+ "@commitlint/config-conventional": "^20.5.0",
77
+ "@stryker-mutator/core": "^9.6.0",
78
+ "@stryker-mutator/jest-runner": "^9.6.1",
79
79
  "@types/express": "^5.0.6",
80
- "@types/node": "^25.2.3",
81
- "c8": "^10.1.3",
82
- "eslint": "^9.17.0",
83
- "globals": "^17.3.0",
80
+ "@types/node": "^25.6.0",
81
+ "c8": "^11.0.0",
82
+ "eslint": "^9.39.4",
83
+ "globals": "^17.5.0",
84
84
  "husky": "^9.1.7",
85
- "jest": "^30.2.0",
86
- "lint-staged": "^16.2.7",
85
+ "jest": "^30.3.0",
86
+ "lint-staged": "^16.3.2",
87
87
  "selfsigned": "^5.5.0",
88
- "typescript": "^5.9.3",
89
- "ws": "^8.19.0"
88
+ "typedoc": "^0.28.19",
89
+ "typedoc-plugin-markdown": "^4.11.0",
90
+ "typescript": "^6.0.3",
91
+ "vitepress": "^2.0.0-alpha.17",
92
+ "ws": "^8.20.0"
90
93
  },
91
94
  "directories": {
92
95
  "lib": "./lib",
@@ -103,7 +106,11 @@
103
106
  "build:types": "tsc --declaration --emitDeclarationOnly --outDir lib",
104
107
  "check": "npm run lint && npm run typecheck && npm run test:coverage",
105
108
  "stats": "echo '=== Source ===' && cloc lib/ --quiet | tail -n +2 && echo '=== Tests ===' && cloc test/ --exclude-dir=docker --quiet | tail -n +2 && echo '=== Package ===' && npm pack --dry-run 2>&1 | grep -E 'package size|unpacked size|total files'",
106
- "prepare": "husky"
109
+ "prepare": "husky",
110
+ "docs:api": "typedoc",
111
+ "docs:dev": "vitepress dev docs",
112
+ "docs:build": "npm run docs:api && vitepress build docs",
113
+ "docs:preview": "vitepress preview docs"
107
114
  },
108
115
  "repository": {
109
116
  "type": "git",