browserid-mcp-auth 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.
- browserid_mcp_auth-0.1.0/.gitignore +20 -0
- browserid_mcp_auth-0.1.0/PKG-INFO +66 -0
- browserid_mcp_auth-0.1.0/README.md +56 -0
- browserid_mcp_auth-0.1.0/browserid_mcp_auth/__init__.py +296 -0
- browserid_mcp_auth-0.1.0/pyproject.toml +18 -0
- browserid_mcp_auth-0.1.0/tests/test_mcp_auth.py +181 -0
- browserid_mcp_auth-0.1.0/uv.lock +8 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/target
|
|
2
|
+
broker-key.json
|
|
3
|
+
browserid.db*
|
|
4
|
+
.env
|
|
5
|
+
|
|
6
|
+
# Test artifacts
|
|
7
|
+
test-results/
|
|
8
|
+
playwright-report/
|
|
9
|
+
|
|
10
|
+
# macOS
|
|
11
|
+
.DS_Store
|
|
12
|
+
|
|
13
|
+
# Node
|
|
14
|
+
node_modules
|
|
15
|
+
|
|
16
|
+
# wallet-service
|
|
17
|
+
wallet-service/wallet.db*
|
|
18
|
+
wallet-service/.dev-kek
|
|
19
|
+
wallet-service/.dev-session-secret
|
|
20
|
+
__pycache__
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: browserid-mcp-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Warrant-gated MCP tools over MCP's own OAuth 2.1: a BrowserID 7521 assertion-grant AS + fail-closed per-call revocation checks.
|
|
5
|
+
Project-URL: Repository, https://github.com/vthunder/browserid-ng
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
|
+
Keywords: agent,browserid,fastmcp,mcp,oauth,rfc7521,warrant
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# browserid-mcp-auth (Python)
|
|
12
|
+
|
|
13
|
+
Warrant-gated MCP tools over MCP's own OAuth 2.1 — the Python port of
|
|
14
|
+
`@browserid-ng/mcp-auth`. **No API keys**: an agent redeems its human's
|
|
15
|
+
short-lived, scoped, **revocable** BrowserID warrant for a bearer, and every
|
|
16
|
+
tool call is attributable to "agent X on behalf of human Y". Revoke at
|
|
17
|
+
`browserid.me/account` and the agent dies on its next call.
|
|
18
|
+
|
|
19
|
+
An embedded authorization server (the RFC 7521 `jwt-bearer` assertion grant) +
|
|
20
|
+
a resource-server guard that re-checks the warrant's revocation status
|
|
21
|
+
**fail-closed on every call**. Verification is delegated to the broker's
|
|
22
|
+
DNSSEC-rooted hosted verifier (`POST /verify-access`) and `POST /status/check`
|
|
23
|
+
— no crypto in Python. Stdlib-only (no runtime deps).
|
|
24
|
+
|
|
25
|
+
## Use
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from browserid_mcp_auth import McpAuth, McpAuthError
|
|
29
|
+
|
|
30
|
+
auth = McpAuth(
|
|
31
|
+
resource="https://mcp.example.com", # this server (OAuth resource + audience)
|
|
32
|
+
scopes_for_tool={"create_issue": ["issues:create"]},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# 1. Discovery (serve as JSON):
|
|
36
|
+
# GET /.well-known/oauth-protected-resource -> auth.protected_resource_metadata()
|
|
37
|
+
# GET /.well-known/oauth-authorization-server -> auth.authorization_server_metadata()
|
|
38
|
+
|
|
39
|
+
# 2. Token endpoint (the embedded AS):
|
|
40
|
+
# POST /token -> auth.handle_token(request_body) # {grant_type, assertion, scope?}
|
|
41
|
+
|
|
42
|
+
# 3. Gate a tool call:
|
|
43
|
+
ctx = auth.require_warrant(authorization_header, "create_issue")
|
|
44
|
+
# ctx.grantor (human), ctx.grantee (agent), ctx.holder, ctx.issuer, ctx.scopes
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
On failure `handle_token` / `require_warrant` raise `McpAuthError` with
|
|
48
|
+
`.oauth_error` and `.http_status`; render `err.to_token_error_response()` at the
|
|
49
|
+
token endpoint and `auth.challenge()` in the `WWW-Authenticate` header on a 401.
|
|
50
|
+
|
|
51
|
+
## FastMCP
|
|
52
|
+
|
|
53
|
+
FastMCP servers speak MCP OAuth; mount the three surfaces above on the server's
|
|
54
|
+
HTTP app (the token endpoint + the two discovery routes), and call
|
|
55
|
+
`auth.require_warrant(request.headers.get("authorization"), tool_name)` at the
|
|
56
|
+
top of each tool. The core is synchronous and dependency-free; call it directly
|
|
57
|
+
or wrap in a thread from async handlers. `http_post` is injectable if you
|
|
58
|
+
prefer `httpx`/`requests` over the stdlib `urllib` default.
|
|
59
|
+
|
|
60
|
+
## API
|
|
61
|
+
|
|
62
|
+
`McpAuth(resource, broker="https://browserid.me", scopes_for_tool=None,
|
|
63
|
+
token_ttl_s=3600, status_cache_s=60, accepted_fallbacks=None, store=None,
|
|
64
|
+
http_post=None)`; `handle_token(params)`, `authenticate(header)`,
|
|
65
|
+
`require_warrant(header, tool_or_scopes)`, `protected_resource_metadata()`,
|
|
66
|
+
`authorization_server_metadata()`, `challenge()`. MPL-2.0.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# browserid-mcp-auth (Python)
|
|
2
|
+
|
|
3
|
+
Warrant-gated MCP tools over MCP's own OAuth 2.1 — the Python port of
|
|
4
|
+
`@browserid-ng/mcp-auth`. **No API keys**: an agent redeems its human's
|
|
5
|
+
short-lived, scoped, **revocable** BrowserID warrant for a bearer, and every
|
|
6
|
+
tool call is attributable to "agent X on behalf of human Y". Revoke at
|
|
7
|
+
`browserid.me/account` and the agent dies on its next call.
|
|
8
|
+
|
|
9
|
+
An embedded authorization server (the RFC 7521 `jwt-bearer` assertion grant) +
|
|
10
|
+
a resource-server guard that re-checks the warrant's revocation status
|
|
11
|
+
**fail-closed on every call**. Verification is delegated to the broker's
|
|
12
|
+
DNSSEC-rooted hosted verifier (`POST /verify-access`) and `POST /status/check`
|
|
13
|
+
— no crypto in Python. Stdlib-only (no runtime deps).
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from browserid_mcp_auth import McpAuth, McpAuthError
|
|
19
|
+
|
|
20
|
+
auth = McpAuth(
|
|
21
|
+
resource="https://mcp.example.com", # this server (OAuth resource + audience)
|
|
22
|
+
scopes_for_tool={"create_issue": ["issues:create"]},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# 1. Discovery (serve as JSON):
|
|
26
|
+
# GET /.well-known/oauth-protected-resource -> auth.protected_resource_metadata()
|
|
27
|
+
# GET /.well-known/oauth-authorization-server -> auth.authorization_server_metadata()
|
|
28
|
+
|
|
29
|
+
# 2. Token endpoint (the embedded AS):
|
|
30
|
+
# POST /token -> auth.handle_token(request_body) # {grant_type, assertion, scope?}
|
|
31
|
+
|
|
32
|
+
# 3. Gate a tool call:
|
|
33
|
+
ctx = auth.require_warrant(authorization_header, "create_issue")
|
|
34
|
+
# ctx.grantor (human), ctx.grantee (agent), ctx.holder, ctx.issuer, ctx.scopes
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
On failure `handle_token` / `require_warrant` raise `McpAuthError` with
|
|
38
|
+
`.oauth_error` and `.http_status`; render `err.to_token_error_response()` at the
|
|
39
|
+
token endpoint and `auth.challenge()` in the `WWW-Authenticate` header on a 401.
|
|
40
|
+
|
|
41
|
+
## FastMCP
|
|
42
|
+
|
|
43
|
+
FastMCP servers speak MCP OAuth; mount the three surfaces above on the server's
|
|
44
|
+
HTTP app (the token endpoint + the two discovery routes), and call
|
|
45
|
+
`auth.require_warrant(request.headers.get("authorization"), tool_name)` at the
|
|
46
|
+
top of each tool. The core is synchronous and dependency-free; call it directly
|
|
47
|
+
or wrap in a thread from async handlers. `http_post` is injectable if you
|
|
48
|
+
prefer `httpx`/`requests` over the stdlib `urllib` default.
|
|
49
|
+
|
|
50
|
+
## API
|
|
51
|
+
|
|
52
|
+
`McpAuth(resource, broker="https://browserid.me", scopes_for_tool=None,
|
|
53
|
+
token_ttl_s=3600, status_cache_s=60, accepted_fallbacks=None, store=None,
|
|
54
|
+
http_post=None)`; `handle_token(params)`, `authenticate(header)`,
|
|
55
|
+
`require_warrant(header, tool_or_scopes)`, `protected_resource_metadata()`,
|
|
56
|
+
`authorization_server_metadata()`, `challenge()`. MPL-2.0.
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""browserid-mcp-auth — warrant-gated MCP tools over MCP's own OAuth 2.1.
|
|
2
|
+
|
|
3
|
+
The Python port of ``@browserid-ng/mcp-auth``: an embedded authorization
|
|
4
|
+
server that redeems a BrowserID warrant presentation for a short-lived scoped
|
|
5
|
+
bearer via the RFC 7521 ``jwt-bearer`` assertion grant, plus a per-call guard
|
|
6
|
+
that re-checks the warrant's revocation status **fail-closed** on every tool
|
|
7
|
+
call. Hosts run their stock MCP OAuth client unmodified.
|
|
8
|
+
|
|
9
|
+
No crypto in Python: verification is delegated to the broker's DNSSEC-rooted
|
|
10
|
+
hosted verifier (``POST /verify-access``) and revocation to ``POST
|
|
11
|
+
/status/check``. Framework-agnostic; wire into FastMCP / any server. See
|
|
12
|
+
docs/plans/2026-08-02-mcp-distribution-design.md.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json as _json
|
|
17
|
+
import secrets
|
|
18
|
+
import time
|
|
19
|
+
import urllib.request
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Callable, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"JWT_BEARER_GRANT",
|
|
27
|
+
"McpAuth",
|
|
28
|
+
"McpAuthError",
|
|
29
|
+
"WarrantContext",
|
|
30
|
+
"MemoryStore",
|
|
31
|
+
"default_http_post",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class McpAuthError(Exception):
|
|
36
|
+
"""Carries an OAuth error code + HTTP status for uniform rendering."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, oauth_error: str, message: str, http_status: int = 400):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.oauth_error = oauth_error
|
|
41
|
+
self.http_status = http_status
|
|
42
|
+
|
|
43
|
+
def to_token_error_response(self) -> Dict[str, str]:
|
|
44
|
+
return {"error": self.oauth_error, "error_description": str(self)}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class WarrantContext:
|
|
49
|
+
grantor: Optional[str]
|
|
50
|
+
grantee: Optional[str]
|
|
51
|
+
holder: Optional[str]
|
|
52
|
+
issuer: Optional[str]
|
|
53
|
+
scopes: List[str]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class _Grant:
|
|
58
|
+
grantor: Optional[str]
|
|
59
|
+
grantee: Optional[str]
|
|
60
|
+
holder: Optional[str]
|
|
61
|
+
issuer: Optional[str]
|
|
62
|
+
scopes: List[str]
|
|
63
|
+
status_refs: List[dict]
|
|
64
|
+
exp: int
|
|
65
|
+
status_checked_at: int
|
|
66
|
+
status_ok: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MemoryStore:
|
|
70
|
+
"""In-memory bearer store (single process). Swap for Redis/db in prod."""
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self._grants: Dict[str, _Grant] = {}
|
|
74
|
+
|
|
75
|
+
def put(self, token: str, grant: _Grant) -> None:
|
|
76
|
+
self._grants[token] = grant
|
|
77
|
+
|
|
78
|
+
def get(self, token: str) -> Optional[_Grant]:
|
|
79
|
+
g = self._grants.get(token)
|
|
80
|
+
if g is None:
|
|
81
|
+
return None
|
|
82
|
+
if g.exp <= int(time.time()):
|
|
83
|
+
self._grants.pop(token, None)
|
|
84
|
+
return None
|
|
85
|
+
return g
|
|
86
|
+
|
|
87
|
+
def delete(self, token: str) -> None:
|
|
88
|
+
self._grants.pop(token, None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def default_http_post(url: str, body: dict, timeout: float = 10.0):
|
|
92
|
+
"""POST JSON, return (status_code, parsed_json). Stdlib-only."""
|
|
93
|
+
data = _json.dumps(body).encode()
|
|
94
|
+
req = urllib.request.Request(
|
|
95
|
+
url, data=data, headers={"content-type": "application/json"}, method="POST"
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
99
|
+
return resp.status, _json.loads(resp.read().decode() or "{}")
|
|
100
|
+
except urllib.error.HTTPError as e: # type: ignore[attr-defined]
|
|
101
|
+
try:
|
|
102
|
+
return e.code, _json.loads(e.read().decode() or "{}")
|
|
103
|
+
except Exception:
|
|
104
|
+
return e.code, {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class McpAuth:
|
|
108
|
+
"""The middleware. Construct once per MCP server.
|
|
109
|
+
|
|
110
|
+
:param resource: canonical URL of THIS server (OAuth resource + the
|
|
111
|
+
audience warrants must bind to).
|
|
112
|
+
:param broker: BrowserID broker origin (default https://browserid.me).
|
|
113
|
+
:param scopes_for_tool: ``{tool_name: [required scopes]}``.
|
|
114
|
+
:param token_ttl_s: bearer lifetime (default 3600).
|
|
115
|
+
:param status_cache_s: max seconds to trust a per-grant status result
|
|
116
|
+
before a fresh re-check (default 60).
|
|
117
|
+
:param accepted_fallbacks: fallback issuer domains (default [broker host]).
|
|
118
|
+
:param store: bearer store (default MemoryStore()).
|
|
119
|
+
:param http_post: injectable ``(url, body) -> (status, dict)`` (tests).
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
resource: str,
|
|
125
|
+
broker: str = "https://browserid.me",
|
|
126
|
+
scopes_for_tool: Optional[Dict[str, List[str]]] = None,
|
|
127
|
+
token_ttl_s: int = 3600,
|
|
128
|
+
status_cache_s: int = 60,
|
|
129
|
+
accepted_fallbacks: Optional[List[str]] = None,
|
|
130
|
+
store: Optional[MemoryStore] = None,
|
|
131
|
+
http_post: Optional[Callable[[str, dict], tuple]] = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
if not resource:
|
|
134
|
+
raise ValueError("resource is required")
|
|
135
|
+
self.resource = resource.rstrip("/")
|
|
136
|
+
self.broker = broker.rstrip("/")
|
|
137
|
+
self.scopes_for_tool = scopes_for_tool or {}
|
|
138
|
+
self.token_ttl_s = token_ttl_s
|
|
139
|
+
self.status_cache_s = status_cache_s
|
|
140
|
+
from urllib.parse import urlparse
|
|
141
|
+
|
|
142
|
+
self.accepted_fallbacks = accepted_fallbacks or [urlparse(self.broker).netloc]
|
|
143
|
+
self.store = store or MemoryStore()
|
|
144
|
+
self._http_post = http_post or default_http_post
|
|
145
|
+
self._verify_url = f"{self.broker}/verify-access"
|
|
146
|
+
self._status_url = f"{self.broker}/status/check"
|
|
147
|
+
|
|
148
|
+
# -- discovery -----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _all_scopes(self) -> List[str]:
|
|
151
|
+
seen: List[str] = []
|
|
152
|
+
for scopes in self.scopes_for_tool.values():
|
|
153
|
+
for s in scopes:
|
|
154
|
+
if s not in seen:
|
|
155
|
+
seen.append(s)
|
|
156
|
+
return seen
|
|
157
|
+
|
|
158
|
+
def protected_resource_metadata(self) -> dict:
|
|
159
|
+
return {
|
|
160
|
+
"resource": self.resource,
|
|
161
|
+
"authorization_servers": [self.resource],
|
|
162
|
+
"scopes_supported": self._all_scopes(),
|
|
163
|
+
"bearer_methods_supported": ["header"],
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def authorization_server_metadata(self) -> dict:
|
|
167
|
+
return {
|
|
168
|
+
"issuer": self.resource,
|
|
169
|
+
"token_endpoint": f"{self.resource}/token",
|
|
170
|
+
"grant_types_supported": [JWT_BEARER_GRANT],
|
|
171
|
+
"token_endpoint_auth_methods_supported": ["none"],
|
|
172
|
+
"scopes_supported": self._all_scopes(),
|
|
173
|
+
"response_types_supported": [],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def challenge(self) -> str:
|
|
177
|
+
return f'Bearer resource_metadata="{self.resource}/.well-known/oauth-protected-resource"'
|
|
178
|
+
|
|
179
|
+
# -- token endpoint ------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def handle_token(self, params: dict) -> dict:
|
|
182
|
+
"""Redeem a warrant presentation for a bearer. ``params`` is the token
|
|
183
|
+
request body ({grant_type, assertion, scope?}). Raises McpAuthError."""
|
|
184
|
+
if params.get("grant_type") != JWT_BEARER_GRANT:
|
|
185
|
+
raise McpAuthError("unsupported_grant_type", f"only {JWT_BEARER_GRANT} is supported")
|
|
186
|
+
presentation = params.get("assertion")
|
|
187
|
+
if not presentation or not isinstance(presentation, str):
|
|
188
|
+
raise McpAuthError("invalid_request", "missing 'assertion' (a browserid presentation)")
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
code, verified = self._http_post(
|
|
192
|
+
self._verify_url,
|
|
193
|
+
{
|
|
194
|
+
"presentation": presentation,
|
|
195
|
+
"audience": self.resource,
|
|
196
|
+
"accepted_fallbacks": self.accepted_fallbacks,
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
except Exception as e: # fail-closed: an unreachable verifier is not approval
|
|
200
|
+
raise McpAuthError("temporarily_unavailable", f"verifier unreachable: {e}", 503)
|
|
201
|
+
if code < 200 or code >= 300:
|
|
202
|
+
raise McpAuthError("invalid_grant", f"verifier HTTP {code}")
|
|
203
|
+
if verified.get("status") != "okay":
|
|
204
|
+
raise McpAuthError("invalid_grant", verified.get("reason") or "presentation did not verify")
|
|
205
|
+
|
|
206
|
+
granted = verified.get("scopes") or []
|
|
207
|
+
requested = (params.get("scope") or "").split()
|
|
208
|
+
if requested:
|
|
209
|
+
scopes = [s for s in requested if s in granted]
|
|
210
|
+
if len(scopes) != len(requested):
|
|
211
|
+
raise McpAuthError("invalid_scope", "requested scope exceeds the warrant's grant")
|
|
212
|
+
else:
|
|
213
|
+
scopes = list(granted)
|
|
214
|
+
|
|
215
|
+
token = "bat_" + secrets.token_urlsafe(32)
|
|
216
|
+
now = int(time.time())
|
|
217
|
+
self.store.put(
|
|
218
|
+
token,
|
|
219
|
+
_Grant(
|
|
220
|
+
grantor=verified.get("email"),
|
|
221
|
+
grantee=verified.get("grantee") or verified.get("email"),
|
|
222
|
+
holder=verified.get("holder"),
|
|
223
|
+
issuer=verified.get("issuer"),
|
|
224
|
+
scopes=scopes,
|
|
225
|
+
status_refs=verified.get("status_refs") or [],
|
|
226
|
+
exp=now + self.token_ttl_s,
|
|
227
|
+
status_checked_at=now,
|
|
228
|
+
status_ok=True,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
return {
|
|
232
|
+
"access_token": token,
|
|
233
|
+
"token_type": "Bearer",
|
|
234
|
+
"expires_in": self.token_ttl_s,
|
|
235
|
+
"scope": " ".join(scopes),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# -- per-call validation -------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def authenticate(self, authorization_header: Optional[str]) -> WarrantContext:
|
|
241
|
+
"""Validate a Bearer header, re-check status fail-closed, return ctx."""
|
|
242
|
+
if not authorization_header:
|
|
243
|
+
raise McpAuthError("invalid_request", "missing Bearer token", 401)
|
|
244
|
+
parts = authorization_header.split(None, 1)
|
|
245
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
246
|
+
raise McpAuthError("invalid_request", "missing Bearer token", 401)
|
|
247
|
+
token = parts[1].strip()
|
|
248
|
+
grant = self.store.get(token)
|
|
249
|
+
if grant is None:
|
|
250
|
+
raise McpAuthError("invalid_token", "unknown or expired token", 401)
|
|
251
|
+
self._ensure_unrevoked(token, grant)
|
|
252
|
+
return WarrantContext(
|
|
253
|
+
grantor=grant.grantor,
|
|
254
|
+
grantee=grant.grantee,
|
|
255
|
+
holder=grant.holder,
|
|
256
|
+
issuer=grant.issuer,
|
|
257
|
+
scopes=list(grant.scopes),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _ensure_unrevoked(self, token: str, grant: _Grant) -> None:
|
|
261
|
+
refs = grant.status_refs
|
|
262
|
+
if not refs:
|
|
263
|
+
return
|
|
264
|
+
now = int(time.time())
|
|
265
|
+
if grant.status_ok and now - grant.status_checked_at < self.status_cache_s:
|
|
266
|
+
return
|
|
267
|
+
try:
|
|
268
|
+
code, body = self._http_post(self._status_url, {"refs": refs})
|
|
269
|
+
if code < 200 or code >= 300:
|
|
270
|
+
raise RuntimeError(f"HTTP {code}")
|
|
271
|
+
except Exception as e: # fail-closed
|
|
272
|
+
grant.status_ok = False
|
|
273
|
+
raise McpAuthError("invalid_token", f"status unavailable (fail-closed): {e}", 401)
|
|
274
|
+
if body.get("ok") is not True or body.get("revoked") is True:
|
|
275
|
+
grant.status_ok = False
|
|
276
|
+
self.store.delete(token)
|
|
277
|
+
raise McpAuthError("invalid_token", "warrant revoked", 401)
|
|
278
|
+
grant.status_ok = True
|
|
279
|
+
grant.status_checked_at = now
|
|
280
|
+
|
|
281
|
+
def require_warrant(self, authorization_header: Optional[str], tool_or_scopes) -> WarrantContext:
|
|
282
|
+
"""authenticate + enforce a tool's required scopes (name or list)."""
|
|
283
|
+
ctx = self.authenticate(authorization_header)
|
|
284
|
+
required = (
|
|
285
|
+
list(tool_or_scopes)
|
|
286
|
+
if isinstance(tool_or_scopes, (list, tuple))
|
|
287
|
+
else self.scopes_for_tool.get(tool_or_scopes, [])
|
|
288
|
+
)
|
|
289
|
+
missing = [s for s in required if s not in ctx.scopes]
|
|
290
|
+
if missing:
|
|
291
|
+
raise McpAuthError(
|
|
292
|
+
"insufficient_scope",
|
|
293
|
+
f"this tool needs scope(s) {missing} not in the warrant",
|
|
294
|
+
403,
|
|
295
|
+
)
|
|
296
|
+
return ctx
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "browserid-mcp-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Warrant-gated MCP tools over MCP's own OAuth 2.1: a BrowserID 7521 assertion-grant AS + fail-closed per-call revocation checks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MPL-2.0"
|
|
12
|
+
keywords = ["browserid", "mcp", "oauth", "agent", "warrant", "fastmcp", "rfc7521"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/vthunder/browserid-ng"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["browserid_mcp_auth"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Unit tests for browserid-mcp-auth (stdlib unittest — no pytest needed)."""
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
8
|
+
|
|
9
|
+
from browserid_mcp_auth import ( # noqa: E402
|
|
10
|
+
JWT_BEARER_GRANT,
|
|
11
|
+
McpAuth,
|
|
12
|
+
McpAuthError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
RESOURCE = "https://mcp.example.com"
|
|
16
|
+
|
|
17
|
+
OK_VERIFY = {
|
|
18
|
+
"status": "okay",
|
|
19
|
+
"email": "dan@sandmill.org",
|
|
20
|
+
"grantee": "dan+claude@sandmill.org",
|
|
21
|
+
"holder": "agents.abc123",
|
|
22
|
+
"issuer": "sandmill.org",
|
|
23
|
+
"scopes": ["repo:read", "issues:create"],
|
|
24
|
+
"status_refs": [{"uri": "https://browserid.me/.well-known/browserid-status", "idx": 7}],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeBroker:
|
|
29
|
+
"""Configurable (status_code, json) responses for /verify-access + /status/check."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, verify=None, status=None):
|
|
32
|
+
self.verify = verify
|
|
33
|
+
self.status = status
|
|
34
|
+
self.calls = {"verify": 0, "status": 0}
|
|
35
|
+
|
|
36
|
+
def post(self, url, body):
|
|
37
|
+
if url.endswith("/verify-access"):
|
|
38
|
+
self.calls["verify"] += 1
|
|
39
|
+
if self.verify == "throw":
|
|
40
|
+
raise ConnectionError("refused")
|
|
41
|
+
v = self.verify if self.verify is not None else {"status": "failure"}
|
|
42
|
+
return v.get("__status", 200), v
|
|
43
|
+
if url.endswith("/status/check"):
|
|
44
|
+
self.calls["status"] += 1
|
|
45
|
+
if self.status == "throw":
|
|
46
|
+
raise ConnectionError("refused")
|
|
47
|
+
s = self.status if self.status is not None else {"ok": True, "revoked": False}
|
|
48
|
+
return s.get("__status", 200), s
|
|
49
|
+
raise AssertionError("unexpected url " + url)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make(verify=OK_VERIFY, status=None, **kw):
|
|
53
|
+
broker = FakeBroker(verify=verify, status=status)
|
|
54
|
+
mcp = McpAuth(resource=RESOURCE, broker="https://browserid.me", http_post=broker.post,
|
|
55
|
+
scopes_for_tool={"create_issue": ["issues:create"], "admin": ["repo:admin"]},
|
|
56
|
+
status_cache_s=kw.pop("status_cache_s", 0), **kw)
|
|
57
|
+
return mcp, broker
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
TOKEN_REQ = {"grant_type": JWT_BEARER_GRANT, "assertion": "AC~AS~WR~CC"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TokenEndpoint(unittest.TestCase):
|
|
64
|
+
def test_rejects_bad_grant(self):
|
|
65
|
+
mcp, _ = make()
|
|
66
|
+
with self.assertRaises(McpAuthError) as c:
|
|
67
|
+
mcp.handle_token({"grant_type": "authorization_code", "assertion": "x"})
|
|
68
|
+
self.assertEqual(c.exception.oauth_error, "unsupported_grant_type")
|
|
69
|
+
|
|
70
|
+
def test_rejects_missing_assertion(self):
|
|
71
|
+
mcp, _ = make()
|
|
72
|
+
with self.assertRaises(McpAuthError) as c:
|
|
73
|
+
mcp.handle_token({"grant_type": JWT_BEARER_GRANT})
|
|
74
|
+
self.assertEqual(c.exception.oauth_error, "invalid_request")
|
|
75
|
+
|
|
76
|
+
def test_mints_bearer(self):
|
|
77
|
+
mcp, _ = make()
|
|
78
|
+
res = mcp.handle_token(TOKEN_REQ)
|
|
79
|
+
self.assertEqual(res["token_type"], "Bearer")
|
|
80
|
+
self.assertTrue(res["access_token"].startswith("bat_"))
|
|
81
|
+
self.assertEqual(res["scope"], "repo:read issues:create")
|
|
82
|
+
ctx = mcp.authenticate("Bearer " + res["access_token"])
|
|
83
|
+
self.assertEqual(ctx.grantor, "dan@sandmill.org")
|
|
84
|
+
self.assertEqual(ctx.grantee, "dan+claude@sandmill.org")
|
|
85
|
+
|
|
86
|
+
def test_bad_presentation_rejected(self):
|
|
87
|
+
mcp, _ = make(verify={"status": "failure", "reason": "expired"})
|
|
88
|
+
with self.assertRaises(McpAuthError) as c:
|
|
89
|
+
mcp.handle_token(TOKEN_REQ)
|
|
90
|
+
self.assertEqual(c.exception.oauth_error, "invalid_grant")
|
|
91
|
+
|
|
92
|
+
def test_unreachable_verifier_fails_closed(self):
|
|
93
|
+
mcp, _ = make(verify="throw")
|
|
94
|
+
with self.assertRaises(McpAuthError) as c:
|
|
95
|
+
mcp.handle_token(TOKEN_REQ)
|
|
96
|
+
self.assertEqual(c.exception.http_status, 503)
|
|
97
|
+
|
|
98
|
+
def test_scope_narrow_not_widen(self):
|
|
99
|
+
mcp, _ = make()
|
|
100
|
+
self.assertEqual(mcp.handle_token({**TOKEN_REQ, "scope": "repo:read"})["scope"], "repo:read")
|
|
101
|
+
with self.assertRaises(McpAuthError) as c:
|
|
102
|
+
mcp.handle_token({**TOKEN_REQ, "scope": "repo:read repo:admin"})
|
|
103
|
+
self.assertEqual(c.exception.oauth_error, "invalid_scope")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PerCall(unittest.TestCase):
|
|
107
|
+
def test_unknown_token(self):
|
|
108
|
+
mcp, _ = make()
|
|
109
|
+
with self.assertRaises(McpAuthError) as c:
|
|
110
|
+
mcp.authenticate("Bearer nope")
|
|
111
|
+
self.assertEqual(c.exception.http_status, 401)
|
|
112
|
+
|
|
113
|
+
def test_revoke_kills_token(self):
|
|
114
|
+
broker = FakeBroker(verify=OK_VERIFY, status={"ok": True, "revoked": False})
|
|
115
|
+
mcp = McpAuth(resource=RESOURCE, http_post=broker.post, status_cache_s=0)
|
|
116
|
+
tok = mcp.handle_token(TOKEN_REQ)["access_token"]
|
|
117
|
+
mcp.authenticate("Bearer " + tok) # fine
|
|
118
|
+
broker.status = {"ok": False, "revoked": True}
|
|
119
|
+
with self.assertRaises(McpAuthError) as c:
|
|
120
|
+
mcp.authenticate("Bearer " + tok)
|
|
121
|
+
self.assertIn("revoked", str(c.exception))
|
|
122
|
+
|
|
123
|
+
def test_status_unreachable_fails_closed(self):
|
|
124
|
+
mcp, _ = make(status="throw")
|
|
125
|
+
tok = mcp.handle_token(TOKEN_REQ)["access_token"]
|
|
126
|
+
with self.assertRaises(McpAuthError) as c:
|
|
127
|
+
mcp.authenticate("Bearer " + tok)
|
|
128
|
+
self.assertIn("fail-closed", str(c.exception))
|
|
129
|
+
|
|
130
|
+
def test_no_refs_no_recheck(self):
|
|
131
|
+
v = {**OK_VERIFY, "status_refs": []}
|
|
132
|
+
mcp, broker = make(verify=v)
|
|
133
|
+
tok = mcp.handle_token(TOKEN_REQ)["access_token"]
|
|
134
|
+
mcp.authenticate("Bearer " + tok)
|
|
135
|
+
self.assertEqual(broker.calls["status"], 0)
|
|
136
|
+
|
|
137
|
+
def test_status_cache(self):
|
|
138
|
+
mcp, broker = make(status={"ok": True, "revoked": False}, status_cache_s=300)
|
|
139
|
+
tok = mcp.handle_token(TOKEN_REQ)["access_token"]
|
|
140
|
+
before = broker.calls["status"]
|
|
141
|
+
mcp.authenticate("Bearer " + tok)
|
|
142
|
+
mcp.authenticate("Bearer " + tok)
|
|
143
|
+
self.assertEqual(broker.calls["status"], before) # cached from verify
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Scopes(unittest.TestCase):
|
|
147
|
+
def test_require_warrant(self):
|
|
148
|
+
mcp, _ = make()
|
|
149
|
+
tok = mcp.handle_token(TOKEN_REQ)["access_token"]
|
|
150
|
+
hdr = "Bearer " + tok
|
|
151
|
+
ctx = mcp.require_warrant(hdr, "create_issue") # issues:create granted
|
|
152
|
+
self.assertEqual(ctx.grantee, "dan+claude@sandmill.org")
|
|
153
|
+
with self.assertRaises(McpAuthError) as c:
|
|
154
|
+
mcp.require_warrant(hdr, "admin") # repo:admin not granted
|
|
155
|
+
self.assertEqual(c.exception.http_status, 403)
|
|
156
|
+
mcp.require_warrant(hdr, ["repo:read"]) # explicit list
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Metadata(unittest.TestCase):
|
|
160
|
+
def test_discovery(self):
|
|
161
|
+
mcp, _ = make()
|
|
162
|
+
prm = mcp.protected_resource_metadata()
|
|
163
|
+
self.assertEqual(prm["resource"], RESOURCE)
|
|
164
|
+
asm = mcp.authorization_server_metadata()
|
|
165
|
+
self.assertEqual(asm["token_endpoint"], RESOURCE + "/token")
|
|
166
|
+
self.assertEqual(asm["grant_types_supported"], [JWT_BEARER_GRANT])
|
|
167
|
+
self.assertIn("resource_metadata=", mcp.challenge())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Expiry(unittest.TestCase):
|
|
171
|
+
def test_expired_bearer(self):
|
|
172
|
+
mcp, _ = make(token_ttl_s=-1)
|
|
173
|
+
tok = mcp.handle_token(TOKEN_REQ)["access_token"]
|
|
174
|
+
time.sleep(0.01)
|
|
175
|
+
with self.assertRaises(McpAuthError) as c:
|
|
176
|
+
mcp.authenticate("Bearer " + tok)
|
|
177
|
+
self.assertEqual(c.exception.http_status, 401)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
unittest.main()
|