feat: passwordless support - #153
Conversation
|
|
||
| def __init__(self, code: str, message: str, cause=None, retry_after: Optional[int] = None): | ||
| super().__init__(code, message, cause, retry_after) | ||
| self.name = "PasswordlessStartError" |
|
|
||
| def __init__(self, code: str, message: str, cause=None, retry_after: Optional[int] = None): | ||
| super().__init__(code, message, cause, retry_after) | ||
| self.name = "PasswordlessVerifyError" |
# Conflicts: # src/auth0_server_python/tests/test_passwordless_client.py
feat: MFA support in passwordless
| ) | ||
| return state_data | ||
|
|
||
| async def _establish_session_from_mfa_verify_response( |
There was a problem hiding this comment.
The helpers shouldn't be here.It should be where all the helpers reside.
| """Access the MFA client for multi-factor authentication operations.""" | ||
| return self._mfa_client | ||
|
|
||
| # ============================================================================ |
There was a problem hiding this comment.
API definitions of independent new feature/functions should go at last - which increases the readibility.
| created_at: int | ||
|
|
||
|
|
||
| # ============================================================================= |
There was a problem hiding this comment.
The whole section can be added to the last.
| """Options for starting an SMS passwordless (OTP) flow.""" | ||
|
|
||
| connection: Literal["sms"] = "sms" | ||
| # E.164 format, e.g. "+14155550100". |
There was a problem hiding this comment.
This can be dropped as we are adding a validation explicitly
| # Unknown keys raise rather than being silently ignored, so a caller | ||
| # passing an unsupported kwarg is told, not quietly dropped. |
There was a problem hiding this comment.
Looks like it tells you why we added this line while development rather than usability
| # Public field name mirrors nextjs-auth0's `verificationCode`; sent to | ||
| # Auth0 as the `otp` form parameter. |
There was a problem hiding this comment.
We can't use other SDK's reference here.
| "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." | ||
| ) | ||
|
|
||
| user_claims = UserClaims.model_validate(claims) |
There was a problem hiding this comment.
This path creates a session but never runs the organization-claim check that the interactive login runs before it persists. A first-login MFA flow started with an organization requirement would end up with a session that was never checked for org membership, and it fails open rather than closed.
Can we run the same org validation here before persisting, or gate this path so an org-scoped login cannot complete through it?
| ) | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_caller_cannot_override_reserved_param(self): |
There was a problem hiding this comment.
This passes even if the reserved-key branch is removed, because redirect_uri also is not in the allowlist, so the second guard raises the same InvalidArgumentError and the test cannot tell which one fired.
Shall we assert on the message, or use a key that is allowed but reserved, so the test actually pins the reserved-key behavior?
There was a problem hiding this comment.
Valid point, added the assertion.
| client._state_store.set.assert_not_awaited() | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_passwordless_mfa_verify_persist_creates_session(self, mocker): |
There was a problem hiding this comment.
There is no test covering an org-scoped login that completes through the MFA-into-session path. Given that path skips org validation today, a test that starts with an organization and asserts the resulting behavior would make the gap visible rather than silent.
Can we add one?
There was a problem hiding this comment.
Yes, I will add one.
| """Options for starting an email passwordless flow (OTP code or magic link).""" | ||
|
|
||
| connection: Literal["email"] = "email" | ||
| email: str |
There was a problem hiding this comment.
email is accepted as any string, so a malformed value or one with embedded CRLF passes model validation and is only rejected later by Auth0.
Shall we validate the format here the way phone numbers are validated, so a bad value fails fast?
| connection: PasswordlessConnection | ||
| # Public field name mirrors nextjs-auth0's `verificationCode`; sent to | ||
| # Auth0 as the `otp` form parameter. | ||
| verification_code: str |
There was a problem hiding this comment.
verification_code accepts an empty string and arbitrarily long input.
A quick length or non-empty check here would fail fast instead of sending an empty OTP and getting back a generic invalid_grant.
| async def test_magic_link_callback_exchanges_code_without_pkce(self, mocker): | ||
| # Magic link is a plain auth-code exchange: lock that code_verifier=None | ||
| # reaches fetch_token (authlib drops the falsy field) so a forced verifier | ||
| # — which Auth0 would reject — is caught. |
There was a problem hiding this comment.
Let's use a plain hyphen here instead of the em dash, to match the repo convention.
| > Passwordless API flows use Auth0 Legacy Passwordless connections (`email` and `sms`). Enable the **Passwordless OTP** grant for your application under **Applications -> Your App -> Advanced Settings -> Grant Types**. See the [Auth0 Passwordless API documentation](https://auth0.com/docs/authenticate/passwordless/implement-login/embedded-login/relevant-api-endpoints). | ||
|
|
||
| > [!IMPORTANT] | ||
| > These flows are for confidential server-side applications. Tokens stay on the server; the browser should only receive your application's session cookie or opaque session reference. |
There was a problem hiding this comment.
Two independent clauses spliced with a semicolon here. Can we split into two sentences?
| 1. **OTP code** - `start()` sends a code by email or SMS. Your app collects that code, then `verify()` exchanges it at `/oauth/token` with the passwordless OTP grant and **creates a server-side session**. | ||
| 2. **Magic link** - `start(send="link")` sends a one-click email link. Auth0 redirects the user back to your callback URL, and your app completes the flow with `complete_interactive_login()`. The callback creates the server-side session. | ||
|
|
||
| OTP start does **not** create a session. The session exists only after `verify()` succeeds. Magic-link start writes a transaction so the callback can validate the returned `state`; the session exists only after the callback completes. |
There was a problem hiding this comment.
Same here, let's split the semicolon into two sentences.
| ) | ||
| ``` | ||
|
|
||
| By default, email OTP requests `openid profile email`; SMS OTP requests `openid profile` because SMS identities do not have an email claim to satisfy. |
There was a problem hiding this comment.
Same, two sentences instead of the semicolon.
Changes
Added
storage for callback completion).
enforced there, not new in this PR).
path does not run organization-claim validation, unlike every other session-creating path in the SDK. Flagging for reviewer visibility — no organization-scoped passwordless/MFA usage should ship until this is
closed.
PasswordlessVerifyError), including retry_after (from Retry-After), raw non-JSON error body capture (truncated), and 429 → too_many_requests mapping.
missing claim. Removed rather than fixed, since there was no working path to preserve. Flag if organization on passwordless was already relied on anywhere.
claim. It now prefers the ID token's sid claim, then user_info, then random. This affects every existing interactive-login session, since OIDC back-channel logout matches by sid and previously could silently
fail to target sessions created this way.
Testing
This has been tested for the following flows
Checklist