@@ -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 ()
0 commit comments