Skip to content

Commit a4e8757

Browse files
committed
Updated documentation, test cases and parameter type for dpop nonce
1 parent 922a3d2 commit a4e8757

6 files changed

Lines changed: 71 additions & 31 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,12 @@ challenge = await my_account.enroll_authentication_method(
229229

230230
For the full enroll/verify ceremony, listing, updating, deleting, and error handling, see [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md).
231231

232-
### 8. DPoP — Sender-Constrained Tokens
232+
### 8. DPoP — Sender-Constrained Tokens (Passkeys & MyAccount)
233233

234-
Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) so a stolen token alone cannot be replayed. Generate an EC P-256 key and pass it to passkey sign-in or any My Account API call:
234+
Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) so a stolen token alone cannot be replayed. Generate an EC P-256 key and pass it to passkey sign-in or to the authentication-methods/factors calls on `MyAccountClient`:
235+
236+
> [!NOTE]
237+
> `dpop_key` is currently supported only for `signin_with_passkey` and the authentication-methods/factors methods on `MyAccountClient` (enroll, verify, list, get, update, delete, get factors). It is **not yet supported for Connected Accounts** — see [examples/ConnectedAccounts.md](examples/ConnectedAccounts.md).
235238
236239
```python
237240
from jwcrypto import jwk
@@ -246,7 +249,7 @@ result = await auth0.signin_with_passkey(
246249
)
247250
```
248251

249-
`dpop_key` is a Tier 0 secret you generate, keep in your secret store, and reuse for the bound token's lifetime — pass the **same** key to passkey sign-in and every My Account API call. For usage, see [examples/Passkeys.md](examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) and [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md#dpop).
252+
`dpop_key` is a Tier 0 secret you generate, keep in your secret store, and reuse for the bound token's lifetime — pass the **same** key to passkey sign-in and to the `MyAccountClient` authentication-methods/factors calls listed above. For usage, see [examples/Passkeys.md](examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) and [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md#dpop).
250253

251254
### 9. Session Expiry from the Upstream IdP
252255

