agentic-fabriq-sdk 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of agentic-fabriq-sdk might be problematic. Click here for more details.
- af_sdk/__init__.py +55 -0
- af_sdk/auth/__init__.py +31 -0
- af_sdk/auth/dpop.py +43 -0
- af_sdk/auth/oauth.py +247 -0
- af_sdk/auth/token_cache.py +318 -0
- af_sdk/connectors/__init__.py +23 -0
- af_sdk/connectors/base.py +231 -0
- af_sdk/connectors/registry.py +262 -0
- af_sdk/dx/__init__.py +12 -0
- af_sdk/dx/decorators.py +40 -0
- af_sdk/dx/runtime.py +170 -0
- af_sdk/events.py +699 -0
- af_sdk/exceptions.py +140 -0
- af_sdk/fabriq_client.py +198 -0
- af_sdk/models/__init__.py +47 -0
- af_sdk/models/audit.py +44 -0
- af_sdk/models/types.py +242 -0
- af_sdk/py.typed +0 -0
- af_sdk/transport/__init__.py +7 -0
- af_sdk/transport/http.py +366 -0
- af_sdk/vault.py +500 -0
- agentic_fabriq_sdk-0.1.3.dist-info/METADATA +81 -0
- agentic_fabriq_sdk-0.1.3.dist-info/RECORD +24 -0
- agentic_fabriq_sdk-0.1.3.dist-info/WHEEL +4 -0
af_sdk/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agentic Fabric SDK
|
|
3
|
+
|
|
4
|
+
Official Python SDK for building connectors and interacting with Agentic Fabric.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .auth.oauth import oauth_required
|
|
8
|
+
from .connectors.base import AgentConnector, ConnectorContext, ToolConnector
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
AFError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
AuthorizationError,
|
|
13
|
+
ConnectorError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
from .models.types import (
|
|
18
|
+
AgentInvokeRequest,
|
|
19
|
+
AgentInvokeResult,
|
|
20
|
+
ToolInvokeRequest,
|
|
21
|
+
ToolInvokeResult,
|
|
22
|
+
)
|
|
23
|
+
from .transport.http import HTTPClient
|
|
24
|
+
from .fabriq_client import FabriqClient
|
|
25
|
+
from .models.audit import AuditEvent
|
|
26
|
+
|
|
27
|
+
__version__ = "1.0.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"oauth_required",
|
|
31
|
+
"ToolConnector",
|
|
32
|
+
"AgentConnector",
|
|
33
|
+
"ConnectorContext",
|
|
34
|
+
"AFError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"AuthorizationError",
|
|
37
|
+
"ConnectorError",
|
|
38
|
+
"NotFoundError",
|
|
39
|
+
"ValidationError",
|
|
40
|
+
"AgentInvokeRequest",
|
|
41
|
+
"AgentInvokeResult",
|
|
42
|
+
"ToolInvokeRequest",
|
|
43
|
+
"ToolInvokeResult",
|
|
44
|
+
"HTTPClient",
|
|
45
|
+
"FabriqClient",
|
|
46
|
+
"AuditEvent",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# Lazy expose dx submodule under af_sdk.dx
|
|
50
|
+
from importlib import import_module as _import_module # noqa: E402
|
|
51
|
+
|
|
52
|
+
def __getattr__(name):
|
|
53
|
+
if name == "dx":
|
|
54
|
+
return _import_module("af_sdk.dx")
|
|
55
|
+
raise AttributeError(name)
|
af_sdk/auth/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication and authorization utilities for Agentic Fabric SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .oauth import (
|
|
6
|
+
api_key_required,
|
|
7
|
+
mtls_required,
|
|
8
|
+
no_auth_required,
|
|
9
|
+
oauth_required,
|
|
10
|
+
ScopeValidator,
|
|
11
|
+
TokenValidator,
|
|
12
|
+
)
|
|
13
|
+
from .token_cache import TokenManager, VaultClient
|
|
14
|
+
|
|
15
|
+
# DPoP helper will be provided from af_sdk.auth.dpop
|
|
16
|
+
try:
|
|
17
|
+
from .dpop import create_dpop_proof
|
|
18
|
+
except Exception: # pragma: no cover - optional import if file missing
|
|
19
|
+
create_dpop_proof = None # type: ignore
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"oauth_required",
|
|
23
|
+
"api_key_required",
|
|
24
|
+
"mtls_required",
|
|
25
|
+
"no_auth_required",
|
|
26
|
+
"ScopeValidator",
|
|
27
|
+
"TokenValidator",
|
|
28
|
+
"TokenManager",
|
|
29
|
+
"VaultClient",
|
|
30
|
+
"create_dpop_proof",
|
|
31
|
+
]
|
af_sdk/auth/dpop.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client-side DPoP (Proof of Possession) helper for AF SDK.
|
|
3
|
+
|
|
4
|
+
This provides a simple HMAC-signed JWT for development that matches the
|
|
5
|
+
Gateway's mock PoP verifier semantics. In production, replace with a
|
|
6
|
+
DPoP JWT signed using a private key corresponding to the client cert
|
|
7
|
+
thumbprint per RFC 9449.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from typing import Dict, Optional
|
|
14
|
+
|
|
15
|
+
import jwt
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_dpop_proof(*, method: str, url: str, thumbprint: str = "dev-thumbprint", lifetime_s: int = 60, secret: Optional[str] = None) -> str:
|
|
19
|
+
"""Create a development DPoP-like JWT for AF mock endpoints.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
method: HTTP method, e.g., "POST".
|
|
23
|
+
url: Full request URL.
|
|
24
|
+
thumbprint: x5t#S256 thumbprint string (dev default).
|
|
25
|
+
lifetime_s: Token lifetime in seconds.
|
|
26
|
+
secret: HMAC secret for signing (dev only). If not provided, uses a default.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
A compact JWT string to send in the DPoP header.
|
|
30
|
+
"""
|
|
31
|
+
now = int(time.time())
|
|
32
|
+
payload: Dict = {
|
|
33
|
+
"htm": method.upper(),
|
|
34
|
+
"htu": url,
|
|
35
|
+
"iat": now,
|
|
36
|
+
"exp": now + lifetime_s,
|
|
37
|
+
"cnf": {"x5t#S256": thumbprint},
|
|
38
|
+
"typ": "pop",
|
|
39
|
+
}
|
|
40
|
+
key = secret or "af-dev-pop-secret"
|
|
41
|
+
return jwt.encode(payload, key, algorithm="HS256")
|
|
42
|
+
|
|
43
|
+
|
af_sdk/auth/oauth.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth authentication decorator and helpers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Any, Awaitable, Callable, List, Optional
|
|
8
|
+
|
|
9
|
+
from ..exceptions import AuthenticationError, TokenRefreshError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def oauth_required(*, scopes: List[str], refresh_if_expired: bool = True):
|
|
13
|
+
"""
|
|
14
|
+
Decorator that injects a valid OAuth2 Bearer token for the current user.
|
|
15
|
+
|
|
16
|
+
Automatically:
|
|
17
|
+
1. Pulls access token from TokenManager (refreshes if expired)
|
|
18
|
+
2. Populates `Authorization` header
|
|
19
|
+
3. Enforces that requested scopes ⊆ granted scopes
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
scopes: List of required OAuth scopes
|
|
23
|
+
refresh_if_expired: Whether to attempt token refresh if expired
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
AuthenticationError: If token is invalid or missing
|
|
27
|
+
TokenRefreshError: If token refresh fails
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
|
|
31
|
+
@wraps(fn)
|
|
32
|
+
async def wrapper(self, *args, **kwargs):
|
|
33
|
+
ctx = getattr(self, "ctx", None)
|
|
34
|
+
if not ctx:
|
|
35
|
+
raise AuthenticationError("Connector context not available")
|
|
36
|
+
|
|
37
|
+
tool_id = getattr(self, "TOOL_ID", None)
|
|
38
|
+
if not tool_id:
|
|
39
|
+
raise AuthenticationError("TOOL_ID not set in connector")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# Get OAuth token from token manager
|
|
43
|
+
token = await ctx.token_manager.get_oauth_token(
|
|
44
|
+
tool_id=tool_id,
|
|
45
|
+
user_id=ctx.user_id,
|
|
46
|
+
scopes=scopes,
|
|
47
|
+
refresh_if_expired=refresh_if_expired,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Inject Authorization header
|
|
51
|
+
headers = kwargs.setdefault("headers", {})
|
|
52
|
+
headers.setdefault("Authorization", f"Bearer {token}")
|
|
53
|
+
|
|
54
|
+
# Log the request (without token)
|
|
55
|
+
ctx.logger.info(
|
|
56
|
+
f"Making OAuth request to {tool_id}",
|
|
57
|
+
extra={"scopes": scopes, "user_id": ctx.user_id},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return await fn(self, *args, **kwargs)
|
|
61
|
+
|
|
62
|
+
except Exception as e:
|
|
63
|
+
ctx.logger.error(f"OAuth authentication failed: {e}")
|
|
64
|
+
if isinstance(e, (AuthenticationError, TokenRefreshError)):
|
|
65
|
+
raise
|
|
66
|
+
raise AuthenticationError(f"OAuth authentication failed: {e}")
|
|
67
|
+
|
|
68
|
+
return wrapper
|
|
69
|
+
|
|
70
|
+
return decorator
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def api_key_required(*, key_name: str = "api_key", header_name: str = "X-API-Key"):
|
|
74
|
+
"""
|
|
75
|
+
Decorator that injects an API key for the current user.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
key_name: Name of the API key in the vault
|
|
79
|
+
header_name: HTTP header name for the API key
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
AuthenticationError: If API key is missing or invalid
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
|
|
86
|
+
@wraps(fn)
|
|
87
|
+
async def wrapper(self, *args, **kwargs):
|
|
88
|
+
ctx = getattr(self, "ctx", None)
|
|
89
|
+
if not ctx:
|
|
90
|
+
raise AuthenticationError("Connector context not available")
|
|
91
|
+
|
|
92
|
+
tool_id = getattr(self, "TOOL_ID", None)
|
|
93
|
+
if not tool_id:
|
|
94
|
+
raise AuthenticationError("TOOL_ID not set in connector")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
# Get API key from vault
|
|
98
|
+
secret_path = f"af/{ctx.tenant_id}/{ctx.user_id}/api_keys/{tool_id}/{key_name}"
|
|
99
|
+
secret = await ctx.token_manager.vault_client.read_secret(secret_path)
|
|
100
|
+
|
|
101
|
+
if not secret or "value" not in secret:
|
|
102
|
+
raise AuthenticationError(f"API key not found: {key_name}")
|
|
103
|
+
|
|
104
|
+
api_key = secret["value"]
|
|
105
|
+
|
|
106
|
+
# Inject API key header
|
|
107
|
+
headers = kwargs.setdefault("headers", {})
|
|
108
|
+
headers.setdefault(header_name, api_key)
|
|
109
|
+
|
|
110
|
+
# Log the request
|
|
111
|
+
ctx.logger.info(
|
|
112
|
+
f"Making API key request to {tool_id}",
|
|
113
|
+
extra={"key_name": key_name, "user_id": ctx.user_id},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return await fn(self, *args, **kwargs)
|
|
117
|
+
|
|
118
|
+
except Exception as e:
|
|
119
|
+
ctx.logger.error(f"API key authentication failed: {e}")
|
|
120
|
+
if isinstance(e, AuthenticationError):
|
|
121
|
+
raise
|
|
122
|
+
raise AuthenticationError(f"API key authentication failed: {e}")
|
|
123
|
+
|
|
124
|
+
return wrapper
|
|
125
|
+
|
|
126
|
+
return decorator
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def mtls_required(*, cert_path: Optional[str] = None, key_path: Optional[str] = None):
|
|
130
|
+
"""
|
|
131
|
+
Decorator that configures mutual TLS authentication.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
cert_path: Path to client certificate (optional, uses default if not provided)
|
|
135
|
+
key_path: Path to client private key (optional, uses default if not provided)
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
AuthenticationError: If mTLS configuration fails
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
|
|
142
|
+
@wraps(fn)
|
|
143
|
+
async def wrapper(self, *args, **kwargs):
|
|
144
|
+
ctx = getattr(self, "ctx", None)
|
|
145
|
+
if not ctx:
|
|
146
|
+
raise AuthenticationError("Connector context not available")
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
# Configure mTLS for the HTTP client
|
|
150
|
+
# This would typically be done at the session level
|
|
151
|
+
# For now, we'll add it to the kwargs
|
|
152
|
+
cert_config = (cert_path, key_path) if cert_path and key_path else None
|
|
153
|
+
kwargs.setdefault("cert", cert_config)
|
|
154
|
+
|
|
155
|
+
ctx.logger.info(
|
|
156
|
+
"Making mTLS request",
|
|
157
|
+
extra={"cert_path": cert_path, "user_id": ctx.user_id},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return await fn(self, *args, **kwargs)
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
ctx.logger.error(f"mTLS authentication failed: {e}")
|
|
164
|
+
raise AuthenticationError(f"mTLS authentication failed: {e}")
|
|
165
|
+
|
|
166
|
+
return wrapper
|
|
167
|
+
|
|
168
|
+
return decorator
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def no_auth_required(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
|
|
172
|
+
"""
|
|
173
|
+
Decorator that marks a method as not requiring authentication.
|
|
174
|
+
Useful for public endpoints or health checks.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
@wraps(fn)
|
|
178
|
+
async def wrapper(self, *args, **kwargs):
|
|
179
|
+
ctx = getattr(self, "ctx", None)
|
|
180
|
+
if ctx:
|
|
181
|
+
ctx.logger.info("Making unauthenticated request")
|
|
182
|
+
return await fn(self, *args, **kwargs)
|
|
183
|
+
|
|
184
|
+
return wrapper
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ScopeValidator:
|
|
188
|
+
"""Helper class for validating OAuth scopes."""
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def validate_scopes(required_scopes: List[str], granted_scopes: List[str]) -> bool:
|
|
192
|
+
"""
|
|
193
|
+
Validate that all required scopes are granted.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
required_scopes: List of required scopes
|
|
197
|
+
granted_scopes: List of granted scopes
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if all required scopes are granted, False otherwise
|
|
201
|
+
"""
|
|
202
|
+
return set(required_scopes).issubset(set(granted_scopes))
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def missing_scopes(required_scopes: List[str], granted_scopes: List[str]) -> List[str]:
|
|
206
|
+
"""
|
|
207
|
+
Get list of missing scopes.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
required_scopes: List of required scopes
|
|
211
|
+
granted_scopes: List of granted scopes
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of missing scopes
|
|
215
|
+
"""
|
|
216
|
+
return list(set(required_scopes) - set(granted_scopes))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TokenValidator:
|
|
220
|
+
"""Helper class for validating OAuth tokens."""
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def is_expired(expires_at: float) -> bool:
|
|
224
|
+
"""
|
|
225
|
+
Check if a token is expired.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
expires_at: Expiration timestamp
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if token is expired, False otherwise
|
|
232
|
+
"""
|
|
233
|
+
return time.time() >= expires_at
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def expires_soon(expires_at: float, buffer_seconds: int = 300) -> bool:
|
|
237
|
+
"""
|
|
238
|
+
Check if a token expires soon.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
expires_at: Expiration timestamp
|
|
242
|
+
buffer_seconds: Buffer time in seconds
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
True if token expires within buffer time, False otherwise
|
|
246
|
+
"""
|
|
247
|
+
return time.time() + buffer_seconds >= expires_at
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Token cache manager for OAuth tokens.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..exceptions import AuthenticationError, TokenRefreshError
|
|
9
|
+
from ..models.types import OAuthToken
|
|
10
|
+
from .oauth import ScopeValidator, TokenValidator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VaultClient:
|
|
14
|
+
"""Client for interacting with the vault service."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, base_url: str, http_client, logger):
|
|
17
|
+
self.base_url = base_url
|
|
18
|
+
self.http_client = http_client
|
|
19
|
+
self.logger = logger
|
|
20
|
+
|
|
21
|
+
async def read_secret(self, path: str) -> Optional[Dict]:
|
|
22
|
+
"""Read a secret from the vault."""
|
|
23
|
+
try:
|
|
24
|
+
response = await self.http_client.get(f"{self.base_url}/api/secrets/{path}")
|
|
25
|
+
if response.status_code == 200:
|
|
26
|
+
return response.json()
|
|
27
|
+
elif response.status_code == 404:
|
|
28
|
+
return None
|
|
29
|
+
else:
|
|
30
|
+
response.raise_for_status()
|
|
31
|
+
except Exception as e:
|
|
32
|
+
self.logger.error(f"Failed to read secret from vault: {e}")
|
|
33
|
+
raise
|
|
34
|
+
|
|
35
|
+
async def write_secret(self, path: str, data: Dict) -> None:
|
|
36
|
+
"""Write a secret to the vault."""
|
|
37
|
+
try:
|
|
38
|
+
response = await self.http_client.post(
|
|
39
|
+
f"{self.base_url}/api/secrets",
|
|
40
|
+
json={"path": path, "data": data}
|
|
41
|
+
)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
except Exception as e:
|
|
44
|
+
self.logger.error(f"Failed to write secret to vault: {e}")
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
async def delete_secret(self, path: str) -> None:
|
|
48
|
+
"""Delete a secret from the vault."""
|
|
49
|
+
try:
|
|
50
|
+
response = await self.http_client.delete(f"{self.base_url}/api/secrets/{path}")
|
|
51
|
+
response.raise_for_status()
|
|
52
|
+
except Exception as e:
|
|
53
|
+
self.logger.error(f"Failed to delete secret from vault: {e}")
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TokenManager:
|
|
58
|
+
"""Handles per-user OAuth token lifecycle."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, tenant_id: str, vault_client: VaultClient, gateway_client=None):
|
|
61
|
+
self.tenant_id = tenant_id
|
|
62
|
+
self.vault_client = vault_client
|
|
63
|
+
self.gateway_client = gateway_client
|
|
64
|
+
self.cache: Dict[str, OAuthToken] = {}
|
|
65
|
+
self.lock = asyncio.Lock()
|
|
66
|
+
self.refresh_locks: Dict[str, asyncio.Lock] = {}
|
|
67
|
+
|
|
68
|
+
async def get_oauth_token(
|
|
69
|
+
self,
|
|
70
|
+
tool_id: str,
|
|
71
|
+
user_id: Optional[str],
|
|
72
|
+
scopes: List[str],
|
|
73
|
+
refresh_if_expired: bool = True,
|
|
74
|
+
) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Get a valid OAuth token for the specified tool and user.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
tool_id: ID of the tool requiring authentication
|
|
80
|
+
user_id: ID of the user (None for service accounts)
|
|
81
|
+
scopes: Required OAuth scopes
|
|
82
|
+
refresh_if_expired: Whether to refresh expired tokens
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Valid access token
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
AuthenticationError: If token is not available or invalid
|
|
89
|
+
TokenRefreshError: If token refresh fails
|
|
90
|
+
"""
|
|
91
|
+
cache_key = f"{tool_id}:{user_id or 'service'}"
|
|
92
|
+
|
|
93
|
+
# Check cache first
|
|
94
|
+
token = self.cache.get(cache_key)
|
|
95
|
+
if token and not TokenValidator.is_expired(token.expires_at.timestamp()):
|
|
96
|
+
# Validate scopes
|
|
97
|
+
if ScopeValidator.validate_scopes(scopes, token.scopes):
|
|
98
|
+
return token.access_token
|
|
99
|
+
else:
|
|
100
|
+
missing = ScopeValidator.missing_scopes(scopes, token.scopes)
|
|
101
|
+
raise AuthenticationError(f"Insufficient scopes. Missing: {missing}")
|
|
102
|
+
|
|
103
|
+
# Get or create refresh lock for this token
|
|
104
|
+
if cache_key not in self.refresh_locks:
|
|
105
|
+
self.refresh_locks[cache_key] = asyncio.Lock()
|
|
106
|
+
|
|
107
|
+
refresh_lock = self.refresh_locks[cache_key]
|
|
108
|
+
|
|
109
|
+
async with refresh_lock:
|
|
110
|
+
# Double-check after acquiring lock
|
|
111
|
+
token = self.cache.get(cache_key)
|
|
112
|
+
if token and not TokenValidator.is_expired(token.expires_at.timestamp()):
|
|
113
|
+
if ScopeValidator.validate_scopes(scopes, token.scopes):
|
|
114
|
+
return token.access_token
|
|
115
|
+
|
|
116
|
+
# Load token from vault
|
|
117
|
+
secret_path = self._get_token_path(tool_id, user_id)
|
|
118
|
+
try:
|
|
119
|
+
secret_data = await self.vault_client.read_secret(secret_path)
|
|
120
|
+
if not secret_data:
|
|
121
|
+
raise AuthenticationError(f"No OAuth token found for {tool_id}")
|
|
122
|
+
|
|
123
|
+
token = OAuthToken(**secret_data)
|
|
124
|
+
|
|
125
|
+
# Check if token is expired
|
|
126
|
+
if TokenValidator.is_expired(token.expires_at.timestamp()):
|
|
127
|
+
if refresh_if_expired and token.refresh_token:
|
|
128
|
+
token = await self._refresh_token(tool_id, user_id, token)
|
|
129
|
+
else:
|
|
130
|
+
raise AuthenticationError(f"OAuth token expired for {tool_id}")
|
|
131
|
+
|
|
132
|
+
# Validate scopes
|
|
133
|
+
if not ScopeValidator.validate_scopes(scopes, token.scopes):
|
|
134
|
+
missing = ScopeValidator.missing_scopes(scopes, token.scopes)
|
|
135
|
+
raise AuthenticationError(f"Insufficient scopes. Missing: {missing}")
|
|
136
|
+
|
|
137
|
+
# Cache the token
|
|
138
|
+
self.cache[cache_key] = token
|
|
139
|
+
return token.access_token
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
if isinstance(e, (AuthenticationError, TokenRefreshError)):
|
|
143
|
+
raise
|
|
144
|
+
raise AuthenticationError(f"Failed to get OAuth token: {e}")
|
|
145
|
+
|
|
146
|
+
async def _refresh_token(
|
|
147
|
+
self, tool_id: str, user_id: Optional[str], token: OAuthToken
|
|
148
|
+
) -> OAuthToken:
|
|
149
|
+
"""
|
|
150
|
+
Refresh an OAuth token using the refresh token.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
tool_id: ID of the tool
|
|
154
|
+
user_id: ID of the user
|
|
155
|
+
token: Current token with refresh_token
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
New token with updated access_token and expires_at
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
TokenRefreshError: If refresh fails
|
|
162
|
+
"""
|
|
163
|
+
if not self.gateway_client:
|
|
164
|
+
raise TokenRefreshError("Gateway client not configured")
|
|
165
|
+
|
|
166
|
+
if not token.refresh_token:
|
|
167
|
+
raise TokenRefreshError("No refresh token available")
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
# Call gateway to refresh token
|
|
171
|
+
response = await self.gateway_client.post(
|
|
172
|
+
"/token/refresh",
|
|
173
|
+
json={
|
|
174
|
+
"refresh_token": token.refresh_token,
|
|
175
|
+
"tool_id": tool_id,
|
|
176
|
+
"user_id": user_id,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
response.raise_for_status()
|
|
180
|
+
|
|
181
|
+
refresh_data = response.json()
|
|
182
|
+
|
|
183
|
+
# Create new token
|
|
184
|
+
new_token = OAuthToken(
|
|
185
|
+
access_token=refresh_data["access_token"],
|
|
186
|
+
refresh_token=refresh_data.get("refresh_token", token.refresh_token),
|
|
187
|
+
token_type=refresh_data.get("token_type", "Bearer"),
|
|
188
|
+
expires_at=refresh_data["expires_at"],
|
|
189
|
+
scopes=refresh_data.get("scopes", token.scopes),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Store in vault
|
|
193
|
+
secret_path = self._get_token_path(tool_id, user_id)
|
|
194
|
+
await self.vault_client.write_secret(secret_path, new_token.dict())
|
|
195
|
+
|
|
196
|
+
return new_token
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
raise TokenRefreshError(f"Failed to refresh token: {e}")
|
|
200
|
+
|
|
201
|
+
async def store_oauth_token(
|
|
202
|
+
self, tool_id: str, user_id: Optional[str], token: OAuthToken
|
|
203
|
+
) -> None:
|
|
204
|
+
"""
|
|
205
|
+
Store an OAuth token in the vault and cache.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
tool_id: ID of the tool
|
|
209
|
+
user_id: ID of the user
|
|
210
|
+
token: OAuth token to store
|
|
211
|
+
"""
|
|
212
|
+
secret_path = self._get_token_path(tool_id, user_id)
|
|
213
|
+
await self.vault_client.write_secret(secret_path, token.dict())
|
|
214
|
+
|
|
215
|
+
# Update cache
|
|
216
|
+
cache_key = f"{tool_id}:{user_id or 'service'}"
|
|
217
|
+
self.cache[cache_key] = token
|
|
218
|
+
|
|
219
|
+
async def revoke_oauth_token(self, tool_id: str, user_id: Optional[str]) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Revoke an OAuth token (remove from vault and cache).
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
tool_id: ID of the tool
|
|
225
|
+
user_id: ID of the user
|
|
226
|
+
"""
|
|
227
|
+
secret_path = self._get_token_path(tool_id, user_id)
|
|
228
|
+
await self.vault_client.delete_secret(secret_path)
|
|
229
|
+
|
|
230
|
+
# Remove from cache
|
|
231
|
+
cache_key = f"{tool_id}:{user_id or 'service'}"
|
|
232
|
+
self.cache.pop(cache_key, None)
|
|
233
|
+
|
|
234
|
+
async def list_tokens(self, user_id: Optional[str]) -> List[Dict]:
|
|
235
|
+
"""
|
|
236
|
+
List all tokens for a user.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
user_id: ID of the user
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
List of token metadata
|
|
243
|
+
"""
|
|
244
|
+
# This would need to be implemented based on vault capabilities
|
|
245
|
+
# For now, return cached tokens
|
|
246
|
+
result = []
|
|
247
|
+
user_key = user_id or 'service'
|
|
248
|
+
|
|
249
|
+
for cache_key, token in self.cache.items():
|
|
250
|
+
if cache_key.endswith(f":{user_key}"):
|
|
251
|
+
tool_id = cache_key.split(":")[0]
|
|
252
|
+
result.append({
|
|
253
|
+
"tool_id": tool_id,
|
|
254
|
+
"user_id": user_id,
|
|
255
|
+
"scopes": token.scopes,
|
|
256
|
+
"expires_at": token.expires_at.isoformat(),
|
|
257
|
+
"is_expired": TokenValidator.is_expired(token.expires_at.timestamp()),
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
def _get_token_path(self, tool_id: str, user_id: Optional[str]) -> str:
|
|
263
|
+
"""
|
|
264
|
+
Get the vault path for a token.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
tool_id: ID of the tool
|
|
268
|
+
user_id: ID of the user
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Vault path for the token
|
|
272
|
+
"""
|
|
273
|
+
user_part = user_id or "service"
|
|
274
|
+
return f"af/{self.tenant_id}/{user_part}/oauth/{tool_id}/token"
|
|
275
|
+
|
|
276
|
+
async def cleanup_expired_tokens(self) -> None:
|
|
277
|
+
"""Remove expired tokens from cache."""
|
|
278
|
+
expired_keys = []
|
|
279
|
+
|
|
280
|
+
for cache_key, token in self.cache.items():
|
|
281
|
+
if TokenValidator.is_expired(token.expires_at.timestamp()):
|
|
282
|
+
expired_keys.append(cache_key)
|
|
283
|
+
|
|
284
|
+
for key in expired_keys:
|
|
285
|
+
del self.cache[key]
|
|
286
|
+
|
|
287
|
+
async def get_token_info(self, tool_id: str, user_id: Optional[str]) -> Optional[Dict]:
|
|
288
|
+
"""
|
|
289
|
+
Get information about a token without returning the actual token.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
tool_id: ID of the tool
|
|
293
|
+
user_id: ID of the user
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Token information or None if not found
|
|
297
|
+
"""
|
|
298
|
+
cache_key = f"{tool_id}:{user_id or 'service'}"
|
|
299
|
+
token = self.cache.get(cache_key)
|
|
300
|
+
|
|
301
|
+
if not token:
|
|
302
|
+
# Try to load from vault
|
|
303
|
+
secret_path = self._get_token_path(tool_id, user_id)
|
|
304
|
+
secret_data = await self.vault_client.read_secret(secret_path)
|
|
305
|
+
if secret_data:
|
|
306
|
+
token = OAuthToken(**secret_data)
|
|
307
|
+
|
|
308
|
+
if token:
|
|
309
|
+
return {
|
|
310
|
+
"tool_id": tool_id,
|
|
311
|
+
"user_id": user_id,
|
|
312
|
+
"scopes": token.scopes,
|
|
313
|
+
"expires_at": token.expires_at.isoformat(),
|
|
314
|
+
"is_expired": TokenValidator.is_expired(token.expires_at.timestamp()),
|
|
315
|
+
"expires_soon": TokenValidator.expires_soon(token.expires_at.timestamp()),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return None
|