Skip to content

Commit c8cf2d7

Browse files
added test cases to cover enroll, challenge and verify steps
1 parent 3fcdbf9 commit c8cf2d7

3 files changed

Lines changed: 270 additions & 0 deletions

File tree

src/auth0_server_python/auth_types/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,8 @@ class MfaVerifyResponse(BaseModel):
530530
id_token: Optional[str] = None
531531
refresh_token: Optional[str] = None
532532
scope: Optional[str] = None
533+
audience: Optional[str] = None
534+
recovery_code: Optional[str] = None
533535

534536

535537
# MFA Requirements

src/auth0_server_python/tests/test_mfa_client.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,28 @@ async def test_enroll_email_oob_success(self, mocker):
238238
assert isinstance(result, OobEnrollmentResponse)
239239
assert result.oob_channel == "email"
240240

241+
@pytest.mark.asyncio
242+
async def test_enroll_push_auth0_channel_success(self, mocker):
243+
client = _make_client()
244+
response = AsyncMock()
245+
response.status_code = 200
246+
response.json = MagicMock(return_value={
247+
"authenticator_type": "oob",
248+
"oob_channel": "auth0",
249+
"oob_code": "oob_push_123",
250+
"binding_method": "prompt",
251+
"recovery_codes": ["rc1", "rc2"]
252+
})
253+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
254+
255+
result = await client.enroll_authenticator({
256+
"mfa_token": "tok",
257+
"authenticator_types": ["oob"],
258+
"oob_channels": ["auth0"]
259+
})
260+
assert isinstance(result, OobEnrollmentResponse)
261+
assert result.oob_channel == "auth0"
262+
241263
@pytest.mark.asyncio
242264
async def test_enroll_api_error(self, mocker):
243265
client = _make_client()
@@ -379,6 +401,68 @@ async def test_challenge_api_error(self, mocker):
379401
})
380402
assert "Token expired" in str(exc.value)
381403

404+
@pytest.mark.asyncio
405+
async def test_challenge_expired_mfa_token(self, mocker):
406+
"""When Auth0 returns expired_token for an expired mfa_token."""
407+
client = _make_client()
408+
response = AsyncMock()
409+
response.status_code = 401
410+
response.json = MagicMock(return_value={
411+
"error": "expired_token",
412+
"error_description": "mfa_token is expired"
413+
})
414+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
415+
416+
with pytest.raises(MfaChallengeError) as exc:
417+
await client.challenge_authenticator({
418+
"mfa_token": "expired_tok",
419+
"challenge_type": "otp"
420+
})
421+
assert "mfa_token is expired" in str(exc.value)
422+
423+
@pytest.mark.asyncio
424+
async def test_challenge_email_with_authenticator_id(self, mocker):
425+
"""Challenge an email authenticator with a specific authenticator_id."""
426+
client = _make_client()
427+
response = AsyncMock()
428+
response.status_code = 200
429+
response.json = MagicMock(return_value={
430+
"challenge_type": "oob",
431+
"oob_code": "oob_email_challenge_123",
432+
"binding_method": "prompt"
433+
})
434+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
435+
436+
result = await client.challenge_authenticator({
437+
"mfa_token": "tok",
438+
"challenge_type": "oob",
439+
"authenticator_id": "email|dev_Fvx38nHufsGL5lWI"
440+
})
441+
assert result.challenge_type == "oob"
442+
assert result.oob_code == "oob_email_challenge_123"
443+
assert result.binding_method == "prompt"
444+
445+
@pytest.mark.asyncio
446+
async def test_challenge_sms_with_authenticator_id(self, mocker):
447+
"""Challenge an SMS authenticator with a specific authenticator_id."""
448+
client = _make_client()
449+
response = AsyncMock()
450+
response.status_code = 200
451+
response.json = MagicMock(return_value={
452+
"challenge_type": "oob",
453+
"oob_code": "oob_sms_challenge_456",
454+
"binding_method": "prompt"
455+
})
456+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
457+
458+
result = await client.challenge_authenticator({
459+
"mfa_token": "tok",
460+
"challenge_type": "oob",
461+
"authenticator_id": "sms|dev_h1uXXoVjQ5BpU9iQ"
462+
})
463+
assert result.challenge_type == "oob"
464+
assert result.oob_code == "oob_sms_challenge_456"
465+
382466

