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