legant-sdk 0.1.0__tar.gz
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.
- legant_sdk-0.1.0/PKG-INFO +70 -0
- legant_sdk-0.1.0/README.md +57 -0
- legant_sdk-0.1.0/legant_sdk/__init__.py +40 -0
- legant_sdk-0.1.0/legant_sdk/agentguard.py +101 -0
- legant_sdk-0.1.0/legant_sdk/middleware.py +151 -0
- legant_sdk-0.1.0/legant_sdk/revocation.py +95 -0
- legant_sdk-0.1.0/legant_sdk/verifier.py +248 -0
- legant_sdk-0.1.0/legant_sdk.egg-info/PKG-INFO +70 -0
- legant_sdk-0.1.0/legant_sdk.egg-info/SOURCES.txt +15 -0
- legant_sdk-0.1.0/legant_sdk.egg-info/dependency_links.txt +1 -0
- legant_sdk-0.1.0/legant_sdk.egg-info/requires.txt +1 -0
- legant_sdk-0.1.0/legant_sdk.egg-info/top_level.txt +1 -0
- legant_sdk-0.1.0/pyproject.toml +21 -0
- legant_sdk-0.1.0/setup.cfg +4 -0
- legant_sdk-0.1.0/tests/test_agentguard.py +69 -0
- legant_sdk-0.1.0/tests/test_conformance.py +87 -0
- legant_sdk-0.1.0/tests/test_middleware.py +124 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: legant-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline verifier + authorizer for Legant delegation tokens (RFC 8693 sub/act), with Tier-B revocation-feed support.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/legant-dev/legant/tree/main/clients/python
|
|
7
|
+
Project-URL: Repository, https://github.com/legant-dev/legant
|
|
8
|
+
Project-URL: Issues, https://github.com/legant-dev/legant/issues
|
|
9
|
+
Keywords: legant,delegation,rfc8693,oauth2,ai-agents,mcp,authorization
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: cryptography>=3.4
|
|
13
|
+
|
|
14
|
+
# legant-sdk (Python)
|
|
15
|
+
|
|
16
|
+
Offline verifier + authorizer for Legant delegation tokens. Its only dependency
|
|
17
|
+
is [`cryptography`](https://pypi.org/project/cryptography/) (Python ≥ 3.9).
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from legant_sdk import fetch_jwks, Verifier, Action, fetch_revocation_feed
|
|
21
|
+
|
|
22
|
+
issuer = "https://auth.example.com"
|
|
23
|
+
keys = fetch_jwks(f"{issuer}/.well-known/jwks.json")
|
|
24
|
+
|
|
25
|
+
# Tier B (optional): reject revoked tokens offline, refreshed in the background.
|
|
26
|
+
feed = fetch_revocation_feed(f"{issuer}/.well-known/revoked", issuer, keys)
|
|
27
|
+
feed.start_polling(10.0, on_error=print)
|
|
28
|
+
|
|
29
|
+
verifier = Verifier(issuer, "https://my-api.example/", keys, feed=feed)
|
|
30
|
+
|
|
31
|
+
# Per request:
|
|
32
|
+
claims = verifier.verify(bearer_token) # raises VerifyError / RevokedError on failure
|
|
33
|
+
claims.authorize(Action(scope="expenses:submit", amount=120, category="travel")) # raises AuthorizeError on 403
|
|
34
|
+
print(claims.provenance()) # "user:alice -> agent:assistant"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`verify` raises `VerifyError` (or `RevokedError` when the token is in the feed);
|
|
38
|
+
`authorize` raises `AuthorizeError` when a scope or constraint is denied. Catch
|
|
39
|
+
them to return 401 / 403.
|
|
40
|
+
|
|
41
|
+
## Guard an agent's tools — any framework
|
|
42
|
+
|
|
43
|
+
`AgentGuard` wraps any tool callable so every invocation is authorized against
|
|
44
|
+
the agent's delegation token — offline, no callback. The wrapped function is a
|
|
45
|
+
plain callable, so it drops into LangChain, CrewAI, LlamaIndex, AutoGen, or your
|
|
46
|
+
own loop unchanged. A prompt-injected or buggy agent cannot exceed the scoped,
|
|
47
|
+
revocable slice the token carries.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from legant_sdk import Verifier, AgentGuard
|
|
51
|
+
|
|
52
|
+
verifier = Verifier(issuer, "https://my-api.example/", keys, feed=feed)
|
|
53
|
+
guard = AgentGuard(verifier, token=agent_delegation_token) # token may be a callable, to refresh
|
|
54
|
+
|
|
55
|
+
@guard.tool("expenses:submit", amount_arg="amount", category_arg="category")
|
|
56
|
+
def submit_expense(amount: float, category: str) -> str:
|
|
57
|
+
... # only runs if the token permits this scope, amount, and category — else AuthorizeError
|
|
58
|
+
|
|
59
|
+
# LangChain: from langchain_core.tools import tool; lc_tool = tool(submit_expense)
|
|
60
|
+
# CrewAI: @tool("Submit expense") def submit_expense(...): ... then wrap with @guard.tool(...)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or check inline without the decorator: `guard.authorize("scope", amount=…)` (raises)
|
|
64
|
+
or `guard.allowed("scope", amount=…)` (returns a bool).
|
|
65
|
+
|
|
66
|
+
## Test
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python3 -m unittest discover -s tests # runs the shared conformance vectors (see ../conformance)
|
|
70
|
+
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# legant-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Offline verifier + authorizer for Legant delegation tokens. Its only dependency
|
|
4
|
+
is [`cryptography`](https://pypi.org/project/cryptography/) (Python ≥ 3.9).
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from legant_sdk import fetch_jwks, Verifier, Action, fetch_revocation_feed
|
|
8
|
+
|
|
9
|
+
issuer = "https://auth.example.com"
|
|
10
|
+
keys = fetch_jwks(f"{issuer}/.well-known/jwks.json")
|
|
11
|
+
|
|
12
|
+
# Tier B (optional): reject revoked tokens offline, refreshed in the background.
|
|
13
|
+
feed = fetch_revocation_feed(f"{issuer}/.well-known/revoked", issuer, keys)
|
|
14
|
+
feed.start_polling(10.0, on_error=print)
|
|
15
|
+
|
|
16
|
+
verifier = Verifier(issuer, "https://my-api.example/", keys, feed=feed)
|
|
17
|
+
|
|
18
|
+
# Per request:
|
|
19
|
+
claims = verifier.verify(bearer_token) # raises VerifyError / RevokedError on failure
|
|
20
|
+
claims.authorize(Action(scope="expenses:submit", amount=120, category="travel")) # raises AuthorizeError on 403
|
|
21
|
+
print(claims.provenance()) # "user:alice -> agent:assistant"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`verify` raises `VerifyError` (or `RevokedError` when the token is in the feed);
|
|
25
|
+
`authorize` raises `AuthorizeError` when a scope or constraint is denied. Catch
|
|
26
|
+
them to return 401 / 403.
|
|
27
|
+
|
|
28
|
+
## Guard an agent's tools — any framework
|
|
29
|
+
|
|
30
|
+
`AgentGuard` wraps any tool callable so every invocation is authorized against
|
|
31
|
+
the agent's delegation token — offline, no callback. The wrapped function is a
|
|
32
|
+
plain callable, so it drops into LangChain, CrewAI, LlamaIndex, AutoGen, or your
|
|
33
|
+
own loop unchanged. A prompt-injected or buggy agent cannot exceed the scoped,
|
|
34
|
+
revocable slice the token carries.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from legant_sdk import Verifier, AgentGuard
|
|
38
|
+
|
|
39
|
+
verifier = Verifier(issuer, "https://my-api.example/", keys, feed=feed)
|
|
40
|
+
guard = AgentGuard(verifier, token=agent_delegation_token) # token may be a callable, to refresh
|
|
41
|
+
|
|
42
|
+
@guard.tool("expenses:submit", amount_arg="amount", category_arg="category")
|
|
43
|
+
def submit_expense(amount: float, category: str) -> str:
|
|
44
|
+
... # only runs if the token permits this scope, amount, and category — else AuthorizeError
|
|
45
|
+
|
|
46
|
+
# LangChain: from langchain_core.tools import tool; lc_tool = tool(submit_expense)
|
|
47
|
+
# CrewAI: @tool("Submit expense") def submit_expense(...): ... then wrap with @guard.tool(...)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or check inline without the decorator: `guard.authorize("scope", amount=…)` (raises)
|
|
51
|
+
or `guard.allowed("scope", amount=…)` (returns a bool).
|
|
52
|
+
|
|
53
|
+
## Test
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 -m unittest discover -s tests # runs the shared conformance vectors (see ../conformance)
|
|
57
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Legant SDK — offline verification and authorization of delegation tokens."""
|
|
2
|
+
|
|
3
|
+
from .agentguard import AgentGuard
|
|
4
|
+
from .middleware import (
|
|
5
|
+
authenticate,
|
|
6
|
+
bearer_token,
|
|
7
|
+
fastapi_auth,
|
|
8
|
+
flask_require,
|
|
9
|
+
mcp_tool_name,
|
|
10
|
+
)
|
|
11
|
+
from .revocation import RevocationFeed, fetch_revocation_feed
|
|
12
|
+
from .verifier import (
|
|
13
|
+
Action,
|
|
14
|
+
AuthorizeError,
|
|
15
|
+
Claims,
|
|
16
|
+
RevokedError,
|
|
17
|
+
Verifier,
|
|
18
|
+
VerifyError,
|
|
19
|
+
fetch_jwks,
|
|
20
|
+
parse_jwks,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Verifier",
|
|
25
|
+
"Claims",
|
|
26
|
+
"Action",
|
|
27
|
+
"AgentGuard",
|
|
28
|
+
"VerifyError",
|
|
29
|
+
"AuthorizeError",
|
|
30
|
+
"RevokedError",
|
|
31
|
+
"parse_jwks",
|
|
32
|
+
"fetch_jwks",
|
|
33
|
+
"RevocationFeed",
|
|
34
|
+
"fetch_revocation_feed",
|
|
35
|
+
"authenticate",
|
|
36
|
+
"bearer_token",
|
|
37
|
+
"fastapi_auth",
|
|
38
|
+
"flask_require",
|
|
39
|
+
"mcp_tool_name",
|
|
40
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Framework-agnostic tool authorization for AI agents.
|
|
2
|
+
|
|
3
|
+
Wrap any tool callable — a LangChain ``@tool``, a CrewAI / LlamaIndex / AutoGen
|
|
4
|
+
tool, or a plain function — so every invocation is authorized against a Legant
|
|
5
|
+
delegation token, offline, with no callback. The agent only ever wields the
|
|
6
|
+
scoped, attenuating, revocable slice of authority the token carries; a
|
|
7
|
+
prompt-injected or buggy agent physically cannot exceed it.
|
|
8
|
+
|
|
9
|
+
guard = AgentGuard(verifier, token="<the agent's delegation token>")
|
|
10
|
+
|
|
11
|
+
@guard.tool("expenses:submit", amount_arg="amount", category_arg="category")
|
|
12
|
+
def submit_expense(amount: float, category: str) -> str:
|
|
13
|
+
... # only runs if the token permits this scope, amount, and category
|
|
14
|
+
|
|
15
|
+
These wrappers are plain callables, so they drop into any framework that takes a
|
|
16
|
+
function as a tool. ``token`` may also be a zero-arg callable, so it can be
|
|
17
|
+
refreshed (re-exchanged) without rebuilding the guard.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import functools
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Callable, Optional, Union
|
|
25
|
+
|
|
26
|
+
from .verifier import Action, Claims, Verifier
|
|
27
|
+
|
|
28
|
+
TokenSource = Union[str, Callable[[], str]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentGuard:
|
|
32
|
+
"""Authorizes an agent's tool calls against a Legant delegation token."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, verifier: Verifier, token: TokenSource):
|
|
35
|
+
self._verifier = verifier
|
|
36
|
+
self._token = token
|
|
37
|
+
|
|
38
|
+
def _current_token(self) -> str:
|
|
39
|
+
return self._token() if callable(self._token) else self._token
|
|
40
|
+
|
|
41
|
+
def authorize(
|
|
42
|
+
self,
|
|
43
|
+
scope: str,
|
|
44
|
+
*,
|
|
45
|
+
amount: float = 0.0,
|
|
46
|
+
category: str = "",
|
|
47
|
+
tool: str = "",
|
|
48
|
+
resource: str = "",
|
|
49
|
+
at: Optional[datetime] = None,
|
|
50
|
+
) -> Claims:
|
|
51
|
+
"""Verify the current token and authorize one action.
|
|
52
|
+
|
|
53
|
+
Raises ``VerifyError`` / ``RevokedError`` if the token is invalid or
|
|
54
|
+
revoked, or ``AuthorizeError`` if the action exceeds the delegation.
|
|
55
|
+
Returns the verified :class:`Claims` (whose ``provenance()`` you can log).
|
|
56
|
+
"""
|
|
57
|
+
claims = self._verifier.verify(self._current_token())
|
|
58
|
+
claims.authorize(
|
|
59
|
+
Action(scope=scope, amount=amount, category=category, tool=tool, resource=resource, at=at)
|
|
60
|
+
)
|
|
61
|
+
return claims
|
|
62
|
+
|
|
63
|
+
def allowed(self, scope: str, **kwargs) -> bool:
|
|
64
|
+
"""Non-raising check — True iff :meth:`authorize` would succeed."""
|
|
65
|
+
try:
|
|
66
|
+
self.authorize(scope, **kwargs)
|
|
67
|
+
return True
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def tool(
|
|
72
|
+
self,
|
|
73
|
+
scope: str,
|
|
74
|
+
*,
|
|
75
|
+
tool: str = "",
|
|
76
|
+
resource: str = "",
|
|
77
|
+
amount_arg: Optional[str] = None,
|
|
78
|
+
category_arg: Optional[str] = None,
|
|
79
|
+
) -> Callable[[Callable], Callable]:
|
|
80
|
+
"""Decorator that authorizes the wrapped tool before it runs.
|
|
81
|
+
|
|
82
|
+
``amount_arg`` / ``category_arg`` name the wrapped function's keyword
|
|
83
|
+
arguments to read the monetary amount / category from, so constraint
|
|
84
|
+
checks (max-amount, category allow-list) use the real call values.
|
|
85
|
+
``tool`` / ``resource`` are the Legant tool-constraint and resource-audience
|
|
86
|
+
values to check (each defaults to empty = not constrained by that
|
|
87
|
+
dimension).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def deco(fn: Callable) -> Callable:
|
|
91
|
+
@functools.wraps(fn)
|
|
92
|
+
def wrapper(*args, **kwargs):
|
|
93
|
+
amount = float(kwargs.get(amount_arg, 0) or 0) if amount_arg else 0.0
|
|
94
|
+
category = str(kwargs.get(category_arg, "") or "") if category_arg else ""
|
|
95
|
+
self.authorize(scope, amount=amount, category=category, tool=tool, resource=resource)
|
|
96
|
+
return fn(*args, **kwargs)
|
|
97
|
+
|
|
98
|
+
wrapper.__legant_guarded__ = True # type: ignore[attr-defined]
|
|
99
|
+
return wrapper
|
|
100
|
+
|
|
101
|
+
return deco
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Resource-server middleware for FastAPI and Flask.
|
|
2
|
+
|
|
3
|
+
A backend becomes a Legant-protected resource server in a few lines: verify the
|
|
4
|
+
Bearer token, expose the verified ``Claims``, and (optionally) authorize a
|
|
5
|
+
per-request ``Action``. This is the delegation-aware analog of a generic OIDC JWT
|
|
6
|
+
dependency — it understands the ``act`` chain, the constraint dimensions, RFC 8707
|
|
7
|
+
audience canonicalization, and the signed revocation feed.
|
|
8
|
+
|
|
9
|
+
FastAPI and Flask are NOT dependencies of this package; they are imported lazily
|
|
10
|
+
inside the factory functions, so ``import legant_sdk`` works without either.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import Callable, Optional, Union
|
|
17
|
+
|
|
18
|
+
from .verifier import Action, AuthorizeError, Claims, Verifier, VerifyError
|
|
19
|
+
|
|
20
|
+
# An action can be a fixed Action, a callable deriving one from the request, or None.
|
|
21
|
+
ActionSpec = Union[Action, Callable[..., Action], None]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def bearer_token(authorization: Optional[str]) -> str:
|
|
25
|
+
"""Extract the token from an ``Authorization: Bearer <token>`` header value."""
|
|
26
|
+
if not authorization:
|
|
27
|
+
raise VerifyError("missing Authorization header")
|
|
28
|
+
parts = authorization.strip().split(None, 1)
|
|
29
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
30
|
+
raise VerifyError("Authorization header is not a Bearer token")
|
|
31
|
+
return parts[1].strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def authenticate(verifier: Verifier, authorization: Optional[str]) -> Claims:
|
|
35
|
+
"""Verify the Bearer token from an Authorization header value (raises on failure)."""
|
|
36
|
+
return verifier.verify(bearer_token(authorization))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_action(spec: ActionSpec, scope: Optional[str], request) -> Optional[Action]:
|
|
40
|
+
if scope is not None:
|
|
41
|
+
return Action(scope=scope)
|
|
42
|
+
if spec is None:
|
|
43
|
+
return None
|
|
44
|
+
if callable(spec):
|
|
45
|
+
return spec(request)
|
|
46
|
+
return spec
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---- FastAPI ---------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fastapi_auth(
|
|
53
|
+
verifier: Verifier,
|
|
54
|
+
*,
|
|
55
|
+
scope: Optional[str] = None,
|
|
56
|
+
action: ActionSpec = None,
|
|
57
|
+
):
|
|
58
|
+
"""Return a FastAPI dependency that verifies the Bearer token (and optionally
|
|
59
|
+
authorizes ``scope`` or ``action``), yielding the verified ``Claims``.
|
|
60
|
+
|
|
61
|
+
claims_dep = fastapi_auth(verifier, scope="data:read")
|
|
62
|
+
|
|
63
|
+
@app.get("/data")
|
|
64
|
+
def read(claims: Claims = Depends(claims_dep)):
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
``action`` may be a fixed ``Action`` or ``callable(request) -> Action`` for
|
|
68
|
+
per-request constraints (amount/resource/tool/time).
|
|
69
|
+
"""
|
|
70
|
+
from fastapi import HTTPException, Request # lazy import
|
|
71
|
+
|
|
72
|
+
def dependency(request: Request) -> Claims:
|
|
73
|
+
try:
|
|
74
|
+
claims = authenticate(verifier, request.headers.get("authorization"))
|
|
75
|
+
except VerifyError as e:
|
|
76
|
+
raise HTTPException(
|
|
77
|
+
status_code=401, detail=str(e), headers={"WWW-Authenticate": "Bearer"}
|
|
78
|
+
)
|
|
79
|
+
act = _resolve_action(action, scope, request)
|
|
80
|
+
if act is not None:
|
|
81
|
+
try:
|
|
82
|
+
claims.authorize(act)
|
|
83
|
+
except AuthorizeError as e:
|
|
84
|
+
raise HTTPException(status_code=403, detail=str(e))
|
|
85
|
+
return claims
|
|
86
|
+
|
|
87
|
+
return dependency
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---- Flask -----------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def flask_require(
|
|
94
|
+
verifier: Verifier,
|
|
95
|
+
*,
|
|
96
|
+
scope: Optional[str] = None,
|
|
97
|
+
action: ActionSpec = None,
|
|
98
|
+
):
|
|
99
|
+
"""Return a Flask view decorator that verifies the Bearer token (and optionally
|
|
100
|
+
authorizes ``scope``/``action``), storing the ``Claims`` on ``flask.g.legant``.
|
|
101
|
+
|
|
102
|
+
@app.get("/data")
|
|
103
|
+
@flask_require(verifier, scope="data:read")
|
|
104
|
+
def read():
|
|
105
|
+
claims = flask.g.legant
|
|
106
|
+
...
|
|
107
|
+
"""
|
|
108
|
+
from functools import wraps
|
|
109
|
+
|
|
110
|
+
from flask import Response, g, request # lazy import
|
|
111
|
+
|
|
112
|
+
def challenge(code: int, err: str, desc: str) -> "Response":
|
|
113
|
+
resp = Response(f"{err}: {desc}", status=code)
|
|
114
|
+
resp.headers["WWW-Authenticate"] = f'Bearer error="{err}", error_description="{desc}"'
|
|
115
|
+
return resp
|
|
116
|
+
|
|
117
|
+
def decorator(fn):
|
|
118
|
+
@wraps(fn)
|
|
119
|
+
def wrapper(*args, **kwargs):
|
|
120
|
+
try:
|
|
121
|
+
claims = authenticate(verifier, request.headers.get("Authorization"))
|
|
122
|
+
except VerifyError as e:
|
|
123
|
+
return challenge(401, "invalid_token", str(e))
|
|
124
|
+
act = _resolve_action(action, scope, request)
|
|
125
|
+
if act is not None:
|
|
126
|
+
try:
|
|
127
|
+
claims.authorize(act)
|
|
128
|
+
except AuthorizeError as e:
|
|
129
|
+
return challenge(403, "insufficient_scope", str(e))
|
|
130
|
+
g.legant = claims
|
|
131
|
+
return fn(*args, **kwargs)
|
|
132
|
+
|
|
133
|
+
return wrapper
|
|
134
|
+
|
|
135
|
+
return decorator
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---- self-hosted MCP server helper -----------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def mcp_tool_name(body: Union[bytes, str, dict]) -> str:
|
|
142
|
+
"""Extract the tool name from a JSON-RPC MCP ``tools/call`` request body, for
|
|
143
|
+
resource servers that ARE an MCP server. Pair with ``claims.authorize``."""
|
|
144
|
+
if isinstance(body, (bytes, str)):
|
|
145
|
+
body = json.loads(body)
|
|
146
|
+
if not isinstance(body, dict) or body.get("method") != "tools/call":
|
|
147
|
+
raise ValueError(f"not a tools/call (method={body.get('method') if isinstance(body, dict) else None!r})")
|
|
148
|
+
name = (body.get("params") or {}).get("name")
|
|
149
|
+
if not name:
|
|
150
|
+
raise ValueError("tools/call missing params.name")
|
|
151
|
+
return name
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Tier B offline revocation: a pull-based view of revoked tokens.
|
|
2
|
+
|
|
3
|
+
The resource server fetches a signed feed from the issuer on a timer and checks
|
|
4
|
+
token ids against an in-memory set, with no per-request callback. A stale or
|
|
5
|
+
missing feed can only ever MISS a revocation, never invent one, and a regressing
|
|
6
|
+
version is rejected as a rollback.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
from cryptography.exceptions import InvalidSignature
|
|
18
|
+
from cryptography.hazmat.primitives import hashes
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
20
|
+
|
|
21
|
+
from .verifier import _b64url
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RevocationFeed:
|
|
25
|
+
def __init__(self, url: Optional[str], issuer: str, keys: dict) -> None:
|
|
26
|
+
self._url = url
|
|
27
|
+
self._issuer = issuer
|
|
28
|
+
self._keys = keys
|
|
29
|
+
self._revoked: set[str] = set()
|
|
30
|
+
self._version = 0
|
|
31
|
+
self._fetched_at = 0.0
|
|
32
|
+
|
|
33
|
+
def apply_feed(self, jws: str) -> None:
|
|
34
|
+
"""Verifies (RS256 under the issuer's kid, issuer + expiry), enforces a
|
|
35
|
+
monotonic version, then atomically swaps the in-memory set."""
|
|
36
|
+
h, p, s = jws.split(".")
|
|
37
|
+
header = json.loads(_b64url(h))
|
|
38
|
+
if header.get("alg") != "RS256":
|
|
39
|
+
raise ValueError(f"unexpected alg {header.get('alg')}")
|
|
40
|
+
key = self._keys.get(header.get("kid"))
|
|
41
|
+
if key is None:
|
|
42
|
+
raise ValueError(f'unknown feed signing key "{header.get("kid")}"')
|
|
43
|
+
try:
|
|
44
|
+
key.verify(_b64url(s), f"{h}.{p}".encode(), padding.PKCS1v15(), hashes.SHA256())
|
|
45
|
+
except InvalidSignature:
|
|
46
|
+
raise ValueError("feed signature verification failed")
|
|
47
|
+
c = json.loads(_b64url(p))
|
|
48
|
+
if c.get("iss") != self._issuer:
|
|
49
|
+
raise ValueError("feed issuer mismatch")
|
|
50
|
+
exp = c.get("exp")
|
|
51
|
+
if exp is None or time.time() >= exp:
|
|
52
|
+
raise ValueError("feed is expired or missing exp")
|
|
53
|
+
ver = int(c.get("ver", 0))
|
|
54
|
+
if ver < self._version:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"revocation feed version regressed ({ver} < {self._version}) — possible rollback, keeping current"
|
|
57
|
+
)
|
|
58
|
+
self._revoked = set(c.get("jtis") or [])
|
|
59
|
+
self._version = ver
|
|
60
|
+
self._fetched_at = time.time()
|
|
61
|
+
|
|
62
|
+
def refresh(self, timeout: float = 10.0) -> None:
|
|
63
|
+
if not self._url:
|
|
64
|
+
raise ValueError("revocation feed has no URL")
|
|
65
|
+
with urllib.request.urlopen(self._url, timeout=timeout) as r: # noqa: S310 (configured URL)
|
|
66
|
+
self.apply_feed(r.read().decode())
|
|
67
|
+
|
|
68
|
+
def start_polling(self, interval_s: float, on_error: Optional[Callable[[Exception], None]] = None) -> Callable[[], None]:
|
|
69
|
+
"""Refreshes on an interval in a daemon thread until the returned stop
|
|
70
|
+
function is called. Refresh errors are non-fatal."""
|
|
71
|
+
stop = threading.Event()
|
|
72
|
+
|
|
73
|
+
def loop() -> None:
|
|
74
|
+
while not stop.wait(interval_s):
|
|
75
|
+
try:
|
|
76
|
+
self.refresh()
|
|
77
|
+
except Exception as e: # noqa: BLE001 - non-fatal
|
|
78
|
+
if on_error:
|
|
79
|
+
on_error(e)
|
|
80
|
+
|
|
81
|
+
threading.Thread(target=loop, daemon=True).start()
|
|
82
|
+
return stop.set
|
|
83
|
+
|
|
84
|
+
def is_revoked(self, jti: str) -> bool:
|
|
85
|
+
return jti in self._revoked
|
|
86
|
+
|
|
87
|
+
def staleness(self) -> float:
|
|
88
|
+
"""Milliseconds since the feed was last successfully applied."""
|
|
89
|
+
return (time.time() - self._fetched_at) * 1000.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def fetch_revocation_feed(feed_url: str, issuer: str, keys: dict) -> RevocationFeed:
|
|
93
|
+
f = RevocationFeed(feed_url, issuer, keys)
|
|
94
|
+
f.refresh()
|
|
95
|
+
return f
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Offline verifier + authorizer for Legant delegation tokens.
|
|
2
|
+
|
|
3
|
+
Verifies a composite sub/act token against the issuer's JWKS and authorizes a
|
|
4
|
+
request's scope and constraints, entirely offline (no callback to Legant). Its
|
|
5
|
+
only dependency is ``cryptography``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Optional, Union
|
|
17
|
+
from zoneinfo import ZoneInfo
|
|
18
|
+
|
|
19
|
+
from cryptography.exceptions import InvalidSignature
|
|
20
|
+
from cryptography.hazmat.primitives import hashes
|
|
21
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
22
|
+
|
|
23
|
+
# Sentinel Legant puts in an allow-list that intersected to nothing during
|
|
24
|
+
# re-delegation. It matches no real value and denies the dimension entirely.
|
|
25
|
+
# Must match internal/delegation and the Go/TS SDKs.
|
|
26
|
+
_DENY_ALL = "\x00legant:deny-all"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class VerifyError(Exception):
|
|
30
|
+
"""Raised when a token fails verification."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthorizeError(Exception):
|
|
34
|
+
"""Raised when an action is not permitted by the token's scope/constraints."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RevokedError(VerifyError):
|
|
38
|
+
"""Raised by Verifier.verify when the token's jti is in the revocation feed."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__("token revoked")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Action:
|
|
46
|
+
scope: str
|
|
47
|
+
amount: float = 0.0
|
|
48
|
+
category: str = ""
|
|
49
|
+
tool: str = ""
|
|
50
|
+
resource: str = ""
|
|
51
|
+
at: Optional[datetime] = None # instant of the action; None = now
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Claims:
|
|
56
|
+
raw: dict
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def subject(self) -> str:
|
|
60
|
+
return self.raw.get("sub", "")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def jti(self) -> str:
|
|
64
|
+
return self.raw.get("jti", "")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def scope(self) -> str:
|
|
68
|
+
return self.raw.get("scope", "")
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def constraints(self) -> Optional[dict]:
|
|
72
|
+
return self.raw.get("cnst")
|
|
73
|
+
|
|
74
|
+
def provenance(self) -> str:
|
|
75
|
+
"""Renders e.g. 'user:alice -> agent:assistant -> agent:ocr'."""
|
|
76
|
+
parts = [self.raw.get("sub", "")]
|
|
77
|
+
chain = []
|
|
78
|
+
a = self.raw.get("act")
|
|
79
|
+
while a:
|
|
80
|
+
chain.append(a.get("sub", ""))
|
|
81
|
+
a = a.get("act")
|
|
82
|
+
parts.extend(reversed(chain))
|
|
83
|
+
return " -> ".join(parts)
|
|
84
|
+
|
|
85
|
+
def authorize(self, action: Action) -> None:
|
|
86
|
+
"""Raises AuthorizeError if the scope is missing or a constraint is violated."""
|
|
87
|
+
if action.scope not in self.scope.split():
|
|
88
|
+
raise AuthorizeError(f'missing required scope "{action.scope}"')
|
|
89
|
+
k = self.constraints
|
|
90
|
+
if not k:
|
|
91
|
+
return
|
|
92
|
+
max_amount = k.get("max_amount")
|
|
93
|
+
if max_amount is not None and action.amount > max_amount:
|
|
94
|
+
raise AuthorizeError(f"amount {action.amount} exceeds max_amount {max_amount}")
|
|
95
|
+
_permit_list("category", k.get("categories"), action.category, canonical=False)
|
|
96
|
+
_permit_list("tool", k.get("tools"), action.tool, canonical=False)
|
|
97
|
+
_permit_list("resource", k.get("resources"), action.resource, canonical=True)
|
|
98
|
+
tw = k.get("time_window")
|
|
99
|
+
if tw:
|
|
100
|
+
at = action.at or datetime.now(timezone.utc)
|
|
101
|
+
if not _time_window_allows(tw, at):
|
|
102
|
+
raise AuthorizeError("action is outside the delegated time window")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Verifier:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
issuer: str,
|
|
109
|
+
audience: str,
|
|
110
|
+
keys: dict,
|
|
111
|
+
feed=None,
|
|
112
|
+
feed_fail_closed_ms: Optional[int] = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
self._issuer = issuer
|
|
115
|
+
self._audience = audience
|
|
116
|
+
self._keys = keys
|
|
117
|
+
self._feed = feed
|
|
118
|
+
self._feed_fail_closed_ms = feed_fail_closed_ms
|
|
119
|
+
|
|
120
|
+
def verify(self, token: str) -> Claims:
|
|
121
|
+
"""Verifies RS256 signature (by kid), issuer, expiry, not-before, and
|
|
122
|
+
audience, and requires an act claim. Raises VerifyError on any failure."""
|
|
123
|
+
parts = token.split(".")
|
|
124
|
+
if len(parts) != 3:
|
|
125
|
+
raise VerifyError("malformed token")
|
|
126
|
+
h, p, s = parts
|
|
127
|
+
header = json.loads(_b64url(h))
|
|
128
|
+
if header.get("alg") != "RS256":
|
|
129
|
+
raise VerifyError(f"unexpected alg {header.get('alg')}")
|
|
130
|
+
kid = header.get("kid")
|
|
131
|
+
if not kid:
|
|
132
|
+
raise VerifyError("token missing kid header")
|
|
133
|
+
key = self._keys.get(kid)
|
|
134
|
+
if key is None:
|
|
135
|
+
raise VerifyError(f'unknown signing key "{kid}"')
|
|
136
|
+
try:
|
|
137
|
+
key.verify(_b64url(s), f"{h}.{p}".encode(), padding.PKCS1v15(), hashes.SHA256())
|
|
138
|
+
except InvalidSignature:
|
|
139
|
+
raise VerifyError("signature verification failed")
|
|
140
|
+
c = json.loads(_b64url(p))
|
|
141
|
+
if c.get("iss") != self._issuer:
|
|
142
|
+
raise VerifyError("invalid issuer")
|
|
143
|
+
now = time.time()
|
|
144
|
+
exp = c.get("exp")
|
|
145
|
+
if exp is None:
|
|
146
|
+
raise VerifyError("token has no expiration")
|
|
147
|
+
if now >= exp:
|
|
148
|
+
raise VerifyError("token is expired")
|
|
149
|
+
nbf = c.get("nbf")
|
|
150
|
+
if nbf is not None and now < nbf:
|
|
151
|
+
raise VerifyError("token is not valid yet")
|
|
152
|
+
if not c.get("act"):
|
|
153
|
+
raise VerifyError("not a delegation token (no act claim)")
|
|
154
|
+
if not _audience_matches(c.get("aud"), self._audience):
|
|
155
|
+
raise VerifyError("token audience does not include this resource server")
|
|
156
|
+
if self._feed is not None:
|
|
157
|
+
if self._feed_fail_closed_ms is not None and self._feed.staleness() > self._feed_fail_closed_ms:
|
|
158
|
+
raise VerifyError("revocation feed is stale and fail-closed is set")
|
|
159
|
+
jti = c.get("jti")
|
|
160
|
+
if jti and self._feed.is_revoked(jti):
|
|
161
|
+
raise RevokedError()
|
|
162
|
+
return Claims(c)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _b64url(s: str) -> bytes:
|
|
166
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _permit_list(dim: str, allowed, value: str, canonical: bool) -> None:
|
|
170
|
+
if not allowed:
|
|
171
|
+
return
|
|
172
|
+
if _DENY_ALL in allowed:
|
|
173
|
+
raise AuthorizeError(f"{dim} access is fully restricted by the delegation")
|
|
174
|
+
if value == "":
|
|
175
|
+
return
|
|
176
|
+
match = value in allowed
|
|
177
|
+
if not match and canonical:
|
|
178
|
+
cv = _canonicalize_audience(value)
|
|
179
|
+
match = any(_canonicalize_audience(a) == cv for a in allowed)
|
|
180
|
+
if not match:
|
|
181
|
+
raise AuthorizeError(f'{dim} "{value}" not permitted')
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _audience_matches(aud: Union[str, list, None], want: str) -> bool:
|
|
185
|
+
if aud is None:
|
|
186
|
+
auds = []
|
|
187
|
+
elif isinstance(aud, list):
|
|
188
|
+
auds = aud
|
|
189
|
+
else:
|
|
190
|
+
auds = [aud]
|
|
191
|
+
cw = _canonicalize_audience(want)
|
|
192
|
+
return any(_canonicalize_audience(a) == cw for a in auds)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _canonicalize_audience(raw: str) -> str:
|
|
196
|
+
"""Mirrors the issuer's RFC 8707 canonicalization: lowercase scheme+host,
|
|
197
|
+
strip a default port, drop userinfo and fragment, empty path -> '/'.
|
|
198
|
+
Non-absolute values are returned unchanged."""
|
|
199
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
200
|
+
|
|
201
|
+
u = urlsplit(raw)
|
|
202
|
+
if not u.scheme or not u.hostname:
|
|
203
|
+
return raw
|
|
204
|
+
scheme = u.scheme.lower()
|
|
205
|
+
host = u.hostname.lower()
|
|
206
|
+
port = u.port
|
|
207
|
+
if not ((scheme == "https" and port == 443) or (scheme == "http" and port == 80) or port is None):
|
|
208
|
+
host = f"{host}:{port}"
|
|
209
|
+
path = u.path if u.path else "/"
|
|
210
|
+
return urlunsplit((scheme, host, path, u.query, ""))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_WEEKDAY_OFFSET = 1 # Python weekday(): Mon=0..Sun=6 -> Go/Legant: Sun=0..Sat=6
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _time_window_allows(tw: dict, at: datetime) -> bool:
|
|
217
|
+
tz_name = tw.get("tz") or "UTC"
|
|
218
|
+
try:
|
|
219
|
+
tz = ZoneInfo(tz_name)
|
|
220
|
+
except Exception:
|
|
221
|
+
return False # unknown timezone fails closed
|
|
222
|
+
local = at.astimezone(tz)
|
|
223
|
+
weekday = (local.weekday() + _WEEKDAY_OFFSET) % 7 # Sun=0 .. Sat=6
|
|
224
|
+
weekdays = tw.get("weekdays")
|
|
225
|
+
if weekdays and weekday not in weekdays:
|
|
226
|
+
return False
|
|
227
|
+
minutes = local.hour * 60 + local.minute
|
|
228
|
+
return tw.get("start_min", 0) <= minutes <= tw.get("end_min", 0)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_jwks(doc: dict) -> dict:
|
|
232
|
+
"""Parses a JWKS document into a kid -> RSA public key map (RSA keys only)."""
|
|
233
|
+
out = {}
|
|
234
|
+
for k in doc.get("keys", []):
|
|
235
|
+
if k.get("kty") != "RSA" or not k.get("kid"):
|
|
236
|
+
continue
|
|
237
|
+
n = int.from_bytes(_b64url(k["n"]), "big")
|
|
238
|
+
if n.bit_length() < 2048:
|
|
239
|
+
raise ValueError(f"jwk {k['kid']}: modulus too small (want >= 2048 bits)")
|
|
240
|
+
e = int.from_bytes(_b64url(k["e"]), "big")
|
|
241
|
+
out[k["kid"]] = rsa.RSAPublicNumbers(e, n).public_key()
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def fetch_jwks(jwks_url: str, timeout: float = 10.0) -> dict:
|
|
246
|
+
"""Fetches and parses an issuer's JWKS. Pass a trusted, configured URL."""
|
|
247
|
+
with urllib.request.urlopen(jwks_url, timeout=timeout) as r: # noqa: S310 (configured URL)
|
|
248
|
+
return parse_jwks(json.loads(r.read()))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: legant-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline verifier + authorizer for Legant delegation tokens (RFC 8693 sub/act), with Tier-B revocation-feed support.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/legant-dev/legant/tree/main/clients/python
|
|
7
|
+
Project-URL: Repository, https://github.com/legant-dev/legant
|
|
8
|
+
Project-URL: Issues, https://github.com/legant-dev/legant/issues
|
|
9
|
+
Keywords: legant,delegation,rfc8693,oauth2,ai-agents,mcp,authorization
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: cryptography>=3.4
|
|
13
|
+
|
|
14
|
+
# legant-sdk (Python)
|
|
15
|
+
|
|
16
|
+
Offline verifier + authorizer for Legant delegation tokens. Its only dependency
|
|
17
|
+
is [`cryptography`](https://pypi.org/project/cryptography/) (Python ≥ 3.9).
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from legant_sdk import fetch_jwks, Verifier, Action, fetch_revocation_feed
|
|
21
|
+
|
|
22
|
+
issuer = "https://auth.example.com"
|
|
23
|
+
keys = fetch_jwks(f"{issuer}/.well-known/jwks.json")
|
|
24
|
+
|
|
25
|
+
# Tier B (optional): reject revoked tokens offline, refreshed in the background.
|
|
26
|
+
feed = fetch_revocation_feed(f"{issuer}/.well-known/revoked", issuer, keys)
|
|
27
|
+
feed.start_polling(10.0, on_error=print)
|
|
28
|
+
|
|
29
|
+
verifier = Verifier(issuer, "https://my-api.example/", keys, feed=feed)
|
|
30
|
+
|
|
31
|
+
# Per request:
|
|
32
|
+
claims = verifier.verify(bearer_token) # raises VerifyError / RevokedError on failure
|
|
33
|
+
claims.authorize(Action(scope="expenses:submit", amount=120, category="travel")) # raises AuthorizeError on 403
|
|
34
|
+
print(claims.provenance()) # "user:alice -> agent:assistant"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`verify` raises `VerifyError` (or `RevokedError` when the token is in the feed);
|
|
38
|
+
`authorize` raises `AuthorizeError` when a scope or constraint is denied. Catch
|
|
39
|
+
them to return 401 / 403.
|
|
40
|
+
|
|
41
|
+
## Guard an agent's tools — any framework
|
|
42
|
+
|
|
43
|
+
`AgentGuard` wraps any tool callable so every invocation is authorized against
|
|
44
|
+
the agent's delegation token — offline, no callback. The wrapped function is a
|
|
45
|
+
plain callable, so it drops into LangChain, CrewAI, LlamaIndex, AutoGen, or your
|
|
46
|
+
own loop unchanged. A prompt-injected or buggy agent cannot exceed the scoped,
|
|
47
|
+
revocable slice the token carries.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from legant_sdk import Verifier, AgentGuard
|
|
51
|
+
|
|
52
|
+
verifier = Verifier(issuer, "https://my-api.example/", keys, feed=feed)
|
|
53
|
+
guard = AgentGuard(verifier, token=agent_delegation_token) # token may be a callable, to refresh
|
|
54
|
+
|
|
55
|
+
@guard.tool("expenses:submit", amount_arg="amount", category_arg="category")
|
|
56
|
+
def submit_expense(amount: float, category: str) -> str:
|
|
57
|
+
... # only runs if the token permits this scope, amount, and category — else AuthorizeError
|
|
58
|
+
|
|
59
|
+
# LangChain: from langchain_core.tools import tool; lc_tool = tool(submit_expense)
|
|
60
|
+
# CrewAI: @tool("Submit expense") def submit_expense(...): ... then wrap with @guard.tool(...)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or check inline without the decorator: `guard.authorize("scope", amount=…)` (raises)
|
|
64
|
+
or `guard.allowed("scope", amount=…)` (returns a bool).
|
|
65
|
+
|
|
66
|
+
## Test
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python3 -m unittest discover -s tests # runs the shared conformance vectors (see ../conformance)
|
|
70
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
legant_sdk/__init__.py
|
|
4
|
+
legant_sdk/agentguard.py
|
|
5
|
+
legant_sdk/middleware.py
|
|
6
|
+
legant_sdk/revocation.py
|
|
7
|
+
legant_sdk/verifier.py
|
|
8
|
+
legant_sdk.egg-info/PKG-INFO
|
|
9
|
+
legant_sdk.egg-info/SOURCES.txt
|
|
10
|
+
legant_sdk.egg-info/dependency_links.txt
|
|
11
|
+
legant_sdk.egg-info/requires.txt
|
|
12
|
+
legant_sdk.egg-info/top_level.txt
|
|
13
|
+
tests/test_agentguard.py
|
|
14
|
+
tests/test_conformance.py
|
|
15
|
+
tests/test_middleware.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography>=3.4
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
legant_sdk
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "legant-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Offline verifier + authorizer for Legant delegation tokens (RFC 8693 sub/act), with Tier-B revocation-feed support."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
dependencies = ["cryptography>=3.4"]
|
|
13
|
+
keywords = ["legant", "delegation", "rfc8693", "oauth2", "ai-agents", "mcp", "authorization"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/legant-dev/legant/tree/main/clients/python"
|
|
17
|
+
Repository = "https://github.com/legant-dev/legant"
|
|
18
|
+
Issues = "https://github.com/legant-dev/legant/issues"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["legant_sdk*"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""AgentGuard runs the same conformance authorize vectors through the
|
|
2
|
+
framework-agnostic tool wrapper, so the agent-facing adapter can't drift from the
|
|
3
|
+
SDK's authorization. From clients/python:
|
|
4
|
+
|
|
5
|
+
python3 -m unittest discover -s tests
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import unittest
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from legant_sdk import AgentGuard, AuthorizeError, Verifier, parse_jwks
|
|
14
|
+
|
|
15
|
+
_VECTORS = json.loads((Path(__file__).resolve().parents[2] / "conformance" / "vectors.json").read_text())
|
|
16
|
+
_KEYS = parse_jwks(_VECTORS["jwks"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _guard(token: str) -> AgentGuard:
|
|
20
|
+
return AgentGuard(Verifier(_VECTORS["issuer"], _VECTORS["audience"], _KEYS), token)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestAgentGuard(unittest.TestCase):
|
|
24
|
+
def test_authorize_vectors(self):
|
|
25
|
+
for c in _VECTORS["authorize"]:
|
|
26
|
+
with self.subTest(c["name"]):
|
|
27
|
+
g = _guard(c["token"])
|
|
28
|
+
a = c["action"]
|
|
29
|
+
at = datetime.fromisoformat(a["at"].replace("Z", "+00:00")) if a.get("at") else None
|
|
30
|
+
ok = g.allowed(
|
|
31
|
+
a["scope"],
|
|
32
|
+
amount=a.get("amount", 0),
|
|
33
|
+
category=a.get("category", ""),
|
|
34
|
+
tool=a.get("tool", ""),
|
|
35
|
+
resource=a.get("resource", ""),
|
|
36
|
+
at=at,
|
|
37
|
+
)
|
|
38
|
+
self.assertEqual(ok, c["allow"])
|
|
39
|
+
|
|
40
|
+
def test_tool_decorator_enforces(self):
|
|
41
|
+
# Find a vector that allows expenses:submit and one that denies on amount.
|
|
42
|
+
allow = next(c for c in _VECTORS["authorize"] if c["allow"] and c["action"]["scope"] == "expenses:submit")
|
|
43
|
+
deny = next(c for c in _VECTORS["authorize"] if not c["allow"] and "amount" in c["action"])
|
|
44
|
+
calls = []
|
|
45
|
+
|
|
46
|
+
def make_tool(guard):
|
|
47
|
+
@guard.tool("expenses:submit", amount_arg="amount", category_arg="category")
|
|
48
|
+
def submit_expense(amount=0.0, category=""):
|
|
49
|
+
calls.append((amount, category))
|
|
50
|
+
return "submitted"
|
|
51
|
+
|
|
52
|
+
return submit_expense
|
|
53
|
+
|
|
54
|
+
# Allowed call runs the underlying function.
|
|
55
|
+
t = make_tool(_guard(allow["token"]))
|
|
56
|
+
a = allow["action"]
|
|
57
|
+
self.assertEqual(t(amount=a.get("amount", 0), category=a.get("category", "")), "submitted")
|
|
58
|
+
self.assertEqual(len(calls), 1)
|
|
59
|
+
|
|
60
|
+
# Denied call raises BEFORE the function body runs (no new entry in calls).
|
|
61
|
+
t2 = make_tool(_guard(deny["token"]))
|
|
62
|
+
d = deny["action"]
|
|
63
|
+
with self.assertRaises(AuthorizeError):
|
|
64
|
+
t2(amount=d.get("amount", 0), category=d.get("category", ""))
|
|
65
|
+
self.assertEqual(len(calls), 1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
unittest.main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Runs the shared golden vectors (clients/conformance/vectors.json, minted by the
|
|
2
|
+
real Go signer) through the Python SDK. Identical assertions run against the Go
|
|
3
|
+
and TypeScript SDKs, so the three cannot drift. From clients/python:
|
|
4
|
+
|
|
5
|
+
python3 -m unittest discover -s tests
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import unittest
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from legant_sdk import Action, RevocationFeed, RevokedError, Verifier, parse_jwks
|
|
14
|
+
|
|
15
|
+
_VECTORS = json.loads((Path(__file__).resolve().parents[2] / "conformance" / "vectors.json").read_text())
|
|
16
|
+
_KEYS = parse_jwks(_VECTORS["jwks"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestConformance(unittest.TestCase):
|
|
20
|
+
def test_verify_vectors(self):
|
|
21
|
+
ver = Verifier(_VECTORS["issuer"], _VECTORS["audience"], _KEYS)
|
|
22
|
+
for c in _VECTORS["verify"]:
|
|
23
|
+
with self.subTest(c["name"]):
|
|
24
|
+
if c["valid"]:
|
|
25
|
+
claims = ver.verify(c["token"])
|
|
26
|
+
if c.get("provenance"):
|
|
27
|
+
self.assertEqual(claims.provenance(), c["provenance"])
|
|
28
|
+
else:
|
|
29
|
+
with self.assertRaises(Exception):
|
|
30
|
+
ver.verify(c["token"])
|
|
31
|
+
|
|
32
|
+
def test_audience_vectors(self):
|
|
33
|
+
for c in _VECTORS["audienceCanonicalization"]:
|
|
34
|
+
with self.subTest(c["name"]):
|
|
35
|
+
ver = Verifier(_VECTORS["issuer"], c["configuredAudience"], _KEYS)
|
|
36
|
+
if c["valid"]:
|
|
37
|
+
ver.verify(c["token"]) # must not raise
|
|
38
|
+
else:
|
|
39
|
+
with self.assertRaises(Exception):
|
|
40
|
+
ver.verify(c["token"])
|
|
41
|
+
|
|
42
|
+
def test_authorize_vectors(self):
|
|
43
|
+
ver = Verifier(_VECTORS["issuer"], _VECTORS["audience"], _KEYS)
|
|
44
|
+
for c in _VECTORS["authorize"]:
|
|
45
|
+
with self.subTest(c["name"]):
|
|
46
|
+
claims = ver.verify(c["token"])
|
|
47
|
+
a = c["action"]
|
|
48
|
+
at = datetime.fromisoformat(a["at"].replace("Z", "+00:00")) if a.get("at") else None
|
|
49
|
+
action = Action(
|
|
50
|
+
scope=a.get("scope", ""),
|
|
51
|
+
amount=a.get("amount", 0.0),
|
|
52
|
+
category=a.get("category", ""),
|
|
53
|
+
tool=a.get("tool", ""),
|
|
54
|
+
resource=a.get("resource", ""),
|
|
55
|
+
at=at,
|
|
56
|
+
)
|
|
57
|
+
allowed = True
|
|
58
|
+
try:
|
|
59
|
+
claims.authorize(action)
|
|
60
|
+
except Exception:
|
|
61
|
+
allowed = False
|
|
62
|
+
self.assertEqual(allowed, c["allow"])
|
|
63
|
+
|
|
64
|
+
def test_revocation_vectors(self):
|
|
65
|
+
r = _VECTORS["revocation"]
|
|
66
|
+
feed = RevocationFeed(None, _VECTORS["issuer"], _KEYS)
|
|
67
|
+
feed.apply_feed(r["feed"])
|
|
68
|
+
self.assertTrue(feed.is_revoked(r["revokedJti"]))
|
|
69
|
+
self.assertFalse(feed.is_revoked(r["liveJti"]))
|
|
70
|
+
|
|
71
|
+
ver = Verifier(_VECTORS["issuer"], _VECTORS["audience"], _KEYS, feed=feed)
|
|
72
|
+
with self.assertRaises(RevokedError):
|
|
73
|
+
ver.verify(r["revokedToken"])
|
|
74
|
+
ver.verify(r["liveToken"]) # live token verifies
|
|
75
|
+
|
|
76
|
+
# A rollback (lower version) is rejected; revocation persists.
|
|
77
|
+
with self.assertRaises(ValueError):
|
|
78
|
+
feed.apply_feed(r["feedRollback"])
|
|
79
|
+
self.assertTrue(feed.is_revoked(r["revokedJti"]))
|
|
80
|
+
|
|
81
|
+
# A newer feed dropping the jti clears the revocation.
|
|
82
|
+
feed.apply_feed(r["feedNewer"])
|
|
83
|
+
self.assertFalse(feed.is_revoked(r["revokedJti"]))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
unittest.main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Exercises the resource-server middleware against the shared golden vectors.
|
|
2
|
+
The framework-agnostic core always runs; the FastAPI/Flask adapters run only when
|
|
3
|
+
the framework is installed. From clients/python:
|
|
4
|
+
|
|
5
|
+
python3 -m unittest discover -s tests
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from legant_sdk import (
|
|
13
|
+
Verifier,
|
|
14
|
+
VerifyError,
|
|
15
|
+
authenticate,
|
|
16
|
+
bearer_token,
|
|
17
|
+
mcp_tool_name,
|
|
18
|
+
parse_jwks,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_VECTORS = json.loads((Path(__file__).resolve().parents[2] / "conformance" / "vectors.json").read_text())
|
|
22
|
+
_KEYS = parse_jwks(_VECTORS["jwks"])
|
|
23
|
+
_VALID = next(c["token"] for c in _VECTORS["verify"] if c["valid"])
|
|
24
|
+
_ALLOW = next(c for c in _VECTORS["authorize"] if c["allow"])
|
|
25
|
+
_DENY = next(c for c in _VECTORS["authorize"] if not c["allow"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _verifier() -> Verifier:
|
|
29
|
+
return Verifier(_VECTORS["issuer"], _VECTORS["audience"], _KEYS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestCore(unittest.TestCase):
|
|
33
|
+
def test_bearer_token(self):
|
|
34
|
+
self.assertEqual(bearer_token("Bearer abc.def.ghi"), "abc.def.ghi")
|
|
35
|
+
with self.assertRaises(VerifyError):
|
|
36
|
+
bearer_token(None)
|
|
37
|
+
with self.assertRaises(VerifyError):
|
|
38
|
+
bearer_token("Basic xyz")
|
|
39
|
+
|
|
40
|
+
def test_authenticate(self):
|
|
41
|
+
claims = authenticate(_verifier(), f"Bearer {_VALID}")
|
|
42
|
+
self.assertTrue(claims.subject)
|
|
43
|
+
with self.assertRaises(VerifyError):
|
|
44
|
+
authenticate(_verifier(), "Bearer not-a-jwt")
|
|
45
|
+
|
|
46
|
+
def test_mcp_tool_name(self):
|
|
47
|
+
self.assertEqual(
|
|
48
|
+
mcp_tool_name({"method": "tools/call", "params": {"name": "kubectl_scale"}}),
|
|
49
|
+
"kubectl_scale",
|
|
50
|
+
)
|
|
51
|
+
self.assertEqual(mcp_tool_name('{"method":"tools/call","params":{"name":"x"}}'), "x")
|
|
52
|
+
with self.assertRaises(ValueError):
|
|
53
|
+
mcp_tool_name({"method": "tools/list"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestFlask(unittest.TestCase):
|
|
57
|
+
def setUp(self):
|
|
58
|
+
try:
|
|
59
|
+
from flask import Flask, g, jsonify
|
|
60
|
+
except ImportError: # pragma: no cover
|
|
61
|
+
self.skipTest("flask not installed")
|
|
62
|
+
from legant_sdk import flask_require
|
|
63
|
+
|
|
64
|
+
app = Flask(__name__)
|
|
65
|
+
|
|
66
|
+
def action_from(_req):
|
|
67
|
+
from legant_sdk import Action
|
|
68
|
+
|
|
69
|
+
a = _DENY["action"]
|
|
70
|
+
return Action(scope=a["scope"], amount=a.get("amount", 0))
|
|
71
|
+
|
|
72
|
+
@app.get("/scoped")
|
|
73
|
+
@flask_require(_verifier())
|
|
74
|
+
def scoped():
|
|
75
|
+
return jsonify(sub=g.legant.subject)
|
|
76
|
+
|
|
77
|
+
@app.get("/denied")
|
|
78
|
+
@flask_require(_verifier(), action=action_from)
|
|
79
|
+
def denied():
|
|
80
|
+
return jsonify(ok=True)
|
|
81
|
+
|
|
82
|
+
self.client = app.test_client()
|
|
83
|
+
|
|
84
|
+
def test_no_token_401(self):
|
|
85
|
+
r = self.client.get("/scoped")
|
|
86
|
+
self.assertEqual(r.status_code, 401)
|
|
87
|
+
self.assertIn("Bearer", r.headers.get("WWW-Authenticate", ""))
|
|
88
|
+
|
|
89
|
+
def test_valid_token_ok(self):
|
|
90
|
+
r = self.client.get("/scoped", headers={"Authorization": f"Bearer {_VALID}"})
|
|
91
|
+
self.assertEqual(r.status_code, 200)
|
|
92
|
+
|
|
93
|
+
def test_denied_action_403(self):
|
|
94
|
+
r = self.client.get("/denied", headers={"Authorization": f"Bearer {_DENY['token']}"})
|
|
95
|
+
self.assertEqual(r.status_code, 403)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestFastAPI(unittest.TestCase):
|
|
99
|
+
def setUp(self):
|
|
100
|
+
try:
|
|
101
|
+
from fastapi import Depends, FastAPI
|
|
102
|
+
from fastapi.testclient import TestClient
|
|
103
|
+
except ImportError:
|
|
104
|
+
self.skipTest("fastapi not installed")
|
|
105
|
+
from legant_sdk import Claims, fastapi_auth
|
|
106
|
+
|
|
107
|
+
app = FastAPI()
|
|
108
|
+
|
|
109
|
+
@app.get("/scoped")
|
|
110
|
+
def scoped(claims: Claims = Depends(fastapi_auth(_verifier()))):
|
|
111
|
+
return {"sub": claims.subject}
|
|
112
|
+
|
|
113
|
+
self.client = TestClient(app)
|
|
114
|
+
|
|
115
|
+
def test_no_token_401(self):
|
|
116
|
+
self.assertEqual(self.client.get("/scoped").status_code, 401)
|
|
117
|
+
|
|
118
|
+
def test_valid_token_ok(self):
|
|
119
|
+
r = self.client.get("/scoped", headers={"Authorization": f"Bearer {_VALID}"})
|
|
120
|
+
self.assertEqual(r.status_code, 200)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
unittest.main()
|