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`.

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.

"or a per-call dpop_key — each raises" uses an em dash. A comma or a full stop will read the same.


See [examples/MutualTLS.md](examples/MutualTLS.md) for the full setup guide, certificate generation, and token sender-constraining details.

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.

The mTLS setup here lists the tenant prerequisites and Dashboard steps inline, but neither this section nor the guide links to the official Auth0 mTLS configuration doc. Since the tenant-side setup is canonical and maintained elsewhere, we can add a link to the Auth0 mTLS configuration docs alongside this reference.


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

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.

Em dash in the code comment: "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.

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.

Em dash: "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. |

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.

Em dash in the table cell: "One client-auth method only — Auth0 rejects requests carrying both".

| `client_assertion_signing_key` | Same — one method only. |

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.

Em dash: "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).

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.

One case that isn't covered here: passkey_signup_challenge and passkey_login_challenge hit /passkey/register and /passkey/challenge, which as far as I know are not served on the mTLS endpoint aliases, so they take a client_secret in the body rather than the certificate. A client configured for mTLS only has no client_secret, so those two calls would go out with just client_id.

Can you confirm against a tenant whether register and challenge actually work for a mTLS-only client? If they need a client_secret, it's worth calling that out here as the one exception, the way the rest of this doc is precise about what mTLS covers.


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

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.

Em dash in the warning quote: "Sender-constraining is not active — configure Token Sender-Constraining".


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,

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.

The new token_endpoint_override param, and the ConfigurationError this now raises when dpop_key is combined with mTLS, are not in the docstring. The Args and Raises sections still describe the previous surface.

Worth adding both so the documented contract matches what the method does now.

) -> 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 "

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.

The semicolon here splices two clauses. We can add two sentences which will increase the readibility.

"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