383467
# ── verify ───────────────────────────────────────────────────────────────────
384468

@@ -446,6 +530,115 @@ async def test_verify_no_credential_raises(self):
446530
await client.verify({"mfa_token": "tok"})
447531
assert "No verification credential" in str(exc.value)
448532

533+
@pytest.mark.asyncio
534+
async def test_verify_sends_mfa_token_as_form_data(self, mocker):
535+
"""Verify that mfa_token is sent as form_data, not as Authorization header."""
536+
client = _make_client()
537+
response = AsyncMock()
538+
response.status_code = 200
539+
response.json = MagicMock(return_value={
540+
"access_token": "at",
541+
"token_type": "Bearer",
542+
"expires_in": 3600
543+
})
544+
545+
captured_request = {}
546+
547+
async def mock_post(self_client, url, **kwargs):
548+
captured_request["url"] = url
549+
captured_request["kwargs"] = kwargs
550+
return response
551+
552+
mocker.patch("httpx.AsyncClient.post", new=mock_post)
553+
554+
await client.verify({
555+
"mfa_token": "my_mfa_token",
556+
"otp": "123456"
557+
})
558+
559+
# Verify: mfa_token in form data body, NOT in Authorization header
560+
assert "data" in captured_request["kwargs"]
561+
form_data = captured_request["kwargs"]["data"]
562+
assert form_data["mfa_token"] == "my_mfa_token"
563+
assert "Content-Type" in captured_request["kwargs"].get("headers", {})
564+
assert captured_request["kwargs"]["headers"]["Content-Type"] == "application/x-www-form-urlencoded"
565+
# Should NOT use auth= parameter (no BearerAuth)
566+
assert "auth" not in captured_request["kwargs"]
567+
568+
@pytest.mark.asyncio
569+
async def test_verify_expired_mfa_token(self, mocker):
570+
"""When Auth0 returns expired_token for an expired mfa_token."""
571+
client = _make_client()
572+
response = AsyncMock()
573+
response.status_code = 401
574+
response.json = MagicMock(return_value={
575+
"error": "expired_token",
576+
"error_description": "mfa_token is expired"
577+
})
578+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
579+
580+
with pytest.raises(MfaVerifyError) as exc:
581+
await client.verify({"mfa_token": "expired_tok", "otp": "123456"})
582+
assert "mfa_token is expired" in str(exc.value)
583+
584+
@pytest.mark.asyncio
585+
async def test_verify_invalid_challenge_type(self, mocker):
586+
"""When Auth0 returns invalid_request for an unsupported challenge type."""
587+
client = _make_client()
588+
response = AsyncMock()
589+
response.status_code = 400
590+
response.json = MagicMock(return_value={
591+
"error": "invalid_request",
592+
"error_description": "Invalid challenge type"
593+
})
594+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
595+
596+
with pytest.raises(MfaVerifyError) as exc:
597+
await client.verify({"mfa_token": "tok", "recovery_code": "ABCD-1234"})
598+
assert "Invalid challenge type" in str(exc.value)
599+
600+
@pytest.mark.asyncio
601+
async def test_verify_response_includes_recovery_code(self, mocker):
602+
"""When MFA verification returns a new recovery_code (e.g., after recovery code use)."""
603+
client = _make_client()
604+
response = AsyncMock()
605+
response.status_code = 200
606+
response.json = MagicMock(return_value={
607+
"access_token": "new_at",
608+
"token_type": "Bearer",
609+
"expires_in": 3600,
610+
"recovery_code": "NEW-RECOVERY-CODE-XYZ"
611+
})
612+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
613+
614+
result = await client.verify({
615+
"mfa_token": "tok",
616+
"recovery_code": "OLD-RECOVERY-CODE"
617+
})
618+
assert isinstance(result, MfaVerifyResponse)
619+
assert result.recovery_code == "NEW-RECOVERY-CODE-XYZ"
620+
621+
@pytest.mark.asyncio
622+
async def test_verify_push_oob_success(self, mocker):
623+
"""Verify with OOB code from push notification challenge."""
624+
client = _make_client()
625+
response = AsyncMock()
626+
response.status_code = 200
627+
response.json = MagicMock(return_value={
628+
"access_token": "push_at",
629+
"token_type": "Bearer",
630+
"expires_in": 3600
631+
})
632+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
633+
634+
result = await client.verify({
635+
"mfa_token": "tok",
636+
"oob_code": "oob_push_code",
637+
"binding_code": ""
638+
})
639+
assert isinstance(result, MfaVerifyResponse)
640+
assert result.access_token == "push_at"
641+
449642
@pytest.mark.asyncio
450643
async def test_verify_wrong_code_raises(self, mocker):
451644
client = _make_client()

