Skip to content

Commit 3453894

Browse files
committed
Changes for adding Passwordless support
1 parent 1415ec6 commit 3453894

6 files changed

Lines changed: 1356 additions & 56 deletions

File tree

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
"""
2+
Passwordless Client for auth0-server-python SDK.
3+
4+
Implements embedded passwordless login (Legacy Passwordless connections) for a
5+
confidential (Regular Web App) client:
6+
7+
* Email OTP / SMS OTP — ``start()`` sends a code, ``verify()`` exchanges it for
8+
tokens via the passwordless-OTP grant and establishes a server-side session.
9+
* Magic link — ``start(send="link")`` emails a one-click link; completion is
10+
handled by the standard callback (``ServerClient.complete_interactive_login``),
11+
not by ``verify()``.
12+
13+
Tokens never leave the server; the browser holds only the opaque session
14+
reference (RWA / BFF posture).
15+
"""
16+
17+
from typing import TYPE_CHECKING, Any, Optional
18+
19+
import jwt
20+
21+
from auth0_server_python.auth_types import (
22+
PASSWORDLESS_ALLOWED_AUTH_PARAMS,
23+
PASSWORDLESS_RESERVED_AUTH_PARAMS,
24+
PasswordlessStartResult,
25+
StartPasswordlessEmailOptions,
26+
StartPasswordlessOptions,
27+
StartPasswordlessSmsOptions,
28+
TransactionData,
29+
UserClaims,
30+
VerifyPasswordlessOtpOptions,
31+
)
32+
from auth0_server_python.error import (
33+
InvalidArgumentError,
34+
IssuerValidationError,
35+
MissingRequiredArgumentError,
36+
PasswordlessErrorCode,
37+
PasswordlessStartError,
38+
PasswordlessVerifyError,
39+
)
40+
from auth0_server_python.utils import PKCE
41+
from auth0_server_python.utils.helpers import validate_org_claims
42+
43+
if TYPE_CHECKING: # avoid a circular import at runtime
44+
from auth0_server_python.auth_server.server_client import ServerClient
45+
46+
PASSWORDLESS_OTP_GRANT_TYPE = "http://auth0.com/oauth/grant-type/passwordless/otp"
47+
# Email flows request the `email` scope; SMS has no email claim to satisfy.
48+
DEFAULT_PASSWORDLESS_EMAIL_SCOPE = "openid profile email"
49+
DEFAULT_PASSWORDLESS_SMS_SCOPE = "openid profile"
50+
# Header Auth0 reads for the real end-user IP (confidential clients with
51+
# "Trust Token Endpoint IP Header" enabled).
52+
FORWARDED_FOR_HEADER = "auth0-forwarded-for"
53+
54+
55+
class PasswordlessClient:
56+
"""
57+
Client for Auth0 embedded passwordless operations.
58+
59+
Composes the parent :class:`ServerClient` to reuse domain resolution, OIDC
60+
discovery, JWKS/ID-token verification, and session persistence rather than
61+
duplicating that security-critical logic.
62+
"""
63+
64+
def __init__(self, server_client: "ServerClient"):
65+
self._client = server_client
66+
67+
# ------------------------------------------------------------------ start
68+
69+
async def start(
70+
self,
71+
options: StartPasswordlessOptions,
72+
store_options: Optional[dict[str, Any]] = None,
73+
) -> PasswordlessStartResult:
74+
"""
75+
Start a passwordless flow by sending an OTP code or a magic link.
76+
77+
Args:
78+
options: ``StartPasswordlessEmailOptions`` or
79+
``StartPasswordlessSmsOptions``.
80+
store_options: Options passed to the transaction store (e.g.
81+
request/response) — required for the magic-link flow so the
82+
transaction cookie can be written.
83+
84+
Returns:
85+
PasswordlessStartResult with Auth0's start response payload.
86+
87+
Raises:
88+
PasswordlessStartError: When ``POST /passwordless/start`` fails.
89+
InvalidArgumentError: When caller ``auth_params`` attempts to
90+
override an SDK-owned parameter.
91+
MissingRequiredArgumentError: When a magic link is requested but no
92+
``redirect_uri`` is configured on the client.
93+
"""
94+
client = self._client
95+
origin_domain = await client._resolve_current_domain(store_options)
96+
97+
body: dict[str, Any] = {
98+
"client_id": client._client_id,
99+
"client_secret": client._client_secret,
100+
"connection": options.connection,
101+
}
102+
103+
if isinstance(options, StartPasswordlessEmailOptions):
104+
body["email"] = options.email
105+
body["send"] = options.send
106+
elif isinstance(options, StartPasswordlessSmsOptions):
107+
body["phone_number"] = options.phone_number
108+
else:
109+
raise InvalidArgumentError(
110+
"options",
111+
"options must be StartPasswordlessEmailOptions or StartPasswordlessSmsOptions",
112+
)
113+
114+
if options.captcha:
115+
body["captcha"] = options.captcha
116+
117+
is_magic_link = (
118+
isinstance(options, StartPasswordlessEmailOptions) and options.send == "link"
119+
)
120+
121+
if is_magic_link:
122+
body["authParams"] = await self._build_magic_link_auth_params(
123+
options, origin_domain, store_options
124+
)
125+
elif options.auth_params:
126+
# OTP flows: forward safe passthrough params only.
127+
body["authParams"] = self._sanitize_caller_auth_params(options.auth_params)
128+
129+
headers = {"Content-Type": "application/json"}
130+
if options.language:
131+
headers["x-request-language"] = options.language
132+
if options.client_ip:
133+
headers[FORWARDED_FOR_HEADER] = options.client_ip
134+
135+
base_url = client._normalize_url(origin_domain)
136+
url = f"{base_url}/passwordless/start"
137+
138+
try:
139+
async with client._get_http_client() as http:
140+
response = await http.post(url, json=body, headers=headers)
141+
except Exception as e:
142+
raise PasswordlessStartError(
143+
PasswordlessErrorCode.START_FAILED,
144+
f"Unexpected error during passwordless start: {str(e)}",
145+
e,
146+
)
147+
148+
if response.status_code not in (200, 201):
149+
error_body = self._safe_json(response)
150+
raise PasswordlessStartError(
151+
error_body.get("error", PasswordlessErrorCode.START_FAILED),
152+
error_body.get("error_description", "Failed to start passwordless flow"),
153+
error_body,
154+
)
155+
156+
return PasswordlessStartResult(**self._safe_json(response))
157+
158+
# ----------------------------------------------------------------- verify
159+
160+
async def verify(
161+
self,
162+
options: VerifyPasswordlessOtpOptions,
163+
store_options: Optional[dict[str, Any]] = None,
164+
) -> dict[str, Any]:
165+
"""
166+
Verify a passwordless OTP and establish a server-side session.
167+
168+
Only for the OTP flows (email/SMS code). Magic link completes via the
169+
standard callback handler, not here.
170+
171+
Args:
172+
options: VerifyPasswordlessOtpOptions.
173+
store_options: Options passed to the state store (e.g.
174+
request/response) so the session can be written.
175+
176+
Returns:
177+
Dict containing ``state_data`` for the established session.
178+
179+
Raises:
180+
PasswordlessVerifyError: When token exchange or ID-token
181+
verification fails.
182+
"""
183+
client = self._client
184+
origin_domain = await client._resolve_current_domain(store_options)
185+
186+
try:
187+
metadata = await client._get_oidc_metadata_cached(origin_domain)
188+
except Exception as e:
189+
raise PasswordlessVerifyError(
190+
PasswordlessErrorCode.DISCOVERY_ERROR,
191+
"Failed to fetch authorization server metadata",
192+
e,
193+
)
194+
195+
token_endpoint = metadata["token_endpoint"]
196+
origin_issuer = metadata.get("issuer")
197+
198+
default_scope = (
199+
DEFAULT_PASSWORDLESS_EMAIL_SCOPE
200+
if options.connection == "email"
201+
else DEFAULT_PASSWORDLESS_SMS_SCOPE
202+
)
203+
body: dict[str, Any] = {
204+
"grant_type": PASSWORDLESS_OTP_GRANT_TYPE,
205+
"client_id": client._client_id,
206+
"client_secret": client._client_secret,
207+
"realm": options.connection,
208+
"username": options.username,
209+
"otp": options.verification_code,
210+
"scope": options.scope or default_scope,
211+
}
212+
if options.audience:
213+
body["audience"] = options.audience
214+
215+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
216+
if options.client_ip:
217+
headers[FORWARDED_FOR_HEADER] = options.client_ip
218+
219+
try:
220+
async with client._get_http_client() as http:
221+
response = await http.post(
222+
token_endpoint,
223+
data=body,
224+
headers=headers,
225+
)
226+
except Exception as e:
227+
raise PasswordlessVerifyError(
228+
PasswordlessErrorCode.VERIFY_FAILED,
229+
f"Unexpected error during passwordless verify: {str(e)}",
230+
e,
231+
)
232+
233+
if response.status_code != 200:
234+
error_body = self._safe_json(response)
235+
raise PasswordlessVerifyError(
236+
error_body.get("error", PasswordlessErrorCode.INVALID_GRANT),
237+
error_body.get("error_description", "Passwordless verification failed"),
238+
error_body,
239+
)
240+
241+
token_response = response.json()
242+
243+
user_claims, id_token_claims = await self._verify_id_token(
244+
token_response, origin_domain, origin_issuer, metadata, options.organization
245+
)
246+
247+
state_data = await client._persist_session_from_token_response(
248+
token_response=token_response,
249+
user_claims=user_claims,
250+
origin_domain=origin_domain,
251+
audience=options.audience,
252+
session_expires_at=user_claims.session_expiry,
253+
issued_at=id_token_claims.get("iat"),
254+
id_token_claims=id_token_claims,
255+
store_options=store_options,
256+
)
257+
258+
return {"state_data": state_data.model_dump()}
259+
260+
# ------------------------------------------------------------- internals
261+
262+
async def _build_magic_link_auth_params(
263+
self,
264+
options: StartPasswordlessEmailOptions,
265+
origin_domain: str,
266+
store_options: Optional[dict[str, Any]],
267+
) -> dict[str, Any]:
268+
"""
269+
Build the magic-link ``authParams`` and persist the transaction.
270+
271+
The SDK owns ``redirect_uri`` / ``response_type`` / ``state``; caller
272+
``auth_params`` may only contribute non-reserved passthrough keys.
273+
"""
274+
client = self._client
275+
276+
redirect_uri = client._redirect_uri
277+
if not redirect_uri:
278+
raise MissingRequiredArgumentError("redirect_uri")
279+
280+
auth_params = self._sanitize_caller_auth_params(options.auth_params)
281+
282+
state = PKCE.generate_random_string(32)
283+
auth_params["redirect_uri"] = redirect_uri
284+
auth_params["response_type"] = "code"
285+
auth_params["state"] = state
286+
# Magic link is email-only, so the email scope is always appropriate.
287+
auth_params.setdefault("scope", DEFAULT_PASSWORDLESS_EMAIL_SCOPE)
288+
if options.organization:
289+
auth_params["organization"] = options.organization
290+
291+
# Magic link uses a plain authorization-code exchange (no PKCE), so the
292+
# transaction stores no code_verifier. Single-use is enforced by
293+
# transaction deletion on the callback; remove_if_expires signals the
294+
# store to drop the transaction once expired. Its effective lifetime is
295+
# the store's configured duration, not a fixed value set here.
296+
transaction_data = TransactionData(
297+
code_verifier=None,
298+
audience=auth_params.get("audience"),
299+
redirect_uri=redirect_uri,
300+
domain=origin_domain,
301+
organization=options.organization,
302+
)
303+
await client._transaction_store.set(
304+
f"{client._transaction_identifier}:{state}",
305+
transaction_data,
306+
remove_if_expires=True,
307+
options=store_options,
308+
)
309+
310+
return auth_params
311+
312+
def _sanitize_caller_auth_params(self, auth_params: Optional[dict[str, Any]]) -> dict[str, Any]:
313+
"""
314+
Copy caller-supplied auth params, forwarding only allowlisted keys.
315+
316+
Enforced as an allowlist (Global §3): a key outside
317+
``PASSWORDLESS_ALLOWED_AUTH_PARAMS`` is rejected. SDK-owned keys get a
318+
precise "set by the SDK" message; anything else is reported as
319+
unsupported so a new authorize param cannot pass through silently.
320+
321+
Raises:
322+
InvalidArgumentError: When a reserved or unrecognized param is present.
323+
"""
324+
if not auth_params:
325+
return {}
326+
for key in auth_params:
327+
if key in PASSWORDLESS_RESERVED_AUTH_PARAMS:
328+
raise InvalidArgumentError(
329+
"auth_params",
330+
f"'{key}' is set by the SDK and cannot be overridden",
331+
)
332+
if key not in PASSWORDLESS_ALLOWED_AUTH_PARAMS:
333+
raise InvalidArgumentError(
334+
"auth_params",
335+
f"'{key}' is not an allowed passthrough auth parameter",
336+
)
337+
return dict(auth_params)
338+
339+
async def _verify_id_token(
340+
self,
341+
token_response: dict[str, Any],
342+
origin_domain: str,
343+
origin_issuer: Optional[str],
344+
metadata: dict[str, Any],
345+
expected_org: Optional[str],
346+
) -> tuple[UserClaims, dict[str, Any]]:
347+
"""Verify the ID token from the OTP exchange and return its claims."""
348+
client = self._client
349+
id_token = token_response.get("id_token")
350+
if not id_token:
351+
raise PasswordlessVerifyError(
352+
PasswordlessErrorCode.VERIFY_FAILED,
353+
"Token response did not include an ID token; ensure 'openid' scope is requested",
354+
)
355+
356+
jwks = await client._get_jwks_cached(origin_domain, metadata)
357+
358+
try:
359+
claims = await client._verify_and_decode_jwt(id_token, jwks, audience=client._client_id)
360+
except ValueError as e:
361+
raise PasswordlessVerifyError(PasswordlessErrorCode.VERIFY_FAILED, str(e), e)
362+
except jwt.InvalidAudienceError as e:
363+
raise PasswordlessVerifyError(
364+
PasswordlessErrorCode.INVALID_AUDIENCE,
365+
"ID token audience mismatch. Ensure your client_id is configured correctly.",
366+
e,
367+
)
368+
except jwt.InvalidTokenError as e:
369+
# Covers expired signature, bad signature, and other token defects.
370+
raise PasswordlessVerifyError(
371+
PasswordlessErrorCode.VERIFY_FAILED,
372+
f"ID token verification failed: {str(e)}",
373+
e,
374+
)
375+
376+
token_issuer = claims.get("iss", "")
377+
if client._normalize_url(token_issuer) != client._normalize_url(origin_issuer):
378+
raise IssuerValidationError(
379+
"ID token issuer mismatch. Ensure your Auth0 domain is configured correctly."
380+
)
381+
382+
if expected_org:
383+
validate_org_claims(claims, expected_org)
384+
385+
return UserClaims.model_validate(claims), claims
386+
387+
@staticmethod
388+
def _safe_json(response) -> dict[str, Any]:
389+
"""Parse a response body as JSON, returning {} on failure."""
390+
try:
391+
data = response.json()
392+
return data if isinstance(data, dict) else {}
393+
except Exception:
394+
return {}

0 commit comments

Comments
 (0)