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.
@@ -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()
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.9"
4
+
5
+ [[package]]
6
+ name = "browserid-mcp-auth"
7
+ version = "0.1.0"
8
+ source = { editable = "." }