Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7fbe52a
feat: add use_mtls and ssl_context constructor args with validation
cschetan77 Aug 21, 2026
19b1484
feat: pass mTLS ssl_context to httpx and authlib clients
cschetan77 Aug 21, 2026
7f18573
feat: add _resolve_token_endpoint mTLS alias resolver
cschetan77 Aug 21, 2026
8acc86b
feat: return no body credential under mTLS in client auth resolver
cschetan77 Aug 21, 2026
be1fee8
feat: route all token-endpoint calls through mTLS alias resolver
cschetan77 Aug 21, 2026
8f786c9
feat: reject dpop_key + use_mtls in signin_with_passkey
cschetan77 Aug 21, 2026
3849b96
feat: warn when mTLS token lacks cnf.x5t#S256 binding
cschetan77 Aug 21, 2026
09bf72f
feat: thread mTLS ssl_context and alias routing through MFA verify
cschetan77 Aug 21, 2026
5758f63
docs: document mTLS client authentication
cschetan77 Aug 21, 2026
14430d6
docs: document token_endpoint_override and dpop+mTLS ConfigurationErr…
cschetan77 Aug 31, 2026
0398bc6
style: apply repo conventions to mTLS code and docs
cschetan77 Aug 31, 2026
f557dd1
refactor(tests): distribute mTLS tests next to their surfaces
cschetan77 Aug 31, 2026
2f26875
docs: link to Auth0 mTLS configuration docs in README
cschetan77 Aug 31, 2026
5bc3fec
fix: route mTLS token calls through alias resolver in MFA, passwordle…
cschetan77 Aug 31, 2026
4a03a5a
refactor: replace cnf.x5t#S256 UserWarning with documentation
cschetan77 Aug 31, 2026
e620c26
docs: document passkey challenge/register incompatibility with mTLS-o…
cschetan77 Aug 31, 2026
9cd5951
feat: wire ssl_context through MyAccountClient for mTLS cert-bound to…
cschetan77 Aug 31, 2026
e46e28b
fix: route PAR endpoint through mTLS alias when use_mtls is enabled
cschetan77 Sep 1, 2026
82cd04c
test: add mTLS token endpoint routing assertions for refresh, backcha…
cschetan77 Sep 1, 2026
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ The key must be a PKCS8 PEM private key. Register its public key on your Auth0 a
> [!IMPORTANT]
> Private keys must not be committed to source control. Load them from a secure secret store or an environment-provided file.

#### Authenticating with Mutual TLS (mTLS)

The SDK supports mTLS client authentication (RFC 8705): the client presents a TLS certificate during the handshake instead of a client secret. Pass `use_mtls=True` and a caller-built `ssl.SSLContext` that already has the certificate loaded:

```python
import ssl

ssl_context = ssl.create_default_context()
ssl_context.load_cert_chain("client.crt", "client.key")

auth0 = ServerClient(
domain="login.example.com", # self_managed_certs custom domain
client_id="<AUTH0_CLIENT_ID>",
use_mtls=True,
ssl_context=ssl_context,
secret="<AUTH0_SECRET>",
)
```

