client-certificate-auth 1.1.3 → 1.3.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 +107 -1
- package/lib/clientCertificateAuth.js +41 -61
- package/lib/extractor.cjs +20 -0
- package/lib/extractor.d.cts +13 -0
- package/lib/extractor.d.ts +110 -0
- package/lib/extractor.js +160 -0
- package/lib/helpers.cjs +20 -0
- package/lib/helpers.d.cts +13 -0
- package/lib/helpers.d.ts +2 -6
- package/lib/parsers.cjs +20 -0
- package/lib/parsers.d.cts +13 -0
- package/package.json +39 -8
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Express/Connect middleware for client SSL certificate authentication (mTLS).
|
|
|
7
7
|
[](https://codecov.io/gh/tgies/client-certificate-auth)
|
|
8
8
|
[](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. ~
|
|
10
|
+
100% line/branch/function/statement coverage, plus mutation testing and E2E tests against real nginx/Envoy/Traefik containers. ~4,477 lines of test code for ~679 lines of source (measured by [cloc](https://github.com/AlDanial/cloc)).
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -186,6 +186,112 @@ The `cert` parameter contains fields from [`tls.PeerCertificate`](https://nodejs
|
|
|
186
186
|
- `valid_from`, `valid_to` - Validity period
|
|
187
187
|
- `issuerCertificate` - Issuer's certificate (only when `includeChain: true`)
|
|
188
188
|
|
|
189
|
+
### `extractClientCertificate(req, options?)`
|
|
190
|
+
|
|
191
|
+
Framework-agnostic certificate extraction function exported from `client-certificate-auth/extractor`. Use this when building adapters for non-Express frameworks or when you need certificate extraction without middleware.
|
|
192
|
+
|
|
193
|
+
**Parameters:**
|
|
194
|
+
|
|
195
|
+
| Name | Type | Description |
|
|
196
|
+
|------|------|-------------|
|
|
197
|
+
| `req` | `Object` | Request object with `headers` and optional `socket` |
|
|
198
|
+
| `req.headers` | `Record<string, string \| string[]>` | HTTP headers object |
|
|
199
|
+
| `req.socket` | `Object` | Optional TLS socket (for socket-based extraction) |
|
|
200
|
+
| `options` | `Object` | Same options as middleware (except `onAuthenticated`/`onRejected`) |
|
|
201
|
+
|
|
202
|
+
**Returns:** `ExtractionResult`
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
{
|
|
206
|
+
success: boolean;
|
|
207
|
+
certificate: PeerCertificate | null;
|
|
208
|
+
reason: string | null; // Rejection reason if success is false
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Rejection reasons:**
|
|
213
|
+
|
|
214
|
+
- `'verification_header_mismatch'` - Proxy verify header didn't match expected value
|
|
215
|
+
- `'header_missing_or_malformed'` - Header extraction failed and no fallback configured
|
|
216
|
+
- `'socket_not_authorized'` - Socket not authorized for TLS client cert
|
|
217
|
+
- `'certificate_not_retrievable'` - Socket authorized but getPeerCertificate() returned empty
|
|
218
|
+
|
|
219
|
+
**Example - Building a Koa adapter:**
|
|
220
|
+
|
|
221
|
+
```javascript
|
|
222
|
+
import { extractClientCertificate } from 'client-certificate-auth/extractor';
|
|
223
|
+
|
|
224
|
+
function koaClientCert(checkAuth, options = {}) {
|
|
225
|
+
return async (ctx, next) => {
|
|
226
|
+
const result = extractClientCertificate(ctx.req, options);
|
|
227
|
+
|
|
228
|
+
if (!result.success) {
|
|
229
|
+
ctx.throw(401, result.reason);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
ctx.state.clientCertificate = result.certificate;
|
|
233
|
+
|
|
234
|
+
const allowed = await checkAuth(result.certificate, ctx.req);
|
|
235
|
+
if (!allowed) {
|
|
236
|
+
ctx.throw(401, 'Certificate not authorized');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await next();
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Usage
|
|
244
|
+
app.use(koaClientCert(
|
|
245
|
+
(cert) => cert.subject.CN === 'admin',
|
|
246
|
+
{ certificateSource: 'aws-alb' }
|
|
247
|
+
));
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Example - Custom authentication flow:**
|
|
251
|
+
|
|
252
|
+
```javascript
|
|
253
|
+
import { extractClientCertificate } from 'client-certificate-auth/extractor';
|
|
254
|
+
|
|
255
|
+
app.post('/api/login', (req, res) => {
|
|
256
|
+
// Extract certificate without middleware
|
|
257
|
+
const result = extractClientCertificate(req, {
|
|
258
|
+
certificateSource: 'envoy',
|
|
259
|
+
fallbackToSocket: true
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (!result.success) {
|
|
263
|
+
return res.status(401).json({ error: result.reason });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Custom auth logic
|
|
267
|
+
const user = lookupUserByCertFingerprint(result.certificate.fingerprint);
|
|
268
|
+
if (!user) {
|
|
269
|
+
return res.status(403).json({ error: 'Certificate not registered' });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Issue session token
|
|
273
|
+
const token = createSessionToken(user);
|
|
274
|
+
res.json({ token, user });
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Ecosystem
|
|
279
|
+
|
|
280
|
+
This package provides everything you need to build mTLS authentication for any Node.js framework:
|
|
281
|
+
|
|
282
|
+
- **Certificate extraction** via `extractClientCertificate()` - handles both socket and header-based extraction
|
|
283
|
+
- **Authorization helpers** - reusable validation callbacks for common patterns (`allowCN`, `allowFingerprints`, etc.)
|
|
284
|
+
- **Parser library** - decode certificates from various reverse proxy formats (Envoy XFCC, AWS ALB, Cloudflare, etc.)
|
|
285
|
+
- **Type definitions** - full TypeScript support
|
|
286
|
+
|
|
287
|
+
**Official framework adapters:**
|
|
288
|
+
|
|
289
|
+
- **[passport-client-certificate-auth](https://www.npmjs.com/package/passport-client-certificate-auth)** - Passport.js strategy for mTLS authentication
|
|
290
|
+
|
|
291
|
+
**Community adapters:**
|
|
292
|
+
|
|
293
|
+
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
|
+
|
|
189
295
|
### Accessing the Certificate
|
|
190
296
|
|
|
191
297
|
After authentication, the certificate is attached to `req.clientCertificate` for downstream handlers:
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @license MIT
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { extractClientCertificate } from './extractor.js';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* @typedef {import('http').IncomingMessage & { secure?: boolean; socket: import('tls').TLSSocket & { authorized?: boolean; authorizationError?: string }; clientCertificate?: import('tls').PeerCertificate }} ClientCertRequest
|
|
@@ -113,72 +113,52 @@ export default function clientCertificateAuth(callback, options = {}) {
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
// Determine if header-based extraction is configured
|
|
117
|
-
const useHeaders = Boolean(certificateSource || certificateHeader);
|
|
118
|
-
|
|
119
116
|
return function middleware(req, res, next) {
|
|
120
|
-
|
|
117
|
+
// Extract certificate using shared extractor logic
|
|
118
|
+
const result = extractClientCertificate(req, {
|
|
119
|
+
certificateSource,
|
|
120
|
+
certificateHeader,
|
|
121
|
+
headerEncoding,
|
|
122
|
+
fallbackToSocket,
|
|
123
|
+
includeChain,
|
|
124
|
+
verifyHeader,
|
|
125
|
+
verifyValue,
|
|
126
|
+
});
|
|
121
127
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// Verify upstream proxy's certificate validation if configured
|
|
125
|
-
// Stryker disable next-line LogicalOperator: construction-time validation ensures both set or neither, so && vs || is equivalent
|
|
126
|
-
if (verifyHeader && verifyValue) {
|
|
127
|
-
const verifyStatus = req.headers[verifyHeader.toLowerCase()];
|
|
128
|
-
if (Array.isArray(verifyStatus) || verifyStatus !== verifyValue) {
|
|
129
|
-
safeCallHook(onRejected, null, req, 'verification_header_mismatch');
|
|
130
|
-
const e = new Error(`Unauthorized: Certificate verification failed (${verifyStatus || 'header missing'})`);
|
|
131
|
-
e.status = 401;
|
|
132
|
-
return next(e);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
cert = getCertificateFromHeaders(req.headers, {
|
|
136
|
-
certificateSource,
|
|
137
|
-
certificateHeader,
|
|
138
|
-
headerEncoding,
|
|
139
|
-
});
|
|
128
|
+
if (!result.success) {
|
|
129
|
+
safeCallHook(onRejected, null, req, result.reason);
|
|
140
130
|
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
131
|
+
// Map rejection reasons to user-friendly error messages
|
|
132
|
+
let errorMessage;
|
|
133
|
+
let statusCode = 401;
|
|
145
134
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
e.status = 401;
|
|
152
|
-
return next(e);
|
|
135
|
+
switch (result.reason) {
|
|
136
|
+
case 'verification_header_mismatch': {
|
|
137
|
+
const verifyStatus = req.headers[verifyHeader.toLowerCase()];
|
|
138
|
+
errorMessage = `Unauthorized: Certificate verification failed (${verifyStatus || 'header missing'})`;
|
|
139
|
+
break;
|
|
153
140
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
141
|
+
case 'header_missing_or_malformed':
|
|
142
|
+
errorMessage = 'Unauthorized: Client certificate header missing or malformed';
|
|
143
|
+
break;
|
|
144
|
+
case 'socket_not_authorized': {
|
|
145
|
+
const authError = req.socket?.authorizationError || 'unknown';
|
|
146
|
+
errorMessage = `Unauthorized: Client certificate required (${authError})`;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case 'certificate_not_retrievable':
|
|
150
|
+
errorMessage = 'Client certificate was authenticated but certificate information could not be retrieved.';
|
|
151
|
+
statusCode = 500;
|
|
152
|
+
break;
|
|
166
153
|
}
|
|
167
154
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// Handle the bizarre case where a certificate was validated but we can't inspect it
|
|
172
|
-
safeCallHook(onRejected, null, req, 'certificate_not_retrievable');
|
|
173
|
-
const e = new Error(
|
|
174
|
-
'Client certificate was authenticated but certificate information could not be retrieved.'
|
|
175
|
-
);
|
|
176
|
-
e.status = 500;
|
|
177
|
-
return next(e);
|
|
178
|
-
}
|
|
155
|
+
const e = new Error(errorMessage);
|
|
156
|
+
e.status = statusCode;
|
|
157
|
+
return next(e);
|
|
179
158
|
}
|
|
180
159
|
|
|
181
160
|
// Attach certificate to request for downstream access
|
|
161
|
+
const cert = result.certificate;
|
|
182
162
|
req.clientCertificate = cert;
|
|
183
163
|
|
|
184
164
|
/**
|
|
@@ -197,9 +177,9 @@ export default function clientCertificateAuth(callback, options = {}) {
|
|
|
197
177
|
}
|
|
198
178
|
|
|
199
179
|
try {
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
202
|
-
|
|
180
|
+
const callbackResult = callback(cert, req);
|
|
181
|
+
if (callbackResult instanceof Promise) {
|
|
182
|
+
callbackResult.then(doneAuthorizing).catch((err) => {
|
|
203
183
|
safeCallHook(onRejected, cert, req, err.message || 'callback_threw');
|
|
204
184
|
if (err.status === undefined) {
|
|
205
185
|
err.status = 401;
|
|
@@ -207,7 +187,7 @@ export default function clientCertificateAuth(callback, options = {}) {
|
|
|
207
187
|
next(err);
|
|
208
188
|
});
|
|
209
189
|
} else {
|
|
210
|
-
doneAuthorizing(
|
|
190
|
+
doneAuthorizing(callbackResult);
|
|
211
191
|
}
|
|
212
192
|
} catch (err) {
|
|
213
193
|
safeCallHook(onRejected, cert, req, err.message || 'callback_threw');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* client-certificate-auth/extractor - 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('./extractor.js');
|
|
16
|
+
}
|
|
17
|
+
return _module;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { load };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* client-certificate-auth/extractor - CommonJS type declarations
|
|
3
|
+
* Copyright (C) 2013-2026 Tony Gies
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type * as ExtractorModule from './extractor.js';
|
|
8
|
+
|
|
9
|
+
declare const extractor: {
|
|
10
|
+
load(): Promise<typeof ExtractorModule>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export = extractor;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} ExtractionResult
|
|
3
|
+
* @property {boolean} success - Whether extraction succeeded
|
|
4
|
+
* @property {import('tls').PeerCertificate | null} certificate - Extracted certificate (null on failure)
|
|
5
|
+
* @property {string | null} reason - Rejection reason code (null on success)
|
|
6
|
+
*
|
|
7
|
+
* Rejection reasons:
|
|
8
|
+
* - 'verification_header_mismatch' - Proxy verify header didn't match expected value
|
|
9
|
+
* - 'header_missing_or_malformed' - Header extraction failed and no fallback configured
|
|
10
|
+
* - 'socket_not_authorized' - Socket not authorized for TLS client cert
|
|
11
|
+
* - 'certificate_not_retrievable' - Socket authorized but getPeerCertificate() returned empty
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} ExtractorOptions
|
|
15
|
+
* @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Preset configuration
|
|
16
|
+
* @property {string} [certificateHeader] - Custom header name
|
|
17
|
+
* @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] - Header encoding
|
|
18
|
+
* @property {boolean} [fallbackToSocket=false] - Try socket if header extraction fails
|
|
19
|
+
* @property {boolean} [includeChain=false] - Include issuerCertificate chain
|
|
20
|
+
* @property {string} [verifyHeader] - Header name for upstream verification status
|
|
21
|
+
* @property {string} [verifyValue] - Expected value for successful verification
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Extract client certificate from request.
|
|
25
|
+
*
|
|
26
|
+
* Works with both header-based extraction (reverse proxy scenarios) and socket-based
|
|
27
|
+
* extraction (direct TLS connections). Returns a structured result object instead of
|
|
28
|
+
* throwing or using callbacks, making it suitable for any framework adapter.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} req - Request object with headers and optional socket
|
|
31
|
+
* @param {Record<string, string | string[] | undefined>} req.headers - HTTP headers object
|
|
32
|
+
* @param {Object} [req.socket] - TLS socket with getPeerCertificate() method
|
|
33
|
+
* @param {boolean} [req.socket.authorized] - Whether socket was authorized
|
|
34
|
+
* @param {(detailed: boolean) => import('tls').PeerCertificate} [req.socket.getPeerCertificate] - Get peer certificate
|
|
35
|
+
* @param {ExtractorOptions} [options={}] - Extraction options
|
|
36
|
+
* @returns {ExtractionResult}
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* // AWS ALB header extraction
|
|
40
|
+
* const result = extractClientCertificate(req, { certificateSource: 'aws-alb' });
|
|
41
|
+
* if (result.success) {
|
|
42
|
+
* console.log('Certificate CN:', result.certificate.subject.CN);
|
|
43
|
+
* } else {
|
|
44
|
+
* console.error('Extraction failed:', result.reason);
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* // Socket extraction with fallback
|
|
49
|
+
* const result = extractClientCertificate(req, {
|
|
50
|
+
* certificateSource: 'envoy',
|
|
51
|
+
* fallbackToSocket: true
|
|
52
|
+
* });
|
|
53
|
+
*/
|
|
54
|
+
export function extractClientCertificate(req: {
|
|
55
|
+
headers: Record<string, string | string[] | undefined>;
|
|
56
|
+
socket?: {
|
|
57
|
+
authorized?: boolean;
|
|
58
|
+
getPeerCertificate?: (detailed: boolean) => import("tls").PeerCertificate;
|
|
59
|
+
};
|
|
60
|
+
}, options?: ExtractorOptions): ExtractionResult;
|
|
61
|
+
export type ExtractionResult = {
|
|
62
|
+
/**
|
|
63
|
+
* - Whether extraction succeeded
|
|
64
|
+
*/
|
|
65
|
+
success: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* - Extracted certificate (null on failure)
|
|
68
|
+
*/
|
|
69
|
+
certificate: import("tls").PeerCertificate | null;
|
|
70
|
+
/**
|
|
71
|
+
* - Rejection reason code (null on success)
|
|
72
|
+
*
|
|
73
|
+
* Rejection reasons:
|
|
74
|
+
* - 'verification_header_mismatch' - Proxy verify header didn't match expected value
|
|
75
|
+
* - 'header_missing_or_malformed' - Header extraction failed and no fallback configured
|
|
76
|
+
* - 'socket_not_authorized' - Socket not authorized for TLS client cert
|
|
77
|
+
* - 'certificate_not_retrievable' - Socket authorized but getPeerCertificate() returned empty
|
|
78
|
+
*/
|
|
79
|
+
reason: string | null;
|
|
80
|
+
};
|
|
81
|
+
export type ExtractorOptions = {
|
|
82
|
+
/**
|
|
83
|
+
* - Preset configuration
|
|
84
|
+
*/
|
|
85
|
+
certificateSource?: "aws-alb" | "envoy" | "cloudflare" | "traefik";
|
|
86
|
+
/**
|
|
87
|
+
* - Custom header name
|
|
88
|
+
*/
|
|
89
|
+
certificateHeader?: string;
|
|
90
|
+
/**
|
|
91
|
+
* - Header encoding
|
|
92
|
+
*/
|
|
93
|
+
headerEncoding?: "url-pem" | "url-pem-aws" | "xfcc" | "base64-der" | "rfc9440";
|
|
94
|
+
/**
|
|
95
|
+
* - Try socket if header extraction fails
|
|
96
|
+
*/
|
|
97
|
+
fallbackToSocket?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* - Include issuerCertificate chain
|
|
100
|
+
*/
|
|
101
|
+
includeChain?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* - Header name for upstream verification status
|
|
104
|
+
*/
|
|
105
|
+
verifyHeader?: string;
|
|
106
|
+
/**
|
|
107
|
+
* - Expected value for successful verification
|
|
108
|
+
*/
|
|
109
|
+
verifyValue?: string;
|
|
110
|
+
};
|
package/lib/extractor.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* client-certificate-auth - Certificate extraction module
|
|
3
|
+
* Copyright (C) 2013-2026 Tony Gies
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getCertificateFromHeaders } from './parsers.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} ExtractionResult
|
|
11
|
+
* @property {boolean} success - Whether extraction succeeded
|
|
12
|
+
* @property {import('tls').PeerCertificate | null} certificate - Extracted certificate (null on failure)
|
|
13
|
+
* @property {string | null} reason - Rejection reason code (null on success)
|
|
14
|
+
*
|
|
15
|
+
* Rejection reasons:
|
|
16
|
+
* - 'verification_header_mismatch' - Proxy verify header didn't match expected value
|
|
17
|
+
* - 'header_missing_or_malformed' - Header extraction failed and no fallback configured
|
|
18
|
+
* - 'socket_not_authorized' - Socket not authorized for TLS client cert
|
|
19
|
+
* - 'certificate_not_retrievable' - Socket authorized but getPeerCertificate() returned empty
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} ExtractorOptions
|
|
24
|
+
* @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Preset configuration
|
|
25
|
+
* @property {string} [certificateHeader] - Custom header name
|
|
26
|
+
* @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] - Header encoding
|
|
27
|
+
* @property {boolean} [fallbackToSocket=false] - Try socket if header extraction fails
|
|
28
|
+
* @property {boolean} [includeChain=false] - Include issuerCertificate chain
|
|
29
|
+
* @property {string} [verifyHeader] - Header name for upstream verification status
|
|
30
|
+
* @property {string} [verifyValue] - Expected value for successful verification
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract client certificate from request.
|
|
35
|
+
*
|
|
36
|
+
* Works with both header-based extraction (reverse proxy scenarios) and socket-based
|
|
37
|
+
* extraction (direct TLS connections). Returns a structured result object instead of
|
|
38
|
+
* throwing or using callbacks, making it suitable for any framework adapter.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} req - Request object with headers and optional socket
|
|
41
|
+
* @param {Record<string, string | string[] | undefined>} req.headers - HTTP headers object
|
|
42
|
+
* @param {Object} [req.socket] - TLS socket with getPeerCertificate() method
|
|
43
|
+
* @param {boolean} [req.socket.authorized] - Whether socket was authorized
|
|
44
|
+
* @param {(detailed: boolean) => import('tls').PeerCertificate} [req.socket.getPeerCertificate] - Get peer certificate
|
|
45
|
+
* @param {ExtractorOptions} [options={}] - Extraction options
|
|
46
|
+
* @returns {ExtractionResult}
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // AWS ALB header extraction
|
|
50
|
+
* const result = extractClientCertificate(req, { certificateSource: 'aws-alb' });
|
|
51
|
+
* if (result.success) {
|
|
52
|
+
* console.log('Certificate CN:', result.certificate.subject.CN);
|
|
53
|
+
* } else {
|
|
54
|
+
* console.error('Extraction failed:', result.reason);
|
|
55
|
+
* }
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* // Socket extraction with fallback
|
|
59
|
+
* const result = extractClientCertificate(req, {
|
|
60
|
+
* certificateSource: 'envoy',
|
|
61
|
+
* fallbackToSocket: true
|
|
62
|
+
* });
|
|
63
|
+
*/
|
|
64
|
+
export function extractClientCertificate(req, options = {}) {
|
|
65
|
+
const {
|
|
66
|
+
certificateSource,
|
|
67
|
+
certificateHeader,
|
|
68
|
+
headerEncoding,
|
|
69
|
+
fallbackToSocket = false,
|
|
70
|
+
includeChain = false,
|
|
71
|
+
verifyHeader,
|
|
72
|
+
verifyValue,
|
|
73
|
+
} = options;
|
|
74
|
+
|
|
75
|
+
// Validate verifyHeader/verifyValue pairing
|
|
76
|
+
if ((verifyHeader && !verifyValue) || (!verifyHeader && verifyValue)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'extractClientCertificate: verifyHeader and verifyValue must both be provided together, or both omitted'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const useHeaders = Boolean(certificateSource || certificateHeader);
|
|
83
|
+
let cert = null;
|
|
84
|
+
|
|
85
|
+
// Try header-based extraction first if configured
|
|
86
|
+
if (useHeaders) {
|
|
87
|
+
// Verify upstream proxy's certificate validation if configured
|
|
88
|
+
// Stryker disable next-line LogicalOperator: construction-time validation ensures both set or neither, single-side check is redundant but clearer
|
|
89
|
+
if (verifyHeader && verifyValue) {
|
|
90
|
+
const verifyStatus = req.headers[verifyHeader.toLowerCase()];
|
|
91
|
+
if (Array.isArray(verifyStatus) || verifyStatus !== verifyValue) {
|
|
92
|
+
return {
|
|
93
|
+
success: false,
|
|
94
|
+
certificate: null,
|
|
95
|
+
reason: 'verification_header_mismatch',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
cert = getCertificateFromHeaders(req.headers, {
|
|
101
|
+
certificateSource,
|
|
102
|
+
certificateHeader,
|
|
103
|
+
headerEncoding,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Normalize: strip chain unless includeChain is true
|
|
107
|
+
if (cert && !includeChain && 'issuerCertificate' in cert) {
|
|
108
|
+
delete cert.issuerCertificate;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!cert) {
|
|
112
|
+
// If no fallback, return error immediately
|
|
113
|
+
if (!fallbackToSocket) {
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
certificate: null,
|
|
117
|
+
reason: 'header_missing_or_malformed',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fallback to socket-based extraction (original behavior)
|
|
124
|
+
if (!cert) {
|
|
125
|
+
// Ensure that the certificate was validated at the protocol level
|
|
126
|
+
if (!req.socket?.authorized) {
|
|
127
|
+
return {
|
|
128
|
+
success: false,
|
|
129
|
+
certificate: null,
|
|
130
|
+
reason: 'socket_not_authorized',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Obtain certificate details from socket
|
|
135
|
+
// TypeScript: cast to TLSSocket since we've validated this is a TLS connection
|
|
136
|
+
if (typeof req.socket.getPeerCertificate !== 'function') {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
certificate: null,
|
|
140
|
+
reason: 'certificate_not_retrievable',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
cert = /** @type {import('tls').TLSSocket} */ (req.socket).getPeerCertificate(includeChain);
|
|
145
|
+
if (!cert || Object.keys(cert).length === 0) {
|
|
146
|
+
// Handle the case where a certificate was validated but we can't inspect it
|
|
147
|
+
return {
|
|
148
|
+
success: false,
|
|
149
|
+
certificate: null,
|
|
150
|
+
reason: 'certificate_not_retrievable',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
success: true,
|
|
157
|
+
certificate: cert,
|
|
158
|
+
reason: null,
|
|
159
|
+
};
|
|
160
|
+
}
|
package/lib/helpers.cjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* client-certificate-auth/helpers - 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('./helpers.js');
|
|
16
|
+
}
|
|
17
|
+
return _module;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { load };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* client-certificate-auth/helpers - CommonJS type declarations
|
|
3
|
+
* Copyright (C) 2013-2026 Tony Gies
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type * as HelpersModule from './helpers.js';
|
|
8
|
+
|
|
9
|
+
declare const helpers: {
|
|
10
|
+
load(): Promise<typeof HelpersModule>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export = helpers;
|
package/lib/helpers.d.ts
CHANGED
|
@@ -4,13 +4,9 @@
|
|
|
4
4
|
* @license MIT
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
8
|
-
import type { ClientCertRequest } from './clientCertificateAuth.js';
|
|
7
|
+
import type { ValidationCallback } from './clientCertificateAuth.js';
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
* Validation callback for clientCertificateAuth middleware.
|
|
12
|
-
*/
|
|
13
|
-
export type ValidationCallback = (cert: PeerCertificate | DetailedPeerCertificate, req?: ClientCertRequest) => boolean | Promise<boolean>;
|
|
9
|
+
export type { ValidationCallback };
|
|
14
10
|
|
|
15
11
|
/**
|
|
16
12
|
* Distinguished Name fields for matching.
|
package/lib/parsers.cjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* client-certificate-auth/parsers - 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('./parsers.js');
|
|
16
|
+
}
|
|
17
|
+
return _module;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { load };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* client-certificate-auth/parsers - CommonJS type declarations
|
|
3
|
+
* Copyright (C) 2013-2026 Tony Gies
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type * as ParsersModule from './parsers.js';
|
|
8
|
+
|
|
9
|
+
declare const parsers: {
|
|
10
|
+
load(): Promise<typeof ParsersModule>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export = parsers;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "client-certificate-auth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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": {
|
|
@@ -26,35 +26,66 @@
|
|
|
26
26
|
"import": {
|
|
27
27
|
"types": "./lib/parsers.d.ts",
|
|
28
28
|
"default": "./lib/parsers.js"
|
|
29
|
+
},
|
|
30
|
+
"require": {
|
|
31
|
+
"types": "./lib/parsers.d.cts",
|
|
32
|
+
"default": "./lib/parsers.cjs"
|
|
29
33
|
}
|
|
30
34
|
},
|
|
31
35
|
"./helpers": {
|
|
32
36
|
"import": {
|
|
33
37
|
"types": "./lib/helpers.d.ts",
|
|
34
38
|
"default": "./lib/helpers.js"
|
|
39
|
+
},
|
|
40
|
+
"require": {
|
|
41
|
+
"types": "./lib/helpers.d.cts",
|
|
42
|
+
"default": "./lib/helpers.cjs"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"./extractor": {
|
|
46
|
+
"import": {
|
|
47
|
+
"types": "./lib/extractor.d.ts",
|
|
48
|
+
"default": "./lib/extractor.js"
|
|
49
|
+
},
|
|
50
|
+
"require": {
|
|
51
|
+
"types": "./lib/extractor.d.cts",
|
|
52
|
+
"default": "./lib/extractor.cjs"
|
|
35
53
|
}
|
|
36
54
|
}
|
|
37
55
|
},
|
|
38
56
|
"main": "./lib/clientCertificateAuth.cjs",
|
|
39
57
|
"types": "./lib/clientCertificateAuth.d.ts",
|
|
58
|
+
"typesVersions": {
|
|
59
|
+
"*": {
|
|
60
|
+
"parsers": [
|
|
61
|
+
"./lib/parsers.d.ts"
|
|
62
|
+
],
|
|
63
|
+
"helpers": [
|
|
64
|
+
"./lib/helpers.d.ts"
|
|
65
|
+
],
|
|
66
|
+
"extractor": [
|
|
67
|
+
"./lib/extractor.d.ts"
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
40
71
|
"engines": {
|
|
41
72
|
"node": ">= 20"
|
|
42
73
|
},
|
|
43
74
|
"devDependencies": {
|
|
44
75
|
"@commitlint/cli": "^20.4.1",
|
|
45
76
|
"@commitlint/config-conventional": "^20.4.1",
|
|
46
|
-
"@stryker-mutator/core": "^9.
|
|
47
|
-
"@stryker-mutator/jest-runner": "^9.
|
|
48
|
-
"@types/express": "^5.0.
|
|
49
|
-
"@types/node": "^25.2.
|
|
77
|
+
"@stryker-mutator/core": "^9.5.1",
|
|
78
|
+
"@stryker-mutator/jest-runner": "^9.5.1",
|
|
79
|
+
"@types/express": "^5.0.6",
|
|
80
|
+
"@types/node": "^25.2.3",
|
|
50
81
|
"c8": "^10.1.3",
|
|
51
82
|
"eslint": "^9.17.0",
|
|
52
|
-
"globals": "^17.
|
|
83
|
+
"globals": "^17.3.0",
|
|
53
84
|
"husky": "^9.1.7",
|
|
54
85
|
"jest": "^30.2.0",
|
|
55
86
|
"lint-staged": "^16.2.7",
|
|
56
|
-
"selfsigned": "^5.
|
|
57
|
-
"typescript": "^5.
|
|
87
|
+
"selfsigned": "^5.5.0",
|
|
88
|
+
"typescript": "^5.9.3",
|
|
58
89
|
"ws": "^8.19.0"
|
|
59
90
|
},
|
|
60
91
|
"directories": {
|