Skip to content

Commit 7661629

Browse files
committed
Docs update
1 parent 38fee59 commit 7661629

5 files changed

Lines changed: 147 additions & 76 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rf
202202

203203
### 10. Passwordless Authentication
204204

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, and error handling, see [examples/Passwordless.md](examples/Passwordless.md).
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, step-up MFA, and error handling, see [examples/Passwordless.md](examples/Passwordless.md).
206206

207207
## Feedback
208208

examples/Passwordless.md

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ 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 (magic link only)](#6-organizations-magic-link-only)
2120
- [Completing MFA during passwordless login](#completing-mfa-during-passwordless-login)
2221
- [Error Handling](#error-handling)
2322

@@ -254,32 +253,6 @@ result = await server_client.passwordless.verify(
254253
> [!WARNING]
255254
> Only forward a trusted, normalized end-user IP from your edge/proxy layer. Do not blindly copy arbitrary client-supplied headers into `client_ip`.
256255
257-
## 6. Organizations (magic link only)
258-
259-
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.
260-
261-
```python
262-
await server_client.passwordless.start(
263-
StartPasswordlessEmailOptions(
264-
email="user@example.com",
265-
send="link",
266-
organization="org_abc123",
267-
),
268-
store_options={"request": request, "response": response},
269-
)
270-
```
271-
272-
If the callback's ID token does not include a matching organization claim, verification fails before a session is persisted, raising `OrganizationTokenValidationError`.
273-
274-
> [!NOTE]
275-
> `VerifyPasswordlessOtpOptions` (the OTP `verify()` path) has no `organization`
276-
> field. Auth0 does not attach an organization claim to tokens issued by the
277-
> passwordless-OTP grant, so there is nothing for the SDK to validate against
278-
> — an OTP flow that needs organization-scoped login should use magic link
279-
> instead. The model rejects unknown fields, so passing `organization` to
280-
> `verify()` raises a pydantic `ValidationError` rather than being silently
281-
> dropped.
282-
283256
## Completing MFA during passwordless login
284257

285258
Auth0 can require MFA during passwordless OTP verification. In that case, the SDK raises `MfaRequiredError` before it creates a session. Complete the MFA challenge with `server_client.mfa`, then persist the returned tokens according to your framework's session integration.
@@ -328,7 +301,6 @@ Passwordless methods raise typed SDK errors:
328301
- `MfaRequiredError` - Auth0 requires MFA before completing login
329302
- `MissingRequiredArgumentError` - required SDK input is missing, such as magic-link `store_options`
330303
- `InvalidArgumentError` - caller input is rejected before a network call
331-
- `OrganizationTokenValidationError` - magic-link callback only: requested organization does not match the returned token claims
332304

333305
### Basic handling
334306

src/auth0_server_python/auth_server/passwordless_client.py

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,11 @@ async def start(
8787
8888
Raises:
8989
PasswordlessStartError: When ``POST /passwordless/start`` fails.
90-
InvalidArgumentError: When caller ``auth_params`` attempts to
91-
override an SDK-owned parameter.
90+
InvalidArgumentError: When ``options`` is not a recognized type, or
91+
caller ``auth_params`` contains an SDK-owned or unrecognized key.
9292
MissingRequiredArgumentError: When a magic link is requested but no
93-
``redirect_uri`` is configured on the client.
93+
``redirect_uri`` is configured on the client, or ``store_options``
94+
is not provided.
9495
"""
9596
client = self._client
9697
origin_domain = await client._resolve_current_domain(store_options)
@@ -119,10 +120,12 @@ async def start(
119120
isinstance(options, StartPasswordlessEmailOptions) and options.send == "link"
120121
)
121122

123+
magic_link_transaction = None
122124
if is_magic_link:
123-
body["authParams"] = await self._build_magic_link_auth_params(
125+
auth_params, magic_link_transaction = self._prepare_magic_link_start(
124126
options, origin_domain, store_options
125127
)
128+
body["authParams"] = auth_params
126129
elif options.auth_params:
127130
# OTP flows: forward safe passthrough params only.
128131
body["authParams"] = self._sanitize_caller_auth_params(options.auth_params)
@@ -160,6 +163,15 @@ async def start(
160163
self._retry_after(response),
161164
)
162165

166+
if magic_link_transaction is not None:
167+
tx_key, transaction_data = magic_link_transaction
168+
await client._transaction_store.set(
169+
tx_key,
170+
transaction_data,
171+
remove_if_expires=True,
172+
options=store_options,
173+
)
174+
163175
return PasswordlessStartResult(**self._safe_json(response))
164176

165177
# ----------------------------------------------------------------- verify
@@ -187,6 +199,9 @@ async def verify(
187199
PasswordlessVerifyError: When token exchange or ID-token
188200
verification fails.
189201
MfaRequiredError: When Auth0 requires MFA before completing login.
202+
ApiError: When fetching the JWKS used to verify the ID token fails.
203+
SessionExpiredError: When the token's session-expiry ceiling is
204+
already in the past.
190205
"""
191206
client = self._client
192207
origin_domain = await client._resolve_current_domain(store_options)
@@ -282,17 +297,24 @@ async def verify(
282297

283298
# ------------------------------------------------------------- internals
284299

285-
async def _build_magic_link_auth_params(
300+
def _prepare_magic_link_start(
286301
self,
287302
options: StartPasswordlessEmailOptions,
288303
origin_domain: str,
289304
store_options: Optional[dict[str, Any]],
290-
) -> dict[str, Any]:
305+
) -> tuple[dict[str, Any], tuple[str, TransactionData]]:
291306
"""
292-
Build the magic-link ``authParams`` and persist the transaction.
307+
Build the magic-link ``authParams`` and the transaction to persist.
293308
294309
The SDK owns ``redirect_uri`` / ``response_type`` / ``state``; caller
295310
``auth_params`` may only contribute non-reserved passthrough keys.
311+
Persisting the transaction is the caller's job, deferred until after
312+
``POST /passwordless/start`` succeeds — a failed start must not leave a
313+
transaction (and its cookie) behind.
314+
315+
Raises:
316+
MissingRequiredArgumentError: When no ``redirect_uri`` is configured
317+
on the client, or ``store_options`` is not provided.
296318
"""
297319
client = self._client
298320

@@ -302,52 +324,36 @@ async def _build_magic_link_auth_params(
302324

303325
auth_params = self._sanitize_caller_auth_params(options.auth_params)
304326

305-
# Required to persist the transaction cookie; checked after input
306-
# validation so bad auth_params / missing redirect_uri surface first.
307327
if store_options is None:
308328
raise MissingRequiredArgumentError("store_options")
309329

310330
# Auth0 echoes `state` back unvalidated on this flow — it does not
311331
# compare it server-side, and the clicked link's query string can
312332
# overwrite whatever was originally stored. This SDK's single-use,
313-
# state-keyed transaction (below) plus the exact-match, SDK-owned
333+
# state-keyed transaction plus the exact-match, SDK-owned
314334
# `redirect_uri` is therefore the *only* CSRF/authorization-code-
315335
# interception control on magic link; the server provides none.
316336
# Never make `state`/`redirect_uri` caller-overridable.
317337
state = PKCE.generate_random_string(32)
318338
auth_params["redirect_uri"] = redirect_uri
319339
auth_params["response_type"] = "code"
320340
auth_params["state"] = state
321-
# Magic link is email-only, so the email scope is always appropriate.
322341
auth_params.setdefault("scope", DEFAULT_PASSWORDLESS_EMAIL_SCOPE)
323342
# A caller-supplied scope replaces the default wholesale, so `openid`
324343
# is re-injected rather than trusted: without it Auth0 returns no ID
325-
# token, and the callback only demands one when an organization was
326-
# requested — leaving a session with no signature-verified claims.
344+
# token and the callback never demands one, leaving a session with no
345+
# signature-verified claims.
327346
auth_params["scope"] = self._ensure_openid_scope(auth_params["scope"])
328-
if options.organization:
329-
auth_params["organization"] = options.organization
330-
331-
# Magic link uses a plain authorization-code exchange (no PKCE), so the
332-
# transaction stores no code_verifier. Single-use is enforced by
333-
# transaction deletion on the callback; remove_if_expires signals the
334-
# store to drop the transaction once expired. Its effective lifetime is
335-
# the store's configured duration, not a fixed value set here.
347+
336348
transaction_data = TransactionData(
337349
code_verifier=None,
338350
audience=auth_params.get("audience"),
339351
redirect_uri=redirect_uri,
340352
domain=origin_domain,
341-
organization=options.organization,
342-
)
343-
await client._transaction_store.set(
344-
f"{client._transaction_identifier}:{state}",
345-
transaction_data,
346-
remove_if_expires=True,
347-
options=store_options,
348353
)
354+
tx_key = f"{client._transaction_identifier}:{state}"
349355

350-
return auth_params
356+
return auth_params, (tx_key, transaction_data)
351357

352358
@staticmethod
353359
def _ensure_openid_scope(scope: str) -> str:

src/auth0_server_python/auth_types/__init__.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,8 @@ class MfaTokenContext(BaseModel):
700700
# authParams keys the SDK owns and MUST NOT let a caller override for the
701701
# magic-link flow. A caller-controlled redirect_uri/state would allow the
702702
# emailed code+state to be redirected to an attacker (authorization-code
703-
# interception); the PKCE/nonce/response_type keys are protocol-controlled.
703+
# interception); the PKCE/nonce/response_type keys are reserved (not set by
704+
# the SDK for magic link, but never caller-overridable either).
704705
# Mirrors nextjs-auth0's MAGIC_LINK_EXCLUDED_PARAMS / INTERNAL_AUTHORIZE_PARAMS.
705706
# Kept explicit so a rejected override gets a precise "set by the SDK" message.
706707
PASSWORDLESS_RESERVED_AUTH_PARAMS = frozenset(
@@ -742,14 +743,16 @@ class MfaTokenContext(BaseModel):
742743
class _StartPasswordlessBase(BaseModel):
743744
"""Shared options for starting a passwordless flow."""
744745

746+
# Unknown keys raise rather than being silently ignored, so a caller
747+
# passing an unsupported kwarg is told, not quietly dropped.
748+
model_config = ConfigDict(extra="forbid")
749+
745750
# BCP 47 tag (e.g. "fr", "en-US"). Forwarded as x-request-language to
746751
# localise the email/SMS template.
747752
language: Optional[str] = None
748753
# Extra params forwarded to /passwordless/start. SDK-owned keys
749754
# (PASSWORDLESS_RESERVED_AUTH_PARAMS) are stripped in the client.
750755
auth_params: Optional[dict[str, Any]] = None
751-
# Organization id or name; validated against ID token claims on verify.
752-
organization: Optional[str] = None
753756
# Attempted solution to a captcha challenge, when the tenant requires one.
754757
captcha: Optional[str] = None
755758
# End-user client IP, relayed to Auth0 as `auth0-forwarded-for` so brute-force
@@ -803,7 +806,7 @@ class VerifyPasswordlessOtpOptions(BaseModel):
803806
"""
804807

805808
# Unknown keys raise rather than being silently ignored, so a caller
806-
# passing the removed `organization` kwarg is told, not quietly dropped.
809+
# passing an unsupported kwarg is told, not quietly dropped.
807810
model_config = ConfigDict(extra="forbid")
808811

809812
connection: PasswordlessConnection
@@ -814,9 +817,6 @@ class VerifyPasswordlessOtpOptions(BaseModel):
814817
phone_number: Optional[str] = None
815818
scope: Optional[str] = None
816819
audience: Optional[str] = None
817-
# No `organization` field: Auth0 ignores it for the OTP grant (verified
818-
# against auth0-server), so accepting it would silently never succeed.
819-
# Use magic link's `organization` instead.
820820
# End-user client IP, relayed to Auth0 as `auth0-forwarded-for` on the OTP
821821
# token exchange so brute-force protection keys on the real user, not the
822822
# app server. Honored only for confidential clients with "Trust Token

0 commit comments

Comments
 (0)