client-certificate-auth 2.0.2 → 2.1.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
@@ -13,7 +13,27 @@ Comprehensive toolkit for client SSL certificate authentication (mTLS) in Node.j
13
13
 
14
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).
15
15
 
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
+ **Fanatically Tested** - 100% line/branch/function/statement coverage, plus mutation testing and E2E tests against real nginx/Envoy/Traefik containers. ~6,207 lines of test code for ~937 lines of source (measured by [cloc](https://github.com/AlDanial/cloc)).
17
+
18
+ ## What Is This?
19
+
20
+ This library authenticates HTTP clients by their TLS client certificates, a scheme usually called mutual TLS (mTLS). Instead of presenting a password, API key, or bearer token, the client presents an X.509 certificate during the TLS handshake and proves possession of its private key; the server checks that the certificate was issued by a CA it trusts. The certificate is the credential.
21
+
22
+ Typical uses:
23
+
24
+ - **Service-to-service APIs**: internal microservices or partner integrations where each caller holds its own certificate rather than a shared secret.
25
+ - **Machine and device authentication**: CI runners, IoT devices, daemons. Certificates are issued per machine and revoked per machine.
26
+ - **Restricting sensitive endpoints**: admin interfaces, metrics, internal tooling that only known clients should reach.
27
+ - **Certificate-based user login**: enterprise PKI and smart-card environments, where certificates map to user accounts.
28
+
29
+ The TLS handshake proves who the client is; your application decides whether that client is allowed in. This library covers the part in between: it extracts the verified certificate from the request wherever your TLS terminates, parses it into a standard [`tls.PeerCertificate`](https://nodejs.org/api/tls.html#certificate-object) object, and passes it to your authorization callback. Supported certificate sources:
30
+
31
+ - **TLS terminated directly in Node.js**: the certificate is read from the TLS socket.
32
+ - **A TLS-terminating reverse proxy or load balancer** that forwards the certificate in an HTTP header: AWS ALB, Envoy/Istio, Cloudflare, Traefik, Azure App Service, nginx, HAProxy, and any proxy that implements RFC 9440 (e.g. Google Cloud Load Balancer).
33
+ - **AWS Lambda** behind API Gateway mTLS, via `client-certificate-auth/lambda`.
34
+ - **Web-standard `Request` runtimes** (Hono, Next.js, SvelteKit, Cloudflare Workers, Bun, Deno), via `client-certificate-auth/fetch`.
35
+
36
+ Express and Connect get drop-in middleware. Other frameworks use the same extraction logic through `extractClientCertificate()`. Pre-built authorization helpers cover the common policies (allowlist by CN, fingerprint, issuer, OU, SAN, and more).
17
37
 
18
38
  ## Installation
19
39
 
@@ -23,14 +43,6 @@ npm install client-certificate-auth
23
43
 
24
44
  **Requirements:** Node.js >= 20
25
45
 
26
- ## Synopsis
27
-
28
- 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).
29
-
30
- The certificate is parsed into a standard `tls.PeerCertificate` object and passed to your callback for authorization logic.
31
-
32
- Compatible with Express, Connect, or any Node.js HTTP server framework by using the framework-agnostic `extractClientCertificate` function.
33
-
34
46
  ## Usage
35
47
 
36
48
  ### Basic Setup
@@ -32,6 +32,19 @@ function isThenable(value) {
32
32
  && typeof value.then === 'function';
33
33
  }
34
34
 
35
+ /**
36
+ * Wrap primitive values thrown or rejected by the validation callback in an
37
+ * Error so `.message` and `.status` access is safe; objects pass through.
38
+ * @param {unknown} err
39
+ * @returns {any}
40
+ */
41
+ function normalizeCallbackError(err) {
42
+ if (err !== null && (typeof err === 'object' || typeof err === 'function')) {
43
+ return err;
44
+ }
45
+ return new Error(err === null || err === undefined ? '' : String(err));
46
+ }
47
+
35
48
  /**
36
49
  * Options not supported by the sync CJS wrapper.
37
50
  * These require the ESM module's async header parsing.
@@ -39,6 +52,7 @@ function isThenable(value) {
39
52
  const UNSUPPORTED_OPTIONS = [
40
53
  'certificateSource',
41
54
  'certificateHeader',
55
+ 'chainHeader',
42
56
  'headerEncoding',
43
57
  'fallbackToSocket',
44
58
  'verifyHeader',
@@ -144,7 +158,8 @@ function clientCertificateAuth(callback, options = {}) {
144
158
  try {
145
159
  const result = callback(cert, req);
146
160
  if (isThenable(result)) {
147
- Promise.resolve(result).then(doneAuthorizing).catch((err) => {
161
+ Promise.resolve(result).then(doneAuthorizing).catch((rejection) => {
162
+ const err = normalizeCallbackError(rejection);
148
163
  safeCallHook(onRejected, cert, req, err.message || 'callback_threw');
149
164
  if (err.status === undefined) {
150
165
  err.status = 401;
@@ -154,7 +169,8 @@ function clientCertificateAuth(callback, options = {}) {
154
169
  } else {
155
170
  doneAuthorizing(result);
156
171
  }
157
- } catch (err) {
172
+ } catch (thrown) {
173
+ const err = normalizeCallbackError(thrown);
158
174
  safeCallHook(onRejected, cert, req, err.message || 'callback_threw');
159
175
  if (err.status === undefined) {
160
176
  err.status = 401;
@@ -77,6 +77,15 @@ export interface ClientCertificateAuthOptions {
77
77
  */
78
78
  certificateHeader?: string;
79
79
 
80
+ /**
81
+ * Optional second header carrying the certificate chain alongside the leaf.
82
+ * Split on commas per RFC 9440, each item parsed with the same
83
+ * headerEncoding, results linked via issuerCertificate. For non-RFC-9440
84
+ * encodings the comma split may not match the encoding's list convention.
85
+ * Same trust boundary as certificateHeader.
86
+ */
87
+ chainHeader?: string;
88
+
80
89
  /**
81
90
  * How to decode the header value.
82
91
  * Required when using certificateHeader without certificateSource.
@@ -20,6 +20,19 @@ function isThenable(value) {
20
20
  && typeof /** @type {{then?: unknown}} */ (value).then === 'function';
21
21
  }
22
22
 
23
+ /**
24
+ * Wrap primitive values thrown or rejected by the validation callback in an
25
+ * Error so `.message` and `.status` access is safe; objects pass through.
26
+ * @param {unknown} err
27
+ * @returns {any}
28
+ */
29
+ function normalizeCallbackError(err) {
30
+ if (err !== null && (typeof err === 'object' || typeof err === 'function')) {
31
+ return err;
32
+ }
33
+ return new Error(err === null || err === undefined ? '' : String(err));
34
+ }
35
+
23
36
  /**
24
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
25
38
  * @typedef {import('http').ServerResponse & { redirect: (statusOrUrl: number | string, url?: string) => void }} ClientCertResponse
@@ -28,7 +41,7 @@ function isThenable(value) {
28
41
 
29
42
  /**
30
43
  * @typedef {Object} ClientCertificateAuthOptions
31
- * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Use a preset
44
+ * @property {'aws-alb' | 'aws-alb-verify' | 'azure-app-service' | 'cloudflare' | 'cloudflare-rfc9440' | 'envoy' | 'traefik'} [certificateSource] - Use a preset
32
45
  * configuration for a known reverse proxy. Header-based certs are only checked if this or
33
46
  * certificateHeader is set. Trust boundary: the proxy must strip the preset's header from
34
47
  * external requests; any source that can set it is trusted to assert client identity.
@@ -36,7 +49,11 @@ function isThenable(value) {
36
49
  * Overrides preset header name if also using certificateSource. Trust boundary: the proxy
37
50
  * must strip this header from external requests; any source that can set it is trusted to
38
51
  * assert client identity.
39
- * @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] -
52
+ * @property {string} [chainHeader] - Optional second header carrying the certificate chain
53
+ * alongside the leaf in certificateHeader (or the preset's header). Split on commas per
54
+ * RFC 9440, each item parsed with the same headerEncoding, results linked via
55
+ * issuerCertificate. Same trust boundary as certificateHeader.
56
+ * @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] -
40
57
  * How to decode the header value. Required when using certificateHeader without certificateSource.
41
58
  * @property {boolean} [fallbackToSocket=false] - If header-based extraction is configured but
42
59
  * fails (header absent or malformed), try socket.getPeerCertificate() instead of returning 401.
@@ -96,6 +113,7 @@ export default function clientCertificateAuth(callback, options = {}) {
96
113
  const {
97
114
  certificateSource,
98
115
  certificateHeader,
116
+ chainHeader,
99
117
  headerEncoding,
100
118
  fallbackToSocket = false,
101
119
  includeChain = false,
@@ -132,6 +150,7 @@ export default function clientCertificateAuth(callback, options = {}) {
132
150
  const result = extractClientCertificate(req, {
133
151
  certificateSource,
134
152
  certificateHeader,
153
+ chainHeader,
135
154
  headerEncoding,
136
155
  fallbackToSocket,
137
156
  includeChain,
@@ -189,7 +208,8 @@ export default function clientCertificateAuth(callback, options = {}) {
189
208
  try {
190
209
  const callbackResult = callback(cert, req);
191
210
  if (isThenable(callbackResult)) {
192
- Promise.resolve(callbackResult).then(doneAuthorizing).catch((err) => {
211
+ Promise.resolve(callbackResult).then(doneAuthorizing).catch((rejection) => {
212
+ const err = normalizeCallbackError(rejection);
193
213
  safeCallHook(onRejected, cert, req, err.message || 'callback_threw');
194
214
  if (err.status === undefined) {
195
215
  err.status = 401;
@@ -199,7 +219,8 @@ export default function clientCertificateAuth(callback, options = {}) {
199
219
  } else {
200
220
  doneAuthorizing(callbackResult);
201
221
  }
202
- } catch (err) {
222
+ } catch (thrown) {
223
+ const err = normalizeCallbackError(thrown);
203
224
  safeCallHook(onRejected, cert, req, err.message || 'callback_threw');
204
225
  if (err.status === undefined) {
205
226
  err.status = 401;
@@ -12,8 +12,9 @@
12
12
  */
13
13
  /**
14
14
  * @typedef {Object} ExtractorOptions
15
- * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Preset configuration
15
+ * @property {'aws-alb' | 'aws-alb-verify' | 'azure-app-service' | 'cloudflare' | 'cloudflare-rfc9440' | 'envoy' | 'traefik'} [certificateSource] - Preset configuration
16
16
  * @property {string} [certificateHeader] - Custom header name
17
+ * @property {string} [chainHeader] - Optional second header carrying the certificate chain
17
18
  * @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] - Header encoding
18
19
  * @property {boolean} [fallbackToSocket=false] - Try socket if header extraction fails
19
20
  * @property {boolean} [includeChain=false] - Include issuerCertificate chain
@@ -89,11 +90,15 @@ export type ExtractorOptions = {
89
90
  /**
90
91
  * - Preset configuration
91
92
  */
92
- certificateSource?: "aws-alb" | "envoy" | "cloudflare" | "traefik";
93
+ certificateSource?: "aws-alb" | "aws-alb-verify" | "azure-app-service" | "cloudflare" | "cloudflare-rfc9440" | "envoy" | "traefik";
93
94
  /**
94
95
  * - Custom header name
95
96
  */
96
97
  certificateHeader?: string;
98
+ /**
99
+ * - Optional second header carrying the certificate chain
100
+ */
101
+ chainHeader?: string;
97
102
  /**
98
103
  * - Header encoding
99
104
  */
package/lib/extractor.js CHANGED
@@ -4,22 +4,23 @@
4
4
  * @license MIT
5
5
  */
6
6
 
7
- import { getCertificateFromHeaders, PRESETS } from './parsers.js';
7
+ import { getCertificateFromHeaders, parseHeaderValue, PRESETS } from './parsers.js';
8
8
 
9
9
  const VALID_ENCODINGS = ['url-pem', 'url-pem-aws', 'xfcc', 'base64-der', 'rfc9440'];
10
10
 
11
11
  /**
12
12
  * Validate options shared by `extractClientCertificate` and the middleware
13
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`.
14
+ * `certificateHeader` without an encoding source, `chainHeader` without a
15
+ * leaf header source, or only one of `verifyHeader`/`verifyValue`). Omitted
16
+ * or `undefined` options are treated as the empty object; explicit `null`
17
+ * will throw a `TypeError`.
17
18
  *
18
19
  * @param {ExtractorOptions} [options]
19
20
  * @throws {Error} when options are malformed
20
21
  */
21
22
  export function validateExtractorOptions(options = {}) {
22
- const { certificateSource, certificateHeader, headerEncoding, verifyHeader, verifyValue } = options;
23
+ const { certificateSource, certificateHeader, chainHeader, headerEncoding, verifyHeader, verifyValue } = options;
23
24
 
24
25
  if ((verifyHeader && !verifyValue) || (!verifyHeader && verifyValue)) {
25
26
  throw new Error(
@@ -44,6 +45,12 @@ export function validateExtractorOptions(options = {}) {
44
45
  'client-certificate-auth: certificateHeader requires headerEncoding (or a certificateSource preset that supplies one)'
45
46
  );
46
47
  }
48
+
49
+ if (chainHeader && !certificateSource && !certificateHeader) {
50
+ throw new Error(
51
+ 'client-certificate-auth: chainHeader requires certificateSource or certificateHeader (chain header must accompany a leaf header configuration)'
52
+ );
53
+ }
47
54
  }
48
55
 
49
56
  /**
@@ -61,12 +68,16 @@ export function validateExtractorOptions(options = {}) {
61
68
 
62
69
  /**
63
70
  * @typedef {Object} ExtractorOptions
64
- * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Preset
71
+ * @property {'aws-alb' | 'aws-alb-verify' | 'azure-app-service' | 'cloudflare' | 'cloudflare-rfc9440' | 'envoy' | 'traefik'} [certificateSource] - Preset
65
72
  * configuration. Trust boundary: the proxy must strip the preset's header from external
66
73
  * requests; any source that can set it is trusted to assert client identity.
67
74
  * @property {string} [certificateHeader] - Custom header name. Trust boundary: the proxy must
68
75
  * strip this header from external requests; any source that can set it is trusted to assert
69
76
  * client identity.
77
+ * @property {string} [chainHeader] - Optional second header carrying the certificate chain
78
+ * alongside the leaf. Split on commas per RFC 9440, each item parsed with the same
79
+ * `headerEncoding`, results linked via `issuerCertificate`. For non-RFC-9440 encodings
80
+ * the comma split may not match the encoding's list convention.
70
81
  * @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] - Header encoding
71
82
  * @property {boolean} [fallbackToSocket=false] - Try socket if header extraction fails
72
83
  * @property {boolean} [includeChain=false] - Include issuerCertificate chain
@@ -111,6 +122,7 @@ export function extractClientCertificate(req, options = {}) {
111
122
  const {
112
123
  certificateSource,
113
124
  certificateHeader,
125
+ chainHeader,
114
126
  headerEncoding,
115
127
  fallbackToSocket = false,
116
128
  includeChain = false,
@@ -142,6 +154,32 @@ export function extractClientCertificate(req, options = {}) {
142
154
  headerEncoding,
143
155
  });
144
156
 
157
+ // Append chain from a separate chain header if configured.
158
+ // Resolve: explicit chainHeader > preset.chainHeader; explicit encoding > preset.encoding.
159
+ // Split on commas per RFC 9440, parse each item, link via issuerCertificate.
160
+ if (cert) {
161
+ const preset = certificateSource ? PRESETS[certificateSource] : null;
162
+ const chainHeaderName = (chainHeader ?? preset?.chainHeader)?.toLowerCase();
163
+ const chainEncoding = headerEncoding ?? preset?.encoding;
164
+ if (chainHeaderName && chainEncoding) {
165
+ const chainHeaderValue = req.headers[chainHeaderName];
166
+ if (chainHeaderValue && !Array.isArray(chainHeaderValue)) {
167
+ const chainCerts = chainHeaderValue
168
+ .split(',')
169
+ .map(item => item.trim())
170
+ .filter(Boolean)
171
+ .map(item => parseHeaderValue(item, chainEncoding))
172
+ .filter(Boolean);
173
+ if (chainCerts.length > 0) {
174
+ cert.issuerCertificate = chainCerts[0];
175
+ for (let i = 0; i < chainCerts.length - 1; i++) {
176
+ chainCerts[i].issuerCertificate = chainCerts[i + 1];
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+
145
183
  // Normalize: strip chain unless includeChain is true
146
184
  if (cert && !includeChain && 'issuerCertificate' in cert) {
147
185
  delete cert.issuerCertificate;
package/lib/fetch.cjs ADDED
@@ -0,0 +1,20 @@
1
+ /*!
2
+ * client-certificate-auth/fetch - CommonJS wrapper
3
+ * Copyright (C) 2013-2026 Tony Gies
4
+ * @license MIT
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ let _module;
10
+
11
+ async function load() {
12
+ // Stryker disable next-line ConditionalExpression,BlockStatement: test ordering caches _module from prior test; ConditionalExpression→true (always re-import) re-imports same module successfully; BlockStatement→{} uses cached value
13
+ if (!_module) {
14
+ // Stryker disable next-line StringLiteral: test ordering caches _module; StringLiteral→"" fails import but cached value masks it
15
+ _module = await import('./fetch.js');
16
+ }
17
+ return _module;
18
+ }
19
+
20
+ module.exports = { load };
@@ -0,0 +1,13 @@
1
+ /*!
2
+ * client-certificate-auth/fetch - CommonJS type declarations
3
+ * Copyright (C) 2013-2026 Tony Gies
4
+ * @license MIT
5
+ */
6
+
7
+ import type * as FetchModule from './fetch.js';
8
+
9
+ declare const fetchAdapter: {
10
+ load(): Promise<typeof FetchModule>;
11
+ };
12
+
13
+ export = fetchAdapter;
package/lib/fetch.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /*!
2
+ * client-certificate-auth/fetch - TypeScript declarations
3
+ * Copyright (C) 2013-2026 Tony Gies
4
+ * @license MIT
5
+ */
6
+
7
+ import type { ExtractorOptions, ExtractionResult } from './extractor.js';
8
+
9
+ /**
10
+ * Any object whose `headers` field iterates as `[name, value]` tuples.
11
+ * Web `Request`, undici `Headers`, Bun, Deno, and Node 18+ `Headers`
12
+ * all work without modification.
13
+ */
14
+ export interface RequestLike {
15
+ headers: Iterable<[string, string]>;
16
+ }
17
+
18
+ /**
19
+ * Extract a client certificate from a Web standard `Request` or any
20
+ * object with iterable headers. Header-only (no TLS socket in Web Request).
21
+ *
22
+ * @param request - A Web Request or any object with iterable headers
23
+ * @param options - Same options as `extractClientCertificate`. Header options only.
24
+ */
25
+ export declare function extractClientCertificateFromRequest(
26
+ request: RequestLike,
27
+ options?: ExtractorOptions
28
+ ): ExtractionResult;
package/lib/fetch.js ADDED
@@ -0,0 +1,59 @@
1
+ /*!
2
+ * client-certificate-auth/fetch - Web Request adapter
3
+ * Copyright (C) 2013-2026 Tony Gies
4
+ * @license MIT
5
+ */
6
+
7
+ import { extractClientCertificate } from './extractor.js';
8
+
9
+ /**
10
+ * @typedef {import('./extractor.js').ExtractorOptions} ExtractorOptions
11
+ * @typedef {import('./extractor.js').ExtractionResult} ExtractionResult
12
+ */
13
+
14
+ /**
15
+ * Extract a client certificate from a Web standard `Request` (or any object
16
+ * with an iterable `headers` field that yields `[name, value]` tuples).
17
+ *
18
+ * Normalizes header names to lowercase and delegates to the core
19
+ * `extractClientCertificate`. Header-only: Web `Request` has no TLS socket,
20
+ * so `fallbackToSocket` is stripped and has no effect.
21
+ *
22
+ * @param {{ headers: Iterable<[string, string]> }} request - A Web Request or any object whose `headers` iterates `[name, value]` pairs.
23
+ * @param {ExtractorOptions} [options={}] - Same options as `extractClientCertificate`. Header-extraction options only; socket options are ignored.
24
+ * @returns {ExtractionResult}
25
+ *
26
+ * @example
27
+ * // Hono
28
+ * import { extractClientCertificateFromRequest } from 'client-certificate-auth/fetch';
29
+ *
30
+ * app.get('/secure', async (c) => {
31
+ * const result = extractClientCertificateFromRequest(c.req.raw, {
32
+ * certificateSource: 'cloudflare-rfc9440',
33
+ * });
34
+ * if (!result.success) return c.text('Unauthorized', 401);
35
+ * return c.text(`Hello ${result.certificate.subject.CN}`);
36
+ * });
37
+ *
38
+ * @example
39
+ * // Next.js Route Handler
40
+ * export async function GET(request) {
41
+ * const result = extractClientCertificateFromRequest(request, {
42
+ * certificateSource: 'aws-alb',
43
+ * });
44
+ * if (!result.success) return new Response('Unauthorized', { status: 401 });
45
+ * return Response.json({ user: result.certificate.subject.CN });
46
+ * }
47
+ */
48
+ export function extractClientCertificateFromRequest(request, options = {}) {
49
+ // Web Headers normalizes to lowercase, but Map and other iterables
50
+ // preserve casing. Normalize here for the core extractor's lowercase lookup.
51
+ const headers = {};
52
+ for (const [name, value] of request.headers) {
53
+ headers[name.toLowerCase()] = value;
54
+ }
55
+
56
+ const { fallbackToSocket: _ignored, ...rest } = options;
57
+
58
+ return extractClientCertificate({ headers }, rest);
59
+ }
package/lib/lambda.cjs ADDED
@@ -0,0 +1,20 @@
1
+ /*!
2
+ * client-certificate-auth/lambda - CommonJS wrapper
3
+ * Copyright (C) 2013-2026 Tony Gies
4
+ * @license MIT
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ let _module;
10
+
11
+ async function load() {
12
+ // Stryker disable next-line ConditionalExpression,BlockStatement: test ordering caches _module from prior test; ConditionalExpression→true (always re-import) re-imports same module successfully; BlockStatement→{} uses cached value
13
+ if (!_module) {
14
+ // Stryker disable next-line StringLiteral: test ordering caches _module; StringLiteral→"" fails import but cached value masks it
15
+ _module = await import('./lambda.js');
16
+ }
17
+ return _module;
18
+ }
19
+
20
+ module.exports = { load };
@@ -0,0 +1,13 @@
1
+ /*!
2
+ * client-certificate-auth/lambda - CommonJS type declarations
3
+ * Copyright (C) 2013-2026 Tony Gies
4
+ * @license MIT
5
+ */
6
+
7
+ import type * as LambdaModule from './lambda.js';
8
+
9
+ declare const lambda: {
10
+ load(): Promise<typeof LambdaModule>;
11
+ };
12
+
13
+ export = lambda;
@@ -0,0 +1,78 @@
1
+ /*!
2
+ * client-certificate-auth/lambda - TypeScript declarations
3
+ * Copyright (C) 2013-2026 Tony Gies
4
+ * @license MIT
5
+ */
6
+
7
+ import type { PeerCertificate } from 'tls';
8
+
9
+ /**
10
+ * Client certificate object from the API Gateway Lambda event.
11
+ * Common to v1 and v2 payload formats. Compatible with `@types/aws-lambda`
12
+ * without requiring it as a dependency.
13
+ */
14
+ export interface LambdaClientCert {
15
+ /** PEM-encoded client certificate with BEGIN/END delimiters. */
16
+ clientCertPem: string;
17
+ /** Optional subject distinguished name string, parsed by API Gateway. */
18
+ subjectDN?: string;
19
+ /** Optional issuer distinguished name string, parsed by API Gateway. */
20
+ issuerDN?: string;
21
+ /** Optional certificate serial number string, parsed by API Gateway. */
22
+ serialNumber?: string;
23
+ /** Optional validity window parsed by API Gateway. */
24
+ validity?: {
25
+ notBefore?: string;
26
+ notAfter?: string;
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Lambda event subset declaring only the cert-bearing paths.
32
+ * Accepts HTTP API (v2.0) and REST API (v1.0) payload formats.
33
+ */
34
+ export interface LambdaEventWithClientCert {
35
+ requestContext?: {
36
+ /** v2.0 payload format: HTTP API. */
37
+ authentication?: {
38
+ clientCert?: LambdaClientCert;
39
+ };
40
+ /** v1.0 payload format: REST API. */
41
+ identity?: {
42
+ clientCert?: LambdaClientCert;
43
+ };
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Result returned by `extractClientCertificateFromLambdaEvent`.
49
+ * Matches the core extractor's `ExtractionResult`.
50
+ */
51
+ export interface LambdaExtractionResult {
52
+ /** Whether extraction succeeded. */
53
+ success: boolean;
54
+ /** Extracted certificate (null on failure). */
55
+ certificate: PeerCertificate | null;
56
+ /**
57
+ * Rejection reason code (null on success). Lambda-specific reasons:
58
+ * - 'lambda_event_missing_clientcert'
59
+ * - 'lambda_event_clientcert_malformed'
60
+ */
61
+ reason: string | null;
62
+ }
63
+
64
+ /**
65
+ * Extract a client certificate from an AWS API Gateway Lambda event. Handles
66
+ * both v2.0 payload format (`event.requestContext.authentication.clientCert`)
67
+ * and v1.0 payload format (`event.requestContext.identity.clientCert`).
68
+ *
69
+ * Also accepts `null` and `undefined`, returning
70
+ * `lambda_event_missing_clientcert`.
71
+ *
72
+ * @param event - The Lambda event from API Gateway, or null/undefined
73
+ *
74
+ * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-mutual-tls.html
75
+ */
76
+ export declare function extractClientCertificateFromLambdaEvent(
77
+ event: LambdaEventWithClientCert | null | undefined
78
+ ): LambdaExtractionResult;
package/lib/lambda.js ADDED
@@ -0,0 +1,73 @@
1
+ /*!
2
+ * client-certificate-auth/lambda - AWS Lambda event adapter
3
+ * Copyright (C) 2013-2026 Tony Gies
4
+ * @license MIT
5
+ */
6
+
7
+ import { pemToCertificate } from './parsers.js';
8
+
9
+ /**
10
+ * @typedef {import('./extractor.js').ExtractionResult} ExtractionResult
11
+ */
12
+
13
+ /**
14
+ * Extract a client certificate from an AWS API Gateway Lambda event.
15
+ *
16
+ * API Gateway HTTP API (v2.0 payload) delivers the validated mTLS client
17
+ * certificate as a pre-parsed object at
18
+ * `event.requestContext.authentication.clientCert`. The legacy REST API
19
+ * (v1.0 payload) delivers it at `event.requestContext.identity.clientCert`.
20
+ * Both payloads carry a `clientCertPem` field plus parsed `subjectDN`,
21
+ * `issuerDN`, `serialNumber`, and `validity` fields.
22
+ *
23
+ * Parses `clientCertPem` into a `PeerCertificate` so the same validation
24
+ * logic used with `getPeerCertificate()` or `extractClientCertificate()`
25
+ * works inside a Lambda handler. If both v1 and v2 fields are present,
26
+ * v2 takes precedence.
27
+ *
28
+ * @param {object | null | undefined} event - The Lambda event object from API Gateway (also accepts null/undefined)
29
+ * @returns {ExtractionResult}
30
+ *
31
+ * Rejection reasons:
32
+ * - 'lambda_event_missing_clientcert' - No clientCertPem at either v1 or v2 location
33
+ * - 'lambda_event_clientcert_malformed' - clientCertPem present but parsing failed
34
+ *
35
+ * @example
36
+ * import { extractClientCertificateFromLambdaEvent } from 'client-certificate-auth/lambda';
37
+ *
38
+ * export const handler = async (event) => {
39
+ * const result = extractClientCertificateFromLambdaEvent(event);
40
+ * if (!result.success) return { statusCode: 401, body: result.reason };
41
+ * if (result.certificate.subject.CN !== 'authorized-client') {
42
+ * return { statusCode: 403 };
43
+ * }
44
+ * return { statusCode: 200, body: 'OK' };
45
+ * };
46
+ */
47
+ export function extractClientCertificateFromLambdaEvent(event) {
48
+ const v2 = event?.requestContext?.authentication?.clientCert;
49
+ const v1 = event?.requestContext?.identity?.clientCert;
50
+ const clientCert = v2 ?? v1;
51
+
52
+ if (!clientCert?.clientCertPem) {
53
+ return {
54
+ success: false,
55
+ certificate: null,
56
+ reason: 'lambda_event_missing_clientcert',
57
+ };
58
+ }
59
+
60
+ try {
61
+ return {
62
+ success: true,
63
+ certificate: pemToCertificate(clientCert.clientCertPem),
64
+ reason: null,
65
+ };
66
+ } catch {
67
+ return {
68
+ success: false,
69
+ certificate: null,
70
+ reason: 'lambda_event_clientcert_malformed',
71
+ };
72
+ }
73
+ }
package/lib/parsers.d.ts CHANGED
@@ -14,7 +14,14 @@ export type HeaderEncoding = 'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' |
14
14
  /**
15
15
  * Supported certificate source presets.
16
16
  */
17
- export type CertificateSource = 'aws-alb' | 'envoy' | 'cloudflare' | 'traefik';
17
+ export type CertificateSource =
18
+ | 'aws-alb'
19
+ | 'aws-alb-verify'
20
+ | 'azure-app-service'
21
+ | 'cloudflare'
22
+ | 'cloudflare-rfc9440'
23
+ | 'envoy'
24
+ | 'traefik';
18
25
 
19
26
  /**
20
27
  * Preset configuration for a reverse proxy.
@@ -22,6 +29,12 @@ export type CertificateSource = 'aws-alb' | 'envoy' | 'cloudflare' | 'traefik';
22
29
  export interface PresetConfig {
23
30
  /** HTTP header name (lowercase) */
24
31
  header: string;
32
+ /**
33
+ * Optional second header carrying the certificate chain. Set on
34
+ * two-header schemes like RFC 9440 (Client-Cert + Client-Cert-Chain).
35
+ * Lowercased per Node convention.
36
+ */
37
+ chainHeader?: string;
25
38
  /** Encoding format used by this proxy */
26
39
  encoding: HeaderEncoding;
27
40
  }
package/lib/parsers.js CHANGED
@@ -13,7 +13,8 @@ import { X509Certificate } from 'node:crypto';
13
13
 
14
14
  /**
15
15
  * Preset configurations for common reverse proxies.
16
- * Maps preset name to { header, encoding } configuration.
16
+ * Maps preset name to { header, encoding } configuration, with optional
17
+ * `chainHeader` for two-header schemes (RFC 9440).
17
18
  */
18
19
  export const PRESETS = {
19
20
  /**
@@ -25,21 +26,55 @@ export const PRESETS = {
25
26
  encoding: 'url-pem-aws',
26
27
  },
27
28
  /**
28
- * Envoy proxy / Istio service mesh using XFCC header.
29
- * @see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
29
+ * AWS Application Load Balancer in mTLS verify mode. ALB validates the
30
+ * client certificate against a configured trust store and forwards the
31
+ * leaf as URL-encoded PEM (the leaf only, not the chain) plus parsed
32
+ * subject/issuer/serial/validity headers for convenience.
33
+ * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/mutual-authentication.html
30
34
  */
31
- 'envoy': {
32
- header: 'x-forwarded-client-cert',
33
- encoding: 'xfcc',
35
+ 'aws-alb-verify': {
36
+ header: 'x-amzn-mtls-clientcert-leaf',
37
+ encoding: 'url-pem-aws',
38
+ },
39
+ /**
40
+ * Azure App Service mTLS forwarding. App Service injects the bare
41
+ * base64-encoded DER (the body of a PEM cert without delimiters) into
42
+ * `X-ARR-ClientCert`. Same header convention is used by IIS/ARR.
43
+ * @see https://learn.microsoft.com/en-us/azure/app-service/app-service-web-configure-tls-mutual-auth
44
+ */
45
+ 'azure-app-service': {
46
+ header: 'x-arr-clientcert',
47
+ encoding: 'base64-der',
34
48
  },
35
49
  /**
36
- * Cloudflare with client_certificate_forwarding enabled.
50
+ * Cloudflare with client_certificate_forwarding enabled (legacy
51
+ * Cf-Client-Cert-* header family).
37
52
  * @see https://developers.cloudflare.com/api-shield/security/mtls/configure/
38
53
  */
39
54
  'cloudflare': {
40
55
  header: 'cf-client-cert-der-base64',
41
56
  encoding: 'base64-der',
42
57
  },
58
+ /**
59
+ * Cloudflare with RFC 9440 forwarding enabled (March 2026 feature).
60
+ * Operators set `Client-Cert` and `Client-Cert-Chain` headers via
61
+ * Transform Rules. Leaf is `:base64:`-wrapped; chain is a structured
62
+ * field list of `:base64:` items separated by commas.
63
+ * @see https://developers.cloudflare.com/changelog/post/2026-03-25-rfc9440-mtls-fields/
64
+ */
65
+ 'cloudflare-rfc9440': {
66
+ header: 'client-cert',
67
+ chainHeader: 'client-cert-chain',
68
+ encoding: 'rfc9440',
69
+ },
70
+ /**
71
+ * Envoy proxy / Istio service mesh using XFCC header.
72
+ * @see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
73
+ */
74
+ 'envoy': {
75
+ header: 'x-forwarded-client-cert',
76
+ encoding: 'xfcc',
77
+ },
43
78
  /**
44
79
  * Traefik with PassTLSClientCert middleware (pem: true).
45
80
  * Traefik sends raw base64 (no PEM delimiters, not URL-encoded).
@@ -126,7 +161,7 @@ function chainFromMultiBlockPem(pem) {
126
161
  try {
127
162
  return pemToCertificate(block);
128
163
  } catch {
129
- // Stryker disable next-line BlockStatement: empty catch returns undefined which .filter(Boolean) drops alongside null same chain output
164
+ // Equivalent mutant (undefined also filtered by .filter(Boolean)) catch-body BlockStatement unsuppressible via Stryker comments
130
165
  return null;
131
166
  }
132
167
  }).filter(Boolean);
@@ -322,7 +357,7 @@ export function parseRfc9440(headerValue) {
322
357
  let base64 = headerValue;
323
358
  // Stryker disable next-line BlockStatement,ConditionalExpression,StringLiteral,LogicalOperator,MethodExpression: colon-stripping is defensive; all valid RFC 9440 inputs use :base64: format, so base64 decoder ignores colons either way
324
359
  if (base64.startsWith(':') && base64.endsWith(':')) {
325
- // Stryker disable next-line BlockStatement,MethodExpression: removing or no-oping the assignment is equivalent — base64 decoder ignores colons
360
+ // Stryker disable next-line MethodExpression: no-oping the assignment is equivalent — base64 decoder ignores colons
326
361
  base64 = base64.slice(1, -1);
327
362
  }
328
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "client-certificate-auth",
3
- "version": "2.0.2",
3
+ "version": "2.1.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": {
@@ -51,6 +51,26 @@
51
51
  "types": "./lib/extractor.d.cts",
52
52
  "default": "./lib/extractor.cjs"
53
53
  }
54
+ },
55
+ "./lambda": {
56
+ "import": {
57
+ "types": "./lib/lambda.d.ts",
58
+ "default": "./lib/lambda.js"
59
+ },
60
+ "require": {
61
+ "types": "./lib/lambda.d.cts",
62
+ "default": "./lib/lambda.cjs"
63
+ }
64
+ },
65
+ "./fetch": {
66
+ "import": {
67
+ "types": "./lib/fetch.d.ts",
68
+ "default": "./lib/fetch.js"
69
+ },
70
+ "require": {
71
+ "types": "./lib/fetch.d.cts",
72
+ "default": "./lib/fetch.cjs"
73
+ }
54
74
  }
55
75
  },
56
76
  "main": "./lib/clientCertificateAuth.cjs",
@@ -65,6 +85,12 @@
65
85
  ],
66
86
  "extractor": [
67
87
  "./lib/extractor.d.ts"
88
+ ],
89
+ "lambda": [
90
+ "./lib/lambda.d.ts"
91
+ ],
92
+ "fetch": [
93
+ "./lib/fetch.d.ts"
68
94
  ]
69
95
  }
70
96
  },
@@ -73,8 +99,8 @@
73
99
  },
74
100
  "devDependencies": {
75
101
  "@arethetypeswrong/cli": "^0.18.2",
76
- "@commitlint/cli": "^20.4.3",
77
- "@commitlint/config-conventional": "^20.5.0",
102
+ "@commitlint/cli": "^21.0.0",
103
+ "@commitlint/config-conventional": "^21.0.0",
78
104
  "@eslint/js": "^10.0.1",
79
105
  "@stryker-mutator/core": "^9.6.0",
80
106
  "@stryker-mutator/jest-runner": "^9.6.1",
@@ -85,7 +111,7 @@
85
111
  "globals": "^17.5.0",
86
112
  "husky": "^9.1.7",
87
113
  "jest": "^30.3.0",
88
- "lint-staged": "^16.3.2",
114
+ "lint-staged": "^17.0.4",
89
115
  "selfsigned": "^5.5.0",
90
116
  "typedoc": "^0.28.19",
91
117
  "typedoc-plugin-markdown": "^4.11.0",
@@ -146,6 +172,7 @@
146
172
  "lib/**/*.d.cts"
147
173
  ],
148
174
  "overrides": {
149
- "fflate": "0.8.2"
175
+ "fflate": "0.8.2",
176
+ "qs": "^6.15.2"
150
177
  }
151
178
  }