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.
@@ -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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()