Skip to content

Commit 1acf635

Browse files
authored
Updates for CIBA with email (#720)
### Changes - Add `requested_expiry` option to CIBA - A couple of other small updates per https://oktawiki.atlassian.net/wiki/spaces/~6017c37320445000698e9629/pages/3663626850/Auth0+for+AI+Agents+-+GA+SDK+Support+Matrix ### References https://oktawiki.atlassian.net/wiki/spaces/~6017c37320445000698e9629/pages/3663626850/Auth0+for+AI+Agents+-+GA+SDK+Support+Matrix ### Testing - [X] This change adds unit test coverage - [ ] This change adds integration test coverage - [X] This change has been tested on the latest version of the platform/language or why not ### Manual Testing Follow the testing steps in auth0/auth0-server-python#28 - replacing the code snippets with the one's below: #### Get auth_req_id ```python import json from auth0.authentication.back_channel_login import BackChannelLogin def main(): bcl = BackChannelLogin("{DOMAIN}", "{CLIENT_ID}", "{CLIENT_SECRET}") res = bcl.back_channel_login( 'This is a binding message', json.dumps({ "format": "iss_sub", "iss": "https://{DOMAIN}/", "sub": "{USER_ID}", }), "openid profile email", requested_expiry=3600, # More then 30 to trigger email ) print(json.dumps(res, indent=2)) main() ``` <img width="1103" height="753" alt="image" src="https://github.com/user-attachments/assets/49aab808-35e3-47d4-8f38-5114ff0bc314" /> #### Exchange it for tokens once verified ```python import json from auth0.authentication import GetToken def main(): get_token = GetToken("{DOMAIN}", "{CLIENT_ID}", "{CLIENT_SECRET}") res = get_token.backchannel_login( "{auth_req_id}", grant_type="urn:openid:params:grant-type:ciba", ) print(json.dumps(res, indent=2)) main() ``` <img width="1110" height="790" alt="image" src="https://github.com/user-attachments/assets/34d15049-712c-4bb8-af04-0f95a7cee0ad" /> ### Checklist - [X] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md) - [X] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) - [X] All existing and new tests complete without errors
2 parents 24a371f + 67bf283 commit 1acf635

File tree

6 files changed

+156
-14
lines changed

6 files changed

+156
-14
lines changed

auth0/authentication/back_channel_login.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def back_channel_login(
1414
login_hint: str,
1515
scope: str,
1616
authorization_details: Optional[Union[str, List[Dict]]] = None,
17+
requested_expiry: Optional[int] = None,
1718
**kwargs
1819
) -> Any:
1920
"""Send a Back-Channel Login.
@@ -31,6 +32,9 @@ def back_channel_login(
3132
authorization_details (str, list of dict, optional): JSON string or a list of dictionaries representing
3233
Rich Authorization Requests (RAR) details to include in the CIBA request.
3334
35+
requested_expiry (int, optional): Number of seconds the authentication request is valid for.
36+
Auth0 defaults to 300 seconds (5 mins) if not provided.
37+
3438
**kwargs: Other fields to send along with the request.
3539
3640
Returns:
@@ -50,7 +54,12 @@ def back_channel_login(
5054
data["authorization_details"] = authorization_details
5155
elif isinstance(authorization_details, list):
5256
data["authorization_details"] = json.dumps(authorization_details)
53-
57+
58+
if requested_expiry is not None:
59+
if not isinstance(requested_expiry, int) or requested_expiry <= 0:
60+
raise ValueError("requested_expiry must be a positive integer")
61+
data["requested_expiry"] = str(requested_expiry)
62+
5463
data.update(kwargs)
5564

5665
return self.authenticated_post(

auth0/authentication/get_token.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ def backchannel_login(
266266
use urn:openid:params:grant-type:ciba
267267
268268
Returns:
269-
access_token, id_token
269+
access_token, id_token, refresh_token, token_type, expires_in, scope and authorization_details
270270
"""
271271

272272
return self.authenticated_post(
@@ -284,7 +284,8 @@ def access_token_for_connection(
284284
subject_token: str,
285285
requested_token_type: str,
286286
connection: str | None = None,
287-
grant_type: str = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token"
287+
grant_type: str = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token",
288+
login_hint: str = None
288289
) -> Any:
289290
"""Calls /oauth/token endpoint with federated-connection-access-token grant type
290291
@@ -293,22 +294,29 @@ def access_token_for_connection(
293294
294295
subject_token (str): String containing the value of subject_token_type.
295296
296-
requested_token_type (str): String containing the type of rquested token.
297+
requested_token_type (str): String containing the type of requested token.
297298
298299
connection (str, optional): Denotes the name of a social identity provider configured to your application
299300
301+
login_hint (str, optional): A hint to the OpenID Provider regarding the end-user for whom authentication is being requested
302+
300303
Returns:
301-
access_token, scope, issued_token_type, token_type
304+
access_token, scope, issued_token_type, token_type, expires_in
302305
"""
303306

307+
data = {
308+
"client_id": self.client_id,
309+
"grant_type": grant_type,
310+
"subject_token_type": subject_token_type,
311+
"subject_token": subject_token,
312+
"requested_token_type": requested_token_type,
313+
"connection": connection,
314+
}
315+
316+
if login_hint:
317+
data["login_hint"] = login_hint
318+
304319
return self.authenticated_post(
305320
f"{self.protocol}://{self.domain}/oauth/token",
306-
data={
307-
"client_id": self.client_id,
308-
"grant_type": grant_type,
309-
"subject_token_type": subject_token_type,
310-
"subject_token": subject_token,
311-
"requested_token_type": requested_token_type,
312-
"connection": connection,
313-
},
321+
data=data,
314322
)

auth0/exceptions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ def __init__(
1010
error_code: str,
1111
message: str,
1212
content: Any | None = None,
13+
headers: Any | None = None,
1314
) -> None:
1415
self.status_code = status_code
1516
self.error_code = error_code
1617
self.message = message
1718
self.content = content
19+
self.headers = headers
1820

1921
def __str__(self) -> str:
2022
return f"{self.status_code}: {self.message}"

auth0/rest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,12 +296,14 @@ def content(self) -> Any:
296296
error_code=self._error_code(),
297297
message=self._error_message(),
298298
content=self._content,
299+
headers=self._headers
299300
)
300301

301302
raise Auth0Error(
302303
status_code=self._status_code,
303304
error_code=self._error_code(),
304305
message=self._error_message(),
306+
headers=self._headers
305307
)
306308
else:
307309
return self._content

auth0/test/authentication/test_back_channel_login.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ def test_ciba(self, mock_post):
3333
},
3434
)
3535

