Skip to content

Commit 2f030eb

Browse files
authored
Merge pull request #7 from sahandilshan/main
Provide support to fetch OBO tokens with CIBA grant
2 parents ba91c1c + 77d4068 commit 2f030eb

6 files changed

Lines changed: 445 additions & 16 deletions

File tree

examples/README.md

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,61 @@ This directory contains examples for using the Asgardeo Python SDKs.
99

1010
## Setup
1111

12-
Before running the examples, make sure to:
12+
### 1. Create a virtual environment
13+
14+
From the repository root (`sdk/python`):
15+
16+
```bash
17+
python3 -m venv .venv
18+
source .venv/bin/activate # macOS/Linux
19+
# .venv\Scripts\activate # Windows
20+
```
21+
22+
### 2. Install the packages
23+
24+
Install both packages in editable mode so local source changes are picked up immediately:
1325

14-
1. Install the packages:
1526
```bash
16-
cd asgardeo && poetry install
17-
cd ../asgardeo-ai && poetry install
27+
pip install -e packages/asgardeo -e packages/asgardeo-ai
1828
```
1929

20-
2. Set up your configuration in each example file with your actual Asgardeo credentials.
30+
### 3. Configure credentials
31+
32+
Open the example file you want to run and replace the placeholder values with your actual Asgardeo credentials:
33+
34+
```python
35+
config = AsgardeoConfig(
36+
base_url="https://api.asgardeo.io/t/<your-org>",
37+
client_id="<your-client-id>",
38+
redirect_uri="<your-redirect-uri>",
39+
client_secret="<your-client-secret>"
40+
)
41+
```
42+
43+
## Available Examples
44+
45+
| Example | Description |
46+
|---------|-------------|
47+
| `asgardeo/native_auth.py` | App-native authentication (username/password without browser redirect) |
48+
| `asgardeo-ai/agent_auth.py` | AI agent token acquisition using native auth |
49+
| `asgardeo-ai/obo_flow.py` | On-Behalf-Of (OBO) token flow via authorization code |
50+
| `asgardeo-ai/ciba_obo_flow.py` | On-Behalf-Of (OBO) token flow via CIBA with polling |
2151

2252
## Running Examples
2353

24-
Each example is a standalone Python script that can be run directly:
54+
Make sure the virtual environment is activated, then run any example from the repository root:
2555

