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 +32 -0
- authgent/adapters/__init__.py +1 -0
- authgent/adapters/langchain.py +223 -0
- authgent/adapters/mcp.py +40 -0
- authgent/adapters/protected_resource.py +92 -0
- authgent/client.py +329 -0
- authgent/delegation.py +50 -0
- authgent/dpop.py +193 -0
- authgent/errors.py +43 -0
- authgent/jwks.py +63 -0
- authgent/middleware/__init__.py +1 -0
- authgent/middleware/fastapi.py +141 -0
- authgent/middleware/flask.py +106 -0
- authgent/middleware/scope_challenge.py +220 -0
- authgent/models.py +98 -0
- authgent/py.typed +0 -0
- authgent/verify.py +105 -0
- authgent-0.1.0.dist-info/METADATA +308 -0
- authgent-0.1.0.dist-info/RECORD +20 -0
- authgent-0.1.0.dist-info/WHEEL +4 -0
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)
|
authgent/adapters/mcp.py
ADDED
|
@@ -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()
|