Skip to content

Commit b989c85

Browse files
authored
Merge pull request #4 from shashimalcse/python-0002
Add pkce support
2 parents 61cd83b + acb572d commit b989c85

File tree

10 files changed

+121
-25
lines changed

10 files changed

+121
-25
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pip install asgardeo
2323
### [asgardeo-ai](./packages/asgardeo-ai/)
2424
AI agent authentication and on-behalf-of (OBO) token flows.
2525

26+
> ⚠️ WARNING: Asgardeo AI SDK is currently under development, is not intended for production use, and therefore has no official support.
27+
2628
```bash
2729
pip install asgardeo-ai
2830
```

packages/asgardeo-ai/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Asgardeo AI SDK
22

3-
Async Python SDK for Asgardeo AI agent authentication and on-behalf-of (OBO) token flows.
3+
> ⚠️ WARNING: Asgardeo AI SDK is currently under development, is not intended for production use, and therefore has no official support.
4+
5+
Python SDK for Asgardeo AI agent authentication and on-behalf-of (OBO) token flows.
6+
7+
48

59
## Features
610
- **Agent Authentication**: Authenticate AI agents using agent credentials

packages/asgardeo-ai/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "asgardeo_ai"
3-
version = "0.1.0"
3+
version = "0.2.1"
44
description = "Async Python SDK for Asgardeo AI agent authentication"
55
authors = ["Thilina Senarath <thilinas@wso2.com>"]
66
license = "MIT"

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,12 @@
1515

1616
from .agent_auth_manager import (
1717
AgentAuthManager,
18-
AgentConfig,
19-
generate_state,
20-
build_authorization_url,
18+
AgentConfig
2119
)
2220

23-
__version__ = "0.1.0"
21+
__version__ = "0.2.1"
2422

2523
__all__ = [
2624
"AgentAuthManager",
2725
"AgentConfig",
28-
"generate_state",
29-
"build_authorization_url",
3026
]

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

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
AuthenticationError,
3232
TokenError,
3333
ValidationError,
34+
generate_pkce_pair,
35+
generate_state,
36+
build_authorization_url
3437
)
3538

3639
logger = logging.getLogger(__name__)
@@ -43,16 +46,6 @@ class AgentConfig:
4346
agent_id: str
4447
agent_secret: str
4548

46-
47-
def generate_state() -> str:
48-
"""Generate a secure random state parameter."""
49-
return base64.urlsafe_b64encode(os.urandom(16)).decode('utf-8').rstrip('=')
50-
51-
52-
def build_authorization_url(base_url: str, params: Dict[str, Any]) -> str:
53-
"""Build authorization URL with parameters."""
54-
return f"{base_url}?{urlencode(params)}"
55-
5649
class AgentAuthManager:
5750
"""Agent-enhanced OAuth2 authentication manager for AI agents."""
5851

@@ -91,7 +84,12 @@ async def get_agent_token(self, scopes: Optional[List[str]] = None) -> OAuthToke
9184
self.config.scope = ' '.join(scopes)
9285

9386
# Start authentication flow
94-
init_response = await native_client.authenticate()
87+
code_verifier, code_challenge = generate_pkce_pair()
88+
params = {
89+
"code_challenge": code_challenge,
90+
"code_challenge_method": "S256",
91+
}
92+
init_response = await native_client.authenticate(params=params)
9593

9694
if native_client.flow_status == FlowStatus.SUCCESS_COMPLETED:
9795
auth_data = init_response.get('authData', {})
@@ -127,7 +125,7 @@ async def get_agent_token(self, scopes: Optional[List[str]] = None) -> OAuthToke
127125
raise TokenError("No authorization code received from authentication flow.")
128126

129127
# Exchange code for token
130-
token = await self.token_client.get_token('authorization_code', code=code)
128+
token = await self.token_client.get_token('authorization_code', code=code, code_verifier=code_verifier)
131129

132130
# Restore original scope
133131
if scopes:
@@ -180,12 +178,57 @@ def get_authorization_url(
180178
auth_params
181179
)
182180
return auth_url, state
181+
182+
def get_authorization_url_with_pkce(
183+
self,
184+
scopes: List[str],
185+
state: Optional[str] = None,
186+
resource: Optional[str] = None,
187+
**kwargs: Any,
188+
) -> Tuple[str, str, str]:
189+
"""Generate authorization URL for user authentication.
190+
191+
:param scopes: List of OAuth scopes to request
192+
:param state: Optional state parameter (generated if not provided)
193+
:param resource: Optional resource parameter
194+
:param kwargs: Additional parameters for the authorization URL
195+
:return: Tuple of (authorization_url, state)
196+
"""
197+
if not state:
198+
state = generate_state()
199+
200+
code_verifier, code_challenge = generate_pkce_pair()
201+
202+
auth_params = {
203+
"client_id": self.config.client_id,
204+
"redirect_uri": self.config.redirect_uri,
205+
"scope": " ".join(scopes),
206+
"state": state,
207+
"response_type": "code",
208+
"code_challenge": code_challenge,
209+
"code_challenge_method": "S256",
210+
}
211+
212+
if resource:
213+
auth_params["resource"] = resource
214+
215+
if self.agent_config:
216+
auth_params["requested_actor"] = self.agent_config.agent_id
217+
218+
auth_params.update(kwargs)
219+
220+
auth_url = build_authorization_url(
221+
f"{self.config.base_url}/oauth2/authorize",
222+
auth_params
223+
)
224+
return auth_url, state, code_verifier
183225

