client-certificate-auth 2.1.3 → 2.2.0

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
@@ -354,6 +354,8 @@ app.use(clientCertificateAuth((cert) => {
354
354
 
355
355
  When `includeChain: true`, the certificate object includes `issuerCertificate` linking to the issuer's certificate (and so on up the chain). This works consistently for both socket-based and header-based extraction.
356
356
 
357
+ For header-based extraction the first certificate in the header is the leaf. If it is empty or unparseable the header is rejected with `header_missing_or_malformed` rather than promoting the next certificate into the leaf position. See [Reverse Proxy Setup](https://tgies.github.io/client-certificate-auth/guide/reverse-proxy#chains-in-a-single-header) for the per-encoding details.
358
+
357
359
  ### User Login
358
360
 
359
361
  Client certificates provide cryptographically-verified identity, making them ideal for user authentication. Map certificate fields to user accounts in your database:
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'http';
2
2
  import type { Socket } from 'net';
3
3
  import type { PeerCertificate, DetailedPeerCertificate } from 'tls';
4
+ import type { ChainedPeerCertificate } from './parsers.js';
4
5
  import type {
5
6
  ClientCertificateAuthOptions as EsmOptions,
6
7
  ValidationCallback as EsmValidationCallback,
@@ -56,7 +57,7 @@ export interface ClientCertRequest extends IncomingMessage {
56
57
  * Available after successful certificate extraction.
57
58
  * Contains issuerCertificate chain if includeChain option is true.
58
59
  */
59
- clientCertificate?: PeerCertificate | DetailedPeerCertificate;
60
+ clientCertificate?: ChainedPeerCertificate;
60
61
  }
61
62
 
62
63
  /**
@@ -88,7 +89,7 @@ export interface ClientCertificateAuthOptions {
88
89
  * @param req - The HTTP request object
89
90
  */
90
91
  onAuthenticated?: (
91
- cert: PeerCertificate | DetailedPeerCertificate,
92
+ cert: ChainedPeerCertificate,
92
93
  req: ClientCertRequest
93
94
  ) => void | Promise<void>;
94
95
 
@@ -100,14 +101,14 @@ export interface ClientCertificateAuthOptions {
100
101
  * @param reason - Why authentication was rejected
101
102
  */
102
103
  onRejected?: (
103
- cert: PeerCertificate | DetailedPeerCertificate | null,
104
+ cert: ChainedPeerCertificate | null,
104
105
  req: ClientCertRequest,
105
106
  reason: string
106
107
  ) => void | Promise<void>;
107
108
  }
108
109
 
109
110
  export type ValidationCallback = (
110
- cert: PeerCertificate | DetailedPeerCertificate,
111
+ cert: ChainedPeerCertificate,
111
112
  req?: ClientCertRequest
112
113
  ) => boolean | PromiseLike<boolean>;
113
114
 
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'http';
2
2
  import type { Socket } from 'net';
3
3
  import type { PeerCertificate, DetailedPeerCertificate } from 'tls';
4
+ import type { ChainedPeerCertificate } from './parsers.js';
4
5
  import type { CertificateSource, HeaderEncoding } from './parsers.js';
5
6
 
6
7
  export type { CertificateSource, HeaderEncoding };
@@ -54,7 +55,7 @@ export interface ClientCertRequest extends IncomingMessage {
54
55
  * Available after successful certificate extraction, before authorization callback.
55
56
  * Contains issuerCertificate chain if includeChain option is true.
56
57
  */
57
- clientCertificate?: PeerCertificate | DetailedPeerCertificate;
58
+ clientCertificate?: ChainedPeerCertificate;
58
59
  }
59
60
 
60
61
  /**
@@ -127,7 +128,7 @@ export interface ClientCertificateAuthOptions {
127
128
  * @param req - The HTTP request object
128
129
  */
129
130
  onAuthenticated?: (
130
- cert: PeerCertificate | DetailedPeerCertificate,
131
+ cert: ChainedPeerCertificate,
131
132
  req: ClientCertRequest
132
133
  ) => void | Promise<void>;
133
134
 
@@ -139,14 +140,14 @@ export interface ClientCertificateAuthOptions {
139
140
  * @param reason - Why authentication was rejected
140
141
  */
141
142
  onRejected?: (
142
- cert: PeerCertificate | DetailedPeerCertificate | null,
143
+ cert: ChainedPeerCertificate | null,
143
144
  req: ClientCertRequest,
144
145
  reason: string
145
146
  ) => void | Promise<void>;
146
147
  }
147
148
 
148
149
  export type ValidationCallback = (
149
- cert: PeerCertificate | DetailedPeerCertificate,
150
+ cert: ChainedPeerCertificate,
150
151
  req?: ClientCertRequest
151
152
  ) => boolean | PromiseLike<boolean>;
152
153
 
@@ -34,7 +34,7 @@ function normalizeCallbackError(err) {
34
34
  }
35
35
 
36
36
  /**
37
- * @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
37
+ * @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('./parsers.js').ChainedPeerCertificate }} ClientCertRequest
38
38
  * @typedef {import('http').ServerResponse & { redirect: (statusOrUrl: number | string, url?: string) => void }} ClientCertResponse
39
39
  * @typedef {(req: ClientCertRequest, res: ClientCertResponse, next: (err?: Error) => void) => void} Middleware
40
40
  */
@@ -64,9 +64,9 @@ function normalizeCallbackError(err) {
64
64
  * @property {string} [verifyValue] - Expected value indicating successful verification (e.g., 'SUCCESS').
65
65
  * If verifyHeader is set, requests are rejected unless the header matches this value.
66
66
  * Comparison is exact (case-sensitive, no whitespace trimming); set this to the exact string your proxy emits.
67
- * @property {(cert: import('tls').PeerCertificate, req: ClientCertRequest) => void | Promise<void>} [onAuthenticated] -
67
+ * @property {(cert: import('./parsers.js').ChainedPeerCertificate, req: ClientCertRequest) => void | Promise<void>} [onAuthenticated] -
68
68
  * Called when a client is successfully authenticated. Fire-and-forget.
69
- * @property {(cert: import('tls').PeerCertificate | null, req: ClientCertRequest, reason: string) => void | Promise<void>} [onRejected] -
69
+ * @property {(cert: import('./parsers.js').ChainedPeerCertificate | null, req: ClientCertRequest, reason: string) => void | Promise<void>} [onRejected] -
70
70
  * Called when authentication is rejected. Fire-and-forget.
71
71
  */
72
72
 
@@ -78,7 +78,7 @@ function normalizeCallbackError(err) {
78
78
  * `req.socket.getPeerCertificate()` or extracted from headers) and must
79
79
  * return `true` (or a thenable resolving to `true`) for the request to proceed.
80
80
  *
81
- * @param {(cert: import('tls').PeerCertificate, req: ClientCertRequest) => boolean | PromiseLike<boolean>} callback
81
+ * @param {(cert: import('./parsers.js').ChainedPeerCertificate, req: ClientCertRequest) => boolean | PromiseLike<boolean>} callback
82
82
  * Validation function that receives the client certificate and the request
83
83
  * object. Returns true/false (sync) or a `PromiseLike<boolean>` (async,
84
84
  * including native Promises and any thenable resolving to a boolean) to
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @typedef {Object} ExtractionResult
3
3
  * @property {boolean} success - Whether extraction succeeded
4
- * @property {import('tls').PeerCertificate | null} certificate - Extracted certificate (null on failure)
4
+ * @property {import('./parsers.js').ChainedPeerCertificate | null} certificate - Extracted certificate (null on failure)
5
5
  * @property {string | null} reason - Rejection reason code (null on success)
6
6
  *
7
7
  * Rejection reasons:
@@ -74,7 +74,7 @@ export type ExtractionResult = {
74
74
  /**
75
75
  * - Extracted certificate (null on failure)
76
76
  */
77
- certificate: import("tls").PeerCertificate | null;
77
+ certificate: import("./parsers.js").ChainedPeerCertificate | null;
78
78
  /**
79
79
  * - Rejection reason code (null on success)
80
80
  *
package/lib/extractor.js CHANGED
@@ -56,7 +56,7 @@ export function validateExtractorOptions(options = {}) {
56
56
  /**
57
57
  * @typedef {Object} ExtractionResult
58
58
  * @property {boolean} success - Whether extraction succeeded
59
- * @property {import('tls').PeerCertificate | null} certificate - Extracted certificate (null on failure)
59
+ * @property {import('./parsers.js').ChainedPeerCertificate | null} certificate - Extracted certificate (null on failure)
60
60
  * @property {string | null} reason - Rejection reason code (null on success)
61
61
  *
62
62
  * Rejection reasons:
package/lib/parsers.d.ts CHANGED
@@ -6,6 +6,14 @@
6
6
 
7
7
  import type { PeerCertificate } from 'tls';
8
8
 
9
+ /**
10
+ * A certificate with an optional issuer chain. Header-extracted chains end at
11
+ * the topmost certificate, while Node's socket chains self-reference at the root.
12
+ */
13
+ export type ChainedPeerCertificate = PeerCertificate & {
14
+ issuerCertificate?: ChainedPeerCertificate;
15
+ };
16
+
9
17
  /**
10
18
  * Supported header encoding formats.
11
19
  */
@@ -60,26 +68,26 @@ export interface CertificateHeaderConfig {
60
68
  * Parse URL-encoded PEM certificate (nginx, HAProxy format).
61
69
  * @see https://nginx.org/en/docs/http/ngx_http_ssl_module.html#var_ssl_client_escaped_cert
62
70
  */
63
- export declare function parseUrlPem(headerValue: string): PeerCertificate | null;
71
+ export declare function parseUrlPem(headerValue: string): ChainedPeerCertificate | null;
64
72
 
65
73
  /**
66
74
  * Parse URL-encoded PEM certificate with AWS ALB safe character handling.
67
75
  * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/mutual-authentication.html
68
76
  */
69
- export declare function parseUrlPemAws(headerValue: string): PeerCertificate | null;
77
+ export declare function parseUrlPemAws(headerValue: string): ChainedPeerCertificate | null;
70
78
 
71
79
  /**
72
80
  * Parse Envoy XFCC (X-Forwarded-Client-Cert) structured header format.
73
81
  * @see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
74
82
  */
75
- export declare function parseXfcc(headerValue: string): PeerCertificate | null;
83
+ export declare function parseXfcc(headerValue: string): ChainedPeerCertificate | null;
76
84
 
77
85
  /**
78
86
  * Parse base64-encoded DER certificate (Cloudflare, Traefik format).
79
87
  * Also handles Traefik's comma-separated cert chains.
80
88
  * @see https://developers.cloudflare.com/api-shield/security/mtls/configure/
81
89
  */
82
- export declare function parseBase64Der(headerValue: string): PeerCertificate | null;
90
+ export declare function parseBase64Der(headerValue: string): ChainedPeerCertificate | null;
83
91
 
84
92
  /**
85
93
  * Parse RFC 9440 format certificate (used by Google Cloud Load Balancer).
@@ -103,7 +111,7 @@ export declare function derToCertificate(der: Buffer): PeerCertificate;
103
111
  export declare function parseHeaderValue(
104
112
  headerValue: string,
105
113
  encoding: HeaderEncoding
106
- ): PeerCertificate | null;
114
+ ): ChainedPeerCertificate | null;
107
115
 
108
116
  /**
109
117
  * Get certificate from request headers using configuration.
@@ -111,4 +119,4 @@ export declare function parseHeaderValue(
111
119
  export declare function getCertificateFromHeaders(
112
120
  headers: Record<string, string | string[] | undefined>,
113
121
  config: CertificateHeaderConfig
114
- ): PeerCertificate | null;
122
+ ): ChainedPeerCertificate | null;
package/lib/parsers.js CHANGED
@@ -11,6 +11,12 @@ import { X509Certificate } from 'node:crypto';
11
11
  * @typedef {import('tls').PeerCertificate} PeerCertificate
12
12
  */
13
13
 
14
+ /**
15
+ * A certificate with an optional issuer chain. Header-extracted chains end at
16
+ * the topmost certificate, while Node's socket chains self-reference at the root.
17
+ * @typedef {PeerCertificate & { issuerCertificate?: ChainedPeerCertificate }} ChainedPeerCertificate
18
+ */
19
+
14
20
  /**
15
21
  * Preset configurations for common reverse proxies.
16
22
  * Maps preset name to { header, encoding } configuration, with optional
@@ -89,10 +95,11 @@ export const PRESETS = {
89
95
  /**
90
96
  * Parse URL-encoded PEM certificate (nginx, HAProxy format).
91
97
  * Uses standard URL encoding via $ssl_client_escaped_cert or similar.
98
+ * Concatenated PEM blocks are split and linked via issuerCertificate.
92
99
  *
93
100
  * @see https://nginx.org/en/docs/http/ngx_http_ssl_module.html#var_ssl_client_escaped_cert
94
101
  * @param {string} headerValue - URL-encoded PEM certificate
95
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
102
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
96
103
  */
97
104
  export function parseUrlPem(headerValue) {
98
105
  // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
@@ -102,7 +109,7 @@ export function parseUrlPem(headerValue) {
102
109
 
103
110
  try {
104
111
  const pem = decodeURIComponent(headerValue);
105
- return pemToCertificate(pem);
112
+ return chainFromMultiBlockPem(pem);
106
113
  } catch {
107
114
  return null;
108
115
  }
@@ -138,36 +145,48 @@ function splitPemBlocks(pem) {
138
145
 
139
146
  /**
140
147
  * Parse a multi-block PEM blob into a chained PeerCertificate. Splits the
141
- * input into individual blocks, parses each, drops blocks that fail to
142
- * parse, and links the remainder via issuerCertificate.
148
+ * input into individual blocks and links them via issuerCertificate. The
149
+ * first block is the leaf and must parse; later blocks that fail to parse
150
+ * are dropped and the chain links past them.
143
151
  *
144
152
  * Used by parsers whose proxies forward the full chain in a single field
145
- * (parseUrlPemAws, parseXfcc Chain). Without explicit chain linking, calling
146
- * X509Certificate() on a multi-block PEM returns just the leaf with no
153
+ * (parseUrlPem, parseUrlPemAws, parseXfcc Chain). Without explicit chain
154
+ * linking, calling X509Certificate() on a multi-block PEM returns just the leaf with no
147
155
  * issuerCertificate (toLegacyObject drops the property), silently losing
148
156
  * chain information for `includeChain: true` consumers.
149
157
  *
150
158
  * @param {string} pem - Concatenated PEM blocks
151
- * @returns {PeerCertificate | null} Leaf cert with chain via issuerCertificate, or null if no blocks parse
159
+ * @returns {ChainedPeerCertificate | null} Leaf cert with chain via issuerCertificate, or null if the leaf does not parse
152
160
  */
153
161
  function chainFromMultiBlockPem(pem) {
154
162
  const pemBlocks = splitPemBlocks(pem);
155
- // Stryker disable next-line BlockStatement,ConditionalExpression: short-circuit; falling through hits the next certs.length===0 check with the same null result
163
+ // Stryker disable next-line BlockStatement,ConditionalExpression: short-circuit; falling through hands undefined to pemToCertificate, which throws for the same null result
156
164
  if (pemBlocks.length === 0) {
157
165
  return null;
158
166
  }
159
167
 
160
- const certs = pemBlocks.map(block => {
168
+ // Junk ahead of the leaf block means the leaf was damaged past recognition;
169
+ // the next block must not slide into its place. Leading whitespace is fine.
170
+ if (pem.slice(0, pem.indexOf(pemBlocks[0])).trim() !== '') {
171
+ return null;
172
+ }
173
+
174
+ // An unparseable leaf rejects the whole blob. Promoting the next block
175
+ // would authenticate the request as whichever certificate parsed first.
176
+ let leaf;
177
+ try {
178
+ leaf = pemToCertificate(pemBlocks[0]);
179
+ } catch {
180
+ return null;
181
+ }
182
+
183
+ const certs = [leaf];
184
+ for (const block of pemBlocks.slice(1)) {
161
185
  try {
162
- return pemToCertificate(block);
186
+ certs.push(pemToCertificate(block));
163
187
  } catch {
164
- // Equivalent mutant (undefined also filtered by .filter(Boolean)) — catch-body BlockStatement unsuppressible via Stryker comments
165
- return null;
188
+ // Dropped; the chain links past it.
166
189
  }
167
- }).filter(Boolean);
168
-
169
- if (certs.length === 0) {
170
- return null;
171
190
  }
172
191
 
173
192
  // Stryker disable next-line EqualityOperator: setting issuerCertificate = undefined on last cert is same as not setting it
@@ -186,7 +205,7 @@ function chainFromMultiBlockPem(pem) {
186
205
  *
187
206
  * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/mutual-authentication.html
188
207
  * @param {string} headerValue - AWS ALB URL-encoded PEM certificate
189
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
208
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
190
209
  */
191
210
  export function parseUrlPemAws(headerValue) {
192
211
  // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
@@ -214,7 +233,7 @@ export function parseUrlPemAws(headerValue) {
214
233
  *
215
234
  * @see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
216
235
  * @param {string} headerValue - XFCC formatted header value
217
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
236
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
218
237
  */
219
238
  export function parseXfcc(headerValue) {
220
239
  // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
@@ -297,7 +316,7 @@ export function parseXfcc(headerValue) {
297
316
  *
298
317
  * @see https://developers.cloudflare.com/api-shield/security/mtls/configure/
299
318
  * @param {string} headerValue - Base64-encoded DER certificate(s)
300
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
319
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
301
320
  */
302
321
  export function parseBase64Der(headerValue) {
303
322
  // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
@@ -306,27 +325,25 @@ export function parseBase64Der(headerValue) {
306
325
  }
307
326
 
308
327
  // Handle comma-separated cert chains (Traefik format)
309
- // Stryker disable next-line MethodExpression: .trim() no-op (base64 ignores whitespace); .filter(Boolean) redundant (empty strings throw in X509Certificate, caught below)
310
- const certParts = headerValue.split(',').map(s => s.trim()).filter(Boolean);
328
+ // Stryker disable next-line MethodExpression: .trim() no-op (base64 ignores whitespace)
329
+ const certParts = headerValue.split(',').map(s => s.trim());
311
330
 
312
- // Stryker disable next-line BlockStatement,ConditionalExpression: →false equivalent (empty array → zero certs → certs.length===0 catches it); →true killed by valid base64-der tests
313
- if (certParts.length === 0) {
331
+ // An empty or unparseable leaf rejects the whole header. Promoting the next
332
+ // entry would authenticate the request as whichever certificate parsed first.
333
+ let leaf;
334
+ try {
335
+ leaf = derToCertificate(Buffer.from(certParts[0], 'base64'));
336
+ } catch {
314
337
  return null;
315
338
  }
316
339
 
317
- // Parse all certs in the chain
318
- const certs = certParts.map(base64 => {
340
+ const certs = [leaf];
341
+ for (const base64 of certParts.slice(1)) {
319
342
  try {
320
- const derBuffer = Buffer.from(base64, 'base64');
321
- return derToCertificate(derBuffer);
343
+ certs.push(derToCertificate(Buffer.from(base64, 'base64')));
322
344
  } catch {
323
- // Equivalent mutant (undefined also filtered by .filter(Boolean)) — catch-body BlockStatement unsuppressible via Stryker comments
324
- return null;
345
+ // Dropped; the chain links past it.
325
346
  }
326
- }).filter(Boolean);
327
-
328
- if (certs.length === 0) {
329
- return null;
330
347
  }
331
348
 
332
349
  // Link the cert chain via issuerCertificate
@@ -400,7 +417,7 @@ export function derToCertificate(der) {
400
417
  *
401
418
  * @param {string} headerValue - Raw header value
402
419
  * @param {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} encoding - Encoding format
403
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
420
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
404
421
  */
405
422
  export function parseHeaderValue(headerValue, encoding) {
406
423
  // Only strings are parseable; a header name like 'constructor' resolves
@@ -433,7 +450,7 @@ export function parseHeaderValue(headerValue, encoding) {
433
450
  * @param {string} [config.certificateSource] - Preset name (aws-alb, envoy, cloudflare, traefik)
434
451
  * @param {string} [config.certificateHeader] - Custom header name (overrides preset)
435
452
  * @param {string} [config.headerEncoding] - Encoding format (required if certificateHeader is set)
436
- * @returns {PeerCertificate | null} Parsed certificate or null if not found/invalid
453
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null if not found/invalid
437
454
  */
438
455
  export function getCertificateFromHeaders(headers, config) {
439
456
  let headerName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "client-certificate-auth",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
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": {