36+
@mock.patch("requests.request")
37+
def test_server_error(self, mock_requests_request):
38+
response = requests.Response()
39+
response.status_code = 400
40+
response._content = b'{"error":"foo"}'
41+
mock_requests_request.return_value = response
42+
43+
g = BackChannelLogin("my.domain.com", "cid", client_secret="clsec")
44+
with self.assertRaises(Auth0Error) as context:
45+
g.back_channel_login(
46+
binding_message="msg",
47+
login_hint="hint",
48+
scope="openid"
49+
)
50+
self.assertEqual(context.exception.status_code, 400)
51+
self.assertEqual(context.exception.message, 'foo')
52+
3653
@mock.patch("auth0.rest.RestClient.post")
3754
def test_should_require_binding_message(self, mock_post):
3855
g = BackChannelLogin("my.domain.com", "cid", client_secret="clsec")
@@ -136,3 +153,58 @@ def test_with_authorization_details(self, mock_post):
136153
"Request data does not match expected data after JSON serialization."
137154
)
138155

156+
@mock.patch("auth0.rest.RestClient.post")
157+
def test_with_request_expiry(self, mock_post):
158+
g = BackChannelLogin("my.domain.com", "cid", client_secret="clsec")
159+
160+
g.back_channel_login(
161+
binding_message="This is a binding message",
162+
login_hint="{ \"format\": \"iss_sub\", \"iss\": \"https://my.domain.auth0.com/\", \"sub\": \"auth0|[USER ID]\" }",
163+
scope="openid",
164+
requested_expiry=100
165+
)
166+
167+
args, kwargs = mock_post.call_args
168+
169+
self.assertEqual(args[0], "https://my.domain.com/bc-authorize")
170+
self.assertEqual(
171+
kwargs["data"],
172+
{
173+
"client_id": "cid",
174+
"client_secret": "clsec",
175+
"binding_message": "This is a binding message",
176+
"login_hint": "{ \"format\": \"iss_sub\", \"iss\": \"https://my.domain.auth0.com/\", \"sub\": \"auth0|[USER ID]\" }",
177+
"scope": "openid",
178+
"requested_expiry": "100",
179+
},
180+
)
181+
182+
def test_requested_expiry_negative_raises(self):
183+
g = BackChannelLogin("my.domain.com", "cid", client_secret="clsec")
184+
with self.assertRaises(ValueError):
185+
g.back_channel_login(
186+
binding_message="msg",
187+
login_hint="hint",
188+
scope="openid",
189+
requested_expiry=-10
190+
)
191+
192+
def test_requested_expiry_zero_raises(self):
193+
g = BackChannelLogin("my.domain.com", "cid", client_secret="clsec")
194+
with self.assertRaises(ValueError):
195+
g.back_channel_login(
196+
binding_message="msg",
197+
login_hint="hint",
198+
scope="openid",
199+
requested_expiry=0
200+
)
201+
202+
def test_requested_non_int_raises(self):
203+
g = BackChannelLogin("my.domain.com", "cid", client_secret="clsec")
204+
with self.assertRaises(ValueError):
205+
g.back_channel_login(
206+
binding_message="msg",
207+
login_hint="hint",
208+
scope="openid",
209+
requested_expiry="string_instead_of_int"
210+
)

