Skip to content

feat(mtls): mTLS (RFC 8705) client authentication - #865

Merged
jd3vi1 merged 4 commits into
masterfrom
feature/mtls
Aug 7, 2026
Merged

jd3vi1 merged 4 commits into
masterfrom
feature/mtls

Conversation

@jd3vi1

@jd3vi1 jd3vi1 commented Jul 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds mTLS (RFC 8705) client authentication. When enabled, the SDK authenticates to the token endpoint with a TLS client certificate instead of a client secret, and issued access tokens carry a cnf.x5t#S256 claim binding them to the certificate (certificate-bound tokens).

Independent of #863 (JAR); both branch from master.

What's included

  • useMtls: boolean (or AUTH0_MTLS=true) — single opt-in. Internally uses TlsClientAuth() and sets use_mtls_endpoint_aliases: true on the client metadata, so token, refresh, revocation, userinfo, and PAR requests route to the server's mtls_endpoint_aliases. There is no separate CA-signed vs self-signed option; that distinction is an authorization-server concern.
  • customFetch is required with useMtls, and must present the client certificate at the TLS layer (e.g. an undici Agent with connect: { key, cert }). The certificate is never configured through the SDK directly.
  • New MtlsError / MtlsErrorCode (exported), thrown at construction / discovery time:
    • mtls_requires_custom_fetch — useMtls without a customFetch.
    • mtls_incompatible_client_auth — useMtls combined with clientSecret, clientAssertionSigningKey, or an explicit clientAuthMethod.
    • mtls_endpoint_aliases_missing — the discovery document does not advertise mtls_endpoint_aliases.token_endpoint (fails fast rather than a silent runtime invalid_client).
  • A startup warning when useMtls is used against a canonical *.auth0.com issuer, since mTLS requires a custom domain.

Usage

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

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

app.use(
  auth({
    issuerBaseURL: 'https://auth.your-domain.com', // custom domain
    authorizationParams: { response_type: 'code', audience: 'https://your-api/' },
    useMtls: true,
    customFetch: (url, options) => undiciFetch(url, { ...options, dispatcher: tlsAgent }),
  }),
);

Tests

  • Unit tests for use_mtls_endpoint_aliases metadata, aliases surfaced in server metadata, the alias-missing MtlsError, token grants routing to the mTLS alias, and all construction guards (customFetch required, incompatible client auth including an explicit clientAuthMethod, AUTH0_MTLS env, rejection of the tls_client_auth string).
  • Type tests for the useMtls / customFetch surface and the MtlsError / MtlsErrorCode shape.
  • Validated end to end against a live tenant with a self-managed-certs custom domain and a local edge proxy: login succeeds and the access token carries cnf.x5t#S256 matching the client certificate thumbprint, including across refresh.
  • Documentation in EXAMPLES.md and an example at examples/mtls.js.

@jd3vi1
jd3vi1 requested a review from a team as a code owner July 24, 2026 14:00
Comment thread examples/mtls.js Dismissed
@jd3vi1

