Skip to content

feat: passwordless support - #153

Open
rmad17 wants to merge 19 commits into
mainfrom
feat/passwordless-support
Open

feat: passwordless support#153
rmad17 wants to merge 19 commits into
mainfrom
feat/passwordless-support

Conversation

@rmad17

@rmad17 rmad17 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Changes

Added

  • Adds ServerClient.passwordless for embedded passwordless flows: Email OTP, SMS OTP (via /passwordless/start + the passwordless OTP token grant), and Email Magic Link (SDK-owned state/redirect_uri, transaction
    storage for callback completion).
  • Creates server-side sessions after successful OTP verification and magic-link callback completion, reusing the shared _persist_session_from_token_response path (issuer/audience/session-expiry checks already
    enforced there, not new in this PR).
  • Handles mfa_required during passwordless OTP verification; MFA can be completed into a new SDK session via mfa.verify(..., persist=True). Known limitation, not yet fixed: this MFA-initiated session-creation
    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.
  • Adds typed passwordless option models (StartPasswordlessEmailOptions, StartPasswordlessSmsOptions, VerifyPasswordlessOtpOptions) and passwordless-specific typed errors (PasswordlessStartError,
    PasswordlessVerifyError), including retry_after (from Retry-After), raw non-JSON error body capture (truncated), and 429 → too_many_requests mapping.
  • Enforces E.164 validation for SMS phone numbers.
  • Adds safeguards so magic-link callers cannot override SDK-owned protocol values (redirect_uri, response_type, state); supports caller-supplied scope on magic link, re-injecting openid when omitted.
  • Removes organization support from passwordless start/verify. The field existed but always failed in practice — the server ignores it on this grant, and the SDK's own claim validation then hard-raised on the
    missing claim. Removed rather than fixed, since there was no working path to preserve. Flag if organization on passwordless was already relied on anywhere.
  • Fixes session sid sourcing for all interactive logins, not just passwordless — complete_interactive_login previously fell back to a random sid whenever user_info was absent, even when the ID token carried a sid
    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.
  • Adds Passwordless documentation (examples/Passwordless.md) and README links.

Testing

This has been tested for the following flows

# Flow MFA Configuration Result
1 Email OTP No MFA ✅ Pass
2 Email OTP Global MFA ✅ Pass
3 Email OTP Step Up MFA ✅ Pass
4 SMS OTP No MFA ✅ Pass
5 SMS OTP Global MFA ✅ Pass
6 SMS OTP Step Up MFA ✅ Pass
7 Magic Link No MFA ✅ Pass
8 Magic Link Global MFA ✅ Pass
9 Magic Link Step Up MFA ✅ Pass
  • This change adds unit test coverage
  • This change adds integration test coverage
  • This change has been tested on the latest version of the platform/language or why not

Checklist

@rmad17
rmad17 requested a review from a team as a code owner August 8, 2026 05:33

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"
Piyush-85
Piyush-85 previously approved these changes Aug 12, 2026

@Piyush-85 Piyush-85 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed it async with @rmad17, LGTM

feat: MFA support in passwordless
)
return state_data

async def _establish_session_from_mfa_verify_response(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

# ============================================================================

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API definitions of independent new feature/functions should go at last - which increases the readibility.

created_at: int


# =============================================================================

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be dropped as we are adding a validation explicitly

Comment on lines +808 to +809
# Unknown keys raise rather than being silently ignored, so a caller
# passing an unsupported kwarg is told, not quietly dropped.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it tells you why we added this line while development rather than usability

Comment on lines +813 to +814
# Public field name mirrors nextjs-auth0's `verificationCode`; sent to
# Auth0 as the `otp` form parameter.

@kishore7snehil kishore7snehil Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I will add one.

"""Options for starting an email passwordless flow (OTP code or magic link)."""

connection: Literal["email"] = "email"
email: str

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/auth0_server_python/tests/test_passwordless_client.py Outdated
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use a plain hyphen here instead of the em dash, to match the repo convention.

Comment thread examples/Passwordless.md Outdated
> 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two independent clauses spliced with a semicolon here. Can we split into two sentences?

Comment thread examples/Passwordless.md Outdated
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, let's split the semicolon into two sentences.

Comment thread examples/Passwordless.md Outdated
)
```

By default, email OTP requests `openid profile email`; SMS OTP requests `openid profile` because SMS identities do not have an email claim to satisfy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, two sentences instead of the semicolon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants