Skip to content

feat: add mTLS (RFC 8705) client authentication - #159

Open
cschetan77 wants to merge 30 commits into
mainfrom
feat/mtls-client-authentication
Open

feat: add mTLS (RFC 8705) client authentication#159
cschetan77 wants to merge 30 commits into
mainfrom
feat/mtls-client-authentication

Conversation

@cschetan77

@cschetan77 cschetan77 commented Aug 21, 2026

Copy link
Copy Markdown

Summary

Adds Mutual TLS (RFC 8705) client authentication to auth0-server-python. When enabled, the SDK presents a TLS client certificate during the Auth0 token-endpoint handshake instead of a client secret — no credential travels in the request body.

  • Two new ServerClient constructor params: use_mtls: bool = False and ssl_context: Optional[ssl.SSLContext] = None. The caller builds the SSLContext (ssl.create_default_context() + load_cert_chain); the SDK forwards it as verify=ssl_context to every httpx.AsyncClient it constructs (including the authlib client used for the authorization-code exchange).
  • Endpoint routing: new _resolve_token_endpoint(metadata) helper returns mtls_endpoint_aliases.token_endpoint from the discovery document when mTLS is on, raising ConfigurationError if the alias is absent. All token-endpoint call sites in server_client.py, mfa_client.py, and passwordless_client.py are routed through it.
  • PAR routing: new _resolve_par_endpoint(metadata) helper returns mtls_endpoint_aliases.pushed_authorization_request_endpoint when mTLS is on, so PAR requests also hit the mTLS alias.
  • Client authentication: _apply_client_authentication returns None under mTLS — the certificate in the TLS handshake is the sole credential; no client_secret or client_assertion is added to the body. The authlib AsyncOAuth2Client is constructed with client_secret=None and verify=ssl_context under mTLS.
  • cnf.x5t#S256 warning: _warn_if_not_cert_bound inspects the access token's cnf claim and emits a logger.warning when it is absent, signalling that Token Sender-Constraining is not active on the resource server. Scoped to authorization_code and refresh_token grants only (per RFC 8705 Table 1) — does not fire for MFA, passwordless, passkey, connection exchange, or custom token exchange, where the auth server intentionally omits cnf.
  • MFA under mTLS: MfaClient receives use_mtls and ssl_context from ServerClient (cert on all MFA calls). A token_endpoint_resolver callable is injected at construction by ServerClient when mTLS is enabled — it calls _resolve_current_domain + _get_oidc_metadata_cached + _resolve_token_endpoint, keeping metadata caching in ServerClient where it belongs. MfaClient never fetches OIDC metadata directly. /mfa/challenge stays on the standard host; the client certificate is presented in the TLS handshake via ssl_context.
  • Passwordless under mTLS: /passwordless/start stays on the standard host (no mTLS alias is advertised for it in the discovery doc; mTLS cert auth is not supported server-side on that endpoint). passwordless.verify routes the token exchange through the mTLS alias. Behaviour documented in examples/MutualTLS.md.
  • MyAccountClient under mTLS: ssl_context is threaded through MyAccountClient so the client certificate is presented on /me/ calls, allowing the resource server to verify cnf.x5t#S256 binding.
  • DPoP mutual exclusion: signin_with_passkey and mfa.verify raise ConfigurationError when both dpop_key and use_mtls are active — DPoP would bind the token to its own key and suppress cnf.x5t#S256, silently defeating mTLS token binding.
  • Passkey challenge/register under mTLS: /passkey/challenge and /passkey/register are not served on the mTLS endpoint aliases, so a client using use_mtls=True (which forbids client_secret) cannot authenticate those endpoints. Documented in examples/MutualTLS.md.
  • Constructor validation (all ConfigurationError, fail-fast): use_mtls=True without ssl_context; combined with client_secret; combined with client_assertion_signing_key.

Changed files

