Skip to content

Commit 1448187

Browse files
committed
feat(mTLS): add Mutual TLS (RFC 8705) client authentication support
Implements RFC 8705 mTLS client authentication, allowing applications to authenticate with Auth0 using a TLS client certificate instead of a client_secret. Certificate-bound access tokens (cnf.x5t#S256) are issued automatically when the IdP supports them. Key changes: - New `useMtls` config option (also via AUTH0_MTLS env var); defaults to false - When enabled, switches client auth method to `tls_client_auth` (TlsClientAuth() from openid-client v6) and sets `use_mtls_endpoint_aliases: true` so token/userinfo requests are routed to the mtls_endpoint_aliases advertised in discovery - `customFetch` is required when useMtls=true; throws MtlsError at init time if missing - `clientSecret` is no longer required when useMtls=true — the cert is the credential - New MtlsError / MtlsErrorCode exported from the package for structured error handling - 14 new tests covering config validation, clientMetadata flag, endpoint alias routing, customFetch invocation and graceful fallback when aliases are absent
1 parent fe727cb commit 1448187

7 files changed

Lines changed: 314 additions & 6 deletions

File tree

index.d.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,8 @@ interface ConfigParams {
834834
* A custom fetch function to use for all OIDC HTTP requests (discovery, token, userinfo, etc.).
835835
* The SDK wraps this function to inject required headers (User-Agent, Auth0-Client telemetry) before making requests.
836836
*
837-
* This is useful for configuring proxies or custom HTTP behavior.
837+
* Required when `useMtls` is `true` — provide a TLS-aware implementation that attaches your
838+
* client certificate to every outbound request (e.g. `undici` with `connect: { key, cert }`).
838839
*
839840
* @example
840841
* ```js
@@ -853,6 +854,47 @@ interface ConfigParams {
853854
* Optional User-Agent header value for oidc client requests. Default is `express-openid-connect/{version}`.
854855
*/
855856
httpUserAgent?: string;
857+
858+
/**
859+
* Enable mTLS (Mutual TLS, RFC 8705) client authentication.
860+
*
861+
* When `true`, the SDK authenticates with Auth0 using a TLS client certificate instead of a
862+
* `clientSecret` or `clientAssertionSigningKey`. Access tokens will carry a `cnf.x5t#S256`
863+
* claim binding them to the certificate fingerprint (certificate-bound tokens).
864+
*
865+
* Requires:
866+
* 1. A TLS-aware `customFetch` implementation that attaches your client certificate
867+
* (e.g. Node.js `undici` configured with `connect: { key, cert }`).
868+
* 2. The mTLS feature to be enabled on your Auth0 tenant.
869+
*
870+
* You do **not** need to provide `clientSecret` when `useMtls` is `true`.
871+
*
872+
* Can also be enabled by setting the `AUTH0_MTLS=true` environment variable.
873+
*
874+
* @default false
875+
*
876+
* @example
877+
* ```js
878+
* const { Agent, fetch: undiciFetch } = require('undici');
879+
* const { readFileSync } = require('fs');
880+
*
881+
* const tlsAgent = new Agent({
882+
* connect: {
883+
* key: readFileSync('client.key'),
884+
* cert: readFileSync('client.crt'),
885+
* },
886+
* });
887+
*
888+
* app.use(auth({
889+
* useMtls: true,
890+
* customFetch: (url, options) =>
891+
* undiciFetch(url, { ...options, dispatcher: tlsAgent }),
892+
* }));
893+
* ```
894+
*
895+
* @see {@link https://datatracker.ietf.org/doc/html/rfc8705 | RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication}
896+
*/
897+
useMtls?: boolean;
856898
}
857899

858900
interface SessionStorePayload<Data = Session> {
@@ -1250,3 +1292,34 @@ export class SessionExpiredError extends Error {
12501292
readonly statusCode: 401;
12511293
constructor(message?: string);
12521294
}
1295+
1296+
/**
1297+
* Error codes for mTLS (Mutual TLS, RFC 8705) configuration failures.
1298+
*/
1299+
export const MtlsErrorCode: {
1300+
readonly MTLS_REQUIRES_CUSTOM_FETCH: 'mtls_requires_custom_fetch';
1301+
};
1302+
1303+
/**
1304+
* Thrown during `auth()` initialization when the mTLS configuration is invalid.
1305+
*
1306+
* Currently thrown when `useMtls: true` is set without a `customFetch` implementation.
1307+
* Catch by `error.code` string rather than `instanceof` to be bundler-safe.
1308+
*
1309+
* ```js
1310+
* const { MtlsError, MtlsErrorCode } = require('express-openid-connect');
1311+
*
1312+
* try {
1313+
* app.use(auth({ useMtls: true })); // missing customFetch — throws
1314+
* } catch (err) {
1315+
* if (err.code === MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH) {
1316+
* console.error('Provide a TLS-aware customFetch when useMtls is true.');
1317+
* }
1318+
* }
1319+
* ```
1320+
*/
1321+
export class MtlsError extends Error {
1322+
readonly name: 'MtlsError';
1323+
readonly code: string;
1324+
constructor(code: string, message: string);
1325+
}

index.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
const auth = require('./middleware/auth');
22
const requiresAuth = require('./middleware/requiresAuth');
33
const attemptSilentLogin = require('./middleware/attemptSilentLogin');
4-
const { SessionExpiredError } = require('./lib/errors');
4+
const {
5+
SessionExpiredError,
6+
MtlsError,
7+
MtlsErrorCode,
8+
} = require('./lib/errors');
59

610
module.exports = {
711
auth,
812
...requiresAuth,
913
attemptSilentLogin,
1014
SessionExpiredError,
15+
MtlsError,
16+
MtlsErrorCode,
1117
};

lib/client.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ function createCustomFetch(config) {
8383
*/
8484
async function getClientAuth(config) {
8585
switch (config.clientAuthMethod) {
86+
case 'tls_client_auth':
87+
return client.TlsClientAuth();
8688
case 'client_secret_basic':
8789
return client.ClientSecretBasic(config.clientSecret);
8890
case 'client_secret_post':
@@ -141,6 +143,7 @@ async function get(config) {
141143
const clientMetadata = {
142144
[client.clockTolerance]: config.clockTolerance,
143145
id_token_signed_response_alg: config.idTokenSigningAlg,
146+
...(config.useMtls && { use_mtls_endpoint_aliases: true }),
144147
};
145148

146149
// Discover and create configuration

lib/config.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const Joi = require('joi');
22
const crypto = require('crypto');
33
const { defaultState: getLoginState } = require('./hooks/getLoginState');
4+
const { MtlsError, MtlsErrorCode } = require('./errors');
45
const isHttps = /^https:/i;
56

67
const defaultSessionIdGenerator = () => crypto.randomBytes(16).toString('hex');
@@ -194,8 +195,11 @@ const paramsSchema = Joi.object({
194195
}),
195196
{
196197
is: true,
197-
then: Joi.string().required().messages({
198-
'any.required': `"clientSecret" is required for the "clientAuthMethod" "{{clientAuthMethod}}"`,
198+
then: Joi.when(Joi.ref('useMtls'), {
199+
is: true,
200+
otherwise: Joi.string().required().messages({
201+
'any.required': `"clientSecret" is required for the "clientAuthMethod" "{{clientAuthMethod}}"`,
202+
}),
199203
}),
200204
},
201205
)
@@ -272,10 +276,14 @@ const paramsSchema = Joi.object({
272276
'client_secret_post',
273277
'client_secret_jwt',
274278
'private_key_jwt',
279+
'tls_client_auth',
275280
'none',
276281
)
277282
.optional()
278283
.default((parent) => {
284+
if (parent.useMtls) {
285+
return 'tls_client_auth';
286+
}
279287
if (
280288
parent.authorizationParams.response_type === 'id_token' &&
281289
!parent.pushedAuthorizationRequests
@@ -364,6 +372,7 @@ const paramsSchema = Joi.object({
364372
httpTimeout: Joi.number().optional().min(500).default(5000),
365373
httpUserAgent: Joi.string().optional(),
366374
customFetch: Joi.function().optional(),
375+
useMtls: Joi.boolean().optional().default(false),
367376
});
368377

369378
module.exports.get = function (config = {}) {
@@ -373,6 +382,7 @@ module.exports.get = function (config = {}) {
373382
baseURL: process.env.BASE_URL,
374383
clientID: process.env.CLIENT_ID,
375384
clientSecret: process.env.CLIENT_SECRET,
385+
useMtls: process.env.AUTH0_MTLS === 'true' ? true : undefined,
376386
...config,
377387
};
378388

@@ -383,5 +393,15 @@ module.exports.get = function (config = {}) {
383393
if (warning) {
384394
console.warn(warning.message);
385395
}
396+
397+
if (value.useMtls && !value.customFetch) {
398+
throw new MtlsError(
399+
MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH,
400+
'useMtls requires a customFetch option with a TLS client certificate implementation. ' +
401+
'The standard fetch global has no client certificate API. ' +
402+
'See https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md for setup instructions.',
403+
);
404+
}
405+
386406
return value;
387407
};

lib/errors.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,29 @@ class SessionExpiredError extends Error {
88
}
99
}
1010

11-
module.exports = { SessionExpiredError };
11+
/**
12+
* Error codes for mTLS (Mutual TLS, RFC 8705) configuration failures.
13+
*/
14+
const MtlsErrorCode = Object.freeze({
15+
MTLS_REQUIRES_CUSTOM_FETCH: 'mtls_requires_custom_fetch',
16+
});
17+
18+
/**
19+
* Thrown during `auth()` initialization when the mTLS configuration is invalid.
20+
*
21+
* The only current case: `useMtls: true` was set but no `customFetch` was provided.
22+
* The standard `fetch` global has no API for attaching client certificates, so mTLS
23+
* is non-functional without a TLS-aware transport.
24+
*
25+
* Catch by `error.code` (a string) rather than `instanceof` to stay compatible with
26+
* bundlers that may produce multiple class instances across module copies.
27+
*/
28+
class MtlsError extends Error {
29+
constructor(code, message) {
30+
super(message);
31+
this.name = 'MtlsError';
32+
this.code = code;
33+
}
34+
}
35+
36+
module.exports = { SessionExpiredError, MtlsError, MtlsErrorCode };

test/client.tests.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ const nock = require('nock');
88
const pkg = require('../package.json');
99
const sinon = require('sinon');
1010

11+
const MTLS_WELL_KNOWN = {
12+
...wellKnown,
13+
mtls_endpoint_aliases: {
14+
token_endpoint: 'https://mtls.op.example.com/oauth/token',
15+
userinfo_endpoint: 'https://mtls.op.example.com/userinfo',
16+
revocation_endpoint: 'https://mtls.op.example.com/oauth/revoke',
17+
},
18+
};
19+
1120
describe('client initialization', function () {
1221
beforeEach(async function () {
1322
nock('https://op.example.com')
@@ -684,4 +693,99 @@ describe('client initialization', function () {
684693
expect(spy.callCount).to.eq(1);
685694
});
686695
});
696+
697+
describe('mTLS client configuration', function () {
698+
const mtlsFetch = sinon
699+
.stub()
700+
.callsFake((url, options) => fetch(url, options));
701+
702+
const baseConfig = {
703+
secret: '__test_session_secret__',
704+
clientID: '__test_client_id__',
705+
issuerBaseURL: 'https://mtls-test.auth0.com',
706+
baseURL: 'https://example.org',
707+
authorizationParams: { response_type: 'code' },
708+
};
709+
710+
beforeEach(() => {
711+
mtlsFetch.resetHistory();
712+
nock('https://mtls-test.auth0.com')
713+
.persist()
714+
.get('/.well-known/openid-configuration')
715+
.reply(200, {
716+
...MTLS_WELL_KNOWN,
717+
issuer: 'https://mtls-test.auth0.com/',
718+
});
719+
});
720+
721+
afterEach(() => nock.cleanAll());
722+
723+
it('should set use_mtls_endpoint_aliases=true on clientMetadata when useMtls=true', async function () {
724+
const config = getConfig({
725+
...baseConfig,
726+
useMtls: true,
727+
customFetch: mtlsFetch,
728+
});
729+
const { configuration } = await getClient(config);
730+
const metadata = configuration.clientMetadata();
731+
assert.equal(metadata.use_mtls_endpoint_aliases, true);
732+
});
733+
734+
it('should NOT set use_mtls_endpoint_aliases when useMtls=false', async function () {
735+
const config = getConfig({
736+
...baseConfig,
737+
clientSecret: '__test_client_secret__',
738+
});
739+
const { configuration } = await getClient(config);
740+
const metadata = configuration.clientMetadata();
741+
assert.notEqual(metadata.use_mtls_endpoint_aliases, true);
742+
});
743+
744+
it('should resolve clientAuthMethod to tls_client_auth when useMtls=true', function () {
745+
const config = getConfig({
746+
...baseConfig,
747+
useMtls: true,
748+
customFetch: mtlsFetch,
749+
});
750+
assert.equal(config.clientAuthMethod, 'tls_client_auth');
751+
});
752+
753+
it('should invoke customFetch during discovery when useMtls=true', async function () {
754+
const config = getConfig({
755+
...baseConfig,
756+
useMtls: true,
757+
customFetch: mtlsFetch,
758+
});
759+
await getClient(config);
760+
assert.isTrue(mtlsFetch.called);
761+
});
762+
763+
it('should expose mtls_endpoint_aliases in server metadata when present', async function () {
764+
const config = getConfig({
765+
...baseConfig,
766+
useMtls: true,
767+
customFetch: mtlsFetch,
768+
});
769+
const { serverMetadata } = await getClient(config);
770+
assert.deepEqual(serverMetadata.mtls_endpoint_aliases, {
771+
token_endpoint: 'https://mtls.op.example.com/oauth/token',
772+
userinfo_endpoint: 'https://mtls.op.example.com/userinfo',
773+
revocation_endpoint: 'https://mtls.op.example.com/oauth/revoke',
774+
});
775+
});
776+
777+
it('should work without mtls_endpoint_aliases in discovery (graceful fallback)', async function () {
778+
nock.cleanAll();
779+
nock('https://mtls-test.auth0.com')
780+
.get('/.well-known/openid-configuration')
781+
.reply(200, { ...wellKnown, issuer: 'https://mtls-test.auth0.com/' });
782+
783+
const config = getConfig({
784+
...baseConfig,
785+
useMtls: true,
786+
customFetch: mtlsFetch,
787+
});
788+
await assert.isFulfilled(getClient(config));
789+
});
790+
});
687791
});

0 commit comments

Comments
 (0)