2929 BackchannelLogoutError ,
3030 MissingRequiredArgumentError ,
3131 MissingTransactionError ,
32+ PollingApiError ,
3233 StartLinkUserError ,
3334)
3435from auth0_server_python .utils import PKCE , URL , State
@@ -838,21 +839,119 @@ async def backchannel_authentication(
838839 options : dict [str , Any ]
839840 ) -> dict [str , Any ]:
840841 """
841- Initiates backchannel authentication with Auth0.
842+ Performs backchannel authentication with Auth0.
842843
843844 This method starts a Client-Initiated Backchannel Authentication (CIBA) flow,
844845 which allows an application to request authentication from a user via a separate
845846 device or channel.
846847
848+ Then polls the token endpoint until the user has authenticated or the request times out.
849+
847850 Args:
848- options: Configuration options for backchannel authentication
851+ options (dict): Configuration options for backchannel authentication
852+ - login_hint (dict): Must contain a 'sub' field (e.g., {'sub': 'user_id'}).
853+ - binding_message (str, optional): Message to display to the user.
854+ - authorization_params (dict, optional): Additional authorization parameters.
855+ - requested_expiry (int, optional): Requested expiry time in seconds, default is 30 secs.
856+ - authorization_details (str, optional): JSON string for RAR.
849857
850858 Returns:
851859 Token response data from the backchannel authentication
852860
853861 Raises:
854862 ApiError: If the backchannel authentication fails
855863 """
864+ backchannel_data = await self .initiate_backchannel_authentication (options )
865+ auth_req_id = backchannel_data .get ("auth_req_id" )
866+ expires_in = backchannel_data .get (
867+ "expires_in" , 120 ) # Default to 2 minutes
868+ interval = backchannel_data .get (
869+ "interval" , 5 ) # Default to 5 seconds
870+
871+ # Calculate when to stop polling
872+ end_time = time .time () + expires_in
873+
874+ # Poll until we get a response or timeout
875+ while time .time () < end_time :
876+ # Make token request
877+ try :
878+ token_response = await self .backchannel_authentication_grant (auth_req_id )
879+ return token_response
880+
881+ except Exception as e :
882+ if isinstance (e , PollingApiError ):
883+ if e .code == "authorization_pending" :
884+ # Wait for the specified interval before polling again
885+ await asyncio .sleep (interval )
886+ continue
887+ if e .code == "slow_down" :
888+ # Wait for the specified interval before polling again
889+ await asyncio .sleep (e .interval or interval )
890+ continue
891+ raise ApiError (
892+ "backchannel_error" ,
893+ f"Backchannel authentication failed: { str (e ) or 'Unknown error' } " ,
894+ e
895+ )
896+
897+ # If we get here, we've timed out
898+ raise ApiError (
899+ "timeout" , "Backchannel authentication timed out" )
900+
901+ async def initiate_backchannel_authentication (
902+ self ,
903+ options : dict [str , Any ]
904+ ) -> dict [str , Any ]:
905+ """
906+ Start backchannel authentication with Auth0.
907+
908+ This method starts a Client-Initiated Backchannel Authentication (CIBA) flow,
909+ which allows an application to request authentication from a user via a separate
910+ device or channel.
911+
912+ Args:
913+ options (dict): Configuration options for backchannel authentication
914+ - login_hint (dict): Must contain a 'sub' field (e.g., {'sub': 'user_id'}).
915+ - binding_message (str, optional): Message to display to the user.
916+ - authorization_params (dict, optional): Additional authorization parameters.
917+ - requested_expiry (int, optional): Requested expiry time in seconds, default is 30 secs.
918+ - authorization_details (str, optional): JSON string for RAR.
919+
920+ Returns:
921+ dict: Response data from the bc-authorize backchannel authentication
922+ - auth_req_id (str): The authentication request ID.
923+ - expires_in (int): Time in seconds until the request expires.
924+ - interval (int, optional): Polling interval in seconds.
925+
926+ Raises:
927+ ApiError: If the backchannel authentication fails
928+
929+ See:
930+ https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-initiated-backchannel-authentication-flow
931+ """
932+
933+ sub = options .get ('login_hint' , {}).get ("sub" )
934+ if not sub :
935+ raise MissingRequiredArgumentError (
936+ "login_hint.sub"
937+ )
938+
939+ authorization_params = options .get ('authorization_params' )
940+ if authorization_params is not None and not isinstance (authorization_params , dict ):
941+ raise ApiError (
942+ "invalid_argument" ,
943+ "authorization_params must be a dict"
944+ )
945+
946+ if authorization_params :
947+ requested_expiry = authorization_params .get ("requested_expiry" )
948+ if requested_expiry is not None :
949+ if not isinstance (requested_expiry , int ) or requested_expiry <= 0 :
950+ raise ApiError (
951+ "invalid_argument" ,
952+ "authorization_params.requested_expiry must be a positive integer"
953+ )
954+
856955 try :
857956 # Fetch OpenID Connect metadata if not already fetched
858957 if not hasattr (self , '_oauth_metadata' ):
@@ -871,21 +970,6 @@ async def backchannel_authentication(
871970 "Backchannel authentication is not supported by the authorization server"
872971 )
873972
874- # Get token endpoint
875- token_endpoint = self ._oauth_metadata .get ("token_endpoint" )
876- if not token_endpoint :
877- raise ApiError (
878- "configuration_error" ,
879- "Token endpoint is missing in OIDC metadata"
880- )
881-
882- sub = sub = options .get ('login_hint' , {}).get ("sub" )
883- if not sub :
884- raise ApiError (
885- "invalid_parameter" ,
886- "login_hint must contain a 'sub' field"
887- )
888-
889973 # Prepare login hint in the required format
890974 login_hint = json .dumps ({
891975 "format" : "iss_sub" ,
@@ -908,8 +992,8 @@ async def backchannel_authentication(
908992 if self ._default_authorization_params :
909993 params .update (self ._default_authorization_params )
910994
911- if options . get ( ' authorization_params' ) :
912- params .update (options . get ( ' authorization_params' ) )
995+ if authorization_params :
996+ params .update (authorization_params )
913997
914998 # Make the backchannel authentication request
915999 async with httpx .AsyncClient () as client :
@@ -929,71 +1013,98 @@ async def backchannel_authentication(
9291013
9301014 backchannel_data = backchannel_response .json ()
9311015 auth_req_id = backchannel_data .get ("auth_req_id" )
932- expires_in = backchannel_data .get (
933- "expires_in" , 120 ) # Default to 2 minutes
934- interval = backchannel_data .get (
935- "interval" , 5 ) # Default to 5 seconds
9361016
9371017 if not auth_req_id :
9381018 raise ApiError (
9391019 "invalid_response" ,
9401020 "Missing auth_req_id in backchannel authentication response"
9411021 )
9421022
943- # Poll for token using the auth_req_id
944- token_params = {
945- "grant_type" : "urn:openid:params:grant-type:ciba" ,
946- "auth_req_id" : auth_req_id ,
947- "client_id" : self ._client_id ,
948- "client_secret" : self ._client_secret
949- }
1023+ return backchannel_data
9501024
951- # Calculate when to stop polling
952- end_time = time .time () + expires_in
953-
954- # Poll until we get a response or timeout
955- while time .time () < end_time :
956- # Make token request
957- token_response = await client .post (token_endpoint , data = token_params )
958-
959- # Check for success (200 OK)
960- if token_response .status_code == 200 :
961- # Success! Parse and return the token response
962- return token_response .json ()
963-
964- # Check for specific error that indicates we should continue polling
965- if token_response .status_code == 400 :
966- error_data = token_response .json ()
967- error = error_data .get ("error" )
968-
969- # authorization_pending means we should keep polling
970- if error == "authorization_pending" :
971- # Wait for the specified interval before polling again
972- await asyncio .sleep (interval )
973- continue
974-
975- # Other errors should be raised
976- raise ApiError (
977- error ,
978- error_data .get ("error_description" ,
979- "Token request failed" )
980- )
981-
982- # Any other status code is an error
1025+ except Exception as e :
1026+ if isinstance (e , ApiError ):
1027+ raise
1028+ raise ApiError (
1029+ "backchannel_error" ,
1030+ f"Backchannel authentication failed: { str (e ) or 'Unknown error' } " ,
1031+ e
1032+ )
1033+
1034+ async def backchannel_authentication_grant (self , auth_req_id : str ) -> dict [str , Any ]:
1035+ """
1036+ Retrieves a token by exchanging an auth_req_id.
1037+
1038+ Args:
1039+ auth_req_id (str): The authentication request ID obtained from bc-authorize
1040+
1041+ Raises:
1042+ AccessTokenError: If there was an issue requesting the access token.
1043+
1044+ Returns:
1045+ A dictionary containing the token response from Auth0.
1046+ """
1047+ if not auth_req_id :
1048+ raise MissingRequiredArgumentError ("auth_req_id" )
1049+
1050+ try :
1051+ # Ensure we have the OIDC metadata
1052+ if not hasattr (self ._oauth , "metadata" ) or not self ._oauth .metadata :
1053+ self ._oauth .metadata = await self ._fetch_oidc_metadata (self ._domain )
1054+
1055+ token_endpoint = self ._oauth .metadata .get ("token_endpoint" )
1056+ if not token_endpoint :
1057+ raise ApiError ("configuration_error" ,
1058+ "Token endpoint missing in OIDC metadata" )
1059+
1060+ # Prepare the token request parameters
1061+ token_params = {
1062+ "grant_type" : "urn:openid:params:grant-type:ciba" ,
1063+ "auth_req_id" : auth_req_id ,
1064+ "client_id" : self ._client_id ,
1065+ "client_secret" : self ._client_secret
1066+ }
1067+
1068+ # Exchange the auth_req_id for an access token
1069+ async with httpx .AsyncClient () as client :
1070+ response = await client .post (
1071+ token_endpoint ,
1072+ data = token_params ,
1073+ auth = (self ._client_id , self ._client_secret )
1074+ )
1075+
1076+ if response .status_code != 200 :
1077+ error_data = response .json ()
1078+ retry_after = response .headers .get ("Retry-After" )
1079+ interval = int (retry_after ) if retry_after is not None else None
1080+ raise PollingApiError (
1081+ error_data .get ("error" , "auth_req_id_error" ),
1082+ error_data .get ("error_description" ,
1083+ "Failed to exchange auth_req_id" ),
1084+ interval
1085+ )
1086+
1087+ try :
1088+ token_response = response .json ()
1089+ except json .JSONDecodeError :
9831090 raise ApiError (
984- "token_error " ,
985- f"Unexpected status code: { token_response . status_code } "
1091+ "invalid_response " ,
1092+ "Failed to parse token response as JSON "
9861093 )
9871094
988- # If we get here, we've timed out
989- raise ApiError (
990- "timeout" , "Backchannel authentication timed out" )
1095+ # Add required fields if they are missing
1096+ if "expires_in" in token_response and "expires_at" not in token_response :
1097+ token_response ["expires_at" ] = int (
1098+ time .time ()) + token_response ["expires_in" ]
1099+
1100+ return token_response
9911101
9921102 except Exception as e :
993- print ("Caught exception:" , type (e ), e .args , repr (e ))
994- raise ApiError (
995- "backchannel_error" ,
996- f"Backchannel authentication failed: { str (e ) or 'Unknown error' } " ,
1103+ if isinstance (e , (ApiError , PollingApiError )):
1104+ raise
1105+ raise AccessTokenError (
1106+ AccessTokenErrorCode .AUTH_REQ_ID_ERROR ,
1107+ "There was an error while trying to exchange the auth_req_id for an access token." ,
9971108 e
9981109 )
9991110
0 commit comments