Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,35 @@ The `AUTH0_SECRET` is the key used to encrypt the session and transaction cookie
openssl rand -hex 64
```

#### Authenticating with Private Key JWT

The SDK authenticates to Auth0 with either a client secret or a Private Key JWT (`private_key_jwt`). To use Private Key JWT, pass your private signing key as `client_assertion_signing_key` instead of `client_secret`:

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

with open('private_key.pem') as f:
private_key = f.read()

auth0 = ServerClient(
domain='<AUTH0_DOMAIN>',
client_id='<AUTH0_CLIENT_ID>',
client_assertion_signing_key=private_key,
secret='<AUTH0_SECRET>',
authorization_params={
'redirect_uri': '<AUTH0_REDIRECT_URI>',
}
)
```

The key must be a PKCS8 PEM private key. Register its public key on your Auth0 application under **Settings → Credentials**, and set the application's authentication method to Private Key JWT. The signing algorithm defaults to `RS256` and can be overridden with `client_assertion_signing_alg`. Auth0 accepts `RS256`, `RS384`, and `PS256`, all of which use an RSA key. The algorithm must match the key type and the algorithm chosen when the public key credential was created.

> [!NOTE]
> The passkey challenge endpoints (`register` and `challenge`) accept only a client secret, so a client configured with just a signing key cannot use them.

> [!IMPORTANT]
> Private keys must not be committed to source control. Load them from a secure secret store or an environment-provided file.

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

Before using redirect-based login, ensure the `redirect_uri` is configured when initializing the SDK:
Expand Down
3 changes: 3 additions & 0 deletions examples/Passkeys.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ server_client = ServerClient(

The **Passkey** grant (`urn:okta:params:oauth:grant-type:webauthn`) must be enabled for your application under **Applications → Your App → Grant Types**.

> [!NOTE]
> The passkey challenge endpoints accept a client secret only, not a client assertion. A client configured with just a `client_assertion_signing_key` (Private Key JWT) cannot use the passkey flows, so configure a `client_secret` to use them.

## 1. Passkey Signup

### Step 1 — Request a signup challenge
Expand Down
63 changes: 63 additions & 0 deletions src/auth0_server_python/auth_schemes/client_assertion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import secrets
import time
from typing import Union

import jwt

from auth0_server_python.error import ConfigurationError

# RFC 7523 client-assertion type for private_key_jwt authentication.
CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"

# Assertion lifetime in seconds.
_ASSERTION_LIFETIME = 60


def validate_client_assertion_key(private_key: Union[str, bytes], alg: str = "RS256") -> None:
"""
Verify the signing key and algorithm can produce a client assertion.

Args:
private_key: The client's private signing key (PKCS8 PEM string or bytes).
alg: The signing algorithm (defaults to "RS256").

Raises:
ConfigurationError: If the key is malformed or does not match the algorithm.
"""
try:
jwt.encode({"probe": True}, private_key, algorithm=alg)
except Exception as e:
raise ConfigurationError(
f"Invalid client_assertion_signing_key for algorithm {alg}: {e}"
) from e


def build_client_assertion(
private_key: Union[str, bytes],
client_id: str,
issuer: str,
alg: str = "RS256",
) -> str:
"""
Mint a private_key_jwt client assertion for token-endpoint authentication (RFC 7523).

Args:
private_key: The client's private signing key (PKCS8 PEM string or bytes).
client_id: The Auth0 client ID, used as both the issuer and subject claim.
issuer: The authorization server issuer identifier, used as the audience claim.
alg: The signing algorithm (defaults to "RS256").

Returns:
The signed client-assertion JWT.
"""
now = int(time.time())
payload = {
"iss": client_id,
"sub": client_id,
"aud": issuer,
"iat": now,
"nbf": now,
"exp": now + _ASSERTION_LIFETIME,
"jti": secrets.token_urlsafe(32),
}
return jwt.encode(payload, private_key, algorithm=alg)
Comment thread
kishore7snehil marked this conversation as resolved.
20 changes: 16 additions & 4 deletions src/auth0_server_python/auth_server/mfa_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ def __init__(
secret: str,
state_store=None,
state_identifier: str = "_a0_session",
headers: Optional[dict[str, str]] = None
headers: Optional[dict[str, str]] = None,
apply_client_authentication: Optional[Callable] = None
):
if callable(domain):
self._domain = None
Expand All @@ -80,12 +81,20 @@ def __init__(
self._state_store = state_store
self._state_identifier = state_identifier
self._headers = headers or {}
self._apply_client_authentication = apply_client_authentication

def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
"""Return an httpx.AsyncClient with default headers injected."""
headers = {**kwargs.pop("headers", {}), **self._headers}
return httpx.AsyncClient(headers=headers, **kwargs)

def _apply_mfa_client_authentication(self, body: dict, base_url: str) -> None:
"""Add client authentication to an MFA request body (client_secret or client assertion)."""
if self._apply_client_authentication:
self._apply_client_authentication(body, f"{base_url}/", in_body=True)
elif self._client_secret:
body["client_secret"] = self._client_secret

async def _resolve_base_url(
self,
store_options: Optional[dict[str, Any]] = None
Expand Down Expand Up @@ -393,6 +402,7 @@ async def challenge_authenticator(

Raises:
MfaChallengeError: When the challenge fails.
ConfigurationError: If neither client_secret nor client_assertion_signing_key is configured.
"""
mfa_token = options.get("mfa_token")
if not mfa_token:
Expand All @@ -415,9 +425,9 @@ async def challenge_authenticator(
body: dict[str, Any] = {
"mfa_token": context.mfa_token,
"client_id": self._client_id,
"client_secret": self._client_secret,
"challenge_type": challenge_type
}
self._apply_mfa_client_authentication(body, base_url)

if "authenticator_id" in options and options["authenticator_id"]:
body["authenticator_id"] = options["authenticator_id"]
Expand Down Expand Up @@ -482,17 +492,20 @@ 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 neither client_secret nor client_assertion_signing_key is configured.
"""
mfa_token = options.get("mfa_token")
if not mfa_token:
raise MfaTokenInvalidError()
context = self.decrypt_mfa_token(mfa_token)

base_url = await self._resolve_base_url(store_options)

body: dict[str, Any] = {
"client_id": self._client_id,
"client_secret": self._client_secret,
"mfa_token": context.mfa_token
}
self._apply_mfa_client_authentication(body, base_url)

if "otp" in options:
body["grant_type"] = "http://auth0.com/oauth/grant-type/mfa-otp"
Expand All @@ -511,7 +524,6 @@ async def verify(
)

try:
base_url = await self._resolve_base_url(store_options)
token_endpoint = f"{base_url}/oauth/token"

async with self._get_http_client() as client:
Expand Down
Loading
Loading