examples/MyAccountAuthenticationMethods.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ The [My Account API](https://auth0.com/docs/manage-users/my-account-api) lets a
2727
2. [Configure MRRT](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) so your refresh-token policy can mint tokens for the My Account audience (`https://{yourDomain}/me/`) with the authentication-methods scopes.
2828
3. Passkey enrollment additionally requires a [Custom Domain](https://auth0.com/docs/customize/custom-domains) and the native passkey feature on your tenant.
2929

30-
The scopes for this surface (note the **hyphens**):
30+
See [Auth0 Docs → Scope](https://auth0.com/docs/manage-users/my-account-api#scope) for the full scope reference. The scopes for this surface (note the **hyphens**):
3131

3232
| Operation | Scope |
3333
|-----------|-------|
@@ -42,7 +42,7 @@ The scopes for this surface (note the **hyphens**):
4242
4343
## Obtaining a scoped token
4444

45-
`MyAccountClient` is **stateless** — it takes a correctly-scoped user access token on every call. Obtain that token from your `ServerClient` session via MRRT, then construct the client:
45+
`MyAccountClient` is **stateless** — it takes a correctly-scoped user access token on every call. Obtain that token from your `ServerClient` session via MRRT, then construct the client. See [Auth0 Docs → Get an access token](https://auth0.com/docs/manage-users/my-account-api#get-an-access-token) for the underlying token requirements.
4646

4747
```python
4848
from auth0_server_python.auth_server.my_account_client import MyAccountClient
@@ -67,7 +67,7 @@ for factor in factors.factors:
6767

6868
## 2. Enroll an authentication method (passkey)
6969

70-
Enrollment is a **two-step** ceremony, mirroring sign-in: request a challenge, sign it in the browser, then verify.
70+
Enrollment is a **two-step** ceremony, mirroring sign-in: request a challenge, sign it in the browser, then verify. See [Auth0 Docs → Enrollment flow](https://auth0.com/docs/manage-users/my-account-api#enrollment-flow) (and, for passkeys specifically, [Embedded login with native passkeys](https://auth0.com/docs/manage-users/my-account-api#embedded-login-with-native-passkeys)) for the platform-level description of this ceremony.
7171

7272
### Step 1 — Start enrollment
7373

@@ -122,6 +122,8 @@ print(f"Enrolled: {method.id} ({method.type})")
122122
123123
## 3. List authentication methods
124124

125+
See [Auth0 Docs → List authentication methods](https://auth0.com/docs/manage-users/my-account-api#list-authentication-methods).
126+
125127
```python
126128
all_methods = await my_account.list_authentication_methods(access_token=access_token)
127129

@@ -163,6 +165,8 @@ method = await my_account.update_authentication_method(
163165

164166
## 6. Delete an authentication method
165167

168+
See [Auth0 Docs → Delete an authentication method](https://auth0.com/docs/manage-users/my-account-api#delete-an-authentication-method).
169+
166170
```python
167171
await my_account.delete_authentication_method(
168172
access_token=access_token,

examples/Passkeys.md

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,6 @@ A passkey ceremony is always **two steps**, because the WebAuthn signature happe
2525
2. **Browser** — your front end passes those options to `navigator.credentials.create()` (signup) or `navigator.credentials.get()` (login). The authenticator produces a signed credential.
2626
3. **Verify / sign-in** — the SDK exchanges the signed credential for tokens (`signin_with_passkey`) and **creates a server-side session**, exactly like every other login path.
2727

28-
```
29-
ServerClient.passkey_*_challenge() ──► auth_session + authn_params_public_key
30-
31-
navigator.credentials.create()/get() (browser signs)
32-
33-
ServerClient.signin_with_passkey() ◄── signed authn_response
34-
└─► tokens validated, session persisted → PasskeyLoginResult
35-
```
36-
3728
## Prerequisites
3829

3930
```python

src/auth0_server_python/auth_schemes/dpop_auth.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import hashlib
33
import time
44
import uuid
5+
from typing import Optional
56

67
import httpx
78
from jwcrypto import jwk
@@ -20,7 +21,9 @@ def _validate_dpop_key(key: "jwk.JWK") -> dict:
2021
return public_jwk
2122

2223

23-
def make_dpop_proof_for_token_endpoint(key: "jwk.JWK", method: str, url: str, nonce: str = None) -> str:
24+
def make_dpop_proof_for_token_endpoint(
25+
key: "jwk.JWK", method: str, url: str, nonce: Optional[str] = None
26+
) -> str:
2427
"""
2528
Build a DPoP proof JWT for use at the token endpoint (RFC 9449 §4.2).
2629
Unlike resource-server proofs, token-endpoint proofs do NOT include `ath`
@@ -69,15 +72,14 @@ def auth_flow(self, request: httpx.Request):
6972
response = yield request
7073

7174
# RFC 9449 §8.2 — server-nonce retry
72-
if (
73-
response.status_code == 401
74-
and response.headers.get("DPoP-Nonce")
75-
):
75+
if response.status_code == 401 and response.headers.get("DPoP-Nonce"):
7676
nonce = response.headers["DPoP-Nonce"]
77-
request.headers["DPoP"] = self._make_proof(request.method, str(request.url), nonce=nonce)
77+
request.headers["DPoP"] = self._make_proof(
78+
request.method, str(request.url), nonce=nonce
79+
)
7880
yield request
7981

80-
def _make_proof(self, method: str, url: str, nonce: str = None) -> str:
82+
def _make_proof(self, method: str, url: str, nonce: Optional[str] = None) -> str:
8183
htu = url.split("?")[0].split("#")[0]
8284
ath = _base64url(hashlib.sha256(self._token.encode("ascii")).digest())
8385

src/auth0_server_python/auth_server/server_client.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2800,9 +2800,6 @@ async def signin_with_passkey(
28002800
This is step 2 of 2: call passkey_signup_challenge or passkey_login_challenge
28012801
first to obtain auth_session and the WebAuthn challenge options.
28022802
2803-
Uses Content-Type: application/json (required for nested authn_response).
2804-
Persists the session to the state store (same as complete_interactive_login).
2805-
28062803
Args:
28072804
auth_session: Session credential from passkey_signup_challenge or passkey_login_challenge.
28082805
authn_response: Serialized WebAuthn credential from navigator.credentials.create/get.
@@ -2823,6 +2820,9 @@ async def signin_with_passkey(
28232820
Raises:
28242821
MissingRequiredArgumentError: If auth_session or authn_response is missing.
28252822
PasskeyError: If token exchange or session creation fails.
2823+
OrganizationTokenValidationError: If an organization was requested but the
2824+
token response included no ID token, or the ID token's org claim does
2825+
not match.
28262826
"""
28272827
if not auth_session:
28282828
raise MissingRequiredArgumentError("auth_session")
@@ -2920,6 +2920,12 @@ async def signin_with_passkey(
29202920
f"got '{token_response.token_type}'",
29212921
)
29222922

2923+
if resolved_org and not token_response.id_token:
2924+
raise OrganizationTokenValidationError(
2925+
"Organization was requested but the token response included no ID token; "
2926+
"cannot verify organization membership"
2927+
)
2928+
29232929
# Extract user claims from ID token if present
29242930
user_claims = None
29252931
sid = PKCE.generate_random_string(32)

src/auth0_server_python/tests/test_server_client.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
MissingTransactionError,
5858
OrganizationTokenValidationError,
5959
PasskeyError,
60+
PasskeyErrorCode,
6061
PollingApiError,
6162
SessionExpiredError,
6263
StartLinkUserError,
@@ -5455,7 +5456,7 @@ async def test_passkey_signup_challenge_api_error(mocker):
54555456
await client.passkey_signup_challenge(
54565457
user_profile=PasskeyUserProfile(email="test@example.com")
54575458
)
5458-
assert "access_denied" in str(exc.value) or "Passkey not enabled" in str(exc.value)
5459+
assert exc.value.code == "access_denied"
54595460

54605461

54615462
@pytest.mark.asyncio
@@ -5476,7 +5477,7 @@ async def test_passkey_signup_challenge_non_json_error(mocker):
54765477

54775478
with pytest.raises(PasskeyError) as exc:
54785479
await client.passkey_signup_challenge()
5479-
assert "502" in str(exc.value) or "passkey_challenge_error" in str(exc.value)
5480+
assert exc.value.code == PasskeyErrorCode.CHALLENGE_FAILED
54805481

54815482

54825483
@pytest.mark.asyncio
@@ -5494,7 +5495,7 @@ async def test_passkey_signup_challenge_network_error(mocker):
54945495

54955496
with pytest.raises(PasskeyError) as exc:
54965497
await client.passkey_signup_challenge()
5497-
assert "Passkey signup challenge failed" in str(exc.value)
5498+
assert exc.value.code == PasskeyErrorCode.CHALLENGE_FAILED
54985499

54995500

55005501
@pytest.mark.asyncio
@@ -6150,7 +6151,7 @@ async def test_signin_with_passkey_dpop_rejects_bearer_downgrade(mocker):
61506151
authn_response=_make_passkey_authn_response(),
61516152
dpop_key=dpop_key,
61526153
)
6153-
assert "DPoP" in str(exc.value) or "token_type" in str(exc.value).lower()
6154+
assert exc.value.code == PasskeyErrorCode.TOKEN_EXCHANGE_FAILED
61546155

61556156

61566157
@pytest.mark.asyncio
@@ -6179,12 +6180,11 @@ async def test_signin_with_passkey_missing_issuer_in_metadata(mocker):
61796180
mock_response.json = MagicMock(return_value=_PASSKEY_TOKEN_RESPONSE)
61806181
mock_post.return_value = mock_response
61816182

6182-
with pytest.raises(Exception) as exc:
6183+
with pytest.raises(IssuerValidationError):
61836184
await client.signin_with_passkey(
61846185
auth_session="session_xyz",
61856186
authn_response=_make_passkey_authn_response(),
61866187
)
6187-
assert "issuer" in str(exc.value).lower()
61886188

61896189

61906190
@pytest.mark.asyncio
@@ -6691,6 +6691,40 @@ async def test_signin_with_passkey_call_arg_overrides_client_default_organizatio
66916691
assert kwargs["json"]["organization"] == "org_override"
66926692

66936693

6694+
@pytest.mark.asyncio
6695+
async def test_signin_with_passkey_org_requested_no_id_token_raises_and_no_session_stored(mocker):
6696+
"""Organization requested but token response has no id_token: fail closed, no session stored."""
6697+
state_store = AsyncMock()
6698+
client = ServerClient(
6699+
domain="auth0.local",
6700+
client_id="test_client_id",
6701+
client_secret="test_client_secret",
6702+
state_store=state_store,
6703+
transaction_store=AsyncMock(),
6704+
secret="test-secret-value",
6705+
)
6706+
mocker.patch.object(
6707+
client,
6708+
"_get_oidc_metadata_cached",
6709+
return_value={"token_endpoint": "https://auth0.local/oauth/token", "issuer": "https://auth0.local/"},
6710+
)
6711+
mocker.patch.object(client, "_get_jwks_cached", return_value={})
6712+
mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock)
6713+
mock_response = AsyncMock()
6714+
mock_response.status_code = 200
6715+
mock_response.json = MagicMock(return_value={**_PASSKEY_TOKEN_RESPONSE, "id_token": None})
6716+
mock_post.return_value = mock_response
6717+
6718+
with pytest.raises(OrganizationTokenValidationError):
6719+
await client.signin_with_passkey(
6720+
auth_session="session_xyz",
6721+
authn_response=_make_passkey_authn_response(),
6722+
organization="org_expected",
6723+
)
6724+
6725+
state_store.set.assert_not_awaited()
6726+
6727+
66946728
@pytest.mark.asyncio
66956729
async def test_signin_with_passkey_org_id_mismatch_raises_and_no_session_stored(mocker):
66966730
"""org_id in ID token not matching raises OrganizationTokenValidationError; session not stored."""

0 commit comments

Comments
 (0)