2656
```bash
27-
python examples/asgardeo/basic_auth.py
57+
python examples/asgardeo/native_auth.py
2858
python examples/asgardeo-ai/agent_auth.py
29-
```
59+
python examples/asgardeo-ai/obo_flow.py
60+
python examples/asgardeo-ai/ciba_obo_flow.py
61+
```
62+
63+
## Asgardeo Prerequisites
64+
65+
Before running the examples, ensure your application is configured in the Asgardeo Console:
66+
67+
- **Native auth / Agent auth**: Enable **App-Native Authentication** in the Login Flow tab
68+
- **OBO flow**: Enable **Token Exchange** grant type in the Protocol tab
69+
- **CIBA OBO flow**: Enable **CIBA** grant type in the Protocol tab and configure at least one notification channel (Email, SMS, or External)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""
2+
Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
3+
WSO2 LLC. licenses this file to you under the Apache License,
4+
Version 2.0 (the "License"); you may not use this file except
5+
in compliance with the License.
6+
You may obtain a copy of the License at
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
Unless required by applicable law or agreed to in writing,
9+
software distributed under the License is distributed on an
10+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
11+
KIND, either express or implied. See the License for the
12+
specific language governing permissions and limitations
13+
under the License.
14+
"""
15+
16+
"""
17+
On-Behalf-Of (OBO) token flow using CIBA.
18+
19+
This example shows how an AI agent can obtain tokens on behalf of a user
20+
using CIBA. The agent initiates a backchannel authentication request for
21+
the user, and the user authenticates on a separate device (via email, SMS,
22+
or an external link).
23+
"""
24+
25+
import asyncio
26+
import itertools
27+
import sys
28+
29+
from asgardeo import AsgardeoConfig, CIBAResponse
30+
from asgardeo_ai import AgentAuthManager, AgentConfig
31+
32+
33+
async def _spinner(message: str, stop_event: asyncio.Event) -> None:
34+
"""Display a spinning animation until stop_event is set."""
35+
frames = itertools.cycle(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
36+
while not stop_event.is_set():
37+
sys.stdout.write(f"\r{next(frames)} {message}")
38+
sys.stdout.flush()
39+
await asyncio.sleep(0.1)
40+
sys.stdout.write(f"\r{' ' * (len(message) + 2)}\r")
41+
sys.stdout.flush()
42+
43+
44+
async def main():
45+
"""On-Behalf-Of (OBO) CIBA flow example."""
46+
47+
# Asgardeo configuration - Replace with your actual values
48+
config = AsgardeoConfig(
49+
base_url="https://api.asgardeo.io/t/<tenant>",
50+
client_id="<client_id>",
51+
redirect_uri="<redirect_uri>",
52+
client_secret="<client_secret>",
53+
)
54+
55+
# AI Agent configuration - Replace with your actual agent credentials
56+
agent_config = AgentConfig(
57+
agent_id="<agent_id>",
58+
agent_secret="<agent_secret>"
59+
)
60+
61+
try:
62+
async with AgentAuthManager(config, agent_config) as auth_manager:
63+
print("Starting On-Behalf-Of (OBO) CIBA flow...")
64+
65+
# Step 1: Get agent token
66+
print("\nStep 1: Getting agent token...")
67+
agent_scopes = ["openid", "profile"]
68+
agent_token = await auth_manager.get_agent_token(agent_scopes)
69+
print(f"Agent authenticated: {agent_token.access_token[:20]}...")
70+
71+
# Step 2: Get OBO token via CIBA
72+
print("\nStep 2: Initiating CIBA request for user...")
73+
user_scopes = ["openid", "profile", "email"]
74+
75+
stop_spinner = asyncio.Event()
76+
spinner_task = None
77+
78+
def on_ciba_initiated(ciba_response: CIBAResponse) -> None:
79+
nonlocal spinner_task
80+
print(f"\nCIBA request accepted. auth_req_id: {ciba_response.auth_req_id}")
81+
82+
if ciba_response.auth_url:
83+
print(f"Open this URL to authenticate: {ciba_response.auth_url}")
84+
else:
85+
print("Notification sent! Check your email/SMS inbox to approve the request.")
86+
87+
print(f"(expires in {ciba_response.expires_in}s)\n")
88+
89+
spinner_task = asyncio.ensure_future(
90+
_spinner("Waiting for user to complete authentication...", stop_spinner)
91+
)
92+
93+
try:
94+
_, user_token = await auth_manager.get_obo_token_with_ciba(
95+
login_hint="<username>", # Replace with actual username
96+
agent_token=agent_token,
97+
scopes=user_scopes,
98+
binding_message="AI Agent requests access to your account",
99+
on_initiated=on_ciba_initiated,
100+
)
101+
finally:
102+
stop_spinner.set()
103+
if spinner_task:
104+
await spinner_task
105+
106+
print("OBO token obtained successfully!")
107+
print(f"User Access Token: {user_token.access_token[:30]}...")
108+
if user_token.id_token:
109+
print(f"User ID Token: {user_token.id_token[:30]}...")
110+
if user_token.refresh_token:
111+
print(f"Refresh Token: {user_token.refresh_token[:30]}...")
112+
print(f"Expires in: {user_token.expires_in}s")
113+
print(f"Scope: {user_token.scope}")
114+
print("\nThe AI agent can now act on behalf of the user.")
115+
116+
except Exception as e:
117+
print(f"\nCIBA OBO flow failed: {e}")
118+
import traceback
119+
traceback.print_exc()
120+
121+
122+
if __name__ == "__main__":
123+
print("Asgardeo AI On-Behalf-Of (OBO) CIBA Flow Example")
124+
print("=" * 55)
125+
asyncio.run(main())

packages/asgardeo-ai/src/asgardeo_ai/agent_auth_manager.py

Lines changed: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,25 @@
1515

1616
"""Agent-enhanced OAuth2 authentication manager for Asgardeo AI."""
1717

18+
import asyncio
1819
import logging
1920
import base64
2021
import os
21-
from typing import Dict, List, Optional, Tuple, Any
22+
import time
23+
from typing import Callable, Dict, List, Optional, Tuple, Any
2224
from urllib.parse import urlencode
2325
from dataclasses import dataclass
2426

2527
from asgardeo import (
26-
AsgardeoConfig,
27-
OAuthToken,
28-
FlowStatus,
29-
AsgardeoNativeAuthClient,
28+
AsgardeoConfig,
29+
OAuthToken,
30+
FlowStatus,
31+
AsgardeoNativeAuthClient,
3032
AsgardeoTokenClient,
3133
AuthenticationError,
34+
CIBAAuthenticationError,
35+
CIBAResponse,
36+
CIBAStatus,
3237
TokenError,
3338
ValidationError,
3439
generate_pkce_pair,
@@ -259,6 +264,130 @@ async def get_obo_token(
259264
logger.error(f"OBO token exchange failed: {e}")
260265
raise TokenError(f"OBO token exchange failed: {e}")
261266

267+
async def _poll_for_token(
268+
self,
269+
ciba_response: CIBAResponse,
270+
scope: Optional[str] = None,
271+
timeout: Optional[int] = None,
272+
) -> OAuthToken:
273+
"""Poll the token endpoint until CIBA authentication completes.
274+
275+
:param ciba_response: CIBA initiation response with auth_req_id, interval, expires_in
276+
:param scope: Optional scope override
277+
:param timeout: Optional max wait time in seconds (defaults to ciba_response.expires_in)
278+
:return: OAuthToken on successful authentication
279+
:raises CIBAAuthenticationError: If authentication is denied or expires
280+
"""
281+
interval = ciba_response.interval
282+
max_wait = min(
283+
timeout or ciba_response.expires_in,
284+
ciba_response.expires_in,
285+
)
286+
start_time = time.monotonic()
287+
288+
while True:
289+
elapsed = time.monotonic() - start_time
290+
if elapsed >= max_wait:
291+
raise CIBAAuthenticationError(
292+
"CIBA authentication timed out: exceeded maximum wait time."
293+
)
294+
295+
await asyncio.sleep(interval)
296+
297+
try:
298+
token = await self.token_client.get_token(
299+
"urn:openid:params:grant-type:ciba",
300+
auth_req_id=ciba_response.auth_req_id,
301+
scope=scope,
302+
)
303+
return token
304+
except CIBAAuthenticationError as e:
305+
error_msg = str(e)
306+
if CIBAStatus.AUTHORIZATION_PENDING in error_msg:
307+
logger.debug("CIBA authorization pending, continuing to poll...")
308+
continue
309+
elif CIBAStatus.SLOW_DOWN in error_msg:
310+
interval += 5
311+
logger.debug(f"CIBA slow_down received, increasing interval to {interval}s")
312+
continue
313+
elif CIBAStatus.EXPIRED_TOKEN in error_msg:
314+
raise CIBAAuthenticationError(
315+
"CIBA authentication request expired. The user did not authenticate in time."
316+
)
317+
elif CIBAStatus.ACCESS_DENIED in error_msg:
318+
raise CIBAAuthenticationError(
319+
"CIBA authentication denied. The user rejected the authentication request."
320+
)
321+
else:
322+
raise
323+
324+
async def get_obo_token_with_ciba(
325+
self,
326+
login_hint: str,
327+
agent_token: OAuthToken,
328+
scopes: Optional[List[str]] = None,
329+
binding_message: Optional[str] = None,
330+
notification_channel: Optional[str] = None,
331+
timeout: Optional[int] = None,
332+
on_initiated: Optional[Callable[[CIBAResponse], None]] = None,
333+
) -> Tuple[CIBAResponse, OAuthToken]:
334+
"""Get on-behalf-of token using CIBA flow.
335+
336+
Initiates a CIBA request for a user identified by login_hint,
337+
then polls until the user authenticates. The actor_token is sent
338+
in the CIBA initiation to establish OBO delegation.
339+
340+
:param login_hint: Username or identifier of the user to authenticate
341+
:param agent_token: The agent's OAuthToken (used as actor_token for delegation)
342+
:param scopes: List of OAuth scopes to request
343+
:param binding_message: Message displayed to the user during authentication
344+
:param notification_channel: Notification channel (email, sms, external)
345+
:param timeout: Maximum time to wait for authentication in seconds
346+
:param on_initiated: Optional callback invoked with CIBAResponse immediately after
347+
the CIBA request is accepted and before polling begins. Use this to notify the
348+
caller that a push/email/SMS has been sent and polling is starting.
349+
Accepts both sync and async callables.
350+
:return: Tuple of (CIBAResponse, OAuthToken)
351+
"""
352+
if not login_hint:
353+
raise ValidationError("login_hint is required for CIBA OBO token exchange.")
354+
if not agent_token:
355+
raise ValidationError("agent_token is required for CIBA OBO token exchange.")
356+
357+
scope_str = " ".join(scopes) if scopes else None
358+
359+
try:
360+
ciba_response = await self.token_client.initiate_ciba(
361+
login_hint=login_hint,
362+
scope=scope_str,
363+
binding_message=binding_message,
364+
notification_channel=notification_channel,
365+
actor_token=agent_token.access_token,
366+
)
367+
368+
logger.info(
369+
f"CIBA initiated for user '{login_hint}'. auth_req_id: {ciba_response.auth_req_id}, "
370+
f"expires_in: {ciba_response.expires_in}s"
371+
)
372+
373+
if on_initiated is not None:
374+
result = on_initiated(ciba_response)
375+
if asyncio.iscoroutine(result):
376+
await result
377+
378+
token = await self._poll_for_token(
379+
ciba_response=ciba_response,
380+
scope=scope_str,
381+
timeout=timeout,
382+
)
383+
return ciba_response, token
384+
385+
except (CIBAAuthenticationError, ValidationError):
386+
raise
387+
except Exception as e:
388+
logger.error(f"CIBA OBO token exchange failed: {e}")
389+
raise TokenError(f"CIBA OBO token exchange failed: {e}")
390+
262391
async def revoke_token(
263392
self,
264393
token: str,

packages/asgardeo/src/asgardeo/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
AsgardeoConfig,
1919
AsgardeoError,
2020
AuthenticationError,
21+
CIBAAuthenticationError,
22+
CIBAResponse,
23+
CIBAStatus,
2124
FlowStatus,
2225
NetworkError,
2326
OAuthToken,
@@ -34,6 +37,9 @@
3437
"AsgardeoNativeAuthClient",
3538
"AsgardeoTokenClient",
3639
"AuthenticationError",
40+
"CIBAAuthenticationError",
41+
"CIBAResponse",
42+
"CIBAStatus",
3743
"FlowStatus",
3844
"NetworkError",
3945
"OAuthToken",

0 commit comments

Comments
 (0)