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 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)
@@ -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