184226
async def get_obo_token(
185227
self,
186228
auth_code: str,
187229
agent_token: str,
188230
scopes: Optional[List[str]] = None,
231+
code_verifier: Optional[str] = None
189232
) -> OAuthToken:
190233
"""Get on-behalf-of (OBO) token for user using authorization code.
191234
@@ -206,6 +249,7 @@ async def get_obo_token(
206249
code=auth_code,
207250
scope=scope_str,
208251
actor_token=actor_token_val,
252+
code_verifier=code_verifier
209253
)
210254
return token
211255

packages/asgardeo/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "asgardeo"
3-
version = "0.1.0"
3+
version = "0.2.1"
44
description = "Python SDK for Asgardeo"
55
authors = ["Thilina Senarath <thilinas@wso2.com>"]
66
license = "MIT"

packages/asgardeo/src/asgardeo/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424
TokenError,
2525
ValidationError,
2626
)
27+
from .auth.util import generate_pkce_pair, generate_state, build_authorization_url
2728

28-
__version__ = "0.1.0"
29+
__version__ = "0.2.1"
2930

3031
__all__ = [
3132
"AsgardeoConfig",
@@ -38,4 +39,7 @@
3839
"OAuthToken",
3940
"TokenError",
4041
"ValidationError",
42+
"generate_pkce_pair",
43+
"generate_state",
44+
"build_authorization_url",
4145
]

packages/asgardeo/src/asgardeo/auth/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424
ValidationError,
2525
)
2626
from .client import AsgardeoNativeAuthClient, AsgardeoTokenClient
27+
from .util import generate_pkce_pair, generate_state, build_authorization_url
2728

28-
__version__ = "0.1.0"
29+
__version__ = "0.2.1"
2930

3031
__all__ = [
3132
"AsgardeoConfig",
@@ -38,4 +39,7 @@
3839
"OAuthToken",
3940
"TokenError",
4041
"ValidationError",
42+
"generate_pkce_pair",
43+
"generate_state",
44+
"build_authorization_url",
4145
]

packages/asgardeo/src/asgardeo/auth/client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"""Async Asgardeo authentication and token clients."""
1818

1919
import json
20+
import logging
2021
from typing import Any
2122
from urllib.parse import urlencode
2223

@@ -33,6 +34,7 @@
3334
ValidationError,
3435
)
3536

37+
logger = logging.getLogger(__name__)
3638

3739
class AsgardeoNativeAuthClient:
3840
"""Async client for handling Asgardeo App Native Authentication flows.
@@ -72,12 +74,15 @@ async def _initiate_auth(
7274
url = f"{self.base_url}/oauth2/authorize"
7375
data = {
7476
"client_id": self.config.client_id,
75-
"client_secret": self.config.client_secret,
7677
"response_type": "code",
7778
"redirect_uri": self.config.redirect_uri,
7879
"scope": self.config.scope,
7980
"response_mode": "direct",
8081
}
82+
83+
# Only add client_secret if code_verifier is not in params (PKCE flow)
84+
if not (params and "code_challenge" in params):
85+
data["client_secret"] = self.config.client_secret
8186
if state:
8287
data["state"] = state
8388
if params:
@@ -280,7 +285,8 @@ async def get_token(self, grant_type: str, **kwargs: Any) -> OAuthToken:
280285
"""
281286
url = f"{self.base_url}/oauth2/token"
282287
data = {"grant_type": grant_type, "client_id": self.config.client_id}
283-
if self.config.client_secret:
288+
289+
if self.config.client_secret and "code_verifier" not in kwargs:
284290
data["client_secret"] = self.config.client_secret
285291

286292
if grant_type == "authorization_code":
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import base64
2+
import hashlib
3+
import os
4+
import secrets
5+
from typing import Any, Dict, Tuple
6+
from urllib.parse import urlencode
7+
8+
9+
def generate_pkce_pair() -> Tuple[str, str]:
10+
"""
11+
Generate PKCE code verifier and code challenge pair
12+
Returns:
13+
Tuple of (code_verifier, code_challenge)
14+
"""
15+
# Generate code verifier (43-128 characters)
16+
code_verifier = (
17+
base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=")
18+
)
19+
20+
# Generate code challenge (SHA256 hash of verifier)
21+
code_challenge = (
22+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest())
23+
.decode("utf-8")
24+
.rstrip("=")
25+
)
26+
27+
return code_verifier, code_challenge
28+
29+
def generate_state() -> str:
30+
"""Generate a secure random state parameter."""
31+
return base64.urlsafe_b64encode(os.urandom(16)).decode('utf-8').rstrip('=')
32+
33+
34+
def build_authorization_url(base_url: str, params: Dict[str, Any]) -> str:
35+
"""Build authorization URL with parameters."""
36+
return f"{base_url}?{urlencode(params)}"

0 commit comments

Comments
 (0)