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
55 changes: 55 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
14. [Session expiry from upstream IdP (IPSIE `session_expiry`)](#14-session-expiry-from-upstream-idp-ipsie-session_expiry)
15. [JWT-Secured Authorization Requests (JAR)](#15-jwt-secured-authorization-requests-jar)
16. [Decrypt JWE-encrypted access tokens](#16-decrypt-jwe-encrypted-access-tokens)
17. [mTLS client authentication](#17-mtls-client-authentication)

## 1. Basic setup

Expand Down Expand Up @@ -593,3 +594,57 @@ app.use(
The key-management algorithm is read from the JWE protected header (validated against a strong-algorithm allowlist), so decryption works whether the tenant uses `RSA-OAEP-256`, `RSA-OAEP-512`, and so on. Set `accessTokenDecryptionAlg` only if you want to pin one. Decryption requires a code flow, since implicit flow does not return access tokens from the token endpoint.

Full example at [jwe.js](./examples/jwe.js).

## 17. mTLS client authentication

[mTLS](https://www.rfc-editor.org/rfc/rfc8705) (RFC 8705, part of Highly Regulated Identity) authenticates your application to the token endpoint with a TLS client certificate instead of a client secret. Enable it with `useMtls: true` (or the `AUTH0_MTLS=true` environment variable). Issued access tokens carry a `cnf.x5t#S256` claim binding them to the certificate.

The certificate is presented at the TLS layer by your `customFetch`, never by the SDK. Node's global `fetch` ignores the `agent` option, so the certificate must be attached via an [undici](https://github.com/nodejs/undici) `Agent` on the request `dispatcher`.

```js
const { Agent, fetch: undiciFetch } = require('undici');

const tlsAgent = new Agent({
connect: {
cert: fs.readFileSync('./client.crt'),
key: fs.readFileSync('./client.key'),
},
});

app.use(
auth({
// Point issuerBaseURL at your custom domain, not the *.auth0.com host.
issuerBaseURL: 'https://auth.your-domain.com',
authorizationParams: {
response_type: 'code',
audience: 'https://your-api/',
scope: 'openid profile email offline_access',
},
useMtls: true,
customFetch: (url, options) =>
undiciFetch(url, { ...options, dispatcher: tlsAgent }),
}),
);
```

When `useMtls` is set, the SDK routes token, refresh, revocation, userinfo, and PAR requests to the server's `mtls_endpoint_aliases`. mTLS requires:

- A custom domain with self-managed certificates. It does not work on canonical `*.auth0.com` domains (the SDK logs a warning if you try).
- mTLS endpoint aliases enabled on the tenant. If the discovery document does not advertise them, the SDK throws an `MtlsError` with code `mtls_endpoint_aliases_missing`.
- No `clientSecret` or `clientAssertionSigningKey`. Combining either with `useMtls` throws an `MtlsError` (`mtls_incompatible_client_auth`), and a missing `customFetch` throws `mtls_requires_custom_fetch`.

`MtlsError` and `MtlsErrorCode` are exported for structured handling:

```js
const { auth, MtlsError, MtlsErrorCode } = require('express-openid-connect');

try {
app.use(auth({ useMtls: true /* customFetch missing */ }));
} catch (err) {
if (err.code === MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH) {
// provide a TLS-aware customFetch
}
}
```

Full example at [mtls.js](./examples/mtls.js).
61 changes: 61 additions & 0 deletions examples/mtls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const express = require('express');
const { auth } = require('../');
const { Agent, fetch: undiciFetch } = require('undici');
const fs = require('fs');

const app = express();

// mTLS (Mutual TLS, RFC 8705) client authentication demo.
//
// With `useMtls: true` the SDK authenticates to the token endpoint with a TLS
// client certificate instead of a client secret, and routes token/refresh/
// revocation/userinfo/PAR requests to the server's `mtls_endpoint_aliases`.
// Issued access tokens carry a `cnf.x5t#S256` claim binding them to the
// certificate (certificate-bound tokens).
//
// The certificate is presented at the TLS layer by your customFetch, never by
// the SDK. Node's global fetch ignores the `agent` option, so the cert must ride
// on an undici Agent's `connect` options via the `dispatcher`.
//
// Prerequisites (see the mTLS docs):
// - A custom domain with self-managed certs (does NOT work on *.auth0.com).
// - mTLS endpoint aliases enabled on the tenant.
// - App Credentials > Authentication Method set to mTLS, with the client cert
// uploaded.
// - No clientSecret / clientAssertionSigningKey (mutually exclusive with mTLS).
//
// `AUTH0_MTLS=true` can be used instead of `useMtls: true`.

const tlsAgent = new Agent({
connect: {
cert: fs.readFileSync('./client.crt'),
key: fs.readFileSync('./client.key'),
},
});

app.use(
auth({
// Point issuerBaseURL at your custom domain, not the *.auth0.com host.
issuerBaseURL: 'https://auth.your-domain.com',
authRequired: false,
authorizationParams: {
response_type: 'code',
audience: 'https://your-api/',
scope: 'openid profile email offline_access',
},

useMtls: true,
customFetch: (url, options) =>
undiciFetch(url, { ...options, dispatcher: tlsAgent }),
}),
);

app.get('/', (req, res) => {
if (req.oidc.isAuthenticated()) {
res.send(`hello ${req.oidc.user.sub} <a href="/logout">logout</a>`);
} else {
res.send('<a href="/login">login</a>');
}
});
Comment thread
jd3vi1 marked this conversation as resolved.
Dismissed

module.exports = app;
76 changes: 69 additions & 7 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,9 @@ interface ConfigParams {

/**
* String value for the client's authentication method. Default is `none` when using response_type='id_token', `private_key_jwt` when using a `clientAssertionSigningKey`, otherwise `client_secret_basic`.
*
* For mTLS client authentication (RFC 8705), use the {@link ConfigParams.useMtls}
* option instead of setting this directly.
*/
clientAuthMethod?:
| 'client_secret_basic'
Expand Down Expand Up @@ -849,17 +852,13 @@ interface ConfigParams {

/**
* Private key used to decrypt JWE-encrypted access tokens.
* When set, the SDK decrypts the access token at the callback and on refresh
* before writing it to the session, so `req.oidc.accessToken` and `afterCallback`
* always receive a plaintext JWT. Requires a code flow (`response_type: 'code'`
* or `'code id_token'`). Accepts PEM string, Buffer, KeyObject, JWK, or CryptoKey.
* When set, the SDK decrypts the access token at the callback before writing it to the session.
* Requires a code flow (`response_type: 'code'` or `'code id_token'`).
*/
accessTokenDecryptionKey?: KeyObject | JoseCryptoKey | JWK | string | Buffer;

/**
* Optional pin for the JWE key-management algorithm. When omitted, the algorithm
* is read from the JWE protected header (validated against a strong-algorithm
* allowlist), so decryption works with whatever alg the tenant uses.
* Key-wrapping algorithm for JWE access token decryption. Defaults to `RSA-OAEP-256`.
*/
accessTokenDecryptionAlg?: string;

Expand Down Expand Up @@ -901,6 +900,46 @@ 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 to the authorization server with a TLS
* client certificate instead of a `clientSecret` or `clientAssertionSigningKey`,
* and routes token/userinfo requests to the `mtls_endpoint_aliases` advertised
* in the discovery document. Access tokens may carry a `cnf.x5t#S256` claim
* binding them to the certificate (certificate-bound tokens).
*
* Requires:
* - A TLS-aware {@link ConfigParams.customFetch} that attaches the client
* certificate (e.g. Node.js `undici` `Agent` with `connect: { key, cert }`).
* The certificate is never configured through the SDK directly.
* - `clientSecret` and `clientAssertionSigningKey` must not be set.
* - The authorization server must advertise `mtls_endpoint_aliases.token_endpoint`.
* - A custom domain; mTLS does not work on canonical `*.auth0.com` domains.
*
* Can also be enabled with 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}
*/
useMtls?: boolean;
}

interface SessionStorePayload<Data = Session> {
Expand Down Expand Up @@ -1298,3 +1337,26 @@ 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';
readonly MTLS_ENDPOINT_ALIASES_MISSING: 'mtls_endpoint_aliases_missing';
readonly MTLS_INCOMPATIBLE_CLIENT_AUTH: 'mtls_incompatible_client_auth';
};

/**
* Thrown when the mTLS (RFC 8705) configuration is invalid — e.g. `useMtls: true`
* without a `customFetch`, combined with `clientSecret`/`clientAssertionSigningKey`,
* or when the discovery document lacks `mtls_endpoint_aliases`.
*
* Catch by `error.code` (a value from {@link MtlsErrorCode}) rather than
* `instanceof` to be bundler-safe.
*/
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,
};
28 changes: 24 additions & 4 deletions index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { RequestHandler } from 'express';
import { expectType } from 'tsd';
import { auth } from '.';
import { expectType, expectError, expectAssignable } from 'tsd';
import { auth, MtlsError, MtlsErrorCode } from '.';

expectType<RequestHandler>(auth());
expectType<RequestHandler>(auth({ session: { name: 'foo' } }));
expectType<RequestHandler>(auth({ session: { cookie: { secure: true } } }));
expectType<RequestHandler>(auth({ routes: { login: '' } }));

// JAR (JWT-Secured Authorization Requests)
// HRI: JAR
expectType<RequestHandler>(
auth({
requestObjectSigningKey: '-----BEGIN PRIVATE KEY-----',
Expand All @@ -16,10 +16,30 @@ expectType<RequestHandler>(
}),
);

// JWE access token decryption
// HRI: JWE
expectType<RequestHandler>(
auth({
accessTokenDecryptionKey: '-----BEGIN PRIVATE KEY-----',
accessTokenDecryptionAlg: 'RSA-OAEP-512',
}),
);

// HRI: mTLS
expectType<RequestHandler>(
auth({ useMtls: true, customFetch: (url, options) => fetch(url, options) }),
);
// mTLS auth methods are not part of the public clientAuthMethod union
expectError(auth({ clientAuthMethod: 'tls_client_auth' }));
expectError(auth({ clientAuthMethod: 'self_signed_tls_client_auth' }));

// mTLS error handling surface
expectType<'mtls_requires_custom_fetch'>(
MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH,
);
expectType<'mtls_endpoint_aliases_missing'>(
MtlsErrorCode.MTLS_ENDPOINT_ALIASES_MISSING,
);
expectType<'mtls_incompatible_client_auth'>(
MtlsErrorCode.MTLS_INCOMPATIBLE_CLIENT_AUTH,
);
expectAssignable<Error>(new MtlsError('some_code', 'message'));
22 changes: 22 additions & 0 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
} = require('jose');
const pkg = require('../package.json');
const debug = require('./debug')('client');
const { MtlsError, MtlsErrorCode } = require('./errors');

const telemetryHeader = {
name: 'express-oidc',
Expand Down Expand Up @@ -103,6 +104,10 @@ async function getClientAuth(config) {
);
return client.PrivateKeyJwt(privateKey);
}
case 'tls_client_auth':
// mTLS (RFC 8705). Enabled via `useMtls`. Covers both CA-signed and
// self-signed; the distinction is enforced at the authorization server.
return client.TlsClientAuth();
case 'none':
return client.None();
default:
Expand Down Expand Up @@ -148,6 +153,13 @@ async function get(config) {
const clientMetadata = {
[client.clockTolerance]: config.clockTolerance,
id_token_signed_response_alg: config.idTokenSigningAlg,
// For mTLS, token/revocation requests must go to the server's
// mtls_endpoint_aliases rather than the standard endpoints. In a
// self-managed-certs custom domain setup only the mTLS alias host is
// configured (at the edge) to extract the client certificate from the TLS
// handshake; sending to the standard endpoint yields invalid_client.
// openid-client routes to the aliases automatically when this is set.
...(config.useMtls && { use_mtls_endpoint_aliases: true }),
};

// Discover and create configuration
Expand Down Expand Up @@ -221,6 +233,16 @@ async function get(config) {
);
}

if (config.useMtls && !serverMetadata.mtls_endpoint_aliases?.token_endpoint) {
throw new MtlsError(
MtlsErrorCode.MTLS_ENDPOINT_ALIASES_MISSING,
'useMtls is enabled but the authorization server discovery document does ' +
'not advertise "mtls_endpoint_aliases.token_endpoint". Ensure mTLS endpoint ' +
'aliases are enabled on the tenant and requests are routed through your ' +
'custom domain.',
);
}

// Handle Auth0-specific logout
let auth0Logout = false;
if (config.idpLogout) {
Expand Down
Loading
Loading