Skip to content
Merged
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. [Use a proxy for OIDC requests](#14-use-a-proxy-for-oidc-requests)
15. [Session expiry from upstream IdP (IPSIE `session_expiry`)](#15-session-expiry-from-upstream-idp-ipsie-session_expiry)
16. [JWT-Secured Authorization Requests (JAR)](#16-jwt-secured-authorization-requests-jar)
17. [mTLS client authentication](#17-mtls-client-authentication)

## 1. Basic setup

Expand Down Expand Up @@ -713,3 +714,57 @@ openssl ec -in request-object-key.pem -pubout -out request-object-key.pub.pem
```

Full example at [jar.js](./examples/jar.js), to run it: `npm run start:example -- jar`

## 17. mTLS client authentication

[mTLS](https://www.rfc-editor.org/rfc/rfc8705) (RFC 8705) 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` **for each endpoint the server advertises an alias for**. An endpoint without a matching alias is sent to the standard host, which is not an mTLS channel, so the tenant must advertise aliases for every endpoint your configuration uses. The SDK throws at construction if the token endpoint (always used) or the PAR endpoint (when `pushedAuthorizationRequests` is enabled) lacks an alias, and logs a debug warning for a missing userinfo or revocation alias. 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 the token endpoint alias, the SDK throws an `MtlsError` with code `mtls_endpoint_aliases_missing`.
- No `clientSecret` or `clientAssertionSigningKey`. Combining either (or an explicit `clientAuthMethod`) 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).
80 changes: 80 additions & 0 deletions examples/mtls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const express = require('express');
const { auth } = require('../');
const { Agent, fetch: undiciFetch } = require('undici');
const fs = require('fs');
const path = require('path');

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. Requests are sent to the
// server's `mtls_endpoint_aliases` for each endpoint the server advertises an
// alias for; an endpoint without an alias is sent over the standard (non-mTLS)
// channel, so the tenant must advertise aliases for every endpoint you use.
// 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`.

// Resolve the certificate and key relative to this file so the example works
// regardless of the caller's working directory. Fail with a message naming the
// expected files rather than an opaque ENOENT.
const readCertFile = (name) => {
const file = path.join(__dirname, name);
try {
return fs.readFileSync(file);
} catch (e) {
throw new Error(
`mTLS example: could not read "${file}". Place your client certificate ` +
`("client.crt") and private key ("client.key") next to this example. ` +
`(${e.code || e.message})`,
);
}
};

const tlsAgent = new Agent({
connect: {
cert: readCertFile('client.crt'),
key: readCertFile('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;
52 changes: 52 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,31 @@ 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
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc8705 | RFC 8705}
*/
useMtls?: boolean;
}

interface SessionStorePayload<Data = Session> {
Expand Down Expand Up @@ -1395,3 +1420,30 @@ 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: `useMtls: true`
* without a `customFetch`, combined with `clientSecret`/`clientAssertionSigningKey`
* or an explicit `clientAuthMethod`, 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: (typeof MtlsErrorCode)[keyof typeof MtlsErrorCode];
constructor(
code: (typeof MtlsErrorCode)[keyof typeof MtlsErrorCode],
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,
};
30 changes: 28 additions & 2 deletions index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RequestHandler } from 'express';
import { expectType } from 'tsd';
import { auth } from '.';
import { expectType, expectAssignable } from 'tsd';
import { auth, MtlsError, MtlsErrorCode } from '.';

expectType<RequestHandler>(auth());
expectType<RequestHandler>(auth({ session: { name: 'foo' } }));
Expand All @@ -15,3 +15,29 @@ expectType<RequestHandler>(
requestObjectSigningKeyId: 'kid-1',
}),
);

// mTLS
expectType<RequestHandler>(
auth({ useMtls: true, customFetch: (url, options) => fetch(url, options) }),
);

// 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,
);
// The constructor and `code` are typed to the MtlsErrorCode union so consumers
// can narrow on it; an arbitrary string is rejected.
expectAssignable<Error>(
new MtlsError(MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH, 'message'),
);
expectType<
| 'mtls_requires_custom_fetch'
| 'mtls_endpoint_aliases_missing'
| 'mtls_incompatible_client_auth'
>(new MtlsError(MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH, 'message').code);
79 changes: 78 additions & 1 deletion lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const client = require('openid-client');
const { importPKCS8, importJWK, exportPKCS8, SignJWT } = 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 @@ -97,6 +98,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 @@ -142,6 +147,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 @@ -206,15 +218,80 @@ async function get(config) {
);
}

// Resolve an endpoint the way oauth4webapi does: under mTLS, prefer the
// mtls_endpoint_aliases entry, otherwise fall back to the standard endpoint.
// Returns the effective URL string, or undefined if neither is advertised.
// A non-string value (from a malformed discovery document) is treated as
// absent so the precondition checks below fail fast rather than passing on a
// truthy non-URL that oauth4webapi would later reject as INVALID_SERVER_METADATA.
const resolveEndpoint = (name) => {
const val =
(config.useMtls && serverMetadata.mtls_endpoint_aliases?.[name]) ||
serverMetadata[name];
return typeof val === 'string' ? val : undefined;
};

if (
config.pushedAuthorizationRequests &&
!serverMetadata.pushed_authorization_request_endpoint
!resolveEndpoint('pushed_authorization_request_endpoint')
) {
throw new TypeError(
'pushed_authorization_request_endpoint must be configured on the issuer to use pushedAuthorizationRequests',
);
}

if (config.useMtls) {
// Under mTLS every request the SDK sends must go to an mtls_endpoint_aliases
// host, because only that host is configured (at the TLS edge) to extract
// the client certificate. oauth4webapi silently falls back to the standard
// endpoint when an alias is absent, sending the request over a non-mTLS
// channel that fails with invalid_client. Validate the alias for the token
// endpoint (always used) and for every optional endpoint this config will
// reach, so the failure surfaces here rather than mid-flow.
if (!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.',
);
}

if (
config.pushedAuthorizationRequests &&
!serverMetadata.mtls_endpoint_aliases
?.pushed_authorization_request_endpoint
) {
throw new MtlsError(
MtlsErrorCode.MTLS_ENDPOINT_ALIASES_MISSING,
'useMtls is enabled with pushedAuthorizationRequests, but the ' +
'authorization server does not advertise ' +
'"mtls_endpoint_aliases.pushed_authorization_request_endpoint". Without ' +
'it the PAR request would be sent over a non-mTLS channel and fail with ' +
'invalid_client.',
);
}

// userinfo and revocation are used opportunistically; if the standard
// endpoint is advertised but its alias is not, requests to them would fall
// back to a non-mTLS host. Warn rather than throw, since these paths may
// never be exercised by a given deployment.
for (const name of ['userinfo_endpoint', 'revocation_endpoint']) {
if (
serverMetadata[name] &&
!serverMetadata.mtls_endpoint_aliases?.[name]
) {
debug(
'useMtls is enabled but the authorization server does not advertise ' +
'"mtls_endpoint_aliases.%s"; requests to it would be sent over a ' +
'non-mTLS channel.',
name,
);
}
}
}

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