Skip to content

Commit 31e547a

Browse files
committed
Added support for dpop and mfa in passwordless
1 parent f1e2f49 commit 31e547a

7 files changed

Lines changed: 432 additions & 35 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,13 @@ Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys
196196

197197
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).
198198

199-
### 9. DPoP — Sender-Constrained Tokens (Passkeys & MyAccount)
199+
### 9. DPoP — Sender-Constrained Tokens (Passkeys, MyAccount & Passwordless)
200200

201-
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).
201+
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).
202+
203+
### 10. Passwordless Authentication
204+
205+
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).
202206

203207
## Feedback
204208

examples/Passwordless.md

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ Passwordless lets users sign in with a one-time code sent by email or SMS, or wi
1717
- [3. Email magic link](#3-email-magic-link)
1818
- [4. Custom scopes and audiences](#4-custom-scopes-and-audiences)
1919
- [5. Forwarding the end-user IP](#5-forwarding-the-end-user-ip)
20-
- [6. Organizations](#6-organizations)
20+
- [6. Organizations (magic link only)](#6-organizations-magic-link-only)
21+
- [7. DPoP — sender-constrained tokens (optional)](#7-dpop--sender-constrained-tokens-optional)
2122
- [Completing MFA during passwordless login](#completing-mfa-during-passwordless-login)
2223
- [Error Handling](#error-handling)
2324

@@ -32,6 +33,38 @@ OTP start does **not** create a session. The session exists only after `verify()
3233

3334
## Prerequisites
3435

36+
These flows require a **Regular Web Application** — passwordless token exchange
37+
needs a client secret, which a public/SPA client cannot hold safely.
38+
39+
Two tenant-level settings are also required and are easy to miss because
40+
neither failure mode looks like a configuration problem:
41+
42+
1. **Authentication Profile must be "Identifier First."** The default
43+
"Universal Login" profile blocks the direct `/oauth/token` call this SDK
44+
uses for OTP verification — without it, OTP `verify()` fails with
45+
`unauthorized_client`. Set it under your tenant's Authentication Profile
46+
settings. (Skip this if you only use passwordless via Universal Login
47+
redirects rather than this SDK's embedded flow.)
48+
2. **Enable the Passwordless OTP grant type** on your application
49+
(**Applications -> Your App -> Advanced Settings -> Grant Types**). Without
50+
it, OTP verification also fails with `unauthorized_client`.
51+
3. **Magic link only** — set the tenant flag
52+
`universal_login.passwordless.allow_magiclink_verify_without_session` to
53+
`true` via the Management API:
54+
55+
```
56+
PATCH /api/v2/tenants/settings
57+
{ "universal_login": { "passwordless": { "allow_magiclink_verify_without_session": true } } }
58+
```
59+
60+
This is required for **any** server-side SDK completing magic link (this
61+
one, Express, Next.js, etc.) — the browser that opens the emailed link is
62+
not guaranteed to be the same browser/session that started the flow.
63+
Without it, the user sees: *"The link must be opened on the same device
64+
and browser from which you submitted your email address."* This flag is
65+
not documented in the public Auth0 API reference, so if you don't set it
66+
here you will not discover it from a 400 error message.
67+
3568
```python
3669
from auth0_server_python.auth_server.server_client import ServerClient
3770

@@ -152,6 +185,8 @@ user = result["state_data"]["user"]
152185

153186
> [!WARNING]
154187
> 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.
188+
>
189+
> 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.
155190
156191
## 4. Custom scopes and audiences
157192

@@ -218,9 +253,9 @@ result = await server_client.passwordless.verify(
218253
> [!WARNING]
219254
> Only forward a trusted, normalized end-user IP from your edge/proxy layer. Do not blindly copy arbitrary client-supplied headers into `client_ip`.
220255
221-
## 6. Organizations
256+
## 6. Organizations (magic link only)
222257

223-
Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the callback token claims.
258+
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.
224259

225260
```python
226261
await server_client.passwordless.start(
@@ -233,21 +268,41 @@ await server_client.passwordless.start(
233268
)
234269
```
235270

236-
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.
271+
If the callback's ID token does not include a matching organization claim, verification fails before a session is persisted, raising `OrganizationTokenValidationError`.
272+
273+
> [!NOTE]
274+
> `VerifyPasswordlessOtpOptions` (the OTP `verify()` path) has no `organization`
275+
> field. Auth0 does not attach an organization claim to tokens issued by the
276+
> passwordless-OTP grant, so there is nothing for the SDK to validate against
277+
> — an OTP flow that needs organization-scoped login should use magic link
278+
> instead.
279+
280+
## 7. DPoP — sender-constrained tokens (optional)
281+
282+
`verify()` accepts an optional `dpop_key` — an EC P-256 JWK — to bind the
283+
issued access token to that key ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)),
284+
so a stolen token alone cannot be replayed. The passwordless-OTP grant only
285+
*requires* DPoP for public clients; this SDK is a confidential RWA client, so
286+
DPoP here is opt-in, not required by default. Use it if a downstream resource
287+
server independently mandates sender-constrained tokens.
237288

238289
```python
290+
from jwcrypto import jwk
291+
292+
dpop_key = jwk.JWK.generate(kty="EC", crv="P-256")
293+
239294
result = await server_client.passwordless.verify(
240295
VerifyPasswordlessOtpOptions(
241296
connection="email",
242297
email="user@example.com",
243298
verification_code=user_entered_code,
244-
organization="org_abc123",
245299
),
246300
store_options={"request": request, "response": response},
301+
dpop_key=dpop_key,
247302
)
248303
```
249304

250-
If the ID token does not include a matching organization claim, verification fails before a session is persisted.
305+
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.
251306

252307
## Completing MFA during passwordless login
253308

@@ -293,11 +348,11 @@ except MfaRequiredError as e:
293348
Passwordless methods raise typed SDK errors:
294349

295350
- `PasswordlessStartError` - `POST /passwordless/start` failed
296-
- `PasswordlessVerifyError` - OTP token exchange or ID-token verification failed
351+
- `PasswordlessVerifyError` - OTP token exchange or ID-token verification failed, including an issuer or audience mismatch (`invalid_issuer` / `invalid_audience`)
297352
- `MfaRequiredError` - Auth0 requires MFA before completing login
298353
- `MissingRequiredArgumentError` - required SDK input is missing, such as magic-link `store_options`
299354
- `InvalidArgumentError` - caller input is rejected before a network call
300-
- `OrganizationTokenValidationError` - requested organization does not match returned token claims
355+
- `OrganizationTokenValidationError` - magic-link callback only: requested organization does not match the returned token claims
301356

302357
### Basic handling
303358

@@ -351,8 +406,9 @@ except Auth0Error as e:
351406
- `bad.connection` - the passwordless connection is disabled or invalid
352407
- `bad.email` - the email address is invalid or rejected by Auth0
353408
- `sms_provider_error` - Auth0 could not send the SMS
354-
- `too_many_requests` - rate limiting or attack protection blocked the request
409+
- `too_many_requests` - rate limiting or attack protection blocked the request (`start()` or `verify()`)
355410
- `invalid_grant` - the OTP is invalid, expired, or already used
411+
- `invalid_issuer` - returned ID token issuer does not match your configured Auth0 domain
356412
- `invalid_audience` - returned ID token audience does not match the SDK client
357413
- `discovery_error` - the SDK could not load authorization server metadata
358414
- `passwordless_start_failed` - SDK-side start failure
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .mfa_client import MfaClient
22
from .my_account_client import MyAccountClient
3+
from .passwordless_client import PasswordlessClient
34
from .server_client import ServerClient
45

5-
__all__ = ["ServerClient", "MyAccountClient", "MfaClient"]
6+
__all__ = ["ServerClient", "MyAccountClient", "MfaClient", "PasswordlessClient"]

0 commit comments

Comments
 (0)