Skip to content
Open
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 [examples/MutualTLS.md](examples/MutualTLS.md) for the full setup guide, certificate generation, and token sender-constraining details.

### 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).

## 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. The SDK warns if it receives a token that lacks this claim:

> `UserWarning: mTLS is enabled but the access token is not certificate-bound (no cnf.x5t#S256). Sender-constraining is not active — configure Token Sender-Constraining (mTLS) on the API resource server.`

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. Only the token-endpoint call inside `mfa.verify` is routed through the mTLS alias; challenge and enrollment calls stay on the standard host (the standard host does not request a client certificate, so the loaded context is inert on those calls).

When calling `client.mfa.verify` directly (rather than through the SDK's built-in flow), pass the resolved mTLS token endpoint:

```python
metadata = await auth0._get_oidc_metadata_cached(domain)
mtls_token_endpoint = auth0._resolve_token_endpoint(metadata)

await auth0.mfa.verify(
{"mfa_token": encrypted_token, "otp": "123456"},
token_endpoint_override=mtls_token_endpoint,
)
```
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
16 changes: 15 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,8 @@ 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,
):
if callable(domain):
self._domain = None
Expand All @@ -92,10 +95,14 @@ 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

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 @@ -472,6 +479,7 @@ async def verify(
options: dict[str, Any],
store_options: Optional[dict[str, Any]] = None,
dpop_key: Optional["jwk.JWK"] = None,
token_endpoint_override: Optional[str] = None,
) -> MfaVerifyResponse:
"""
Verifies an MFA code and completes authentication.
Expand Down Expand Up @@ -504,6 +512,12 @@ async def verify(
MfaRequiredError: When chained MFA is required.
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 +548,7 @@ async def verify(
)

try:
token_endpoint = f"{base_url}/oauth/token"
token_endpoint = token_endpoint_override or f"{base_url}/oauth/token"

async with self._get_http_client() as client:
headers = {"Content-Type": "application/x-www-form-urlencoded"}
Expand Down
Loading
Loading