Skip to content

feat: Add MFA (Multi-Factor Authentication) API support#79

Merged
kishore7snehil merged 23 commits intomainfrom
feat/mfa-api
Apr 24, 2026
Merged

feat: Add MFA (Multi-Factor Authentication) API support#79
kishore7snehil merged 23 commits intomainfrom
feat/mfa-api

Conversation

@subhankarmaiti
Copy link
Copy Markdown
Contributor

@subhankarmaiti subhankarmaiti commented Feb 16, 2026

Adds MFA support to the auth0-server-python SDK via a new MfaClient (server_client.mfa) that handles the full MFA lifecycle — enrollment, challenge, and verification — for all supported factor types: OTP, SMS, Voice, Email, Push Notification (Auth0 Guardian), and Recovery Code.

What's Included

  • MfaClient with 4 methods: list_authenticators, enroll_authenticator, challenge_authenticator, verify
  • Session persistenceverify(persist=True) auto-updates the session store with new tokens
  • MFA token encryption — secure handling of mfa_token between requests
  • Push notification support — enrollment via Guardian QR code, challenge/verify via polling
  • Recovery code handlingverify() returns recovery_code on first enrollment and after recovery code use
  • New types: AuthenticatorResponse, ChallengeResponse, OtpEnrollmentResponse, OobEnrollmentResponse, MfaVerifyResponse
  • New errors: MfaRequiredError, MfaChallengeError, MfaEnrollmentError, MfaVerifyError, MfaTokenExpiredError
  • Unit tests covering all MFA operations across all factor types
  • Comprehensive docs in examples/MFA.md

Quick Example

from auth0_server_python.error import MfaRequiredError

try:
    access_token = await server_client.get_access_token()
except MfaRequiredError as error:
    mfa_token = error.mfa_token

    authenticators = await server_client.mfa.list_authenticators({"mfa_token": mfa_token})

    if not authenticators:
        enrollment = await server_client.mfa.enroll_authenticator({
            "mfa_token": mfa_token, "factor_type": "otp"
        })
    else:
        auth = authenticators[0]
        factor_type = auth.oob_channel if auth.authenticator_type == "oob" else auth.authenticator_type
        challenge = await server_client.mfa.challenge_authenticator({
            "mfa_token": mfa_token, "factor_type": factor_type, "authenticator_id": auth.id
        })

    verify_response = await server_client.mfa.verify({
        "mfa_token": mfa_token, "otp": user_code, "persist": True,
        "audience": "https://api.example.com"
    })

    if verify_response.recovery_code:
        print(f"Save this recovery code: {verify_response.recovery_code}")

Comment thread src/auth0_server_python/tests/test_mfa_client.py Fixed
@gyaneshgouraw-okta
Copy link
Copy Markdown

gyaneshgouraw-okta commented Feb 18, 2026

@subhankarmaiti please also add a corresponding example.md file for the mfa api usage in this SDK as done for other features. You can checkout spa js/next SDK example if that helps defining the structure - https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md#multi-factor-authentication-mfa

Comment thread src/auth0_server_python/auth_server/mfa_client.py Outdated
Comment thread src/auth0_server_python/auth_server/mfa_client.py Fixed
Comment thread src/auth0_server_python/tests/test_mfa_client.py Fixed
@subhankarmaiti subhankarmaiti marked this pull request as ready for review March 16, 2026 20:28
@subhankarmaiti subhankarmaiti requested a review from a team as a code owner March 16, 2026 20:28
Resolve conflicts to incorporate MCD support, issuer validation,
OIDC/JWKS caching, and release 1.0.0b9 changes from main. MFA
feature code is placed last in each section.
MfaClient now accepts a callable domain (Union[str, Callable, None])
matching ServerClient's pattern, and resolves the domain per-request
via _resolve_base_url(). All API methods take store_options as a
top-level parameter instead of burying it inside the options dict.
kishore7snehil
kishore7snehil previously approved these changes Apr 24, 2026
Copy link
Copy Markdown
Contributor

@kishore7snehil kishore7snehil left a comment

Choose a reason for hiding this comment

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

LGTM!

Chained MFA from verify() returns a raw mfa_token by design.
Encryption is the framework SDK's responsibility, matching the
separation of concerns between MfaClient and ServerClient.
arpit-jn
arpit-jn previously approved these changes Apr 24, 2026
Copy link
Copy Markdown
Contributor

@arpit-jn arpit-jn left a comment

Choose a reason for hiding this comment

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

LGTM

@kishore7snehil kishore7snehil merged commit 344ff66 into main Apr 24, 2026
9 checks passed
@kishore7snehil kishore7snehil deleted the feat/mfa-api branch April 24, 2026 09:47
# MFA (Multi-Factor Authentication)
# ============================================================================

@property
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This .mfa property exposes the entire MfaClient instance directly to SDK users, which is a different pattern from how MyAccountClient is handled there, the sub-client stays private and ServerClient wraps each operation in its own method (e.g. list_connected_accounts, connect_account).

Was this intentional ? A few things to consider:

  • Methods like encrypt_mfa_token() and decrypt_mfa_token() are now publicly callable via server_client.mfa.encrypt_mfa_token(...). Is this intentional ?
  • The full MfaClient class (method names, parameter shapes, return types) becomes a public contract. Any future rename or refactor is a breaking change.
  • Should we either wrap the MFA operations directly on ServerClient (like my_account), or at minimum mark encrypt_mfa_token/decrypt_mfa_token as private (prefix with _) ?

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.

Was this intentional?

Yes. The .mfa sub-client pattern matches auth0-auth-js where MFA is accessed as authClient.mfa.listAuthenticators(),authClient.mfa.challengeAuthenticator(), etc. MFA is a multi-step conversational flow (list -> enroll/challenge -> verify) that shares state (mfa_token) across steps, so a dedicated sub-client fits better than wrapping 5+ methods on ServerClient.

MyAccountClient's operations are independent request/response pairs that naturally map to single ServerClient methods , MFA doesn't have that shape.

Is encrypt_mfa_token() / decrypt_mfa_token() being publicly callable intentional?

encrypt_mfa_token is only called internally by ServerClient.get_access_token(), so it shouldn't be public. Will rename to _encrypt_mfa_token in future PR.

The full MfaClient class becomes a public contract. Any future rename or refactor is a breaking change.

The 4 API methods + decrypt_mfa_token are intentionally public and mirror Auth0's MFA API directly, so there's little reason they'd change. This matches the JS SDK's approach.

Should we wrap MFA operations on ServerClient (like my_account), or mark encrypt/decrypt as private?

Wrapping on ServerClient would add 5+ methods and obscure the multi-step flow. Keeping the sub-client is the better fit. Will mark encrypt_mfa_token as private (_encrypt_mfa_token) since it's internal only. decrypt_mfa_token remains public as it's part of the caller's workflow.

scope=merged_scope or "",
mfa_requirements=mfa_requirements
)
raise MfaRequiredError(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Before this PR, get_access_token() raised AccessTokenError when MFA was required. Now it raises MfaRequiredError, which is a completely different exception type.

Any existing app that catches AccessTokenError around get_access_token() will start getting unhandled exceptions in production after upgrading with no warning.

@kishore7snehil kishore7snehil mentioned this pull request Apr 24, 2026
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.

6 participants