File Change
auth_server/server_client.py use_mtls/ssl_context params, validation, _resolve_token_endpoint, _resolve_par_endpoint, _apply_client_authentication mTLS branch, _warn_if_not_cert_bound (cnf.x5t#S256 logger warning), DPoP guard, all token/PAR call-site routings, _get_http_client + AsyncOAuth2Client verify= injection
auth_server/mfa_client.py use_mtls/ssl_context params, token_endpoint_resolver callable injection, _get_http_client verify= injection, DPoP guard in verify()
auth_server/passwordless_client.py Token-endpoint call routed through _resolve_token_endpoint
auth_server/my_account_client.py ssl_context threaded through for cert-bound token support on /me/ calls
tests/test_server_client.py Constructor validation, _resolve_token_endpoint, _apply_client_authentication mTLS branch, SSLContext threading, DPoP exclusion, alias-routing and body credential-absence assertions for interactive login, PAR, refresh token, backchannel, connection, and custom-exchange flows; AsyncOAuth2Client constructor verification; cnf warning tests
tests/test_mfa_client.py SSLContext threading, token_endpoint_resolver, DPoP exclusion
tests/test_passwordless_client.py TestMtls class: verify routing through mTLS alias, ssl_context threading on start() and verify(), client_secret absence assertions
examples/MutualTLS.md New per-feature guide: prerequisites, cert generation, wiring, mutual-exclusion rules, cnf.x5t#S256 logger warning, MFA proxy note, passwordless caveat, passkey incompatibility callout
README.md mTLS section linking to the guide and to Auth0 mTLS configuration docs
references/flow-map.md mTLS row

Test plan

  • poetry run pytest — all 569 tests pass
  • poetry run ruff check . — no lint errors
  • Constructor raises ConfigurationError for: missing ssl_context with use_mtls=True; client_secret + use_mtls; client_assertion_signing_key + use_mtls
  • _resolve_token_endpoint returns the mTLS alias when present, raises ConfigurationError when alias is absent under mTLS, returns the standard endpoint when mTLS is off
  • _resolve_par_endpoint returns the mTLS PAR alias when use_mtls=True
  • AsyncOAuth2Client constructed with client_secret=None and verify=ssl_context under mTLS
  • complete_interactive_login under mTLS hits mtls_endpoint_aliases.token_endpoint
  • get_token_by_refresh_token, backchannel_authentication_grant, get_token_for_connection, and custom_token_exchange under mTLS all route to mtls_endpoint_aliases.token_endpoint and omit client_secret from the request body
  • start_interactive_login with PAR under mTLS hits mtls_endpoint_aliases.pushed_authorization_request_endpoint and omits client_secret from the request body
  • passwordless.verify under mTLS routes token exchange through the mTLS alias, omits client_secret, and presents ssl_context
  • passwordless.start under mTLS omits client_secret and passes verify=ssl_context to httpx
  • _warn_if_not_cert_bound emits logger.warning when cnf.x5t#S256 is absent; silent on cert-bound tokens, opaque tokens, and missing access token
  • cnf warning fires on complete_interactive_login and get_token_by_refresh_token under mTLS; does not fire for MFA or other non-CNF grants
  • signin_with_passkey raises ConfigurationError when dpop_key + use_mtls
  • mfa.verify raises ConfigurationError when dpop_key + use_mtls; token endpoint resolved via injected token_endpoint_resolver

@cschetan77
cschetan77 requested a review from a team as a code owner August 21, 2026 09:30
@cschetan77
cschetan77 force-pushed the feat/mtls-client-authentication branch from 9ef2878 to 0915865 Compare August 21, 2026 09:37
Comment thread src/auth0_server_python/auth_server/server_client.py
Comment thread src/auth0_server_python/auth_server/server_client.py
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/mfa_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py
Comment thread examples/MutualTLS.md Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/mfa_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/mfa_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py
Comment thread src/auth0_server_python/auth_server/server_client.py
Comment thread src/auth0_server_python/auth_server/server_client.py
…or in docstrings

Add missing token_endpoint_override param and ConfigurationError (dpop_key +
use_mtls) to MfaClient.verify() docstring; add same ConfigurationError to
signin_with_passkey() Raises section.
Replace em dashes with plain hyphens (Rule 4), split semicolon-spliced
clauses into separate sentences (Rule 5), and reword the cryptic
ssl_context error message in plain direct voice (Rule 9).
Move test_signin_with_passkey_rejects_dpop_under_mtls into the PASSKEY
AUTHENTICATION section and test_complete_interactive_login_uses_mtls_token_endpoint
into the IPSIE section, next to the other complete_interactive_login tests.
The remaining mTLS tests (constructor, resolver, credential-drop, cert-bound
warning) stay in the mTLS section.
…ss, and interactive login

C22: inject token_endpoint_resolver into MfaClient so mfa.verify() resolves
the mTLS alias automatically via ServerClient._resolve_mfa_token_endpoint,
keeping metadata fetching and caching in ServerClient where it belongs.

C24: add missing null-check on token_endpoint in complete_interactive_login,
consistent with the other five call sites.

C1: switch passwordless verify from metadata["token_endpoint"] to
client._resolve_token_endpoint(metadata) so it routes through the mTLS alias.
Credential-drop was already handled by _apply_client_authentication.
The runtime warning flags a resource server misconfiguration the SDK
has no control over, using a once-only mechanism inconsistent with the
rest of the SDK. MutualTLS.md now states the requirement directly.
@cschetan77
cschetan77 force-pushed the feat/mtls-client-authentication branch from 0915865 to 82cd04c Compare September 1, 2026 08:00
@cschetan77

Copy link
Copy Markdown
Author

All style fixes addressed in one commit (style: apply repo conventions to mTLS code and docs):

  • Semicolon splices (×2): split into two sentences in both server_client.py and mfa_client.py.
  • Em dashes in README.md and MutualTLS.md (×5): replaced with plain hyphens throughout.
  • Em dashes in server_client.py (×2): the inline comment and warning string were part of _warn_if_not_cert_bound, which was removed entirely — the em dashes are gone with it.
  • Em dash in MutualTLS.md warning quote (×1): the quoted warning text was removed along with _warn_if_not_cert_bound; MutualTLS.md now documents the requirement directly.

Add _warn_if_not_cert_bound to emit a logger.warning when use_mtls is
enabled but the returned access token is not certificate-bound. Called
from complete_interactive_login and get_token_by_refresh_token only,
matching the grant types covered by RFC 8705 sender-constraining.
Tests distributed next to their surfaces: call-site assertions for
complete_interactive_login and get_token_by_refresh_token placed next
to their existing mTLS routing tests; unit tests for the method itself
in the mTLS section covering warn/no-warn/opaque/missing-token cases.
…sswordless caveat

Replace manual openssl thumbprint step with reference to the SDK logger
warning. Clarify that MFA challenge/enrollment calls go to the standard
host with cert presented in TLS handshake, proxy forwarding outside SDK
control. Add passwordless section documenting enforce_client_authentication
tenant flag caveat.
Replace token_endpoint_override with token_endpoint_resolver, which is
the actual parameter name injected into MfaClient under mTLS.
Piyush-85
Piyush-85 previously approved these changes Sep 4, 2026

@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

- Replace em dash with plain hyphen in test_mfa_client.py section header
- Split semicolon clause-splices into two sentences in MutualTLS.md (x2),
  server_client.py comment, and MfaVerifyError message
- Restructure _resolve_token_endpoint docstring with Returns:/Raises: sections
…call

algorithms is a no-op when verify_signature=False - PyJWT only validates
alg against the allowlist when performing signature verification.
kishore7snehil
kishore7snehil previously approved these changes Sep 7, 2026

@kishore7snehil kishore7snehil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

@rmad17 rmad17 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

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.

4 participants