jd3vi1 commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Both majors fixed in 4fc699c:

  1. tls_client_auth removed from the Joi .valid() list. Confirmed your finding that a .default() return is not validated against .valid(), so the useMtls resolver still returns tls_client_auth and mTLS keeps working, while a JS caller passing the string directly (bypassing every useMtls guard) is now rejected.
  2. Explicit clientAuthMethod + useMtls now rejected. Added a guard that throws MTLS_INCOMPATIBLE_CLIENT_AUTH when useMtls is combined with an explicit non-mTLS clientAuthMethod (captured from the raw config before Joi's default resolver fills it), closing the use_mtls_endpoint_aliases + ClientSecretBasic(undefined) contradiction.
  3. Test fixes. Replaced the mislabeled test (it passed self_signed_tls_client_auth, which was never in the list) with an explicit tls_client_auth rejection test, kept a self_signed_tls_client_auth rejection test, and added both incompatible-method cases (client_secret_basic and none).

On the cnf.x5t#S256 debug notice: it is debug()-only output with no assertable side effect and the suite has no debug-capture harness, so I left it untested rather than add brittle plumbing for a minor.

Verified: both fixes exercised end to end; full suite (402), tsd, e2e, and lint all green.

@jd3vi1
jd3vi1 changed the base branch from feature/jwe to master July 28, 2026 05:54
@jd3vi1

jd3vi1 commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Restructured: this PR is now independent of #863 (JAR) and branches directly from master. The JWE PR (#864) was closed (JWE access-token decryption is a Resource Server concern, tracked separately), so mTLS no longer stacks on it.

Heads up on merge order: #863 (JAR) and this PR both touch a few of the same files, so whichever merges second will need a small conflict resolution in EXAMPLES.md, index.test-d.ts, lib/config.js, test/client.tests.js, and test/config.tests.js. All conflicts are "both PRs appended their own block at the same spot" (a config guard, a doc section, test describe blocks) with no overlapping logic, so the resolution is keep-both. Nothing in JAR and mTLS contradicts each other.

@Piyush-85 Piyush-85 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend adding token revocation on logout (POST mtls_endpoint_aliases.revocation_endpoint). Without it, refresh tokens remain valid at the authorization server after a user logs out, which is a security concern for certificate-bound sessions. This can land in this PR or a tracked follow-up — if it's deferred, please link the follow-up.

Comment thread lib/context.js Outdated
Comment thread lib/context.js Outdated
);
}
} catch {
// Opaque (non-JWT) access token — cannot inspect for cnf, skip.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: JWE access tokens (issued when no audience is set) also throw in decodeJwt and get skipped here, but the Testing Doc expects a warning for the JWE case. Flagging for awareness, not blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the mismatch with the Testing Doc. Rather than add a JWE-specific warning, we've dropped the JWE handling from scope here: JWE access-token decryption is a Resource Server concern (tracked separately, and PR #864 was closed), so this SDK does not inspect encrypted access tokens. Updated the comment wording accordingly in e6cac71 (now reads "Opaque (non-JWT) access tokens ... are skipped", no JWE mention).

Comment thread lib/config.js
@jd3vi1

jd3vi1 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@Piyush-85 on token revocation on logout: agreed it's a valid concern, but we're deferring it to a separate PR rather than including it here.

Two reasons: (1) it isn't mTLS-specific. This repo has never revoked the refresh token on logout for any client-auth method; logout() clears the local session and optionally does federated logout (idpLogout), which ends the session server-side at the AS. So this is a pre-existing, SDK-wide behavior, not something mTLS introduces. (2) Adding RFC 7009 revocation is a standalone feature (new config surface, endpoint routing including the mTLS alias, error type, tests) that doesn't belong in a client-authentication PR.

It doesn't exist anywhere in this repo today, so we'll track it as its own follow-up rather than link an existing one. The other three comments (refresh-path binding check, JWE wording, env-var hint in the error message) are addressed in e6cac71.

Piyush-85
Piyush-85 previously approved these changes Jul 30, 2026
jd3vi1 added 4 commits August 7, 2026 17:02
Authenticate to the token endpoint with a TLS client certificate instead of a
client secret, enabled via useMtls: true (or AUTH0_MTLS=true). Routes token,
refresh, revocation, userinfo, and PAR requests to the server's
mtls_endpoint_aliases, and surfaces the certificate-bound cnf.x5t#S256 claim.

The certificate is presented by a developer-provided customFetch (undici Agent),
never by the SDK. Adds MtlsError and MtlsErrorCode for construction-time guards:
customFetch is required; clientSecret, clientAssertionSigningKey, and an explicit
clientAuthMethod are rejected; and a missing mtls_endpoint_aliases in discovery is
a hard error. Warns when used against a canonical *.auth0.com domain.
Address review feedback on the mTLS PR:

- Extract the cnf.x5t#S256 binding check into warnIfNotCertificateBound
  and call it from both the callback and refresh() paths, so a token
  that loses its certificate binding on refresh also warns.
- Mention the CLIENT_SECRET environment variable in the
  MTLS_INCOMPATIBLE_CLIENT_AUTH message to speed up debugging when the
  secret was seeded from the environment.
- Drop the JWE/encrypted-token wording; JWE access tokens are out of
  scope for this SDK.
Address review feedback on the mTLS PR:

- Make endpoint-alias validation alias-aware for every endpoint the config
  will use, not just token_endpoint. oauth4webapi resolves mtls_endpoint_aliases
  per endpoint and silently falls back to the standard (non-mTLS) host when an
  alias is absent, so a missing alias would send that request over a channel
  that cannot extract the client certificate. Throw at construction when the
  token alias (always used) or the PAR alias (when pushedAuthorizationRequests
  is enabled) is missing, and debug-log a missing userinfo/revocation alias.
- Make the PAR precondition alias-aware so a server advertising PAR only under
  mtls_endpoint_aliases is no longer wrongly rejected at construction.
- Raise the "token is not certificate-bound" warning from debug() to
  console.warn(), matching the custom-domain hint, since an unbound token
  silently defeats sender-constraining. Log at debug() in the decode catch so a
  genuine JWT decode failure stays distinguishable from an opaque token.
- Type MtlsError.code (and the constructor) to the MtlsErrorCode union so
  consumers can narrow on it in a switch/if.
- Normalize AUTH0_MTLS parsing to accept case and surrounding whitespace.
- Resolve the example's certificate paths relative to __dirname and fail with a
  message naming the expected files instead of an opaque ENOENT at import.
- Reword the EXAMPLES.md routing claim to state aliases are used when
  advertised, and that a missing alias means a non-mTLS channel.
- Route the mTLS routing test through getClient (exercising the SDK's own
  flag-setting and getClientAuth branch) instead of hand-building a
  Configuration, assert on .code/.name rather than instanceof, and add coverage
  for the new PAR alias guards.
- Revert an unrelated prettier reflow in BackchannelLogoutOptions.
… tests

- Warn when useMtls is set but response_type lacks "code": mTLS applies to
  the token endpoint, which is only reached in the code flow, so an
  implicit-only response_type leaves the entire mTLS setup inert.
- resolveEndpoint now treats a non-string discovery value as absent so the
  precondition checks fail fast instead of passing on a truthy non-URL that
  oauth4webapi would later reject as INVALID_SERVER_METADATA.
- Pin the tls_client_auth / self_signed_tls_client_auth rejection tests to
  the Joi .valid() failure (TypeError + message); the self_signed case was
  previously passing on an unrelated public-client error under a code flow.

@Piyush-85 Piyush-85 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM ! Reviewed it async with @jd3vi1

@jd3vi1
jd3vi1 merged commit 6e9bf33 into master Aug 7, 2026
11 checks passed
@jd3vi1
jd3vi1 deleted the feature/mtls branch August 7, 2026 12:04
@jd3vi1 jd3vi1 mentioned this pull request Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants