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 CHANGED
@@ -8,11 +8,12 @@ Comprehensive toolkit for client SSL certificate authentication (mTLS) in Node.j
8
8
  [![stryker mutation testing](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Ftgies%2Fclient-certificate-auth%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/tgies/client-certificate-auth/master)
9
9
 
10
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. ~4,776 lines of test code for ~711 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. ~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 authError = req.socket?.authorizationError || 'unknown';
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 { TLSSocket, PeerCertificate, DetailedPeerCertificate } from 'tls';
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 TLS socket and optional Express properties.
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
- /** TLS socket with authorization properties */
33
- socket: TLSSocket & {
34
- /** Whether the client certificate was authorized at the TLS layer */
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 message if authorization failed */
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 { TLSSocket, PeerCertificate, DetailedPeerCertificate } from 'tls';
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 TLS socket and optional Express properties.
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
- /** TLS socket with authorization properties */
31
- socket: TLSSocket & {
32
- /** Whether the client certificate was authorized at the TLS layer */
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 message if authorization failed */
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('tls').TLSSocket & { authorized?: boolean; authorizationError?: string }; clientCertificate?: import('tls').PeerCertificate }} ClientCertRequest
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
- const verifyStatus = req.headers[verifyHeader.toLowerCase()];
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
- const authError = req.socket?.authorizationError || 'unknown';
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;
@@ -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.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
  }