authgent 0.1.0__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.
authgent/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """authgent SDK — token validation, delegation chains, DPoP for AI agents."""
2
+
3
+ from authgent.verify import verify_token
4
+ from authgent.delegation import verify_delegation_chain
5
+ from authgent.dpop import verify_dpop_proof, DPoPClient
6
+ from authgent.client import AgentAuthClient
7
+ from authgent.models import AgentIdentity, DelegationChain, TokenClaims
8
+ from authgent.errors import (
9
+ AuthgentError,
10
+ InvalidTokenError,
11
+ DelegationError,
12
+ DPoPError,
13
+ ServerError,
14
+ )
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "verify_token",
20
+ "verify_delegation_chain",
21
+ "verify_dpop_proof",
22
+ "DPoPClient",
23
+ "AgentAuthClient",
24
+ "AgentIdentity",
25
+ "DelegationChain",
26
+ "TokenClaims",
27
+ "AuthgentError",
28
+ "InvalidTokenError",
29
+ "DelegationError",
30
+ "DPoPError",
31
+ "ServerError",
32
+ ]
@@ -0,0 +1 @@
1
+ """SDK adapters for MCP and other protocols."""
@@ -0,0 +1,223 @@
1
+ """LangChain adapter — plug authgent into LangChain tool/agent pipelines.
2
+
3
+ Provides:
4
+ - AuthgentToolWrapper: wraps any LangChain tool with automatic token management
5
+ - AuthgentCallbackHandler: logs auth events (token refresh, exchange) to LangChain callbacks
6
+ - authgent_auth_header: helper to inject Bearer/DPoP headers into HTTP tool calls
7
+
8
+ Usage:
9
+ from authgent.adapters.langchain import AuthgentToolWrapper
10
+ from langchain_core.tools import Tool
11
+
12
+ wrapper = AuthgentToolWrapper(
13
+ server_url="http://localhost:8000",
14
+ client_id="agnt_xxx",
15
+ client_secret="secret",
16
+ scope="read write",
17
+ )
18
+
19
+ # Wrap any tool to auto-inject auth headers
20
+ authed_tool = wrapper.wrap(my_http_tool)
21
+
22
+ # Or get headers directly for custom integrations
23
+ headers = await wrapper.get_auth_headers(resource="https://api.example.com")
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import time
29
+ from dataclasses import dataclass, field
30
+ from typing import Any
31
+
32
+ from authgent.client import AgentAuthClient, TokenResult
33
+ from authgent.dpop import DPoPClient
34
+
35
+
36
+ @dataclass
37
+ class _CachedToken:
38
+ """In-memory token with expiry tracking."""
39
+
40
+ token: TokenResult
41
+ acquired_at: float = field(default_factory=time.monotonic)
42
+
43
+ def is_expired(self, buffer_seconds: int = 30) -> bool:
44
+ """Check if token is expired or about to expire."""
45
+ elapsed = time.monotonic() - self.acquired_at
46
+ return elapsed >= (self.token.expires_in - buffer_seconds)
47
+
48
+
49
+ class AuthgentToolWrapper:
50
+ """Wraps LangChain tools with automatic authgent token management.
51
+
52
+ Handles:
53
+ - Client credentials token acquisition
54
+ - Token caching with automatic refresh
55
+ - Token exchange for downstream resource delegation
56
+ - Optional DPoP proof generation
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ server_url: str,
62
+ client_id: str,
63
+ client_secret: str,
64
+ *,
65
+ scope: str | None = None,
66
+ resource: str | None = None,
67
+ use_dpop: bool = False,
68
+ token_buffer_seconds: int = 30,
69
+ ):
70
+ self._client = AgentAuthClient(server_url)
71
+ self._client_id = client_id
72
+ self._client_secret = client_secret
73
+ self._scope = scope
74
+ self._resource = resource
75
+ self._use_dpop = use_dpop
76
+ self._buffer = token_buffer_seconds
77
+ self._dpop: DPoPClient | None = DPoPClient() if use_dpop else None
78
+ self._cached: _CachedToken | None = None
79
+
80
+ async def get_token(self) -> TokenResult:
81
+ """Get a valid access token, refreshing if needed."""
82
+ if self._cached and not self._cached.is_expired(self._buffer):
83
+ return self._cached.token
84
+
85
+ result = await self._client.get_token(
86
+ client_id=self._client_id,
87
+ client_secret=self._client_secret,
88
+ scope=self._scope,
89
+ resource=self._resource,
90
+ )
91
+ self._cached = _CachedToken(token=result)
92
+ return result
93
+
94
+ async def exchange_for(
95
+ self,
96
+ audience: str,
97
+ scopes: list[str] | None = None,
98
+ ) -> TokenResult:
99
+ """Exchange the current token for a delegated token targeting a downstream resource."""
100
+ parent = await self.get_token()
101
+ return await self._client.exchange_token(
102
+ subject_token=parent.access_token,
103
+ audience=audience,
104
+ scopes=scopes,
105
+ client_id=self._client_id,
106
+ client_secret=self._client_secret,
107
+ )
108
+
109
+ async def get_auth_headers(
110
+ self,
111
+ *,
112
+ resource: str | None = None,
113
+ http_method: str = "GET",
114
+ http_uri: str | None = None,
115
+ ) -> dict[str, str]:
116
+ """Get authorization headers for an HTTP request.
117
+
118
+ If resource differs from the configured resource, performs a token exchange.
119
+ If DPoP is enabled, includes DPoP proof header.
120
+ """
121
+ if resource and resource != self._resource:
122
+ token = await self.exchange_for(resource)
123
+ else:
124
+ token = await self.get_token()
125
+
126
+ headers: dict[str, str] = {}
127
+
128
+ if self._dpop and http_uri:
129
+ proof_headers = self._dpop.create_proof_headers(
130
+ http_method=http_method,
131
+ http_uri=http_uri,
132
+ access_token=token.access_token,
133
+ )
134
+ headers.update(proof_headers)
135
+ headers["Authorization"] = f"DPoP {token.access_token}"
136
+ else:
137
+ headers["Authorization"] = f"Bearer {token.access_token}"
138
+
139
+ return headers
140
+
141
+ def wrap(self, tool: Any) -> Any:
142
+ """Wrap a LangChain tool to auto-inject auth metadata.
143
+
144
+ Returns a new tool that adds `authgent_headers` to the tool's
145
+ kwargs before invocation. The wrapped tool's function should
146
+ accept **kwargs and use `authgent_headers` for HTTP calls.
147
+
148
+ NOTE: This is a skeleton — full integration depends on the
149
+ LangChain tool interface version (BaseTool, StructuredTool, etc).
150
+ """
151
+ # Lazy import to avoid hard dependency on langchain
152
+ try:
153
+ from langchain_core.tools import StructuredTool
154
+ except ImportError:
155
+ raise ImportError(
156
+ "langchain-core is required for tool wrapping. "
157
+ "Install with: pip install langchain-core"
158
+ )
159
+
160
+ original_func = tool.func if hasattr(tool, "func") else tool._run
161
+ wrapper_self = self
162
+
163
+ async def _authed_func(*args: Any, **kwargs: Any) -> Any:
164
+ headers = await wrapper_self.get_auth_headers()
165
+ kwargs["authgent_headers"] = headers
166
+ if hasattr(original_func, "__call__"):
167
+ return await original_func(*args, **kwargs)
168
+ return original_func(*args, **kwargs)
169
+
170
+ return StructuredTool(
171
+ name=getattr(tool, "name", "wrapped_tool"),
172
+ description=getattr(tool, "description", ""),
173
+ coroutine=_authed_func,
174
+ args_schema=getattr(tool, "args_schema", None),
175
+ )
176
+
177
+ async def revoke(self) -> None:
178
+ """Revoke the current cached token."""
179
+ if self._cached:
180
+ await self._client.revoke_token(
181
+ self._cached.token.access_token,
182
+ client_id=self._client_id,
183
+ )
184
+ self._cached = None
185
+
186
+
187
+ class AuthgentCallbackHandler:
188
+ """LangChain callback handler skeleton for auth event logging.
189
+
190
+ Logs token acquisition, refresh, and exchange events. Can be
191
+ extended to integrate with LangChain's callback system.
192
+
193
+ Usage:
194
+ from langchain_core.callbacks import CallbackManager
195
+ handler = AuthgentCallbackHandler()
196
+ callback_manager = CallbackManager([handler])
197
+ """
198
+
199
+ def __init__(self, *, verbose: bool = False):
200
+ self._verbose = verbose
201
+ self._events: list[dict[str, Any]] = []
202
+
203
+ def on_token_acquired(self, token_type: str, scope: str | None) -> None:
204
+ event = {"event": "token_acquired", "token_type": token_type, "scope": scope}
205
+ self._events.append(event)
206
+ if self._verbose:
207
+ print(f"[authgent] Token acquired: type={token_type} scope={scope}")
208
+
209
+ def on_token_exchanged(self, audience: str, scope: str | None) -> None:
210
+ event = {"event": "token_exchanged", "audience": audience, "scope": scope}
211
+ self._events.append(event)
212
+ if self._verbose:
213
+ print(f"[authgent] Token exchanged: audience={audience} scope={scope}")
214
+
215
+ def on_token_revoked(self) -> None:
216
+ event = {"event": "token_revoked"}
217
+ self._events.append(event)
218
+ if self._verbose:
219
+ print("[authgent] Token revoked")
220
+
221
+ @property
222
+ def events(self) -> list[dict[str, Any]]:
223
+ return list(self._events)
@@ -0,0 +1,40 @@
1
+ """MCP Auth Provider adapter — plug authgent into FastMCP servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from authgent.client import AgentAuthClient
6
+ from authgent.verify import verify_token
7
+ from authgent.models import AgentIdentity
8
+
9
+
10
+ class AgentAuthProvider:
11
+ """Auth provider adapter for FastMCP servers.
12
+
13
+ Usage:
14
+ from authgent.adapters.mcp import AgentAuthProvider
15
+ mcp = FastMCP("my-server")
16
+ mcp.auth_provider = AgentAuthProvider(server_url="http://localhost:8000")
17
+ """
18
+
19
+ def __init__(self, server_url: str, audience: str | None = None):
20
+ self._server_url = server_url.rstrip("/")
21
+ self._audience = audience
22
+ self._client = AgentAuthClient(server_url)
23
+
24
+ async def verify(self, token: str) -> AgentIdentity:
25
+ """Verify an access token and return the agent identity."""
26
+ return await verify_token(
27
+ token=token,
28
+ issuer=self._server_url,
29
+ audience=self._audience,
30
+ )
31
+
32
+ @property
33
+ def metadata_url(self) -> str:
34
+ """URL for OAuth server metadata discovery."""
35
+ return f"{self._server_url}/.well-known/oauth-authorization-server"
36
+
37
+ @property
38
+ def jwks_url(self) -> str:
39
+ """URL for JWKS endpoint."""
40
+ return f"{self._server_url}/.well-known/jwks.json"
@@ -0,0 +1,92 @@
1
+ """RFC 9728 — OAuth 2.0 Protected Resource Metadata generator.
2
+
3
+ Generates the /.well-known/oauth-protected-resource JSON document
4
+ that MCP servers and API servers should serve to advertise their
5
+ authorization requirements to clients.
6
+
7
+ Usage:
8
+ from authgent.adapters.protected_resource import ProtectedResourceMetadata
9
+
10
+ metadata = ProtectedResourceMetadata(
11
+ resource="https://mcp-server.example.com",
12
+ authorization_servers=["http://localhost:8000"],
13
+ scopes_supported=["tools:execute", "db:read", "db:write"],
14
+ )
15
+
16
+ # In FastAPI:
17
+ @app.get("/.well-known/oauth-protected-resource")
18
+ def protected_resource():
19
+ return metadata.to_dict()
20
+
21
+ # Or generate as JSON string:
22
+ json_str = metadata.to_json()
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from dataclasses import dataclass, field
29
+
30
+
31
+ @dataclass
32
+ class ProtectedResourceMetadata:
33
+ """RFC 9728 Protected Resource Metadata document.
34
+
35
+ Attributes:
36
+ resource: The protected resource identifier (URL).
37
+ authorization_servers: List of authorization server URLs that can issue
38
+ tokens accepted by this resource.
39
+ scopes_supported: Scopes this resource understands.
40
+ bearer_methods_supported: Token presentation methods supported.
41
+ resource_signing_alg_values_supported: Signing algorithms accepted.
42
+ resource_documentation: URL to human-readable documentation.
43
+ resource_policy_uri: URL to the resource's policy.
44
+ resource_tos_uri: URL to the resource's terms of service.
45
+ dpop_signing_alg_values_supported: DPoP signing algorithms accepted.
46
+ """
47
+
48
+ resource: str
49
+ authorization_servers: list[str]
50
+ scopes_supported: list[str] = field(default_factory=list)
51
+ bearer_methods_supported: list[str] = field(default_factory=lambda: ["header"])
52
+ resource_signing_alg_values_supported: list[str] = field(
53
+ default_factory=lambda: ["ES256"]
54
+ )
55
+ resource_documentation: str | None = None
56
+ resource_policy_uri: str | None = None
57
+ resource_tos_uri: str | None = None
58
+ dpop_signing_alg_values_supported: list[str] | None = None
59
+
60
+ def to_dict(self) -> dict:
61
+ """Generate the RFC 9728 metadata document as a dictionary."""
62
+ doc: dict = {
63
+ "resource": self.resource,
64
+ "authorization_servers": self.authorization_servers,
65
+ }
66
+ if self.scopes_supported:
67
+ doc["scopes_supported"] = self.scopes_supported
68
+ if self.bearer_methods_supported:
69
+ doc["bearer_methods_supported"] = self.bearer_methods_supported
70
+ if self.resource_signing_alg_values_supported:
71
+ doc["resource_signing_alg_values_supported"] = (
72
+ self.resource_signing_alg_values_supported
73
+ )
74
+ if self.resource_documentation:
75
+ doc["resource_documentation"] = self.resource_documentation
76
+ if self.resource_policy_uri:
77
+ doc["resource_policy_uri"] = self.resource_policy_uri
78
+ if self.resource_tos_uri:
79
+ doc["resource_tos_uri"] = self.resource_tos_uri
80
+ if self.dpop_signing_alg_values_supported:
81
+ doc["dpop_signing_alg_values_supported"] = (
82
+ self.dpop_signing_alg_values_supported
83
+ )
84
+ return doc
85
+
86
+ def to_json(self, indent: int = 2) -> str:
87
+ """Generate the RFC 9728 metadata document as a JSON string."""
88
+ return json.dumps(self.to_dict(), indent=indent)
89
+
90
+ def fastapi_route(self) -> dict:
91
+ """Return the metadata dict, suitable as a FastAPI route return value."""
92
+ return self.to_dict()