auth0/test/authentication/test_get_token.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import unittest
2+
import requests
23
from fnmatch import fnmatch
34
from unittest import mock
45
from unittest.mock import ANY
56

67
from cryptography.hazmat.primitives import asymmetric, serialization
78

9+
from ... import Auth0Error
810
from ...authentication.get_token import GetToken
911

1012

@@ -335,7 +337,25 @@ def test_backchannel_login(self, mock_post):
335337
"grant_type": "urn:openid:params:grant-type:ciba",
336338
},
337339
)
338-
340+
341+
@mock.patch("requests.request")
342+
def test_backchannel_login_headers_on_failure(self, mock_requests_request):
343+
response = requests.Response()
344+
response.status_code = 400
345+
response.headers = {"Retry-After": "100"}
346+
response._content = b'{"error":"slow_down"}'
347+
mock_requests_request.return_value = response
348+
349+
g = GetToken("my.domain.com", "cid", client_secret="csec")
350+
351+
with self.assertRaises(Auth0Error) as context:
352+
g.backchannel_login(
353+
auth_req_id="reqid",
354+
grant_type="urn:openid:params:grant-type:ciba",
355+
)
356+
self.assertEqual(context.exception.headers["Retry-After"], "100")
357+
self.assertEqual(context.exception.status_code, 400)
358+
339359
@mock.patch("auth0.rest.RestClient.post")
340360
def test_connection_login(self, mock_post):
341361
g = GetToken("my.domain.com", "cid", client_secret="csec")
@@ -364,4 +384,33 @@ def test_connection_login(self, mock_post):
364384
"requested_token_type": "http://auth0.com/oauth/token-type/federated-connection-access-token",
365385
"connection": "google-oauth2"
366386
},
387+
)
388+
389+
@mock.patch("auth0.rest.RestClient.post")
390+
def test_connection_login_with_login_hint(self, mock_post):
391+
g = GetToken("my.domain.com", "cid", client_secret="csec")
392+
393+
g.access_token_for_connection(
394+
subject_token_type="urn:ietf:params:oauth:token-type:refresh_token",
395+
subject_token="refid",
396+
requested_token_type="http://auth0.com/oauth/token-type/federated-connection-access-token",
397+
connection="google-oauth2",
398+
login_hint="[email protected]"
399+
)
400+
401+
args, kwargs = mock_post.call_args
402+
403+
self.assertEqual(args[0], "https://my.domain.com/oauth/token")
404+
self.assertEqual(
405+
kwargs["data"],
406+
{
407+
"grant_type": "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token",
408+
"client_id": "cid",
409+
"client_secret": "csec",
410+
"subject_token_type": "urn:ietf:params:oauth:token-type:refresh_token",
411+
"subject_token": "refid",
412+
"requested_token_type": "http://auth0.com/oauth/token-type/federated-connection-access-token",
413+
"connection": "google-oauth2",
414+
"login_hint": "[email protected]"
415+
},
367416
)

0 commit comments

Comments
 (0)