src/auth0_server_python/tests/test_server_client.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
ListConnectedAccountsResponse,
2020
LoginWithCustomTokenExchangeOptions,
2121
LogoutOptions,
22+
MfaRequirements,
2223
TransactionData,
2324
)
2425
from auth0_server_python.error import (
@@ -2936,3 +2937,77 @@ async def _fake_fetch(self, domain):
29362937
assert exc.value.mfa_token != "raw_mfa_token_xyz" # encrypted
29372938
finally:
29382939
sc_mod.ServerClient._fetch_oidc_metadata = original_fetch
2940+
2941+
2942+
@pytest.mark.asyncio
2943+
async def test_get_access_token_mfa_required_with_enroll_requirements(mocker):
2944+
"""
2945+
When get_token_by_refresh_token returns mfa_required with enroll requirements,
2946+
get_access_token should raise MfaRequiredError with mfa_requirements containing enroll.
2947+
"""
2948+
mock_secret = "a-test-secret-with-enough-length"
2949+
mock_store = MagicMock()
2950+
mock_store.get = AsyncMock(return_value=None)
2951+
mock_store.set = AsyncMock()
2952+
mock_store.delete = AsyncMock()
2953+
2954+
import auth0_server_python.auth_server.server_client as sc_mod
2955+
2956+
original_fetch = sc_mod.ServerClient._fetch_oidc_metadata
2957+
2958+
async def _fake_fetch(self, domain):
2959+
return {
2960+
"authorization_endpoint": f"https://{domain}/authorize",
2961+
"token_endpoint": f"https://{domain}/oauth/token",
2962+
"end_session_endpoint": f"https://{domain}/v2/logout",
2963+
"backchannel_logout_supported": True,
2964+
}
2965+
2966+
sc_mod.ServerClient._fetch_oidc_metadata = _fake_fetch
2967+
try:
2968+
client = ServerClient(
2969+
domain="auth0.local",
2970+
client_id="cid",
2971+
client_secret="csecret",
2972+
secret=mock_secret,
2973+
transaction_store=mock_store,
2974+
state_store=mock_store,
2975+
)
2976+
2977+
# Simulate state with a refresh_token and expired access token
2978+
mock_store.get = AsyncMock(return_value={
2979+
"refresh_token": "rt_123",
2980+
"token_sets": [
2981+
{
2982+
"audience": "default",
2983+
"access_token": "expired_at",
2984+
"expires_at": 0,
2985+
}
2986+
]
2987+
})
2988+
2989+
# Simulate mfa_required with enroll requirements
2990+
mfa_err = ApiError(
2991+
code="mfa_required",
2992+
message="Multifactor authentication required",
2993+
)
2994+
mfa_err.mfa_token = "raw_mfa_token_enroll"
2995+
mfa_err.mfa_requirements = MfaRequirements(
2996+
enroll=[
2997+
{"type": "otp"},
2998+
{"type": "phone"},
2999+
{"type": "push-notification"}
3000+
]
3001+
)
3002+
3003+
mocker.patch.object(client, "get_token_by_refresh_token",
3004+
new_callable=AsyncMock, side_effect=mfa_err)
3005+
3006+
with pytest.raises(MfaRequiredError) as exc:
3007+
await client.get_access_token()
3008+
3009+
assert exc.value.mfa_token is not None
3010+
assert exc.value.mfa_token != "raw_mfa_token_enroll" # encrypted
3011+
assert exc.value.mfa_requirements is not None
3012+
finally:
3013+
sc_mod.ServerClient._fetch_oidc_metadata = original_fetch

0 commit comments

Comments
 (0)