Skip to content

Commit 0915865

Browse files
committed
docs: document mTLS client authentication
1 parent 1a4bb00 commit 0915865

3 files changed

Lines changed: 111 additions & 0 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,29 @@ The key must be a PKCS8 PEM private key. Register its public key on your Auth0 a
8484
> [!IMPORTANT]
8585
> Private keys must not be committed to source control. Load them from a secure secret store or an environment-provided file.
8686
87+
#### Authenticating with Mutual TLS (mTLS)
88+
89+
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:
90+
91+
```python
92+
import ssl
93+
94+
ssl_context = ssl.create_default_context()
95+
ssl_context.load_cert_chain("client.crt", "client.key")
96+
97+
auth0 = ServerClient(
98+
domain="login.example.com", # self_managed_certs custom domain
99+
client_id="<AUTH0_CLIENT_ID>",
100+
use_mtls=True,
101+
ssl_context=ssl_context,
102+
secret="<AUTH0_SECRET>",
103+
)
104+
```
105+
106+
`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`.
107+
108+
See [examples/MutualTLS.md](examples/MutualTLS.md) for the full setup guide, certificate generation, and token sender-constraining details.
109+
87110
### 3. Add login to your Application (interactive)
88111

89112
Before using redirect-based login, ensure the `redirect_uri` is configured when initializing the SDK:

examples/MutualTLS.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Mutual TLS (mTLS) Client Authentication
2+
3+
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.
4+
5+
## Prerequisites
6+
7+
- Auth0 **Enterprise** tenant with the **Highly Regulated Identity** add-on
8+
- A `self_managed_certs` **custom domain** configured on the tenant
9+
- **Allow mTLS Endpoint Aliases** enabled on the tenant (Dashboard → Settings → Advanced)
10+
- Client application's authentication method set to **mTLS** in Dashboard → Applications → Settings → Credentials
11+
12+
## Generating a client certificate (development)
13+
14+
```bash
15+
# Self-signed CA + client cert (development only — use your PKI in production)
16+
openssl req -x509 -newkey rsa:4096 -keyout ca.key -out ca.crt -days 365 -nodes \
17+
-subj "/CN=dev-ca"
18+
openssl req -newkey rsa:2048 -keyout client.key -out client.csr -nodes \
19+
-subj "/CN=my-app-client"
20+
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
21+
-out client.crt -days 365
22+
```
23+
24+
## Wiring into `ServerClient`
25+
26+
```python
27+
import ssl
28+
from auth0_server_python.auth_server.server_client import ServerClient
29+
30+
ssl_context = ssl.create_default_context() # trusts system/public CAs for the server side
31+
ssl_context.load_cert_chain("client.crt", "client.key") # attaches the client identity
32+
33+
auth0 = ServerClient(
34+
domain="login.example.com", # self_managed_certs custom domain
35+
client_id="<AUTH0_CLIENT_ID>",
36+
use_mtls=True,
37+
ssl_context=ssl_context,
38+
secret="<AUTH0_SECRET>",
39+
authorization_params={
40+
"audience": "<API_IDENTIFIER>",
41+
"scope": "openid profile email offline_access",
42+
},
43+
)
44+
```
45+
46+
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.
47+
48+
## Mutual exclusion
49+
50+
`use_mtls=True` cannot be combined with:
51+
52+
| Parameter | Reason |
53+
|-----------|--------|
54+
| `client_secret` | One client-auth method only — Auth0 rejects requests carrying both. |
55+
| `client_assertion_signing_key` | Same — one method only. |
56+
| `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. |
57+
58+
All three raise `ConfigurationError` immediately (constructor for the first two, at the call site for DPoP).
59+
60+
## Token sender-constraining
61+
62+
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:
63+
64+
> `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.`
65+
66+
To verify the thumbprint yourself:
67+
68+
```bash
69+
openssl x509 -in client.crt -outform DER | openssl dgst -sha256 -binary | openssl enc -base64 | tr '+/' '-_' | tr -d '='
70+
# Compare the output to the cnf.x5t#S256 claim in the decoded access token.
71+
```
72+
73+
## MFA under mTLS
74+
75+
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).
76+
77+
When calling `client.mfa.verify` directly (rather than through the SDK's built-in flow), pass the resolved mTLS token endpoint:
78+
79+
```python
80+
metadata = await auth0._get_oidc_metadata_cached(domain)
81+
mtls_token_endpoint = auth0._resolve_token_endpoint(metadata)
82+
83+
await auth0.mfa.verify(
84+
{"mfa_token": encrypted_token, "otp": "123456"},
85+
token_endpoint_override=mtls_token_endpoint,
86+
)
87+
```

references/flow-map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Before working on a flow, read its entry points and supporting modules. Every fl
1717
| 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` |
1818
| My Account | `MyAccountClient` (factors, authentication methods, enroll/verify) | `auth_schemes/dpop_auth.py`; stateless — every call takes a user token | `examples/MyAccountAuthenticationMethods.md` |
1919
| MCD | any flow — `domain` may be an async resolver | `_resolve_current_domain`, pitfall 5 in `references/pitfalls.md` | `examples/MultipleCustomDomains.md` |
20+
| 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` |
2021

2122
Two rules cut across every flow above, so check them on any change here: resolve the domain through
2223
`await self._resolve_current_domain(store_options)` rather than reading `self._domain`, and accept

0 commit comments

Comments
 (0)