|
| 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