1919from auth0_server_python .error import (
2020 MissingTransactionError ,
2121 ApiError ,
22+ PollingApiError ,
2223 MissingRequiredArgumentError ,
2324 BackchannelLogoutError ,
2425 AccessTokenError ,
@@ -842,21 +843,103 @@ async def backchannel_authentication(
842843 options : Dict [str , Any ]
843844 ) -> Dict [str , Any ]:
844845 """
845- Initiates backchannel authentication with Auth0.
846+ Performs backchannel authentication with Auth0.
846847
847848 This method starts a Client-Initiated Backchannel Authentication (CIBA) flow,
848849 which allows an application to request authentication from a user via a separate
849850 device or channel.
850851
852+ Then polls the token endpoint until the user has authenticated or the request times out.
853+
851854 Args:
852- options: Configuration options for backchannel authentication
855+ options (dict): Configuration options for backchannel authentication
856+ - login_hint (dict): Must contain a 'sub' field (e.g., {'sub': 'user_id'}).
857+ - binding_message (str, optional): Message to display to the user.
858+ - authorization_params (dict, optional): Additional authorization parameters.
859+ - requested_expiry (int, optional): Requested expiry time in seconds, default is 30 secs.
860+ - authorization_details (str, optional): JSON string for RAR.
853861
854862 Returns:
855863 Token response data from the backchannel authentication
856864
857865 Raises:
858866 ApiError: If the backchannel authentication fails
859867 """
868+ backchannel_data = await self .start_backchannel_authentication (options )
869+ auth_req_id = backchannel_data .get ("auth_req_id" )
870+ expires_in = backchannel_data .get (
871+ "expires_in" , 120 ) # Default to 2 minutes
872+ interval = backchannel_data .get (
873+ "interval" , 5 ) # Default to 5 seconds
874+
875+ # Calculate when to stop polling
876+ end_time = time .time () + expires_in
877+
878+ # Poll until we get a response or timeout
879+ while time .time () < end_time :
880+ # Make token request
881+ try :
882+ token_response = await self .get_token_by_auth_req_id (auth_req_id )
883+ return token_response
884+
885+ except Exception as e :
886+ if isinstance (e , PollingApiError ):
887+ if e .code == "authorization_pending" :
888+ # Wait for the specified interval before polling again
889+ await asyncio .sleep (interval )
890+ continue
891+ if e .code == "slow_down" :
892+ # Wait for the specified interval before polling again
893+ await asyncio .sleep (e .interval or interval )
894+ continue
895+ raise ApiError (
896+ "backchannel_error" ,
897+ f"Backchannel authentication failed: { str (e ) or 'Unknown error' } " ,
898+ e
899+ )
900+
901+ # If we get here, we've timed out
902+ raise ApiError (
903+ "timeout" , "Backchannel authentication timed out" )
904+
905+ async def start_backchannel_authentication (
906+ self ,
907+ options : Dict [str , Any ]
908+ ) -> Dict [str , Any ]:
909+ """
910+ Start backchannel authentication with Auth0.
911+
912+ This method starts a Client-Initiated Backchannel Authentication (CIBA) flow,
913+ which allows an application to request authentication from a user via a separate
914+ device or channel.
915+
916+ Args:
917+ options (dict): Configuration options for backchannel authentication
918+ - login_hint (dict): Must contain a 'sub' field (e.g., {'sub': 'user_id'}).
919+ - binding_message (str, optional): Message to display to the user.
920+ - authorization_params (dict, optional): Additional authorization parameters.
921+ - requested_expiry (int, optional): Requested expiry time in seconds, default is 30 secs.
922+ - authorization_details (str, optional): JSON string for RAR.
923+
924+ Returns:
925+ dict: Response data from the bc-authorize backchannel authentication
926+ - auth_req_id (str): The authentication request ID.
927+ - expires_in (int): Time in seconds until the request expires.
928+ - interval (int, optional): Polling interval in seconds.
929+
930+ Raises:
931+ ApiError: If the backchannel authentication fails
932+
933+ See:
934+ https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-initiated-backchannel-authentication-flow
935+ """
936+
937+ sub = options .get ('login_hint' , {}).get ("sub" )
938+ if not sub :
939+ raise MissingRequiredArgumentError (
940+ "login_hint.sub"
941+ )
942+
860943 try :
861944 # Fetch OpenID Connect metadata if not already fetched
862945 if not hasattr (self , '_oauth_metadata' ):
@@ -875,21 +958,6 @@ async def backchannel_authentication(
875958 "Backchannel authentication is not supported by the authorization server"
876959 )
877960
878- # Get token endpoint
879- token_endpoint = self ._oauth_metadata .get ("token_endpoint" )
880- if not token_endpoint :
881- raise ApiError (
882- "configuration_error" ,
883- "Token endpoint is missing in OIDC metadata"
884- )
885-
886- sub = sub = options .get ('login_hint' , {}).get ("sub" )
887- if not sub :
888- raise ApiError (
889- "invalid_parameter" ,
890- "login_hint must contain a 'sub' field"
891- )
892-
893961 # Prepare login hint in the required format
894962 login_hint = json .dumps ({
895963 "format" : "iss_sub" ,
@@ -933,74 +1001,95 @@ async def backchannel_authentication(
9331001
9341002 backchannel_data = backchannel_response .json ()
9351003 auth_req_id = backchannel_data .get ("auth_req_id" )
936- expires_in = backchannel_data .get (
937- "expires_in" , 120 ) # Default to 2 minutes
938- interval = backchannel_data .get (
939- "interval" , 5 ) # Default to 5 seconds
9401004
9411005 if not auth_req_id :
9421006 raise ApiError (
9431007 "invalid_response" ,
9441008 "Missing auth_req_id in backchannel authentication response"
9451009 )
9461010
947- # Poll for token using the auth_req_id
948- token_params = {
949- "grant_type" : "urn:openid:params:grant-type:ciba" ,
950- "auth_req_id" : auth_req_id ,
951- "client_id" : self ._client_id ,
952- "client_secret" : self ._client_secret
953- }
954-
955- # Calculate when to stop polling
956- end_time = time .time () + expires_in
957-
958- # Poll until we get a response or timeout
959- while time .time () < end_time :
960- # Make token request
961- token_response = await client .post (token_endpoint , data = token_params )
962-
963- # Check for success (200 OK)
964- if token_response .status_code == 200 :
965- # Success! Parse and return the token response
966- return token_response .json ()
967-
968- # Check for specific error that indicates we should continue polling
969- if token_response .status_code == 400 :
970- error_data = token_response .json ()
971- error = error_data .get ("error" )
972-
973- # authorization_pending means we should keep polling
974- if error == "authorization_pending" :
975- # Wait for the specified interval before polling again
976- await asyncio .sleep (interval )
977- continue
978-
979- # Other errors should be raised
980- raise ApiError (
981- error ,
982- error_data .get ("error_description" ,
983- "Token request failed" )
984- )
985-
986- # Any other status code is an error
987- raise ApiError (
988- "token_error" ,
989- f"Unexpected status code: { token_response .status_code } "
990- )
991-
992- # If we get here, we've timed out
993- raise ApiError (
994- "timeout" , "Backchannel authentication timed out" )
1011+ return backchannel_data
9951012
9961013 except Exception as e :
997- print ("Caught exception:" , type (e ), e .args , repr (e ))
1014+ if isinstance (e , ApiError ):
1015+ raise
9981016 raise ApiError (
9991017 "backchannel_error" ,
10001018 f"Backchannel authentication failed: { str (e ) or 'Unknown error' } " ,
10011019 e
10021020 )
10031021
1022+ async def get_token_by_auth_req_id (self , auth_req_id : str ) -> Dict [str , Any ]:
1023+ """
1024+ Retrieves a token by exchanging an auth_req_id.
1025+
1026+ Args:
1027+ auth_req_id (str): The authentication request ID obtained from bc-authorize
1028+
1029+ Raises:
1030+ AccessTokenError: If there was an issue requesting the access token.
1031+
1032+ Returns:
1033+ A dictionary containing the token response from Auth0.
1034+ """
1035+ if not auth_req_id :
1036+ raise MissingRequiredArgumentError ("auth_req_id" )
1037+
1038+ try :
1039+ # Ensure we have the OIDC metadata
1040+ if not hasattr (self ._oauth , "metadata" ) or not self ._oauth .metadata :
1041+ self ._oauth .metadata = await self ._fetch_oidc_metadata (self ._domain )
1042+
1043+ token_endpoint = self ._oauth .metadata .get ("token_endpoint" )
1044+ if not token_endpoint :
1045+ raise ApiError ("configuration_error" ,
1046+ "Token endpoint missing in OIDC metadata" )
1047+
1048+ # Prepare the token request parameters
1049+ token_params = {
1050+ "grant_type" : "urn:openid:params:grant-type:ciba" ,
1051+ "auth_req_id" : auth_req_id ,
1052+ "client_id" : self ._client_id ,
1053+ "client_secret" : self ._client_secret
1054+ }
1055+
1056+ # Exchange the auth_req_id for an access token
1057+ async with httpx .AsyncClient () as client :
1058+ response = await client .post (
1059+ token_endpoint ,
1060+ data = token_params ,
1061+ auth = (self ._client_id , self ._client_secret )
1062+ )
1063+
1064+ if response .status_code != 200 :
1065+ error_data = response .json ()
1066+ retry_after = response .headers .get ("Retry-After" )
1067+ interval = int (retry_after ) if retry_after is not None else None
1068+ raise PollingApiError (
1069+ error_data .get ("error" , "auth_req_id_error" ),
1070+ error_data .get ("error_description" ,
1071+ "Failed to exchange auth_req_id" ),
1072+ interval
1073+ )
1074+
1075+ token_response = response .json ()
1076+
1077+ # Add required fields if they are missing
1078+ if "expires_in" in token_response and "expires_at" not in token_response :
1079+ token_response ["expires_at" ] = int (
1080+ time .time ()) + token_response ["expires_in" ]
1081+
1082+ return token_response
1083+
1084+ except Exception as e :
1085+ if isinstance (e , (ApiError , PollingApiError )):
1086+ raise
1087+ raise AccessTokenError (
1088+ AccessTokenErrorCode .AUTH_REQ_ID_ERROR ,
1089+ "There was an error while trying to exchange the auth_req_id for an access token." ,
1090+ e
1091+ )
1092+
10041093 async def get_token_by_refresh_token (self , options : Dict [str , Any ]) -> Dict [str , Any ]:
10051094 """
10061095 Retrieves a token by exchanging a refresh token.
0 commit comments