Skip to content

Commit f2746e1

Browse files
feat(mfa): Implement session persistence for MFA tokens in the client
1 parent 69b96f8 commit f2746e1

3 files changed

Lines changed: 179 additions & 11 deletions

File tree

examples/MFA.md

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ The Auth0 MFA API allows you to manage multi-factor authentication for users in
3030
- [Verify with OOB (SMS or Email)](#verify-with-oob-sms-or-email)
3131
- [Verify with OTP](#verify-with-otp)
3232
- [Verify with Recovery Code](#verify-with-recovery-code)
33+
- [Session Persistence](#session-persistence)
34+
- [Automatic Session Update](#automatic-session-update)
35+
- [Manual Session Update](#manual-session-update)
3336
- [Complete MFA Flow Examples](#complete-mfa-flow-examples)
3437
- [Enrollment Flow](#enrollment-flow)
3538
- [Challenge Flow](#challenge-flow)
@@ -311,34 +314,43 @@ try:
311314
verify_response = await server_client.mfa.verify({
312315
"mfa_token": mfa_token,
313316
"oob_code": challenge.oob_code,
314-
"binding_code": "123456" # Code user received via SMS/Email
317+
"binding_code": "123456", # Code user received via SMS/Email
318+
"persist": True, # Persist tokens to session store
319+
"audience": "https://api.example.com", # Required when persist=True
320+
"scope": "openid profile email" # Optional scope
315321
})
316322

317323
access_token = verify_response.access_token
318324
id_token = verify_response.id_token
319-
token_type = verify_response.token_type
320325

321326
print(f"MFA verification successful!")
322327
print(f"Access Token: {access_token}")
323328
print(f"ID Token: {id_token}")
329+
print("Tokens have been persisted to session store")
324330

325331
except Exception as error:
326332
print(f"Verification failed: {error}")
327333
```
328334

335+
> [!NOTE]
336+
> Setting `persist=True` automatically updates the session store with the new tokens, similar to nextjs-auth0 and auth0-spa-js SDKs. This eliminates the need for manual token management after MFA verification.
337+
329338
### Verify with OTP
330339

331340
```python
332341
try:
333342
verify_response = await server_client.mfa.verify({
334343
"mfa_token": mfa_token,
335-
"otp": "123456" # 6-digit code from authenticator app
344+
"otp": "123456", # 6-digit code from authenticator app
345+
"persist": True, # Persist tokens to session store
346+
"audience": "https://api.example.com", # Required when persist=True
347+
"scope": "openid profile email"
336348
})
337349

338350
access_token = verify_response.access_token
339-
id_token = verify_response.id_token
340351

341352
print("MFA verification successful!")
353+
print("Tokens have been persisted to session store")
342354

343355
except Exception as error:
344356
print(f"Invalid OTP code: {error}")
@@ -352,17 +364,64 @@ Recovery codes can be used to complete MFA verification without initiating a cha
352364
try:
353365
verify_response = await server_client.mfa.verify({
354366
"mfa_token": mfa_token,
355-
"recovery_code": "XXXX-XXXX-XXXX" # One of the recovery codes
367+
"recovery_code": "XXXX-XXXX-XXXX", # One of the recovery codes
368+
"persist": True, # Persist tokens to session store
369+
"audience": "https://api.example.com" # Required when persist=True
356370
})
357371

358372
access_token = verify_response.access_token
359373

360374
print("MFA verification successful using recovery code!")
375+
print("Tokens have been persisted to session store")
361376

362377
except Exception as error:
363378
print(f"Verification failed: {error}")
364379
```
365380

381+
## Session Persistence
382+
383+
By default, `verify()` returns tokens without persisting them to the session store. However, you can automatically persist tokens by setting `persist=True`, similar to how nextjs-auth0 and auth0-spa-js handle MFA.
384+
385+
### Automatic Session Update
386+
387+
When you set `persist=True`, the SDK will:
388+
1. Update the session's `access_token` for the specified audience
389+
2. Update the session's `id_token` if present
390+
3. Add the token to the `token_sets` array with expiration information
391+
392+
```python
393+
verify_response = await server_client.mfa.verify({
394+
"mfa_token": mfa_token,
395+
"otp": "123456",
396+
"persist": True, # Enable automatic persistence
397+
"audience": "https://api.example.com", # Required when persist=True
398+
"scope": "openid profile email" # Optional
399+
})
400+
401+
# Tokens are now available in the session store
402+
# User can call server_client.get_user() to access updated session
403+
user = await server_client.get_user()
404+
```
405+
406+
### Manual Session Update
407+
408+
If you prefer to manage session updates yourself:
409+
410+
```python
411+
verify_response = await server_client.mfa.verify({
412+
"mfa_token": mfa_token,
413+
"otp": "123456"
414+
# persist=False (default)
415+
})
416+
417+
# Handle token storage manually if needed
418+
access_token = verify_response.access_token
419+
id_token = verify_response.id_token
420+
421+
# Store tokens in your application's session management
422+
await my_session_store.update_tokens(access_token, id_token)
423+
```
424+
366425
## Complete MFA Flow Examples
367426

368427
### Enrollment Flow
@@ -396,10 +455,14 @@ async def handle_mfa_enrollment_flow(server_client, mfa_token):
396455
# Verify enrollment
397456
verify_response = await server_client.mfa.verify({
398457
"mfa_token": mfa_token,
399-
"otp": user_code
458+
"otp": user_code,
459+
"persist": True,
460+
"audience": "https://api.example.com",
461+
"scope": "openid profile email"
400462
})
401463

402464
print("MFA enrollment successful!")
465+
print("Tokens have been persisted to session store")
403466
return verify_response.access_token
404467

405468
except Exception as error:
@@ -441,17 +504,24 @@ async def handle_mfa_challenge_flow(server_client, mfa_token):
441504
user_code = input("Enter 6-digit code from authenticator: ")
442505
verify_response = await server_client.mfa.verify({
443506
"mfa_token": mfa_token,
444-
"otp": user_code
507+
"otp": user_code,
508+
"persist": True,
509+
"audience": "https://api.example.com",
510+
"scope": "openid profile email"
445511
})
446512
else:
447513
user_code = input(f"Enter code from {selected_auth.authenticator_type}: ")
448514
verify_response = await server_client.mfa.verify({
449515
"mfa_token": mfa_token,
450516
"oob_code": challenge.oob_code,
451-
"binding_code": user_code
517+
"binding_code": user_code,
518+
"persist": True,
519+
"audience": "https://api.example.com",
520+
"scope": "openid profile email"
452521
})
453522

454523
print("MFA verification successful!")
524+
print("Tokens have been persisted to session store")
455525
return verify_response.access_token
456526

457527
except Exception as error:

src/auth0_server_python/auth_server/mfa_client.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,17 @@ def __init__(
5050
domain: str,
5151
client_id: str,
5252
client_secret: str,
53-
secret: str
53+
secret: str,
54+
state_store=None,
55+
state_identifier: str = "_a0_session"
5456
):
5557
self._domain = domain
5658
self._base_url = f"https://{domain}"
5759
self._client_id = client_id
5860
self._client_secret = client_secret
5961
self._secret = secret
62+
self._state_store = state_store
63+
self._state_identifier = state_identifier
6064

6165
# ============================================================================
6266
# MFA TOKEN ENCRYPTION / DECRYPTION
@@ -283,6 +287,10 @@ async def verify(
283287
- 'otp': OTP code
284288
- 'oob_code' + 'binding_code': OOB verification
285289
- 'recovery_code': Recovery code
290+
- 'persist': bool (optional, default=False) - Persist tokens to state store
291+
- 'audience': str (optional, required if persist=True) - Audience for token_set
292+
- 'scope': str (optional) - Scope for token_set
293+
- 'store_options': dict (optional) - Store-specific options
286294
287295
Returns:
288296
MfaVerifyResponse with access_token, token_type, etc.
@@ -348,11 +356,99 @@ async def verify(
348356
)
349357

350358
token_response = response.json()
351-
return MfaVerifyResponse(**token_response)
359+
verify_response = MfaVerifyResponse(**token_response)
360+
361+
# Persist tokens to state store if requested
362+
if options.get("persist") and self._state_store:
363+
await self._persist_mfa_tokens(
364+
verify_response=verify_response,
365+
options=options
366+
)
367+
368+
return verify_response
352369

353370
except (MfaVerifyError, MfaRequiredError):
354371
raise
355372
except Exception as e:
356373
raise MfaVerifyError(
357374
f"Unexpected error during MFA verification: {str(e)}"
358375
)
376+
377+
async def _persist_mfa_tokens(
378+
self,
379+
verify_response: MfaVerifyResponse,
380+
options: dict[str, Any]
381+
) -> None:
382+
"""
383+
Persist MFA verification tokens to the state store.
384+
385+
Updates the session with the new access_token and id_token from MFA verification.
386+
387+
Args:
388+
verify_response: The response from verify() containing tokens
389+
options: Dict containing:
390+
- 'audience': str - Audience for token_set
391+
- 'scope': str (optional) - Scope for token_set
392+
- 'store_options': dict (optional) - Store-specific options
393+
"""
394+
from auth0_server_python.auth_types import TokenSet, StateData
395+
import time
396+
397+
audience = options.get("audience")
398+
scope = options.get("scope")
399+
store_options = options.get("store_options")
400+
401+
if not audience:
402+
raise MfaVerifyError(
403+
"audience is required when persist=True"
404+
)
405+
406+
try:
407+
# Get existing state
408+
state_data = await self._state_store.get(
409+
self._state_identifier,
410+
store_options
411+
)
412+
413+
if not state_data:
414+
raise MfaVerifyError(
415+
"No existing session found to update with MFA tokens"
416+
)
417+
418+
# Parse state data
419+
existing_state = StateData(**state_data) if isinstance(state_data, dict) else state_data
420+
421+
# Update id_token if present
422+
if verify_response.id_token:
423+
existing_state.id_token = verify_response.id_token
424+
425+
# Create token_set for the access_token
426+
expires_in = verify_response.get("expires_in", 86400) # Default 24 hours
427+
expires_at = int(time.time()) + expires_in
428+
429+
new_token_set = TokenSet(
430+
audience=audience,
431+
access_token=verify_response.access_token,
432+
scope=scope,
433+
expires_at=expires_at
434+
)
435+
436+
# Add to token_sets, replacing any existing token_set for this audience
437+
existing_state.token_sets = [
438+
ts for ts in existing_state.token_sets if ts.audience != audience
439+
]
440+
existing_state.token_sets.append(new_token_set)
441+
442+
# Persist updated state
443+
await self._state_store.set(
444+
self._state_identifier,
445+
existing_state.model_dump() if hasattr(existing_state, 'model_dump') else existing_state,
446+
options=store_options
447+
)
448+
449+
except MfaVerifyError:
450+
raise
451+
except Exception as e:
452+
raise MfaVerifyError(
453+
f"Failed to persist MFA tokens to state store: {str(e)}"
454+
)

src/auth0_server_python/auth_server/server_client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ def __init__(
131131
domain=self._domain,
132132
client_id=self._client_id,
133133
client_secret=self._client_secret,
134-
secret=self._secret
134+
secret=self._secret,
135+
state_store=self._state_store,
136+
state_identifier=self._state_identifier
135137
)
136138

137139
@property

0 commit comments

Comments
 (0)