`use_mtls=True` requires an Enterprise tenant with the Highly Regulated Identity add-on, a `self_managed_certs` custom domain, and mTLS endpoint aliases enabled. It cannot be combined with `client_secret`, `client_assertion_signing_key`, or a per-call `dpop_key`. Each raises `ConfigurationError`. See the [Auth0 mTLS configuration docs](https://auth0.com/docs/get-started/applications/configure-mtls) for tenant-side setup steps.

See [examples/MutualTLS.md](examples/MutualTLS.md) for the full setup guide, certificate generation, and token sender-constraining details.
Comment thread
cschetan77 marked this conversation as resolved.

### 3. Add login to your Application (interactive)

Before using redirect-based login, ensure the `redirect_uri` is configured when initializing the SDK:
Expand Down
87 changes: 87 additions & 0 deletions examples/MutualTLS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Mutual TLS (mTLS) Client Authentication

Authenticate to Auth0 with a TLS client certificate instead of a client secret (RFC 8705). The certificate is presented during the TLS handshake; no credential travels in the request body.

## Prerequisites

- Auth0 **Enterprise** tenant with the **Highly Regulated Identity** add-on
- A `self_managed_certs` **custom domain** configured on the tenant
- **Allow mTLS Endpoint Aliases** enabled on the tenant (Dashboard → Settings → Advanced)
- Client application's authentication method set to **mTLS** in Dashboard → Applications → Settings → Credentials

## Generating a client certificate (development)

```bash
# Self-signed CA + client cert (development only - use your PKI in production)
openssl req -x509 -newkey rsa:4096 -keyout ca.key -out ca.crt -days 365 -nodes \
-subj "/CN=dev-ca"
openssl req -newkey rsa:2048 -keyout client.key -out client.csr -nodes \
-subj "/CN=my-app-client"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out client.crt -days 365
```

## Wiring into `ServerClient`

```python
import ssl
from auth0_server_python.auth_server.server_client import ServerClient

ssl_context = ssl.create_default_context() # trusts system/public CAs for the server side
ssl_context.load_cert_chain("client.crt", "client.key") # attaches the client identity

auth0 = ServerClient(
domain="login.example.com", # self_managed_certs custom domain
client_id="<AUTH0_CLIENT_ID>",
use_mtls=True,
ssl_context=ssl_context,
secret="<AUTH0_SECRET>",
authorization_params={
"audience": "<API_IDENTIFIER>",
"scope": "openid profile email offline_access",
},
)
```

The SDK passes `ssl_context` as `verify=ssl_context` to every `httpx.AsyncClient` it constructs, including the authlib client used for the authorization-code exchange. You never call `load_cert_chain` inside the SDK - the caller owns the TLS material.

## Mutual exclusion

`use_mtls=True` cannot be combined with:

| Parameter | Reason |
|-----------|--------|
| `client_secret` | One client-auth method only - Auth0 rejects requests carrying both. |
| `client_assertion_signing_key` | Same - one method only. |
| `dpop_key` (per-call on `signin_with_passkey` / `mfa.verify`) | DPoP binds to its own key (`cnf.jkt`) and suppresses `cnf.x5t#S256`; combining them silently defeats mTLS token binding. |

All three raise `ConfigurationError` immediately (constructor for the first two, at the call site for DPoP).
Comment thread
cschetan77 marked this conversation as resolved.

## Token sender-constraining

When the target API has **Token Sender-Constraining (mTLS)** enabled, issued access tokens carry a `cnf.x5t#S256` claim binding the token to the certificate thumbprint. If your tokens do not contain this claim, enable **Token Sender-Constraining (mTLS)** on the API resource server in the Auth0 dashboard.

To verify the thumbprint yourself:

```bash
openssl x509 -in client.crt -outform DER | openssl dgst -sha256 -binary | openssl enc -base64 | tr '+/' '-_' | tr -d '='
# Compare the output to the cnf.x5t#S256 claim in the decoded access token.
```

## MFA under mTLS

The client certificate is presented on all MFA API calls. The token-endpoint call inside `mfa.verify` is routed through the mTLS alias automatically. Challenge and enrollment calls stay on the standard host, which does not request a client certificate.

```python
await auth0.mfa.verify(
{"mfa_token": encrypted_token, "otp": "123456"},
)
```

## Passkeys under mTLS

`/passkey/challenge` and `/passkey/register` are not served on the mTLS endpoint aliases. Auth0 only accepts `client_secret` as the credential on those endpoints - the client certificate is not a valid credential there.

Because `use_mtls=True` forbids `client_secret` at construction time, an mTLS-configured client has no valid credential for `passkey_login_challenge` and `passkey_signup_challenge`. Those calls will be rejected by Auth0 if the application is registered as a confidential client.

`signin_with_passkey` (the token-exchange step) is not affected - it calls the token endpoint, which is served on the mTLS alias and routed correctly.
1 change: 1 addition & 0 deletions references/flow-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Before working on a flow, read its entry points and supporting modules. Every fl
| Passkeys | `passkey_signup_challenge`, `passkey_login_challenge`, `signin_with_passkey` | `auth_schemes/dpop_auth.py` — passkey sign-in is the DPoP-bound path | `examples/Passkeys.md` |
| My Account | `MyAccountClient` (factors, authentication methods, enroll/verify) | `auth_schemes/dpop_auth.py`; stateless — every call takes a user token | `examples/MyAccountAuthenticationMethods.md` |
| MCD | any flow — `domain` may be an async resolver | `_resolve_current_domain`, pitfall 5 in `references/pitfalls.md` | `examples/MultipleCustomDomains.md` |
| mTLS client auth | constructor `use_mtls` + `ssl_context` | `_resolve_token_endpoint`, `_apply_client_authentication`, `_warn_if_not_cert_bound`, `mfa_client.py` (`use_mtls`, `ssl_context`, `verify` `token_endpoint_override`) | `examples/MutualTLS.md` |

Two rules cut across every flow above, so check them on any change here: resolve the domain through
`await self._resolve_current_domain(store_options)` rather than reading `self._domain`, and accept
Expand Down
22 changes: 21 additions & 1 deletion src/auth0_server_python/auth_server/mfa_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import json
import ssl
import time
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any, Optional, Union
Expand Down Expand Up @@ -74,6 +75,9 @@ def __init__(
] = None,
mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL,
apply_client_authentication: Optional[Callable] = None,
use_mtls: bool = False,
ssl_context: Optional[ssl.SSLContext] = None,
token_endpoint_resolver: Optional[Callable[..., Awaitable[str]]] = None,
):
if callable(domain):
self._domain = None
Expand All @@ -92,10 +96,15 @@ def __init__(
raise ConfigurationError("mfa_token_ttl must be a positive number of seconds")
self._mfa_token_ttl = mfa_token_ttl
self._apply_client_authentication = apply_client_authentication
self._use_mtls = use_mtls
self._ssl_context = ssl_context
self._token_endpoint_resolver = token_endpoint_resolver

def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
"""Return an httpx.AsyncClient with default headers injected."""
headers = {**kwargs.pop("headers", {}), **self._headers}
if self._use_mtls and "verify" not in kwargs:
kwargs["verify"] = self._ssl_context
return httpx.AsyncClient(headers=headers, **kwargs)

def _apply_mfa_client_authentication(self, body: dict, base_url: str) -> None:
Expand Down Expand Up @@ -502,8 +511,16 @@ async def verify(
MfaVerifyError: When verification fails, or when dpop_key was supplied
but the server returned an unbound (Bearer) token.
MfaRequiredError: When chained MFA is required.
ConfigurationError: If dpop_key is combined with use_mtls. DPoP and mTLS
use incompatible token-binding mechanisms and cannot be used together.
ConfigurationError: If neither client_secret nor client_assertion_signing_key is configured.
"""
if self._use_mtls and dpop_key is not None:
raise ConfigurationError(
"dpop_key cannot be combined with use_mtls. DPoP and mTLS bind tokens "
"differently. DPoP would take precedence and the token would not be "
"certificate-bound."
)
mfa_token = options.get("mfa_token")
if not mfa_token:
raise MfaTokenInvalidError()
Expand Down Expand Up @@ -534,7 +551,10 @@ async def verify(
)

try:
token_endpoint = f"{base_url}/oauth/token"
if self._use_mtls and self._token_endpoint_resolver:
token_endpoint = await self._token_endpoint_resolver(store_options)
else:
token_endpoint = f"{base_url}/oauth/token"

async with self._get_http_client() as client:
headers = {"Content-Type": "application/x-www-form-urlencoded"}
Expand Down
14 changes: 13 additions & 1 deletion src/auth0_server_python/auth_server/my_account_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import ssl
from typing import TYPE_CHECKING, Optional
from urllib.parse import quote, unquote, urlparse

Expand Down Expand Up @@ -46,20 +47,31 @@ class MyAccountClient:
Client for interacting with the Auth0 MyAccount API.
"""

def __init__(self, domain: str, headers: Optional[dict[str, str]] = None):
def __init__(
self,
domain: str,
headers: Optional[dict[str, str]] = None,
ssl_context: Optional[ssl.SSLContext] = None,
):
"""
Initialize the MyAccount API client.

Args:
domain: Auth0 domain (e.g., '<tenant>.<locality>.auth0.com')
headers: Optional default headers to include on every request
ssl_context: Optional SSL context for mTLS. When provided, the client
certificate is presented on every request so the My Account API can
verify cnf.x5t#S256 binding on cert-bound access tokens.
"""
self._domain = domain
self._headers = headers or {}
self._ssl_context = ssl_context

def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
"""Return an httpx.AsyncClient with default headers injected."""
headers = {**kwargs.pop("headers", {}), **self._headers}
if self._ssl_context is not None and "verify" not in kwargs:
kwargs["verify"] = self._ssl_context
return httpx.AsyncClient(headers=headers, **kwargs)

@property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,12 @@ async def verify(
e,
)

token_endpoint = metadata["token_endpoint"]
token_endpoint = client._resolve_token_endpoint(metadata)
if not token_endpoint:
raise PasswordlessVerifyError(
PasswordlessErrorCode.DISCOVERY_ERROR,
"Token endpoint missing in OIDC metadata",
)
origin_issuer = metadata.get("issuer")

default_scope = (
Expand Down
Loading
Loading