Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,7 +834,8 @@ interface ConfigParams {
* A custom fetch function to use for all OIDC HTTP requests (discovery, token, userinfo, etc.).
* The SDK wraps this function to inject required headers (User-Agent, Auth0-Client telemetry) before making requests.
*
* This is useful for configuring proxies or custom HTTP behavior.
* Required when `useMtls` is `true` — provide a TLS-aware implementation that attaches your
* client certificate to every outbound request (e.g. `undici` with `connect: { key, cert }`).
*
* @example
* ```js
Expand All @@ -853,6 +854,47 @@ interface ConfigParams {
* Optional User-Agent header value for oidc client requests. Default is `express-openid-connect/{version}`.
*/
httpUserAgent?: string;

/**
* Enable mTLS (Mutual TLS, RFC 8705) client authentication.
*
* When `true`, the SDK authenticates with Auth0 using a TLS client certificate instead of a
* `clientSecret` or `clientAssertionSigningKey`. Access tokens will carry a `cnf.x5t#S256`
* claim binding them to the certificate fingerprint (certificate-bound tokens).
*
* Requires:
* 1. A TLS-aware `customFetch` implementation that attaches your client certificate
* (e.g. Node.js `undici` configured with `connect: { key, cert }`).
* 2. The mTLS feature to be enabled on your Auth0 tenant.
*
* You do **not** need to provide `clientSecret` when `useMtls` is `true`.
*
* Can also be enabled by setting the `AUTH0_MTLS=true` environment variable.
*
* @default false
*
* @example
* ```js
* const { Agent, fetch: undiciFetch } = require('undici');
* const { readFileSync } = require('fs');
*
* const tlsAgent = new Agent({
* connect: {
* key: readFileSync('client.key'),
* cert: readFileSync('client.crt'),
* },
* });
*
* app.use(auth({
* useMtls: true,
* customFetch: (url, options) =>
* undiciFetch(url, { ...options, dispatcher: tlsAgent }),
* }));
* ```
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc8705 | RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication}
*/
useMtls?: boolean;
}

