diff --git a/README.md b/README.md index 93df38f..c6ba7e9 100644 --- a/README.md +++ b/README.md @@ -196,9 +196,13 @@ Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys Let a logged-in user manage their own enrolled authentication methods — enroll a new passkey (or other factor), list, rename, and delete — via the [My Account API](https://auth0.com/docs/manage-users/my-account-api). For obtaining a scoped token, the enroll/verify ceremony, listing, updating, deleting, and error handling, see [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md). -### 9. DPoP — Sender-Constrained Tokens (Passkeys & MyAccount) +### 9. DPoP — Sender-Constrained Tokens (Passkeys, MyAccount & Passwordless) -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. DPoP is supported for Passkey sign-in (`signin_with_passkey`) and the authentication-methods/factors methods on `MyAccountClient`. For key generation and usage, see [examples/Passkeys.md](examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) and [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md#dpop). +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. DPoP is supported for Passkey sign-in (`signin_with_passkey`), the authentication-methods/factors methods on `MyAccountClient`, and passwordless OTP verification (`passwordless.verify`). For key generation and usage, see [examples/Passkeys.md](examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional), [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md#dpop), and [examples/Passwordless.md](examples/Passwordless.md#7-dpop--sender-constrained-tokens-optional). + +### 10. Passwordless Authentication + +Sign users in with a one-time code sent by email or SMS, or with a magic link sent by email, via [Auth0 embedded passwordless login](https://auth0.com/docs/authenticate/passwordless/implement-login/embedded-login/relevant-api-endpoints). OTP verification and the magic-link callback each establish a server-side session like every other login path. For prerequisites, both flows, custom scopes/audiences, organizations, step-up MFA, DPoP, and error handling, see [examples/Passwordless.md](examples/Passwordless.md). ## Feedback diff --git a/examples/Passwordless.md b/examples/Passwordless.md index 099c057..85c67ec 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -17,7 +17,8 @@ Passwordless lets users sign in with a one-time code sent by email or SMS, or wi - [3. Email magic link](#3-email-magic-link) - [4. Custom scopes and audiences](#4-custom-scopes-and-audiences) - [5. Forwarding the end-user IP](#5-forwarding-the-end-user-ip) -- [6. Organizations](#6-organizations) +- [6. Organizations (magic link only)](#6-organizations-magic-link-only) +- [7. DPoP — sender-constrained tokens (optional)](#7-dpop--sender-constrained-tokens-optional) - [Completing MFA during passwordless login](#completing-mfa-during-passwordless-login) - [Error Handling](#error-handling) @@ -32,6 +33,38 @@ OTP start does **not** create a session. The session exists only after `verify() ## Prerequisites +These flows require a **Regular Web Application** — passwordless token exchange +needs a client secret, which a public/SPA client cannot hold safely. + +Two tenant-level settings are also required and are easy to miss because +neither failure mode looks like a configuration problem: + +1. **Authentication Profile must be "Identifier First."** The default + "Universal Login" profile blocks the direct `/oauth/token` call this SDK + uses for OTP verification — without it, OTP `verify()` fails with + `unauthorized_client`. Set it under your tenant's Authentication Profile + settings. (Skip this if you only use passwordless via Universal Login + redirects rather than this SDK's embedded flow.) +2. **Enable the Passwordless OTP grant type** on your application + (**Applications -> Your App -> Advanced Settings -> Grant Types**). Without + it, OTP verification also fails with `unauthorized_client`. +3. **Magic link only** — set the tenant flag + `universal_login.passwordless.allow_magiclink_verify_without_session` to + `true` via the Management API: + + ``` + PATCH /api/v2/tenants/settings + { "universal_login": { "passwordless": { "allow_magiclink_verify_without_session": true } } } + ``` + + This is required for **any** server-side SDK completing magic link (this + one, Express, Next.js, etc.) — the browser that opens the emailed link is + not guaranteed to be the same browser/session that started the flow. + Without it, the user sees: *"The link must be opened on the same device + and browser from which you submitted your email address."* This flag is + not documented in the public Auth0 API reference, so if you don't set it + here you will not discover it from a 400 error message. + ```python from auth0_server_python.auth_server.server_client import ServerClient @@ -152,6 +185,8 @@ user = result["state_data"]["user"] > [!WARNING] > Do not let callers override `redirect_uri`, `state`, `response_type`, `nonce`, or PKCE fields in magic-link `auth_params`. The SDK owns these values so the emailed authorization code and state cannot be redirected to an attacker-controlled URL. +> +> This matters more than it looks: Auth0 treats magic-link `state` as a pure echo and does not validate it server-side, and the clicked link's query string can overwrite whatever the browser originally stored. The SDK's single-use, `state`-keyed transaction plus the exact-match `redirect_uri` are therefore the *entire* CSRF / authorization-code-interception defense on this flow — Auth0 will not catch a bypass for you. ## 4. Custom scopes and audiences @@ -218,9 +253,9 @@ result = await server_client.passwordless.verify( > [!WARNING] > Only forward a trusted, normalized end-user IP from your edge/proxy layer. Do not blindly copy arbitrary client-supplied headers into `client_ip`. -## 6. Organizations +## 6. Organizations (magic link only) -Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the callback token claims. +Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the claims returned by the callback. ```python await server_client.passwordless.start( @@ -233,21 +268,41 @@ await server_client.passwordless.start( ) ``` -For OTP verification, pass `organization` only when your Auth0 passwordless OTP configuration returns organization claims for that flow. The SDK validates the ID token's `org_id` or `org_name` claim against the supplied value. +If the callback's ID token does not include a matching organization claim, verification fails before a session is persisted, raising `OrganizationTokenValidationError`. + +> [!NOTE] +> `VerifyPasswordlessOtpOptions` (the OTP `verify()` path) has no `organization` +> field. Auth0 does not attach an organization claim to tokens issued by the +> passwordless-OTP grant, so there is nothing for the SDK to validate against +> — an OTP flow that needs organization-scoped login should use magic link +> instead. + +## 7. DPoP — sender-constrained tokens (optional) + +`verify()` accepts an optional `dpop_key` — an EC P-256 JWK — to bind the +issued access token to that key ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)), +so a stolen token alone cannot be replayed. The passwordless-OTP grant only +*requires* DPoP for public clients; this SDK is a confidential RWA client, so +DPoP here is opt-in, not required by default. Use it if a downstream resource +server independently mandates sender-constrained tokens. ```python +from jwcrypto import jwk + +dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") + result = await server_client.passwordless.verify( VerifyPasswordlessOtpOptions( connection="email", email="user@example.com", verification_code=user_entered_code, - organization="org_abc123", ), store_options={"request": request, "response": response}, + dpop_key=dpop_key, ) ``` -If the ID token does not include a matching organization claim, verification fails before a session is persisted. +Keep the same `dpop_key` around for any later step-up (`server_client.mfa.verify(..., dpop_key=dpop_key)`) to preserve the sender constraint through MFA. If a resource server requires DPoP for *all* clients (not just public ones), Auth0 rejects the request with `invalid_request` / *"Resource server requires the use of DPoP to issue sender-constrained access tokens"* — that case is not specific to this SDK and surfaces as a `PasswordlessVerifyError` with that description. ## Completing MFA during passwordless login @@ -293,11 +348,11 @@ except MfaRequiredError as e: Passwordless methods raise typed SDK errors: - `PasswordlessStartError` - `POST /passwordless/start` failed -- `PasswordlessVerifyError` - OTP token exchange or ID-token verification failed +- `PasswordlessVerifyError` - OTP token exchange or ID-token verification failed, including an issuer or audience mismatch (`invalid_issuer` / `invalid_audience`) - `MfaRequiredError` - Auth0 requires MFA before completing login - `MissingRequiredArgumentError` - required SDK input is missing, such as magic-link `store_options` - `InvalidArgumentError` - caller input is rejected before a network call -- `OrganizationTokenValidationError` - requested organization does not match returned token claims +- `OrganizationTokenValidationError` - magic-link callback only: requested organization does not match the returned token claims ### Basic handling @@ -351,8 +406,9 @@ except Auth0Error as e: - `bad.connection` - the passwordless connection is disabled or invalid - `bad.email` - the email address is invalid or rejected by Auth0 - `sms_provider_error` - Auth0 could not send the SMS -- `too_many_requests` - rate limiting or attack protection blocked the request +- `too_many_requests` - rate limiting or attack protection blocked the request (`start()` or `verify()`) - `invalid_grant` - the OTP is invalid, expired, or already used +- `invalid_issuer` - returned ID token issuer does not match your configured Auth0 domain - `invalid_audience` - returned ID token audience does not match the SDK client - `discovery_error` - the SDK could not load authorization server metadata - `passwordless_start_failed` - SDK-side start failure diff --git a/src/auth0_server_python/auth_server/__init__.py b/src/auth0_server_python/auth_server/__init__.py index 611f6b7..a06ef2e 100644 --- a/src/auth0_server_python/auth_server/__init__.py +++ b/src/auth0_server_python/auth_server/__init__.py @@ -1,5 +1,6 @@ from .mfa_client import MfaClient from .my_account_client import MyAccountClient +from .passwordless_client import PasswordlessClient from .server_client import ServerClient -__all__ = ["ServerClient", "MyAccountClient", "MfaClient"] +__all__ = ["ServerClient", "MyAccountClient", "MfaClient", "PasswordlessClient"] diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index 119c1da..e6df862 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -18,6 +18,7 @@ import jwt +from auth0_server_python.auth_schemes.dpop_auth import make_dpop_proof_for_token_endpoint from auth0_server_python.auth_types import ( PASSWORDLESS_ALLOWED_AUTH_PARAMS, PASSWORDLESS_RESERVED_AUTH_PARAMS, @@ -38,9 +39,10 @@ PasswordlessVerifyError, ) from auth0_server_python.utils import PKCE -from auth0_server_python.utils.helpers import validate_org_claims if TYPE_CHECKING: # avoid a circular import at runtime + from jwcrypto import jwk + from auth0_server_python.auth_server.server_client import ServerClient PASSWORDLESS_OTP_GRANT_TYPE = "http://auth0.com/oauth/grant-type/passwordless/otp" @@ -147,10 +149,15 @@ async def start( if response.status_code not in (200, 201): error_body = self._safe_json(response) + default_code = ( + PasswordlessErrorCode.TOO_MANY_REQUESTS + if response.status_code == 429 + else PasswordlessErrorCode.START_FAILED + ) raise PasswordlessStartError( - error_body.get("error", PasswordlessErrorCode.START_FAILED), + error_body.get("error", default_code), error_body.get("error_description", "Failed to start passwordless flow"), - error_body, + error_body if error_body else self._raw_text(response), ) return PasswordlessStartResult(**self._safe_json(response)) @@ -161,6 +168,7 @@ async def verify( self, options: VerifyPasswordlessOtpOptions, store_options: Optional[dict[str, Any]] = None, + dpop_key: Optional["jwk.JWK"] = None, ) -> dict[str, Any]: """ Verify a passwordless OTP and establish a server-side session. @@ -172,6 +180,12 @@ async def verify( options: VerifyPasswordlessOtpOptions. store_options: Options passed to the state store (e.g. request/response) so the session can be written. + dpop_key: Optional EC P-256 JWK for DPoP-bound token exchange. When + provided, attaches a DPoP proof header so Auth0 issues a + DPoP-bound token (token_type: DPoP). The passwordless-OTP + grant only mandates DPoP for public clients, so this is + optional for this (confidential, RWA) SDK unless a resource + server independently requires sender-constrained tokens. Returns: Dict containing ``state_data`` for the established session. @@ -179,6 +193,7 @@ async def verify( Raises: PasswordlessVerifyError: When token exchange or ID-token verification fails. + MfaRequiredError: When Auth0 requires MFA before completing login. """ client = self._client origin_domain = await client._resolve_current_domain(store_options) @@ -200,6 +215,7 @@ async def verify( if options.connection == "email" else DEFAULT_PASSWORDLESS_SMS_SCOPE ) + scope = options.scope or default_scope body: dict[str, Any] = { "grant_type": PASSWORDLESS_OTP_GRANT_TYPE, "client_id": client._client_id, @@ -207,7 +223,7 @@ async def verify( "realm": options.connection, "username": options.username, "otp": options.verification_code, - "scope": options.scope or default_scope, + "scope": scope, } if options.audience: body["audience"] = options.audience @@ -215,6 +231,8 @@ async def verify( headers = {"Content-Type": "application/x-www-form-urlencoded"} if options.client_ip: headers[FORWARDED_FOR_HEADER] = options.client_ip + if dpop_key is not None: + headers["DPoP"] = make_dpop_proof_for_token_endpoint(dpop_key, "POST", token_endpoint) try: async with client._get_http_client() as http: @@ -223,6 +241,23 @@ async def verify( data=body, headers=headers, ) + + # RFC 9449 §8.2: a DPoP-Nonce challenge triggers exactly one + # retry with the server-supplied nonce. + if ( + dpop_key is not None + and response.status_code in (400, 401) + and response.headers.get("DPoP-Nonce") + ): + nonce = response.headers["DPoP-Nonce"] + headers["DPoP"] = make_dpop_proof_for_token_endpoint( + dpop_key, "POST", token_endpoint, nonce=nonce + ) + response = await http.post( + token_endpoint, + data=body, + headers=headers, + ) except Exception as e: raise PasswordlessVerifyError( PasswordlessErrorCode.VERIFY_FAILED, @@ -232,16 +267,46 @@ async def verify( if response.status_code != 200: error_body = self._safe_json(response) + if error_body.get("error") == "mfa_required" and error_body.get("mfa_token"): + await client._mfa_client._raise_mfa_required( + error_body, + audience=options.audience or client.DEFAULT_AUDIENCE_STATE_KEY, + scope=scope, + default_description="Multifactor authentication required", + store_options=store_options, + ) + default_code = ( + PasswordlessErrorCode.TOO_MANY_REQUESTS + if response.status_code == 429 + else PasswordlessErrorCode.INVALID_GRANT + ) raise PasswordlessVerifyError( - error_body.get("error", PasswordlessErrorCode.INVALID_GRANT), + error_body.get("error", default_code), error_body.get("error_description", "Passwordless verification failed"), - error_body, + error_body if error_body else self._raw_text(response), ) token_response = response.json() + token_is_dpop = token_response.get("token_type", "").lower() == "dpop" + if dpop_key is not None and not token_is_dpop: + raise PasswordlessVerifyError( + PasswordlessErrorCode.VERIFY_FAILED, + "DPoP token binding failed: expected token_type 'DPoP', " + f"got '{token_response.get('token_type')}'", + ) + if dpop_key is None and token_is_dpop: + # Server issued a DPoP-bound token but no proof key was supplied — + # we cannot prove possession on later calls, so storing it as + # Bearer would silently fail open. Reject. + raise PasswordlessVerifyError( + PasswordlessErrorCode.VERIFY_FAILED, + "Server returned a DPoP-bound token but no dpop_key was " + "provided; pass dpop_key to verify() to bind it", + ) + user_claims, id_token_claims = await self._verify_id_token( - token_response, origin_domain, origin_issuer, metadata, options.organization + token_response, origin_domain, origin_issuer, metadata ) state_data = await client._persist_session_from_token_response( @@ -284,6 +349,13 @@ async def _build_magic_link_auth_params( if store_options is None: raise MissingRequiredArgumentError("store_options") + # Auth0 echoes `state` back unvalidated on this flow — it does not + # compare it server-side, and the clicked link's query string can + # overwrite whatever was originally stored. This SDK's single-use, + # state-keyed transaction (below) plus the exact-match, SDK-owned + # `redirect_uri` is therefore the *only* CSRF/authorization-code- + # interception control on magic link; the server provides none. + # Never make `state`/`redirect_uri` caller-overridable. state = PKCE.generate_random_string(32) auth_params["redirect_uri"] = redirect_uri auth_params["response_type"] = "code" @@ -347,7 +419,6 @@ async def _verify_id_token( origin_domain: str, origin_issuer: Optional[str], metadata: dict[str, Any], - expected_org: Optional[str], ) -> tuple[UserClaims, dict[str, Any]]: """Verify the ID token from the OTP exchange and return its claims.""" client = self._client @@ -380,13 +451,14 @@ async def _verify_id_token( token_issuer = claims.get("iss", "") if client._normalize_url(token_issuer) != client._normalize_url(origin_issuer): - raise IssuerValidationError( - "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." + raise PasswordlessVerifyError( + PasswordlessErrorCode.INVALID_ISSUER, + "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly.", + IssuerValidationError( + "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." + ), ) - if expected_org: - validate_org_claims(claims, expected_org) - return UserClaims.model_validate(claims), claims @staticmethod @@ -397,3 +469,11 @@ def _safe_json(response) -> dict[str, Any]: return data if isinstance(data, dict) else {} except Exception: return {} + + @staticmethod + def _raw_text(response) -> Optional[str]: + """Return the raw response body text, for diagnosing a non-JSON error body.""" + try: + return response.text + except Exception: + return None diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 32941c2..5f48cd5 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -17,7 +17,9 @@ # challenge type (e.g. a future webauthn second factor) does not fail closed. OobChannel = Literal["sms", "voice", "auth0", "email"] ChallengeType = Literal["otp", "oob"] -EnrollmentType = Literal["passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password"] +EnrollmentType = Literal[ + "passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password" +] PreferredAuthMethod = Literal["sms", "voice"] # Deprecated public aliases resolved lazily (PEP 562) so access emits a warning @@ -411,6 +413,7 @@ class SessionTransferTokenResult(BaseModel): token_type: Token type as returned by the server (typically "N_A") scope: Granted scopes (if returned) """ + session_transfer_token: str issued_token_type: str expires_in: int @@ -726,6 +729,7 @@ class MfaTokenContext(BaseModel): "prompt", "max_age", "acr_values", + "scope", } ) @@ -806,7 +810,9 @@ class VerifyPasswordlessOtpOptions(BaseModel): phone_number: Optional[str] = None scope: Optional[str] = None audience: Optional[str] = None - organization: Optional[str] = None + # No `organization` field: Auth0 ignores it for the OTP grant (verified + # against auth0-server), so accepting it would silently never succeed. + # Use magic link's `organization` instead. # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` on the OTP # token exchange so brute-force protection keys on the real user, not the # app server. Honored only for confidential clients with "Trust Token diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index f482a49..5da666d 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -39,8 +39,13 @@ def __init__(self, code: str, message: str, cause=None): self.code = code self.cause = cause - # Extract additional error details if available - if cause: + # Extract additional error details if available. A dict cause (the + # raw Auth0 error body) has no attributes for getattr to read, so it + # is handled separately rather than yielding None for every subclass. + if isinstance(cause, dict): + self.error = cause.get("error") + self.error_description = cause.get("error_description") + elif cause: self.error = getattr(cause, "error", None) self.error_description = getattr(cause, "error_description", None) else: @@ -371,11 +376,6 @@ class PasswordlessError(ApiError): def __init__(self, code: str, message: str, cause=None): super().__init__(code, message, cause) self.name = "PasswordlessError" - # When cause is the raw error dict from Auth0, surface its fields even - # though ApiError only reads attributes off exception-like causes. - if isinstance(cause, dict): - self.error = cause.get("error") - self.error_description = cause.get("error_description") class PasswordlessStartError(PasswordlessError): @@ -416,10 +416,12 @@ class PasswordlessErrorCode: # Passkey Error Classes # ============================================================================= + class PasskeyError(Auth0Error): """ Error raised during passkey authentication operations. """ + def __init__(self, code: str, message: str, cause=None): super().__init__(message) self.code = code @@ -429,6 +431,7 @@ def __init__(self, code: str, message: str, cause=None): class PasskeyErrorCode: """Error codes for passkey operations.""" + CHALLENGE_FAILED = "passkey_challenge_error" TOKEN_EXCHANGE_FAILED = "passkey_token_error" INVALID_RESPONSE = "invalid_response" diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index 1e8a49b..beaa910 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from jwcrypto import jwk from pydantic import ValidationError from auth0_server_python.auth_server.passwordless_client import ( @@ -21,6 +22,7 @@ from auth0_server_python.error import ( InvalidArgumentError, IssuerValidationError, + MfaRequiredError, MissingRequiredArgumentError, PasswordlessStartError, PasswordlessVerifyError, @@ -279,6 +281,57 @@ async def test_client_ip_forwarded_on_start(self): headers = http.post.call_args.kwargs["headers"] assert headers["auth0-forwarded-for"] == "203.0.113.7" + @pytest.mark.asyncio + async def test_caller_scope_forwarded_on_magic_link(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"scope": "openid profile email offline_access read:orders"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "openid profile email offline_access read:orders" + + @pytest.mark.asyncio + async def test_magic_link_default_scope_applies_when_caller_omits_it(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"login_hint": "user@example.com"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "openid profile email" + + @pytest.mark.asyncio + async def test_magic_link_organization_reaches_auth_params_and_transaction(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + organization="org_abc123", + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["organization"] == "org_abc123" + + tx_data = client._transaction_store.set.await_args.args[1] + assert tx_data.organization == "org_abc123" + # ── Magic link callback completion (complete_interactive_login) ────────────── @@ -455,12 +508,14 @@ async def test_verify_issuer_mismatch_rejected(self, mocker): self._patch_verify_deps(client, mocker, claims) _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}) - with pytest.raises(IssuerValidationError): + with pytest.raises(PasswordlessVerifyError) as exc: await client.passwordless.verify( VerifyPasswordlessOtpOptions( connection="email", email="user@example.com", verification_code="123456" ) ) + assert exc.value.code == "invalid_issuer" + assert isinstance(exc.value.cause, IssuerValidationError) client._state_store.set.assert_not_awaited() @pytest.mark.asyncio @@ -510,3 +565,195 @@ async def test_verify_ceiling_in_past_rejected(self, mocker): ) ) client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_rate_limited_maps_to_too_many_requests(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _mock_http(client, 429, {}) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "too_many_requests" + client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_mfa_required_raises_typed_error(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _mock_http( + client, + 403, + { + "error": "mfa_required", + "error_description": "Additional factor required", + "mfa_token": "raw_server_mfa_token", + }, + ) + + with pytest.raises(MfaRequiredError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + # Token is re-encrypted by the SDK, never passed through raw. + assert exc.value.mfa_token is not None + assert exc.value.mfa_token != "raw_server_mfa_token" + decrypted = client._mfa_client.decrypt_mfa_token(exc.value.mfa_token) + assert decrypted.mfa_token == "raw_server_mfa_token" + client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_mfa_required_without_token_falls_through(self, mocker): + # Third-party-strict / flex-commands-with-FF-off: 403 mfa_required with + # no mfa_token. Must fall through to the generic typed error, not hang + # or raise an unrelated exception. + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _mock_http( + client, + 403, + {"error": "mfa_required", "error_description": "MFA required"}, + ) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "mfa_required" + client._state_store.set.assert_not_awaited() + + +# ── verify(): DPoP ─────────────────────────────────────────────────────────── + + +class TestVerifyDpop: + def _patch_verify_deps(self, client, mocker, claims): + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + mocker.patch.object( + client, + "_get_jwks_cached", + return_value={"keys": [{"kty": "RSA", "kid": "k1"}]}, + ) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value=claims) + + @pytest.mark.asyncio + async def test_verify_with_dpop_key_sends_proof_and_accepts_dpop_token(self, mocker): + client = _make_client() + claims = {"iss": ISSUER, "sub": "auth0|1", "sid": "SID-1", "iat": 1_000} + self._patch_verify_deps(client, mocker, claims) + dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") + http = _mock_http( + client, + 200, + { + "access_token": "bound_at", + "token_type": "DPoP", + "id_token": "idt", + "expires_in": 3600, + }, + ) + http.post.return_value.headers = {} + + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ), + dpop_key=dpop_key, + ) + headers = http.post.call_args.kwargs["headers"] + assert "DPoP" in headers + + @pytest.mark.asyncio + async def test_verify_dpop_nonce_retry(self, mocker): + """RFC 9449 §8.2: a DPoP-Nonce challenge triggers exactly one retry with the nonce.""" + client = _make_client() + claims = {"iss": ISSUER, "sub": "auth0|1", "sid": "SID-1", "iat": 1_000} + self._patch_verify_deps(client, mocker, claims) + dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") + + challenge = MagicMock(status_code=400) + challenge.headers = {"DPoP-Nonce": "server-nonce-123"} + challenge.json = MagicMock(return_value={"error": "use_dpop_nonce"}) + + success = MagicMock(status_code=200) + success.headers = {} + success.json = MagicMock( + return_value={ + "access_token": "bound_at", + "token_type": "DPoP", + "id_token": "idt", + "expires_in": 3600, + } + ) + + proofs = [] + + async def mock_post(url, **kwargs): + proofs.append(kwargs["headers"].get("DPoP")) + return challenge if len(proofs) == 1 else success + + http = AsyncMock() + http.post = mock_post + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=http) + ctx.__aexit__ = AsyncMock(return_value=False) + client._get_http_client = MagicMock(return_value=ctx) + + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ), + dpop_key=dpop_key, + ) + assert len(proofs) == 2 + assert proofs[0] != proofs[1] + + @pytest.mark.asyncio + async def test_verify_dpop_rejects_bearer_downgrade(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") + http = _mock_http( + client, + 200, + {"access_token": "at", "token_type": "Bearer", "id_token": "idt", "expires_in": 3600}, + ) + http.post.return_value.headers = {} + + with pytest.raises(PasswordlessVerifyError, match="DPoP token binding failed"): + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ), + dpop_key=dpop_key, + ) + client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_rejects_unsolicited_dpop_upgrade(self, mocker): + # Server returns a DPoP-bound token even though no dpop_key was + # supplied: storing it as Bearer would silently fail open, so reject. + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + http = _mock_http( + client, + 200, + {"access_token": "at", "token_type": "DPoP", "id_token": "idt", "expires_in": 3600}, + ) + http.post.return_value.headers = {} + + with pytest.raises(PasswordlessVerifyError, match="no dpop_key was"): + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + client._state_store.set.assert_not_awaited()