diff --git a/EXAMPLES.md b/EXAMPLES.md index 8086d0a4..f1c80df0 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -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 @@ -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). diff --git a/examples/mtls.js b/examples/mtls.js new file mode 100644 index 00000000..c92eaa63 --- /dev/null +++ b/examples/mtls.js @@ -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} logout`); + } else { + res.send('login'); + } +}); + +module.exports = app; diff --git a/index.d.ts b/index.d.ts index 0912dde6..fa99ae67 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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 { @@ -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, + ); +} diff --git a/index.js b/index.js index 7e373846..e33e2665 100644 --- a/index.js +++ b/index.js @@ -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, }; diff --git a/index.test-d.ts b/index.test-d.ts index 64e36613..4a4e992c 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -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(auth()); expectType(auth({ session: { name: 'foo' } })); @@ -15,3 +15,29 @@ expectType( requestObjectSigningKeyId: 'kid-1', }), ); + +// mTLS +expectType( + 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( + 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); diff --git a/lib/client.js b/lib/client.js index e9e7d0fb..1e6385e7 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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', @@ -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: @@ -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 @@ -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) { diff --git a/lib/config.js b/lib/config.js index 5b9efd06..ed5486f5 100644 --- a/lib/config.js +++ b/lib/config.js @@ -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'); @@ -280,6 +281,11 @@ const paramsSchema = Joi.object({ .default() .unknown(false), clientAuthMethod: Joi.string() + // 'tls_client_auth' is intentionally NOT a public value: mTLS is opted into + // via `useMtls`, which resolves the method internally. A `.default()` return + // is not validated against `.valid()`, so the resolver below still works, but + // a JS caller passing the string directly (bypassing every mTLS guard) is + // rejected here. .valid( 'client_secret_basic', 'client_secret_post', @@ -289,6 +295,12 @@ const paramsSchema = Joi.object({ ) .optional() .default((parent) => { + // mTLS is opted into via `useMtls`; it maps to tls_client_auth (RFC 8705). + // CA-signed vs self-signed is an authorization-server credential concern, + // not a client-library one, so a single method covers both. + if (parent.useMtls) { + return 'tls_client_auth'; + } if ( parent.authorizationParams.response_type === 'id_token' && !parent.pushedAuthorizationRequests @@ -381,6 +393,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 = {}) { @@ -390,9 +403,14 @@ module.exports.get = function (config = {}) { baseURL: process.env.BASE_URL, clientID: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET, + useMtls: /^true$/i.test((process.env.AUTH0_MTLS || '').trim()) || undefined, ...config, }; + // Capture the caller's explicit clientAuthMethod before Joi's default resolver + // fills it, so we can detect useMtls combined with an explicit non-mTLS method. + const explicitClientAuthMethod = config.clientAuthMethod; + const { value, error, warning } = paramsSchema.validate(config); if (error) { throw new TypeError(error.details[0].message); @@ -408,5 +426,64 @@ module.exports.get = function (config = {}) { ); } + if (value.useMtls) { + // The client certificate is presented by the developer-provided customFetch + // at the TLS layer. Without it there is no way to authenticate. + if (!value.customFetch) { + throw new MtlsError( + MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH, + 'useMtls requires a customFetch option with a TLS-aware implementation ' + + '(e.g. Node.js undici with a client certificate). The standard fetch ' + + 'global has no client certificate API.', + ); + } + + // mTLS replaces secret/assertion-based client auth. Allowing both is a + // misconfiguration that would not use the certificate as intended. + if (value.clientSecret || value.clientAssertionSigningKey) { + throw new MtlsError( + MtlsErrorCode.MTLS_INCOMPATIBLE_CLIENT_AUTH, + 'useMtls cannot be combined with clientSecret or clientAssertionSigningKey. ' + + 'mTLS replaces secret-based client authentication entirely. ' + + 'Note that clientSecret may be seeded from the CLIENT_SECRET environment ' + + 'variable; unset it if it is no longer intended.', + ); + } + + // An explicit non-mTLS clientAuthMethod beats the useMtls default resolver, + // producing use_mtls_endpoint_aliases with a contradictory auth (e.g. + // ClientSecretBasic). Reject the combination instead of silently accepting it. + if ( + explicitClientAuthMethod && + explicitClientAuthMethod !== 'tls_client_auth' + ) { + throw new MtlsError( + MtlsErrorCode.MTLS_INCOMPATIBLE_CLIENT_AUTH, + `useMtls cannot be combined with an explicit clientAuthMethod of "${explicitClientAuthMethod}". ` + + 'mTLS is the client authentication method; do not set clientAuthMethod alongside useMtls.', + ); + } + + if (/\.auth0\.com$/.test(new URL(value.issuerBaseURL).hostname)) { + console.warn( + 'mTLS client authentication requires a custom Auth0 domain. ' + + 'It will not work with canonical *.auth0.com domains.', + ); + } + + // mTLS only takes effect on the token endpoint, which is reached only when + // the flow includes the authorization code grant. With an implicit-only + // response_type (e.g. the default id_token) no token request is made, so + // the certificate is never presented and the entire mTLS setup is inert. + if (!value.authorizationParams.response_type.includes('code')) { + console.warn( + 'useMtls has no effect with response_type ' + + `"${value.authorizationParams.response_type}": mTLS applies to the ` + + 'token endpoint, which is only called when response_type includes ' + + '"code". Add "code" to response_type to use mTLS.', + ); + } + } + return value; }; diff --git a/lib/context.js b/lib/context.js index 639aae2b..8cd28839 100644 --- a/lib/context.js +++ b/lib/context.js @@ -203,6 +203,40 @@ function normalizeTokenType(tokenType) { return tokenType?.toLowerCase() === 'bearer' ? 'Bearer' : tokenType; } +/** + * Warns when mTLS is enabled but a JWT access token is not certificate-bound. + * + * A certificate-bound access token carries a `cnf.x5t#S256` claim (RFC 8705 §3). + * Its absence on a JWT access token means sender-constraining is not active + * (e.g. the resource server is not configured for mTLS token binding). Warn so + * the developer is not surprised by unbound tokens. Called on both the initial + * callback and every refresh, since a token can lose its binding on refresh. + * Opaque (non-JWT) access tokens cannot be inspected and are skipped. + */ +function warnIfNotCertificateBound(config, accessToken) { + if (!config.useMtls || !accessToken) { + return; + } + try { + const decoded = decodeJwt(accessToken); + if (!decoded.cnf?.['x5t#S256']) { + // An unbound token defeats the sender-constraining the feature exists to + // provide, so surface it on console.warn (matching the custom-domain hint + // in config.js) rather than debug, which is off unless DEBUG is set. + console.warn( + 'mTLS is enabled but the access token has no "cnf.x5t#S256" claim; ' + + 'it is not certificate-bound. Enable Token Sender-Constraining ' + + '(mTLS) on the resource server to bind tokens to the certificate.', + ); + } + } catch { + // Opaque (non-JWT) access token — cannot inspect for cnf, skip. Log at + // debug so a genuine decode failure on a JWT-looking token is still + // distinguishable from the expected opaque-token case. + debug('warnIfNotCertificateBound: access token is not an inspectable JWT'); + } +} + async function refresh({ tokenEndpointParams } = {}) { let { config, req } = weakRef(this); const session = req[config.session.name]; @@ -249,6 +283,10 @@ async function refresh({ tokenEndpointParams } = {}) { }), }); + // Re-check certificate binding: a refreshed access token can lose its + // `cnf.x5t#S256` claim if the resource server's mTLS binding changes. + warnIfNotCertificateBound(config, session.access_token); + // Delete the old token set const cachedTokenSet = weakRef(session); delete cachedTokenSet.value; @@ -1051,6 +1089,9 @@ class ResponseContext { : undefined, }; + // Warn if mTLS is enabled but the access token is not certificate-bound. + warnIfNotCertificateBound(config, session.access_token); + // Must store the `sid` separately as the ID Token gets overridden by // ID Token from the Refresh Grant which may not contain a sid (In Auth0 currently). session.sid = claims?.sid; diff --git a/lib/errors.js b/lib/errors.js index 4dac14fc..68b81fc8 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -8,4 +8,36 @@ class SessionExpiredError extends Error { } } -module.exports = { SessionExpiredError }; +/** + * Error codes for mTLS (Mutual TLS, RFC 8705) configuration failures. + */ +const MtlsErrorCode = Object.freeze({ + // `useMtls` was set but no `customFetch` was provided. The standard fetch + // global has no API for attaching client certificates, so mTLS cannot work. + MTLS_REQUIRES_CUSTOM_FETCH: 'mtls_requires_custom_fetch', + // `useMtls` was set but the discovery document does not advertise + // `mtls_endpoint_aliases.token_endpoint`. The SDK refuses to proceed rather + // than silently sending token requests to an endpoint that will not forward + // the client certificate (which would fail with invalid_client). + MTLS_ENDPOINT_ALIASES_MISSING: 'mtls_endpoint_aliases_missing', + // `useMtls` was combined with `clientSecret` or `clientAssertionSigningKey`. + // mTLS replaces secret-based client authentication entirely. + MTLS_INCOMPATIBLE_CLIENT_AUTH: 'mtls_incompatible_client_auth', +}); + +/** + * Thrown when the mTLS (RFC 8705) configuration is invalid. + * + * Catch by `error.code` (a string from {@link MtlsErrorCode}) 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 }; diff --git a/test/client.tests.js b/test/client.tests.js index 8c2c8a4a..ac72fc59 100644 --- a/test/client.tests.js +++ b/test/client.tests.js @@ -426,6 +426,31 @@ describe('client initialization', function () { .reply(200, { ...wellKnown, issuer: 'https://par-test.auth0.com/' }); await expect(getClient(config)).to.be.fulfilled; }); + + it('should fail if the PAR endpoint in discovery is not a string', async function () { + // A malformed discovery document with a non-string endpoint value must be + // treated as absent so the precondition fails fast here, rather than + // passing on a truthy non-URL that oauth4webapi rejects later. + const config = getConfig({ + secret: '__test_session_secret__', + clientID: '__test_client_id__', + clientSecret: '__test_client_secret__', + issuerBaseURL: 'https://par-nonstring.auth0.com', + baseURL: 'https://example.org', + pushedAuthorizationRequests: true, + }); + nock('https://par-nonstring.auth0.com') + .persist() + .get('/.well-known/openid-configuration') + .reply(200, { + ...wellKnown, + issuer: 'https://par-nonstring.auth0.com/', + pushed_authorization_request_endpoint: {}, + }); + await expect(getClient(config)).to.be.rejectedWith( + `pushed_authorization_request_endpoint must be configured on the issuer to use pushedAuthorizationRequests`, + ); + }); }); describe('client respects clientAssertionSigningAlg configuration', function () { @@ -876,4 +901,226 @@ describe('client initialization', function () { } }); }); + + describe('mTLS (useMtls)', function () { + const { MtlsErrorCode } = require('../lib/errors'); + const customFetch = (url, options) => fetch(url, options); + + // Each test uses a distinct issuer to bypass the persistent op.example.com + // discovery mock (setup.js) and to avoid discovery cache collisions. + const mtlsWellKnown = (issuer) => ({ + ...wellKnown, + issuer: `${issuer}/`, + mtls_endpoint_aliases: { + token_endpoint: `${issuer.replace('https://', 'https://mtls.')}/oauth/token`, + userinfo_endpoint: `${issuer.replace('https://', 'https://mtls.')}/userinfo`, + revocation_endpoint: `${issuer.replace('https://', 'https://mtls.')}/oauth/revoke`, + }, + }); + + const baseConfig = (issuer) => ({ + secret: '__test_session_secret__', + clientID: '__test_client_id__', + issuerBaseURL: issuer, + baseURL: 'https://example.org', + authorizationParams: { response_type: 'code' }, + }); + + it('should set use_mtls_endpoint_aliases=true on client metadata when useMtls=true', async function () { + const issuer = 'https://mtls-meta.example.com'; + nock(issuer) + .get('/.well-known/openid-configuration') + .reply(200, mtlsWellKnown(issuer)); + + const config = getConfig({ + ...baseConfig(issuer), + useMtls: true, + customFetch, + }); + const { configuration } = await getClient(config); + assert.equal( + configuration.clientMetadata().use_mtls_endpoint_aliases, + true, + ); + }); + + it('should NOT set use_mtls_endpoint_aliases when useMtls is not enabled', async function () { + const config = getConfig({ + secret: '__test_session_secret__', + clientID: '__test_client_id__', + clientSecret: '__test_client_secret__', + issuerBaseURL: 'https://op.example.com', + baseURL: 'https://example.org', + }); + const { configuration } = await getClient(config); + assert.notEqual( + configuration.clientMetadata().use_mtls_endpoint_aliases, + true, + ); + }); + + it('should expose mtls_endpoint_aliases in server metadata when advertised', async function () { + const issuer = 'https://mtls-expose.example.com'; + nock(issuer) + .get('/.well-known/openid-configuration') + .reply(200, mtlsWellKnown(issuer)); + + const config = getConfig({ + ...baseConfig(issuer), + useMtls: true, + customFetch, + }); + const { serverMetadata } = await getClient(config); + assert.equal( + serverMetadata.mtls_endpoint_aliases.token_endpoint, + 'https://mtls.mtls-expose.example.com/oauth/token', + ); + }); + + it('should invoke the provided customFetch during discovery', async function () { + const issuer = 'https://mtls-fetch.example.com'; + nock(issuer) + .get('/.well-known/openid-configuration') + .reply(200, mtlsWellKnown(issuer)); + + const spy = sinon.spy((url, options) => fetch(url, options)); + const config = getConfig({ + ...baseConfig(issuer), + useMtls: true, + customFetch: spy, + }); + await getClient(config); + assert.isTrue(spy.called); + }); + + it('should throw MtlsError(MTLS_ENDPOINT_ALIASES_MISSING) when discovery has no aliases', async function () { + const issuer = 'https://mtls-noalias.example.com'; + nock(issuer) + .get('/.well-known/openid-configuration') + .reply(200, { ...wellKnown, issuer: `${issuer}/` }); + + const config = getConfig({ + ...baseConfig(issuer), + useMtls: true, + customFetch, + }); + let caught; + try { + await getClient(config); + } catch (e) { + caught = e; + } + assert.ok(caught, 'expected getClient to reject'); + assert.equal(caught.code, MtlsErrorCode.MTLS_ENDPOINT_ALIASES_MISSING); + }); + + it('should route token grants to the mTLS alias endpoint through the SDK', async function () { + // Build the Configuration through the SDK's own getClient (which is what + // sets use_mtls_endpoint_aliases and resolves TlsClientAuth), then drive a + // refresh grant through it. This fails if lib/client.js stops setting the + // flag or getClientAuth stops returning TlsClientAuth for useMtls. + const issuer = 'https://mtls-route.example.com'; + const tokenAlias = `${issuer.replace('https://', 'https://mtls.')}/oauth/token`; + nock(issuer) + .get('/.well-known/openid-configuration') + .reply(200, mtlsWellKnown(issuer)); + + const captured = []; + const spyFetch = (url, options) => { + captured.push(String(url)); + return fetch(url, options); + }; + const config = getConfig({ + ...baseConfig(issuer), + useMtls: true, + customFetch: spyFetch, + }); + const { configuration } = await getClient(config); + + nock(tokenAlias.replace(/\/oauth\/token$/, '')) + .post('/oauth/token') + .reply(200, { access_token: 'x', token_type: 'bearer' }); + + try { + await client.refreshTokenGrant(configuration, 'rt'); + } catch { + // response processing may reject; we only assert the URL that was hit + } + assert.include( + captured, + tokenAlias, + 'refresh grant should be routed to the mtls_endpoint_aliases token endpoint', + ); + }); + + it('should throw MTLS_ENDPOINT_ALIASES_MISSING when PAR is enabled but its alias is absent', async function () { + // token_endpoint alias present, PAR alias missing: construction must fail + // rather than let the PAR request silently fall back to the non-mTLS host. + const issuer = 'https://mtls-par-noalias.example.com'; + nock(issuer) + .get('/.well-known/openid-configuration') + .reply(200, { + ...mtlsWellKnown(issuer), + pushed_authorization_request_endpoint: `${issuer}/oauth/par`, + }); + + const config = getConfig({ + ...baseConfig(issuer), + useMtls: true, + pushedAuthorizationRequests: true, + customFetch, + }); + let caught; + try { + await getClient(config); + } catch (e) { + caught = e; + } + assert.ok(caught, 'expected getClient to reject'); + assert.equal(caught.code, MtlsErrorCode.MTLS_ENDPOINT_ALIASES_MISSING); + }); + + it('should accept PAR under mTLS when the PAR alias is advertised', async function () { + const issuer = 'https://mtls-par-alias.example.com'; + const mtlsHost = issuer.replace('https://', 'https://mtls.'); + nock(issuer) + .get('/.well-known/openid-configuration') + .reply(200, { + ...mtlsWellKnown(issuer), + pushed_authorization_request_endpoint: `${issuer}/oauth/par`, + mtls_endpoint_aliases: { + ...mtlsWellKnown(issuer).mtls_endpoint_aliases, + pushed_authorization_request_endpoint: `${mtlsHost}/oauth/par`, + }, + }); + + const config = getConfig({ + ...baseConfig(issuer), + useMtls: true, + pushedAuthorizationRequests: true, + customFetch, + }); + await expect(getClient(config)).to.be.fulfilled; + }); + + it('should accept PAR without mTLS when only the standard PAR endpoint is advertised', async function () { + // Regression for the inverse bug: a valid non-mTLS PAR config must not be + // rejected by an alias-aware check. + const issuer = 'https://par-standard.example.com'; + nock(issuer) + .get('/.well-known/openid-configuration') + .reply(200, { + ...wellKnown, + issuer: `${issuer}/`, + pushed_authorization_request_endpoint: `${issuer}/oauth/par`, + }); + + const config = getConfig({ + ...baseConfig(issuer), + clientSecret: '__test_client_secret__', + pushedAuthorizationRequests: true, + }); + await expect(getClient(config)).to.be.fulfilled; + }); + }); }); diff --git a/test/config.tests.js b/test/config.tests.js index c28b78f3..b61e6de6 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -1012,4 +1012,286 @@ describe('get config', () => { assert.notOk(parWarn); }); }); + + describe('mTLS (useMtls)', () => { + const { MtlsError, MtlsErrorCode } = require('../lib/errors'); + const customFetch = () => Promise.resolve(new Response()); + + it('should default useMtls to false', () => { + const config = getConfig({ + ...defaultConfig, + clientSecret: '__test_client_secret__', + }); + assert.equal(config.useMtls, false); + }); + + it('should resolve clientAuthMethod to tls_client_auth when useMtls=true', () => { + const config = getConfig({ + ...defaultConfig, + useMtls: true, + customFetch, + authorizationParams: { response_type: 'code' }, + }); + assert.equal(config.useMtls, true); + assert.equal(config.clientAuthMethod, 'tls_client_auth'); + }); + + it('should not require clientSecret when useMtls=true', () => { + assert.doesNotThrow(() => + getConfig({ + ...defaultConfig, + useMtls: true, + customFetch, + authorizationParams: { response_type: 'code' }, + }), + ); + }); + + it('should throw MtlsError(MTLS_REQUIRES_CUSTOM_FETCH) when customFetch is missing', () => { + let caught; + try { + getConfig({ + ...defaultConfig, + useMtls: true, + authorizationParams: { response_type: 'code' }, + }); + } catch (e) { + caught = e; + } + // One instanceof check documents the class identity; the rest of the + // suite asserts on the bundler-safe .code/.name, which is the pattern the + // SDK directs consumers toward. + assert.instanceOf(caught, MtlsError); + assert.equal(caught.code, MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH); + }); + + it('should throw MtlsError(MTLS_INCOMPATIBLE_CLIENT_AUTH) when combined with clientSecret', () => { + let caught; + try { + getConfig({ + ...defaultConfig, + useMtls: true, + customFetch, + clientSecret: '__test_client_secret__', + authorizationParams: { response_type: 'code' }, + }); + } catch (e) { + caught = e; + } + assert.equal(caught.name, 'MtlsError'); + assert.equal(caught.code, MtlsErrorCode.MTLS_INCOMPATIBLE_CLIENT_AUTH); + }); + + it('should throw MtlsError(MTLS_INCOMPATIBLE_CLIENT_AUTH) when combined with clientAssertionSigningKey', () => { + let caught; + try { + getConfig({ + ...defaultConfig, + useMtls: true, + customFetch, + clientAssertionSigningKey: '__test_key__', + clientAssertionSigningAlg: 'RS256', + authorizationParams: { response_type: 'code' }, + }); + } catch (e) { + caught = e; + } + assert.equal(caught.name, 'MtlsError'); + assert.equal(caught.code, MtlsErrorCode.MTLS_INCOMPATIBLE_CLIENT_AUTH); + }); + + it('should enable useMtls from the AUTH0_MTLS env var', () => { + sinon.stub(process, 'env').value({ ...process.env, AUTH0_MTLS: 'true' }); + const config = getConfig({ + ...defaultConfig, + customFetch, + authorizationParams: { response_type: 'code' }, + }); + assert.equal(config.useMtls, true); + assert.equal(config.clientAuthMethod, 'tls_client_auth'); + }); + + it('should enable useMtls from AUTH0_MTLS regardless of case or surrounding whitespace', () => { + const env = sinon.stub(process, 'env').value({ ...process.env }); + for (const raw of ['TRUE', ' true', 'True ']) { + env.value({ ...process.env, AUTH0_MTLS: raw }); + const config = getConfig({ + ...defaultConfig, + customFetch, + authorizationParams: { response_type: 'code' }, + }); + assert.equal(config.useMtls, true, `expected "${raw}" to enable mTLS`); + } + }); + + it('should not enable useMtls for other AUTH0_MTLS values', () => { + const env = sinon.stub(process, 'env').value({ ...process.env }); + for (const raw of ['1', 'yes', 'false', '']) { + env.value({ ...process.env, AUTH0_MTLS: raw }); + const config = getConfig({ + ...defaultConfig, + clientSecret: '__test_client_secret__', + }); + assert.equal( + config.useMtls, + false, + `expected "${raw}" to leave mTLS off`, + ); + } + }); + + it('should throw for AUTH0_MTLS=true without customFetch', () => { + sinon.stub(process, 'env').value({ ...process.env, AUTH0_MTLS: 'true' }); + let caught; + try { + getConfig({ + ...defaultConfig, + authorizationParams: { response_type: 'code' }, + }); + } catch (e) { + caught = e; + } + assert.equal(caught.name, 'MtlsError'); + assert.equal(caught.code, MtlsErrorCode.MTLS_REQUIRES_CUSTOM_FETCH); + }); + + it('should reject an explicit clientAuthMethod of tls_client_auth (not a public value)', () => { + // tls_client_auth is resolved internally via useMtls; passing it directly + // must be rejected so the mTLS guards cannot be bypassed. (id_token response + // type avoids the code-flow rule so the .valid() rejection is what fires.) + assert.throws( + () => + getConfig({ + ...defaultConfig, + clientAuthMethod: 'tls_client_auth', + customFetch, + authorizationParams: { response_type: 'id_token' }, + }), + TypeError, + /"clientAuthMethod" must be one of/, + ); + }); + + it('should reject the self_signed_tls_client_auth string (never a public value)', () => { + // id_token response type avoids the code-flow rule so the .valid() + // rejection is what fires, rather than an unrelated public-client error. + assert.throws( + () => + getConfig({ + ...defaultConfig, + clientAuthMethod: 'self_signed_tls_client_auth', + customFetch, + authorizationParams: { response_type: 'id_token' }, + }), + TypeError, + /"clientAuthMethod" must be one of/, + ); + }); + + it('should throw MTLS_INCOMPATIBLE_CLIENT_AUTH when useMtls is combined with an explicit clientAuthMethod', () => { + let caught; + try { + getConfig({ + ...defaultConfig, + useMtls: true, + customFetch, + clientAuthMethod: 'client_secret_basic', + clientSecret: '__test_client_secret__', + authorizationParams: { response_type: 'code' }, + }); + } catch (e) { + caught = e; + } + assert.equal(caught.name, 'MtlsError'); + assert.equal(caught.code, MtlsErrorCode.MTLS_INCOMPATIBLE_CLIENT_AUTH); + }); + + it('should throw MTLS_INCOMPATIBLE_CLIENT_AUTH when useMtls is combined with clientAuthMethod none', () => { + let caught; + try { + getConfig({ + ...defaultConfig, + useMtls: true, + customFetch, + clientAuthMethod: 'none', + authorizationParams: { response_type: 'id_token' }, + }); + } catch (e) { + caught = e; + } + assert.equal(caught.name, 'MtlsError'); + assert.equal(caught.code, MtlsErrorCode.MTLS_INCOMPATIBLE_CLIENT_AUTH); + }); + + it('should emit console.warn when useMtls is used with a *.auth0.com issuer', () => { + const warnCallsBefore = console.warn.callCount; + getConfig({ + ...defaultConfig, + issuerBaseURL: 'https://tenant.auth0.com', + useMtls: true, + customFetch, + authorizationParams: { response_type: 'code' }, + }); + const warnCalls = console.warn.getCalls().slice(warnCallsBefore); + const customDomainWarn = warnCalls.some( + (call) => + call.args[0] && + /custom domain|canonical.*\.auth0\.com/i.test(call.args[0]), + ); + assert.ok( + customDomainWarn, + 'Should warn about custom domain requirement', + ); + }); + + it('should NOT emit the custom-domain warning for a non-auth0 issuer', () => { + const warnCallsBefore = console.warn.callCount; + getConfig({ + ...defaultConfig, + issuerBaseURL: 'https://login.example.com', + useMtls: true, + customFetch, + authorizationParams: { response_type: 'code' }, + }); + const warnCalls = console.warn.getCalls().slice(warnCallsBefore); + const customDomainWarned = warnCalls.some( + (call) => call.args[0] && /custom domain/i.test(call.args[0]), + ); + assert.notOk(customDomainWarned); + }); + + it('should warn when useMtls is set but response_type does not include code', () => { + const warnCallsBefore = console.warn.callCount; + getConfig({ + ...defaultConfig, + useMtls: true, + customFetch, + // id_token is the default; make the implicit-only flow explicit. + authorizationParams: { response_type: 'id_token' }, + }); + const warnCalls = console.warn.getCalls().slice(warnCallsBefore); + const noEffectWarn = warnCalls.some( + (call) => call.args[0] && /useMtls has no effect/i.test(call.args[0]), + ); + assert.ok( + noEffectWarn, + 'Should warn that mTLS is inert without a code flow', + ); + }); + + it('should NOT emit the no-effect warning when response_type includes code', () => { + const warnCallsBefore = console.warn.callCount; + getConfig({ + ...defaultConfig, + useMtls: true, + customFetch, + authorizationParams: { response_type: 'code' }, + }); + const warnCalls = console.warn.getCalls().slice(warnCallsBefore); + const noEffectWarned = warnCalls.some( + (call) => call.args[0] && /useMtls has no effect/i.test(call.args[0]), + ); + assert.notOk(noEffectWarned); + }); + }); });