Skip to content

Commit b6c9324

Browse files
committed
Updates for CIBA with Email
1 parent 00a33c5 commit b6c9324

File tree

4 files changed

+322
-73
lines changed

4 files changed

+322
-73
lines changed

packages/auth0_server_python/examples/ClientInitiatedBackChannelLogin.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ async def authenticate_via_backchannel():
2929
# Configure backchannel options
3030
options = {
3131
"login_hint": {"sub": "user-id"}, # The user identifier
32-
"binding_message": "Login to Example App" # Message displayed to the user
32+
"binding_message": "Login to Example App", # Message displayed to the user
33+
"authorization_params": {
34+
"requested_expiry": 300, # Optional: Token expiry time in seconds
35+
}
3336
}
3437

3538
# Initiate backchannel authentication
@@ -50,6 +53,7 @@ async def authenticate_via_backchannel():
5053

5154
- `binding_message`: An optional, human-readable message to be displayed at the consumption device and authentication device. This allows the user to ensure the transaction initiated by the consumption device is the same that triggers the action on the authentication device.
5255
- `login_hint['sub']`: The `sub` claim of the user that is trying to login using Client-Initiated Backchannel Authentication, and to which a push notification to authorize the login will be sent.
56+
- `requested_expiry`: The requested lifetime, in seconds, of the authentication request. The default value on Auth0 is 30 seconds.
5357

5458
> [!IMPORTANT]
5559
> - Using Client-Initiated Backchannel Authentication requires the feature to be enabled in the Auth0 dashboard.

packages/auth0_server_python/src/auth0_server_python/auth_server/server_client.py

Lines changed: 159 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from 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.

packages/auth0_server_python/src/auth0_server_python/error/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,25 @@ def __init__(self, code: str, message: str, cause=None):
4444
self.error_description = None
4545

4646

47+
class PollingApiError(ApiError):
48+
"""
49+
Error raised when a polling API request to Auth0 fails.
50+
Contains details about the original error from Auth0 and the requested polling interval.
51+
"""
52+
53+
def __init__(self, code: str, message: str, interval: int | None, cause=None):
54+
super().__init__(code, message, cause)
55+
self.interval = interval
56+
57+
4758
class AccessTokenError(Auth0Error):
4859
"""Error raised when there's an issue with access tokens."""
4960

50-
def __init__(self, code: str, message: str):
61+
def __init__(self, code: str, message: str, cause=None):
5162
super().__init__(message)
5263
self.code = code
5364
self.name = "AccessTokenError"
65+
self.cause = cause
5466

5567

5668
class MissingRequiredArgumentError(Auth0Error):
@@ -109,6 +121,7 @@ class AccessTokenErrorCode:
109121
FAILED_TO_REFRESH_TOKEN = "failed_to_refresh_token"
110122
FAILED_TO_REQUEST_TOKEN = "failed_to_request_token"
111123
REFRESH_TOKEN_ERROR = "refresh_token_error"
124+
AUTH_REQ_ID_ERROR = "auth_req_id_error"
112125

113126

114127
class AccessTokenForConnectionErrorCode:

0 commit comments

Comments
 (0)