Skip to content

Commit fb5136b

Browse files
authored
Updates for CIBA with Email (#28)
* Updates for CIBA with Email * couple of renames to match auth0-auth-js PR * Add handling for unexpected successful response type * Fix lint errors * Fix py 3.9 tests * Update examples/ClientInitiatedBackChannelLogin.md * Add validation for authorization_params and requested_expiry
1 parent 2448d66 commit fb5136b

File tree

4 files changed

+391
-73
lines changed

4 files changed

+391
-73
lines changed

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 300 seconds.
5357

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

src/auth0_server_python/auth_server/server_client.py

Lines changed: 182 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
BackchannelLogoutError,
3030
MissingRequiredArgumentError,
3131
MissingTransactionError,
32+
PollingApiError,
3233
StartLinkUserError,
3334
)
3435
from 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

src/auth0_server_python/error/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Error classes for the auth0-server-python SDK.
33
These exceptions provide specific error types for different failure scenarios.
44
"""
5+
from typing import Optional
6+
57

68
class Auth0Error(Exception):
79
"""Base class for all Auth0 SDK errors."""
@@ -44,13 +46,25 @@ def __init__(self, code: str, message: str, cause=None):
4446
self.error_description = None
4547

4648

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

50-
def __init__(self, code: str, message: str):
63+
def __init__(self, code: str, message: str, cause=None):
5164
super().__init__(message)
5265
self.code = code
5366
self.name = "AccessTokenError"
67+
self.cause = cause
5468

5569

5670
class MissingRequiredArgumentError(Auth0Error):
@@ -109,6 +123,7 @@ class AccessTokenErrorCode:
109123
FAILED_TO_REFRESH_TOKEN = "failed_to_refresh_token"
110124
FAILED_TO_REQUEST_TOKEN = "failed_to_request_token"
111125
REFRESH_TOKEN_ERROR = "refresh_token_error"
126+
AUTH_REQ_ID_ERROR = "auth_req_id_error"
112127

113128

114129
class AccessTokenForConnectionErrorCode:

0 commit comments

Comments
 (0)