interface SessionStorePayload<Data = Session> {
Expand Down Expand Up @@ -1250,3 +1292,34 @@ export class SessionExpiredError extends Error {
readonly statusCode: 401;
constructor(message?: string);
}

/**
* Error codes for mTLS (Mutual TLS, RFC 8705) configuration failures.
*/
export const MtlsErrorCode: {
readonly MTLS_REQUIRES_CUSTOM_FETCH: 'mtls_requires_custom_fetch';
};

/**
* Thrown during `auth()` initialization when the mTLS configuration is invalid.
*
* Currently thrown when `useMtls: true` is set without a `customFetch` implementation.
* Catch by `error.code` string rather than `instanceof` to be bundler-safe.
*
* ```js
* const { MtlsError, MtlsErrorCode } = require('express-openid-connect');
*
* try {
* app.use(auth({ useMtls: true })); // missing customFetch — throws
* } catch (err) {
* if (err.code === MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH) {
* console.error('Provide a TLS-aware customFetch when useMtls is true.');
* }
* }
* ```
*/
export class MtlsError extends Error {
readonly name: 'MtlsError';
readonly code: string;
constructor(code: string, message: string);
}
8 changes: 7 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
const auth = require('./middleware/auth');
const requiresAuth = require('./middleware/requiresAuth');
const attemptSilentLogin = require('./middleware/attemptSilentLogin');
const { SessionExpiredError } = require('./lib/errors');
const {
SessionExpiredError,
MtlsError,
MtlsErrorCode,
} = require('./lib/errors');

module.exports = {
auth,
...requiresAuth,
attemptSilentLogin,
SessionExpiredError,
MtlsError,
MtlsErrorCode,
};
3 changes: 3 additions & 0 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ function createCustomFetch(config) {
*/
async function getClientAuth(config) {
switch (config.clientAuthMethod) {
case 'tls_client_auth':
return client.TlsClientAuth();
case 'client_secret_basic':
return client.ClientSecretBasic(config.clientSecret);
case 'client_secret_post':
Expand Down Expand Up @@ -141,6 +143,7 @@ async function get(config) {
const clientMetadata = {
[client.clockTolerance]: config.clockTolerance,
id_token_signed_response_alg: config.idTokenSigningAlg,
...(config.useMtls && { use_mtls_endpoint_aliases: true }),
};

// Discover and create configuration
Expand Down
24 changes: 22 additions & 2 deletions lib/config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const Joi = require('joi');
const crypto = require('crypto');
const { defaultState: getLoginState } = require('./hooks/getLoginState');
const { MtlsError, MtlsErrorCode } = require('./errors');
const isHttps = /^https:/i;

const defaultSessionIdGenerator = () => crypto.randomBytes(16).toString('hex');
Expand Down Expand Up @@ -194,8 +195,11 @@ const paramsSchema = Joi.object({
}),
{
is: true,
then: Joi.string().required().messages({
'any.required': `"clientSecret" is required for the "clientAuthMethod" "{{clientAuthMethod}}"`,
then: Joi.when(Joi.ref('useMtls'), {
is: true,
otherwise: Joi.string().required().messages({
'any.required': `"clientSecret" is required for the "clientAuthMethod" "{{clientAuthMethod}}"`,
}),
}),
},
)
Expand Down Expand Up @@ -272,10 +276,14 @@ const paramsSchema = Joi.object({
'client_secret_post',
'client_secret_jwt',
'private_key_jwt',
'tls_client_auth',
'none',
)
.optional()
.default((parent) => {
if (parent.useMtls) {
return 'tls_client_auth';
}
if (
parent.authorizationParams.response_type === 'id_token' &&
!parent.pushedAuthorizationRequests
Expand Down Expand Up @@ -364,6 +372,7 @@ const paramsSchema = Joi.object({
httpTimeout: Joi.number().optional().min(500).default(5000),
httpUserAgent: Joi.string().optional(),
customFetch: Joi.function().optional(),
useMtls: Joi.boolean().optional().default(false),
});

module.exports.get = function (config = {}) {
Expand All @@ -373,6 +382,7 @@ module.exports.get = function (config = {}) {
baseURL: process.env.BASE_URL,
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
useMtls: process.env.AUTH0_MTLS === 'true' ? true : undefined,
...config,
};

Expand All @@ -383,5 +393,15 @@ module.exports.get = function (config = {}) {
if (warning) {
console.warn(warning.message);
}

if (value.useMtls && !value.customFetch) {
throw new MtlsError(
MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH,
'useMtls requires a customFetch option with a TLS client certificate implementation. ' +
'The standard fetch global has no client certificate API. ' +
'See https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md for setup instructions.',
);
}

return value;
};
27 changes: 26 additions & 1 deletion lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,29 @@ class SessionExpiredError extends Error {
}
}

module.exports = { SessionExpiredError };
/**
* Error codes for mTLS (Mutual TLS, RFC 8705) configuration failures.
*/
const MtlsErrorCode = Object.freeze({
MTLS_REQUIRES_CUSTOM_FETCH: 'mtls_requires_custom_fetch',
});

/**
* Thrown during `auth()` initialization when the mTLS configuration is invalid.
*
* The only current case: `useMtls: true` was set but no `customFetch` was provided.
* The standard `fetch` global has no API for attaching client certificates, so mTLS
* is non-functional without a TLS-aware transport.
*
* Catch by `error.code` (a string) rather than `instanceof` to stay compatible with
* bundlers that may produce multiple class instances across module copies.
*/
class MtlsError extends Error {
constructor(code, message) {
super(message);
this.name = 'MtlsError';
this.code = code;
}
}

module.exports = { SessionExpiredError, MtlsError, MtlsErrorCode };
104 changes: 104 additions & 0 deletions test/client.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ const nock = require('nock');
const pkg = require('../package.json');
const sinon = require('sinon');

const MTLS_WELL_KNOWN = {
...wellKnown,
mtls_endpoint_aliases: {
token_endpoint: 'https://mtls.op.example.com/oauth/token',
userinfo_endpoint: 'https://mtls.op.example.com/userinfo',
revocation_endpoint: 'https://mtls.op.example.com/oauth/revoke',
},
};

describe('client initialization', function () {
beforeEach(async function () {
nock('https://op.example.com')
Expand Down Expand Up @@ -684,4 +693,99 @@ describe('client initialization', function () {
expect(spy.callCount).to.eq(1);
});
});

describe('mTLS client configuration', function () {
const mtlsFetch = sinon
.stub()
.callsFake((url, options) => fetch(url, options));

const baseConfig = {
secret: '__test_session_secret__',
clientID: '__test_client_id__',
issuerBaseURL: 'https://mtls-test.auth0.com',
baseURL: 'https://example.org',
authorizationParams: { response_type: 'code' },
};

beforeEach(() => {
mtlsFetch.resetHistory();
nock('https://mtls-test.auth0.com')
.persist()
.get('/.well-known/openid-configuration')
.reply(200, {
...MTLS_WELL_KNOWN,
issuer: 'https://mtls-test.auth0.com/',
});
});

afterEach(() => nock.cleanAll());

it('should set use_mtls_endpoint_aliases=true on clientMetadata when useMtls=true', async function () {
const config = getConfig({
...baseConfig,
useMtls: true,
customFetch: mtlsFetch,
});
const { configuration } = await getClient(config);
const metadata = configuration.clientMetadata();
assert.equal(metadata.use_mtls_endpoint_aliases, true);
});

it('should NOT set use_mtls_endpoint_aliases when useMtls=false', async function () {
const config = getConfig({
...baseConfig,
clientSecret: '__test_client_secret__',
});
const { configuration } = await getClient(config);
const metadata = configuration.clientMetadata();
assert.notEqual(metadata.use_mtls_endpoint_aliases, true);
});

it('should resolve clientAuthMethod to tls_client_auth when useMtls=true', function () {
const config = getConfig({
...baseConfig,
useMtls: true,
customFetch: mtlsFetch,
});
assert.equal(config.clientAuthMethod, 'tls_client_auth');
});

it('should invoke customFetch during discovery when useMtls=true', async function () {
const config = getConfig({
...baseConfig,
useMtls: true,
customFetch: mtlsFetch,
});
await getClient(config);
assert.isTrue(mtlsFetch.called);
});

it('should expose mtls_endpoint_aliases in server metadata when present', async function () {
const config = getConfig({
...baseConfig,
useMtls: true,
customFetch: mtlsFetch,
});
const { serverMetadata } = await getClient(config);
assert.deepEqual(serverMetadata.mtls_endpoint_aliases, {
token_endpoint: 'https://mtls.op.example.com/oauth/token',
userinfo_endpoint: 'https://mtls.op.example.com/userinfo',
revocation_endpoint: 'https://mtls.op.example.com/oauth/revoke',
});
});

it('should work without mtls_endpoint_aliases in discovery (graceful fallback)', async function () {
nock.cleanAll();
nock('https://mtls-test.auth0.com')
.get('/.well-known/openid-configuration')
.reply(200, { ...wellKnown, issuer: 'https://mtls-test.auth0.com/' });

const config = getConfig({
...baseConfig,
useMtls: true,
customFetch: mtlsFetch,
});
await assert.isFulfilled(getClient(config));
});
});
});
Loading
Loading