aiohttp-mcp-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1021 @@
1
+ """A self-contained OAuth 2.1 authorization server for a single MCP resource."""
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import heapq
7
+ import hmac
8
+ import html
9
+ import json
10
+ import logging
11
+ import secrets
12
+ import socket
13
+ import sys
14
+ import time
15
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
16
+ from dataclasses import dataclass
17
+ from ipaddress import ip_address
18
+ from typing import Final, NoReturn, TypedDict
19
+
20
+ if sys.version_info >= (3, 12):
21
+ from typing import TypeAliasType
22
+ else:
23
+ from typing_extensions import TypeAliasType
24
+
25
+ from aiohttp import (
26
+ ClientError,
27
+ ClientSession,
28
+ ClientTimeout,
29
+ DefaultResolver,
30
+ TCPConnector,
31
+ web,
32
+ )
33
+ from aiohttp.abc import AbstractResolver, ResolveResult
34
+ from yarl import URL
35
+
36
+ from ._auth import (
37
+ _LOOPBACK,
38
+ _SCOPE_TOKEN,
39
+ Authorization,
40
+ ScopeMatcher,
41
+ TokenInfo,
42
+ _secure_url,
43
+ )
44
+
45
+ logger = logging.getLogger("aiohttp_mcp_server")
46
+
47
+ # TODO(PY312): use the `type` statement.
48
+ AuthenticateRequest = TypeAliasType(
49
+ "AuthenticateRequest", Callable[[web.Request], Awaitable["str | None"]]
50
+ )
51
+ CheckCredentials = TypeAliasType(
52
+ "CheckCredentials", Callable[[str, str, web.Request], Awaitable[bool]]
53
+ )
54
+
55
+ _CIMD_TIMEOUT: Final = ClientTimeout(total=10)
56
+ _CIMD_MAX_SIZE: Final = 64 * 1024
57
+ _CIMD_CACHE_TTL: Final = 300.0
58
+ _CIMD_CACHE_MAX: Final = 16
59
+ _SESSION_COOKIE: Final = "aiohttp_mcp_server_session"
60
+ # Our endpoints all live under this prefix; the session cookie is scoped to it.
61
+ _OAUTH_PATH: Final = "/oauth2"
62
+ _CONSENT_TTL: Final = 600.0
63
+ _REVOKED: Final = -1
64
+
65
+ _FORM_FIELDS: Final = (
66
+ "response_type",
67
+ "client_id",
68
+ "redirect_uri",
69
+ "state",
70
+ "scope",
71
+ "resource",
72
+ "code_challenge",
73
+ "code_challenge_method",
74
+ )
75
+
76
+ _CONSENT_TEMPLATE: Final = """<!DOCTYPE html>
77
+ <html>
78
+ <head><meta charset="utf-8"><title>Authorize {server}</title></head>
79
+ <body>
80
+ <h1>Authorize access to {server}</h1>
81
+ <p><b>{client}</b> is requesting access{scopes}.</p>
82
+ <p>Approving will send the authorization code to <b>{redirect_host}</b>.</p>
83
+ {warning}<p>Signed in as <b>{subject}</b>.</p>
84
+ {error}<form method="post">
85
+ {hidden}<button name="action" value="approve">Approve</button>
86
+ <button name="action" value="deny">Deny</button>
87
+ </form>
88
+ </body>
89
+ </html>"""
90
+
91
+ _LOGIN_TEMPLATE: Final = """<!DOCTYPE html>
92
+ <html>
93
+ <head><meta charset="utf-8"><title>Sign in to {server}</title></head>
94
+ <body>
95
+ <h1>Sign in to {server}</h1>
96
+ {error}<form method="post">
97
+ {hidden}<label>Username <input name="username" autocomplete="username"></label><br>
98
+ <label>Password <input type="password" name="password" autocomplete="current-password"></label><br>
99
+ <button>Sign in</button>
100
+ </form>
101
+ </body>
102
+ </html>"""
103
+
104
+
105
+ def _valid_redirect_uris(uris: Sequence[object]) -> bool:
106
+ """Redirect URIs MUST be either localhost or use HTTPS."""
107
+ try:
108
+ return bool(uris) and all(
109
+ isinstance(u, str) and _secure_url(URL(u)) for u in uris
110
+ )
111
+ except ValueError: # unparseable URI
112
+ return False
113
+
114
+
115
+ def _cimd_url_allowed(url: URL) -> bool:
116
+ """Static fetch policy for Client ID Metadata Documents.
117
+
118
+ These are server-side fetches of a *client-controlled* URL: https only,
119
+ public hosts only - a private, loopback, or link-local target would turn
120
+ the authorization server into an SSRF proxy for its own network.
121
+
122
+ Only the scheme and literal-address hosts are rejected here; hostnames
123
+ are policed by _PublicHostResolver at connect time, against the
124
+ actual addresses the connection will use. Checking them here instead would
125
+ leave a window for DNS rebinding between check and fetch.
126
+ """
127
+ host = url.host
128
+ if url.scheme != "https" or host is None:
129
+ return False
130
+ try:
131
+ address = ip_address(host)
132
+ except ValueError: # Hostname: resolved addresses checked at connect time.
133
+ return True
134
+ return address.is_global
135
+
136
+
137
+ class _PublicHostResolver(AbstractResolver):
138
+ """DNS resolver that refuses to resolve hostnames to non-public addresses.
139
+
140
+ Enforcing the policy inside the connector's resolver means the checked
141
+ addresses are exactly the ones used: a rebinding DNS server cannot
142
+ answer differently for the policy check and the connection.
143
+ """
144
+
145
+ def __init__(self) -> None:
146
+ self._resolver = DefaultResolver()
147
+
148
+ async def resolve(
149
+ self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET
150
+ ) -> list[ResolveResult]:
151
+ infos = await self._resolver.resolve(host, port, family=family)
152
+ try:
153
+ allowed = bool(infos) and all(
154
+ ip_address(info["host"]).is_global for info in infos
155
+ )
156
+ except ValueError:
157
+ allowed = False
158
+ if not allowed:
159
+ raise OSError(f"{host!r} did not resolve to public addresses")
160
+ return infos
161
+
162
+ async def close(self) -> None:
163
+ await self._resolver.close()
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class ConsentContext:
168
+ """Everything a consent page needs to render and echo back.
169
+
170
+ A custom page MUST render an HTML form that POSTs back to the same URL, carrying
171
+ every entry of :attr:`fields` unmodified (hidden inputs, see
172
+ :attr:`hidden_fields_html`) together with ``action`` (``"approve"`` to
173
+ proceed; any other value denies).
174
+ """
175
+
176
+ # The MCP server's display name (``resource_name`` or the resource URI).
177
+ server_name: str
178
+ # The requesting client's self-reported display name. Client-controlled:
179
+ # MUST be HTML-escaped when rendered.
180
+ client_name: str
181
+ # The requesting client's identifier. Client-controlled, as above.
182
+ client_id: str
183
+ # Scopes that approval will grant.
184
+ scopes: tuple[str, ...]
185
+ # The authenticated user approving the request.
186
+ subject: str
187
+ # Hostname of the redirect URI that will receive the authorization code.
188
+ # Pages MUST display it clearly..
189
+ redirect_host: str
190
+ # True when the redirect goes to the local machine.
191
+ # Pages SHOULD warn that any locally running program could be listening.
192
+ redirect_is_loopback: bool
193
+ # Parameters the form must echo back verbatim, including the anti-forgery consent token.
194
+ fields: Mapping[str, str]
195
+ # Set when re-prompting (e.g. after an expired consent token).
196
+ error: str | None
197
+
198
+ @property
199
+ def hidden_fields_html(self) -> str:
200
+ """:attr:`fields` as escaped ``<input type="hidden">`` elements."""
201
+ return "".join(
202
+ f'<input type="hidden" name="{html.escape(name, quote=True)}"'
203
+ f' value="{html.escape(value, quote=True)}">\n'
204
+ for name, value in self.fields.items()
205
+ )
206
+
207
+
208
+ # TODO(PY312): use the `type` statement.
209
+ RenderConsent = TypeAliasType(
210
+ "RenderConsent", Callable[[web.Request, ConsentContext], Awaitable[web.Response]]
211
+ )
212
+
213
+
214
+ async def _render_consent_default(request: web.Request, context: ConsentContext) -> web.Response:
215
+ scopes = " ".join(context.scopes)
216
+ warning = (
217
+ "<p><b>Warning:</b> the code will be sent to an application running "
218
+ "on this computer; make sure you initiated this request locally.</p>\n"
219
+ if context.redirect_is_loopback
220
+ else ""
221
+ )
222
+ body = _CONSENT_TEMPLATE.format(
223
+ server=html.escape(context.server_name),
224
+ client=html.escape(context.client_name),
225
+ subject=html.escape(context.subject),
226
+ redirect_host=html.escape(context.redirect_host),
227
+ warning=warning,
228
+ scopes=f" with scopes: {html.escape(scopes)}" if scopes else "",
229
+ error=f"<p><b>{html.escape(context.error)}</b></p>\n" if context.error else "",
230
+ hidden=context.hidden_fields_html,
231
+ )
232
+ return web.Response(text=body, content_type="text/html")
233
+
234
+
235
+ def _b64(data: bytes) -> str:
236
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
237
+
238
+
239
+ def _unb64(data: str) -> bytes:
240
+ return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
241
+
242
+
243
+ class _Signer:
244
+ """HMAC-SHA256 signing of JSON payloads (``body.signature``, base64url)."""
245
+
246
+ __slots__ = ("_secret",)
247
+
248
+ def __init__(self, secret: bytes) -> None:
249
+ self._secret = secret
250
+
251
+ def sign(self, payload: Mapping[str, object]) -> str:
252
+ body = _b64(json.dumps(payload, separators=(",", ":")).encode())
253
+ return f"{body}.{_b64(hmac.digest(self._secret, body.encode(), 'sha256'))}"
254
+
255
+ def verify(self, token: str) -> dict[str, object] | None:
256
+ body, _, signature = token.partition(".")
257
+ if not signature:
258
+ return None
259
+ expected = _b64(hmac.digest(self._secret, body.encode(), "sha256"))
260
+ if not hmac.compare_digest(signature, expected):
261
+ return None
262
+ try:
263
+ payload = json.loads(_unb64(body))
264
+ except (ValueError, binascii.Error):
265
+ logger.error(
266
+ "Correctly signed token carries an unparseable payload (%d chars)", len(body)
267
+ )
268
+ return None
269
+ if not isinstance(payload, dict):
270
+ logger.error(
271
+ "Correctly signed token carries a non-object payload of type %s",
272
+ type(payload).__name__,
273
+ )
274
+ return None
275
+ return payload
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class _Client:
280
+ redirect_uris: Sequence[str]
281
+ name: str
282
+
283
+
284
+ class _PageError(Exception):
285
+ """Invalid client/redirect_uri: rendered as a 400 page, never redirected."""
286
+
287
+
288
+ class _RedirectError(Exception):
289
+ """Reported to the client via redirect (RFC 6749 section 4.1.2.1).
290
+
291
+ ``redirect_uri`` has passed _redirect_allowed(): it is safe to
292
+ send the error there.
293
+ """
294
+
295
+ def __init__(self, error: str, description: str, redirect_uri: str) -> None:
296
+ super().__init__(description)
297
+ self.error = error
298
+ self.description = description
299
+ self.redirect_uri = redirect_uri
300
+
301
+
302
+ class _AuthServerMetadata(TypedDict):
303
+ """The RFC 8414 metadata subset emitted by this server."""
304
+
305
+ issuer: str
306
+ authorization_endpoint: str
307
+ token_endpoint: str
308
+ response_types_supported: Sequence[str]
309
+ grant_types_supported: Sequence[str]
310
+ code_challenge_methods_supported: Sequence[str]
311
+ token_endpoint_auth_methods_supported: Sequence[str]
312
+ authorization_response_iss_parameter_supported: bool
313
+ client_id_metadata_document_supported: bool
314
+ scopes_supported: Sequence[str]
315
+
316
+
317
+ def _redirect_allowed(registered: Sequence[str], uri: str) -> bool:
318
+ """Exact match, with the OAuth 2.1 variable-port exception for loopback."""
319
+ if uri in registered:
320
+ return True
321
+ requested = URL(uri)
322
+ if requested.host not in _LOOPBACK:
323
+ return False
324
+ return any(
325
+ (reg := URL(r)).host == requested.host
326
+ and reg.scheme == requested.scheme
327
+ and reg.path == requested.path
328
+ and reg.query_string == requested.query_string
329
+ for r in registered
330
+ )
331
+
332
+
333
+ def _token_error(code: str, description: str) -> web.Response:
334
+ # https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
335
+ return web.json_response(
336
+ {"error": code, "error_description": description},
337
+ status=400,
338
+ headers={"Cache-Control": "no-store"},
339
+ )
340
+
341
+
342
+ class LocalAuthorization(Authorization):
343
+ """Self-contained authorization: this app is its own authorization server.
344
+
345
+ :param check_credentials: Asynchronous callable verifying a username/password
346
+ pair presented at the hosted login page.
347
+ :param resource: Base URL this MCP server is reachable at, as for
348
+ :class:`Authorization`. It is also the authorization
349
+ server's issuer identity, and the :brand:`OAuth` endpoints live under
350
+ ``/oauth2/`` beneath it: they are registered on this application by
351
+ :func:`~aiohttp_mcp_server.setup`; register the well-known documents via
352
+ :meth:`register_well_known` on the root application.
353
+ :param secret: Key signing tokens and authorization codes. Random when
354
+ omitted, which invalidates tokens on restart. Can be the same secret
355
+ passed as ``state_secret`` to :func:`~aiohttp_mcp_server.setup`.
356
+ :param clients: Statically registered clients, mapping ``client_id`` to
357
+ its exact ``redirect_uris``. Clients using Client ID Metadata
358
+ Documents (an ``https`` ``client_id`` URL) need no registration.
359
+ :param default_scopes: As for :class:`Authorization`, the minimal scope
360
+ set for basic functionality, advertised to clients as the starting
361
+ point and granted when a client requests no particular scopes.
362
+ :param grantable_scopes: Every scope this authorization server is willing
363
+ to grant (advertised as the :rfc:`8414` ``scopes_supported``). Requested
364
+ scopes outside it are rejected with ``invalid_scope``. Defaults to
365
+ ``default_scopes`` - set it when step-up scopes beyond the starting
366
+ set exist.
367
+ :param token_ttl: Access token lifetime in seconds.
368
+ :param code_ttl: Authorization code lifetime in seconds.
369
+ :param refresh_ttl: Absolute lifetime in seconds of a refresh-token grant
370
+ (the whole rotation chain, not each token), after which the user must
371
+ re-authorize. :data:`None` disables refresh tokens entirely.
372
+ :param render_consent: Replaces the hosted consent page. Receives the
373
+ request and a :class:`ConsentContext`. The page it returns must
374
+ satisfy the form contract documented there.
375
+ :param authenticate_request: Returns the authenticated subject for a
376
+ request using the application's existing session mechanism (with
377
+ :mod:`aiohttp_security` use :func:`~aiohttp_security.authorized_userid`), or
378
+ :data:`None`. Consulted before the built-in session cookie.
379
+ :param login_url: The application's own login page, to which
380
+ unauthenticated users are sent (with the authorize URL appended as a
381
+ query parameter) instead of the built-in ``/oauth2/login``. After
382
+ logging the user in, the page must redirect back to that URL.
383
+ Requires ``authenticate_request``, which is how the authorization
384
+ endpoint sees the resulting session.
385
+ :param login_redirect_param: Name of the query parameter carrying the
386
+ post-login return URL, for both the built-in and ``login_url`` pages.
387
+ :param login_ttl: Lifetime in seconds of the built-in login session cookie.
388
+ """
389
+
390
+ def __init__(
391
+ self,
392
+ check_credentials: CheckCredentials | None = None,
393
+ *,
394
+ resource: str,
395
+ secret: bytes | None = None,
396
+ clients: Mapping[str, Sequence[str]] | None = None,
397
+ default_scopes: Sequence[str] = (),
398
+ grantable_scopes: Sequence[str] = (),
399
+ scope_matcher: ScopeMatcher | None = None,
400
+ token_ttl: float = 3600.0,
401
+ code_ttl: float = 120.0,
402
+ refresh_ttl: float | None = 30 * 86400.0,
403
+ render_consent: RenderConsent | None = None,
404
+ authenticate_request: AuthenticateRequest | None = None,
405
+ login_url: str | None = None,
406
+ login_redirect_param: str = "redirect",
407
+ login_ttl: float = 86400.0,
408
+ resource_name: str | None = None,
409
+ resource_documentation: str | None = None,
410
+ ) -> None:
411
+ if token_ttl <= 0 or code_ttl <= 0:
412
+ raise ValueError("token_ttl and code_ttl must be > 0")
413
+ if refresh_ttl is not None and refresh_ttl <= 0:
414
+ raise ValueError("refresh_ttl must be > 0 or None")
415
+ if login_ttl <= 0:
416
+ raise ValueError("login_ttl must be > 0")
417
+ if check_credentials is None and login_url is None:
418
+ raise ValueError(
419
+ "a way to log users in is required: provide check_credentials "
420
+ "(built-in login page) and/or login_url (the app's own page)"
421
+ )
422
+ if login_url is not None and authenticate_request is None:
423
+ raise ValueError(
424
+ "login_url requires authenticate_request: without it the authorization "
425
+ "endpoint cannot see the session the app's login page establishes"
426
+ )
427
+ if not login_redirect_param:
428
+ raise ValueError("login_redirect_param must be a non-empty string")
429
+ super().__init__(
430
+ self._validate_token,
431
+ resource=resource,
432
+ auth_servers=(resource.rstrip("/"),),
433
+ default_scopes=default_scopes,
434
+ scope_matcher=scope_matcher,
435
+ resource_name=resource_name,
436
+ resource_documentation=resource_documentation,
437
+ )
438
+ self._issuer: Final = self._base
439
+ self._prefix = URL(self._base).path.rstrip("/")
440
+ self._oauth_path = f"{self._prefix}{_OAUTH_PATH}"
441
+ self._oauth_app: web.Application | None = None
442
+ self._check_credentials = check_credentials
443
+ self._render_consent = (
444
+ _render_consent_default if render_consent is None else render_consent
445
+ )
446
+ self._authenticate_request = authenticate_request
447
+ self._login_url = login_url
448
+ self._login_redirect_param = login_redirect_param
449
+ self._login_ttl = login_ttl
450
+ # Relative paths the login page may redirect back to.
451
+ self._return_prefixes: tuple[str, ...] = ()
452
+ self._extra_return_prefixes: list[Callable[[], str]] = []
453
+ self._signer = _Signer(secret if secret is not None else secrets.token_bytes(32))
454
+ self._grantable_scopes = (
455
+ tuple(grantable_scopes) if grantable_scopes else tuple(default_scopes)
456
+ )
457
+ for scope in self._grantable_scopes:
458
+ if not _SCOPE_TOKEN.fullmatch(scope):
459
+ raise ValueError(f"invalid scope token: {scope!r}")
460
+ if not set(default_scopes) <= set(self._grantable_scopes):
461
+ raise ValueError("default_scopes must all be grantable")
462
+ self._token_ttl = token_ttl
463
+ self._code_ttl = code_ttl
464
+ self._refresh_ttl = refresh_ttl
465
+ for client_id, uris in (clients or {}).items():
466
+ if not _valid_redirect_uris(tuple(uris)):
467
+ raise ValueError(f"redirect URIs must be localhost or HTTPS: {client_id!r}")
468
+ self._static_clients = {
469
+ client_id: _Client(tuple(uris), client_id)
470
+ for client_id, uris in (clients or {}).items()
471
+ }
472
+ self._cimd_cache: dict[str, tuple[float, _Client]] = {}
473
+ self._used_codes: dict[str, float] = {}
474
+ # Latest issued generation (or _REVOKED) and expiry per refresh family.
475
+ self._refresh_families: dict[str, tuple[int, float]] = {}
476
+ self._family_heap: list[tuple[float, str]] = []
477
+ self._http: ClientSession | None = None
478
+ self._as_metadata: _AuthServerMetadata = {
479
+ # https://datatracker.ietf.org/doc/html/rfc8414#section-2
480
+ "issuer": self._issuer,
481
+ "authorization_endpoint": f"{self._issuer}{_OAUTH_PATH}/authorize",
482
+ "token_endpoint": f"{self._issuer}{_OAUTH_PATH}/token",
483
+ "response_types_supported": ("code",),
484
+ "grant_types_supported": ("authorization_code",)
485
+ if refresh_ttl is None else ("authorization_code", "refresh_token"),
486
+ "code_challenge_methods_supported": ("S256",),
487
+ "token_endpoint_auth_methods_supported": ("none",),
488
+ "authorization_response_iss_parameter_supported": True,
489
+ "client_id_metadata_document_supported": True,
490
+ "scopes_supported": self._grantable_scopes,
491
+ }
492
+
493
+ def _register_routes(self, app: web.Application) -> None:
494
+ """Mount the authorization server as a sub-application at ``/oauth2``."""
495
+ super()._register_routes(app)
496
+ oauth = web.Application()
497
+ oauth.router.add_get("/authorize", self._handle_authorize_page, name="authorize")
498
+ oauth.router.add_post("/authorize", self._handle_authorize_submit)
499
+ oauth.router.add_post("/token", self._handle_token, name="token")
500
+ if self._check_credentials is not None:
501
+ oauth.router.add_get("/login", self._handle_login_page, name="login")
502
+ oauth.router.add_post("/login", self._handle_login_submit)
503
+ oauth.cleanup_ctx.append(self._client_session)
504
+ oauth.on_startup.append(self._finalise_return_prefixes)
505
+ app.add_subapp(_OAUTH_PATH, oauth)
506
+ self._oauth_app = oauth
507
+
508
+ async def _finalise_return_prefixes(self, app: web.Application) -> None:
509
+ self._return_prefixes = (
510
+ self._endpoint_path("authorize"), *(r() for r in self._extra_return_prefixes)
511
+ )
512
+
513
+ def _endpoint_path(self, name: str) -> str:
514
+ """The real mounted path of one of our endpoints, from the router."""
515
+ assert self._oauth_app is not None # _register_routes() ran
516
+ return str(self._oauth_app.router[name].url_for())
517
+
518
+ def register_well_known(self, app: web.Application) -> None:
519
+ super().register_well_known(app)
520
+ # https://datatracker.ietf.org/doc/html/rfc8414#section-3: path
521
+ # insertion for a path-bearing issuer, at the origin root.
522
+ as_metadata_path = URL(self._issuer).path
523
+ app.router.add_get(
524
+ "/.well-known/oauth-authorization-server"
525
+ + (as_metadata_path if as_metadata_path != "/" else ""),
526
+ self._handle_as_metadata,
527
+ )
528
+
529
+ async def _client_session(self, app: web.Application) -> AsyncIterator[None]:
530
+ resolver = _PublicHostResolver()
531
+ try:
532
+ connector = TCPConnector(resolver=resolver, limit=10)
533
+ async with ClientSession(timeout=_CIMD_TIMEOUT, connector=connector) as sess:
534
+ self._http = sess
535
+ yield
536
+ finally:
537
+ await resolver.close()
538
+
539
+ # - Token validation (the resource-server side) -------------------------
540
+
541
+ async def _validate_token(self, token: str, request: web.Request) -> TokenInfo | None:
542
+ payload = self._signer.verify(token)
543
+ if payload is None or payload.get("t") != "token":
544
+ return None
545
+ expiry = payload.get("exp")
546
+ if not isinstance(expiry, (int, float)) or expiry <= time.time():
547
+ return None
548
+ if payload.get("aud") != self._resource: # RFC 8707 audience binding
549
+ return None
550
+ scope = payload.get("scope")
551
+ subject = payload.get("sub")
552
+ return TokenInfo(
553
+ scopes=frozenset(scope.split()) if isinstance(scope, str) else frozenset(),
554
+ subject=subject if isinstance(subject, str) else None,
555
+ claims=payload,
556
+ )
557
+
558
+ # - Client resolution ---------------------------------------------------
559
+
560
+ async def _resolve_client(self, client_id: str) -> _Client:
561
+ if (static := self._static_clients.get(client_id)) is not None:
562
+ return static
563
+ # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00
564
+ url = URL(client_id)
565
+ if not _cimd_url_allowed(url):
566
+ raise _PageError("Unknown client_id")
567
+ cached = self._cimd_cache.get(client_id)
568
+ if cached is not None and cached[0] > time.monotonic():
569
+ return cached[1]
570
+ assert self._http is not None
571
+ try:
572
+ async with self._http.get(client_id, allow_redirects=False) as resp:
573
+ if resp.status != 200:
574
+ raise _PageError("Failed to fetch client metadata")
575
+ raw = await resp.content.read(_CIMD_MAX_SIZE + 1)
576
+ except ClientError:
577
+ raise _PageError("Failed to fetch client metadata") from None
578
+ if len(raw) > _CIMD_MAX_SIZE:
579
+ raise _PageError("Client metadata document too large")
580
+ try:
581
+ doc = json.loads(raw)
582
+ except ValueError:
583
+ raise _PageError("Invalid client metadata document") from None
584
+ redirect_uris = doc.get("redirect_uris") if isinstance(doc, dict) else None
585
+ if (
586
+ not isinstance(doc, dict)
587
+ or doc.get("client_id") != client_id
588
+ or not isinstance(redirect_uris, list)
589
+ or not _valid_redirect_uris(redirect_uris)
590
+ ):
591
+ raise _PageError("Invalid client metadata document")
592
+ name = doc.get("client_name")
593
+ client = _Client(tuple(redirect_uris), name if isinstance(name, str) else client_id)
594
+ now = time.monotonic()
595
+ for stale in [c for c, (expiry, _) in self._cimd_cache.items() if expiry <= now]:
596
+ del self._cimd_cache[stale]
597
+ while len(self._cimd_cache) >= _CIMD_CACHE_MAX:
598
+ del self._cimd_cache[min(self._cimd_cache, key=lambda c: self._cimd_cache[c][0])]
599
+ self._cimd_cache[client_id] = (now + _CIMD_CACHE_TTL, client)
600
+ return client
601
+
602
+ # - Authorization endpoint ----------------------------------------------
603
+
604
+ async def _check_authorize(self, args: Mapping[str, str]) -> tuple[_Client, dict[str, str]]:
605
+ client_id = args.get("client_id")
606
+ if not client_id:
607
+ raise _PageError("Missing client_id")
608
+ client = await self._resolve_client(client_id)
609
+ redirect_uri = args.get("redirect_uri")
610
+ if not redirect_uri or not _redirect_allowed(client.redirect_uris, redirect_uri):
611
+ raise _PageError("Invalid redirect_uri")
612
+
613
+ if args.get("response_type") != "code":
614
+ raise _RedirectError(
615
+ "unsupported_response_type", "Must be response_type=code", redirect_uri
616
+ )
617
+ # PKCE is mandatory (OAuth 2.1); only S256 is offered.
618
+ if not args.get("code_challenge"):
619
+ raise _RedirectError("invalid_request", "code_challenge is required", redirect_uri)
620
+ if args.get("code_challenge_method") != "S256":
621
+ raise _RedirectError(
622
+ "invalid_request", "code_challenge_method must be S256", redirect_uri
623
+ )
624
+ resource = args.get("resource")
625
+ if resource and resource != self._resource:
626
+ # RFC 8707: tokens are only issued for this server's resource.
627
+ raise _RedirectError("invalid_target", "Unknown resource", redirect_uri)
628
+ requested = args.get("scope", "").split()
629
+ if not set(requested) <= set(self._grantable_scopes):
630
+ raise _RedirectError("invalid_scope", "Unknown scope requested", redirect_uri)
631
+ params = {field: args.get(field, "") for field in _FORM_FIELDS}
632
+ # Least privilege: an unspecific request grants the default starting
633
+ # set, not everything this server could grant.
634
+ params["scope"] = " ".join(requested if requested else self._default_scopes)
635
+ return client, params
636
+
637
+ # - Sessions --------------------------------------------------------------
638
+
639
+ async def _subject(self, request: web.Request) -> str | None:
640
+ """The authenticated user, from the app's session or our own cookie."""
641
+ if self._authenticate_request is not None:
642
+ subject = await self._authenticate_request(request)
643
+ if subject is not None:
644
+ return subject
645
+ cookie = request.cookies.get(_SESSION_COOKIE)
646
+ if cookie is not None:
647
+ payload = self._signer.verify(cookie)
648
+ if payload is not None and payload.get("t") == "session":
649
+ expiry = payload.get("exp")
650
+ cookie_sub = payload.get("sub")
651
+ if (
652
+ isinstance(expiry, (int, float))
653
+ and expiry > time.time()
654
+ and isinstance(cookie_sub, str)
655
+ ):
656
+ return cookie_sub
657
+ return None
658
+
659
+ def _login_redirect(self, authorize_url: URL) -> NoReturn:
660
+ """Send an unauthenticated user to the login page, and back after."""
661
+ target = URL(self._login_url or self._endpoint_path("login"))
662
+ location = target.update_query({self._login_redirect_param: str(authorize_url)})
663
+ raise web.HTTPSeeOther(location=location)
664
+
665
+ def _return_url(self, args: Mapping[str, str]) -> URL:
666
+ """The URL a login page should send the user back to."""
667
+ return URL(self._endpoint_path("authorize")).with_query(
668
+ {field: value for field in _FORM_FIELDS if (value := args.get(field))}
669
+ )
670
+
671
+ def _consent_fields(self, subject: str, params: Mapping[str, str]) -> dict[str, str]:
672
+ """The echoed form fields, plus the subject-bound anti-forgery token."""
673
+ fields = {f: params[f] for f in _FORM_FIELDS if params[f]}
674
+ fields["consent"] = self._signer.sign(
675
+ {"t": "consent", "sub": subject, "exp": time.time() + _CONSENT_TTL}
676
+ )
677
+ return fields
678
+
679
+ def _consent_valid(self, token: str, subject: str) -> bool:
680
+ payload = self._signer.verify(token)
681
+ if payload is None or payload.get("t") != "consent":
682
+ return False
683
+ expiry = payload.get("exp")
684
+ return (
685
+ isinstance(expiry, (int, float))
686
+ and expiry > time.time()
687
+ and payload.get("sub") == subject
688
+ )
689
+
690
+ async def _render_consent_page(
691
+ self,
692
+ request: web.Request,
693
+ client: _Client,
694
+ params: Mapping[str, str],
695
+ subject: str,
696
+ error: str | None = None,
697
+ ) -> web.Response:
698
+ redirect_url = URL(params["redirect_uri"])
699
+ context = ConsentContext(
700
+ server_name=self._metadata.get("resource_name", self._resource),
701
+ client_name=client.name,
702
+ client_id=params["client_id"],
703
+ scopes=tuple(params["scope"].split()),
704
+ subject=subject,
705
+ redirect_host=redirect_url.host or params["redirect_uri"],
706
+ redirect_is_loopback=redirect_url.host in _LOOPBACK,
707
+ fields=self._consent_fields(subject, params),
708
+ error=error,
709
+ )
710
+ return await self._render_consent(request, context)
711
+
712
+ def _redirect(self, redirect_uri: str, state: str, **params: str) -> NoReturn:
713
+ location = URL(redirect_uri).update_query(
714
+ # https://datatracker.ietf.org/doc/html/rfc9207#section-2
715
+ {**params, "iss": self._issuer, **({"state": state} if state else {})}
716
+ )
717
+ raise web.HTTPSeeOther(location=location)
718
+
719
+ async def _error_redirect(
720
+ self, request: web.Request, args: Mapping[str, str], exc: _RedirectError
721
+ ) -> NoReturn:
722
+ """Report a validation error to the client via its redirect URI.
723
+
724
+ The user MUST be authenticated first (OAuth 2.1 section 7.12.2):
725
+ otherwise a crafted link with a deliberately invalid request would
726
+ bounce an unauthenticated victim straight to any registered (e.g.
727
+ Client ID Metadata Document) site, using our origin as a trusted
728
+ one-click redirect.
729
+ """
730
+ if await self._subject(request) is None:
731
+ self._login_redirect(self._return_url(args))
732
+ self._redirect(
733
+ exc.redirect_uri,
734
+ args.get("state", ""),
735
+ error=exc.error,
736
+ error_description=exc.description,
737
+ )
738
+
739
+ async def _handle_authorize_page(self, request: web.Request) -> web.StreamResponse:
740
+ try:
741
+ client, params = await self._check_authorize(request.query)
742
+ except _PageError as exc:
743
+ raise web.HTTPBadRequest(text=str(exc)) from None
744
+ except _RedirectError as exc:
745
+ await self._error_redirect(request, request.query, exc)
746
+ subject = await self._subject(request)
747
+ if subject is None:
748
+ self._login_redirect(self._return_url(params))
749
+ return await self._render_consent_page(request, client, params, subject)
750
+
751
+ async def _handle_authorize_submit(self, request: web.Request) -> web.StreamResponse:
752
+ form = await request.post()
753
+ args = {k: v for k, v in form.items() if isinstance(v, str)}
754
+ try:
755
+ client, params = await self._check_authorize(args)
756
+ except _PageError as exc:
757
+ raise web.HTTPBadRequest(text=str(exc)) from None
758
+ except _RedirectError as exc:
759
+ await self._error_redirect(request, args, exc)
760
+
761
+ origin = request.headers.get("Origin")
762
+ if origin is not None and origin != self._issuer:
763
+ raise web.HTTPForbidden(text="Cross-origin form submission")
764
+ subject = await self._subject(request)
765
+ if subject is None:
766
+ self._login_redirect(self._return_url(params))
767
+
768
+ state = params["state"]
769
+ if args.get("action") != "approve":
770
+ self._redirect(
771
+ params["redirect_uri"],
772
+ state,
773
+ error="access_denied",
774
+ error_description="The user denied the request",
775
+ )
776
+ if not self._consent_valid(args.get("consent", ""), subject):
777
+ return await self._render_consent_page(
778
+ request, client, params, subject, error="Please confirm again"
779
+ )
780
+
781
+ code = self._signer.sign(
782
+ {
783
+ "t": "code",
784
+ "jti": secrets.token_urlsafe(16),
785
+ "sub": subject,
786
+ "cid": params["client_id"],
787
+ "ruri": params["redirect_uri"],
788
+ "aud": self._resource,
789
+ "scope": params["scope"],
790
+ "cc": params["code_challenge"],
791
+ "exp": time.time() + self._code_ttl,
792
+ }
793
+ )
794
+ self._redirect(params["redirect_uri"], state, code=code)
795
+
796
+ # - Built-in login page ---------------------------------------------------
797
+
798
+ def _safe_return_url(self, value: str | None) -> str:
799
+ if value is not None:
800
+ try:
801
+ url = URL(value)
802
+ except ValueError:
803
+ return "/"
804
+ if (
805
+ not url.scheme
806
+ and not url.is_absolute()
807
+ and url.path.startswith(self._return_prefixes)
808
+ ):
809
+ return str(
810
+ URL.build(
811
+ path=url.raw_path,
812
+ query_string=url.raw_query_string,
813
+ encoded=True,
814
+ )
815
+ )
816
+ return "/"
817
+
818
+ def _render_login_page(self, redirect: str, error: str | None) -> web.Response:
819
+ hidden = (
820
+ f'<input type="hidden" name="{html.escape(self._login_redirect_param, quote=True)}"'
821
+ f' value="{html.escape(redirect, quote=True)}">\n'
822
+ )
823
+ body = _LOGIN_TEMPLATE.format(
824
+ server=html.escape(self._metadata.get("resource_name", self._resource)),
825
+ error=f"<p><b>{html.escape(error)}</b></p>\n" if error else "",
826
+ hidden=hidden,
827
+ )
828
+ return web.Response(text=body, content_type="text/html")
829
+
830
+ async def _handle_login_page(self, request: web.Request) -> web.StreamResponse:
831
+ redirect = self._safe_return_url(request.query.get(self._login_redirect_param))
832
+ if await self._subject(request) is not None:
833
+ # Already signed in: straight back to the authorization flow.
834
+ raise web.HTTPSeeOther(location=redirect)
835
+ return self._render_login_page(redirect, error=None)
836
+
837
+ async def _handle_login_submit(self, request: web.Request) -> web.StreamResponse:
838
+ assert self._check_credentials is not None # route only added with it
839
+ form = await request.post()
840
+ args = {k: v for k, v in form.items() if isinstance(v, str)}
841
+ redirect = self._safe_return_url(args.get(self._login_redirect_param))
842
+ username = args.get("username", "")
843
+ if not username or not await self._check_credentials(
844
+ username, args.get("password", ""), request
845
+ ):
846
+ return self._render_login_page(redirect, error="Invalid credentials")
847
+
848
+ session = self._signer.sign(
849
+ {"t": "session", "sub": username, "exp": time.time() + self._login_ttl}
850
+ )
851
+ response = web.HTTPSeeOther(location=redirect)
852
+ response.set_cookie(
853
+ _SESSION_COOKIE,
854
+ session,
855
+ max_age=int(self._login_ttl),
856
+ path=self._endpoint_path("login").removesuffix("/login"),
857
+ httponly=True,
858
+ samesite="Lax",
859
+ secure=request.secure,
860
+ )
861
+ raise response
862
+
863
+ # - Token endpoint -------------------------------------------------------
864
+
865
+ def _consume_code(self, jti: str, expiry: float) -> bool:
866
+ """Single-use enforcement. Returns False on replay."""
867
+ now = time.time()
868
+ for used_jti, used_expiry in tuple(self._used_codes.items()):
869
+ if used_expiry <= now:
870
+ del self._used_codes[used_jti]
871
+ if jti in self._used_codes:
872
+ return False
873
+ self._used_codes[jti] = expiry
874
+ return True
875
+
876
+ async def _handle_token(self, request: web.Request) -> web.Response:
877
+ form = await request.post()
878
+ args = {k: v for k, v in form.items() if isinstance(v, str)}
879
+ grant_type = args.get("grant_type")
880
+ if grant_type == "authorization_code":
881
+ return self._grant_authorization_code(args)
882
+ if grant_type == "refresh_token" and self._refresh_ttl is not None:
883
+ return self._grant_refresh_token(args)
884
+ return _token_error("unsupported_grant_type", "Unsupported grant_type")
885
+
886
+ def _grant_authorization_code(self, args: Mapping[str, str]) -> web.Response:
887
+ payload = self._signer.verify(args.get("code", ""))
888
+ if payload is None or payload.get("t") != "code":
889
+ return _token_error("invalid_grant", "Invalid authorization code")
890
+ expiry = payload.get("exp")
891
+ jti = payload.get("jti")
892
+ if (
893
+ not isinstance(expiry, (int, float))
894
+ or expiry <= time.time()
895
+ or not isinstance(jti, str)
896
+ or not self._consume_code(jti, expiry)
897
+ ):
898
+ return _token_error("invalid_grant", "Expired or already used authorization code")
899
+ if args.get("client_id") != payload.get("cid"):
900
+ return _token_error("invalid_grant", "Code was issued to a different client")
901
+ if args.get("redirect_uri") != payload.get("ruri"):
902
+ return _token_error("invalid_grant", "redirect_uri does not match")
903
+ verifier = args.get("code_verifier", "")
904
+ # https://datatracker.ietf.org/doc/html/rfc7636#section-4.6
905
+ if not verifier or _b64(hashlib.sha256(verifier.encode()).digest()) != payload.get("cc"):
906
+ return _token_error("invalid_grant", "PKCE verification failed")
907
+ resource = args.get("resource")
908
+ if resource and resource != self._resource:
909
+ return _token_error("invalid_target", "Unknown resource")
910
+
911
+ subject = payload.get("sub")
912
+ scope = payload.get("scope")
913
+ return self._issue_grant(
914
+ subject=subject if isinstance(subject, str) else None,
915
+ grant_scope=scope if isinstance(scope, str) else "",
916
+ access_scope=scope if isinstance(scope, str) else "",
917
+ family=secrets.token_urlsafe(16),
918
+ generation=0,
919
+ family_exp=time.time() + (self._refresh_ttl or 0),
920
+ )
921
+
922
+ def _grant_refresh_token(self, args: Mapping[str, str]) -> web.Response:
923
+ # Refresh token rotation for public clients
924
+ # (https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-4.3.1).
925
+ payload = self._signer.verify(args.get("refresh_token", ""))
926
+ if payload is None or payload.get("t") != "refresh":
927
+ return _token_error("invalid_grant", "Invalid refresh token")
928
+ expiry = payload.get("exp")
929
+ family = payload.get("fam")
930
+ generation = payload.get("gen")
931
+ grant_scope = payload.get("scope")
932
+ if (
933
+ not isinstance(expiry, (int, float))
934
+ or expiry <= time.time()
935
+ or not isinstance(family, str)
936
+ or not isinstance(generation, int)
937
+ or not isinstance(grant_scope, str)
938
+ or payload.get("aud") != self._resource
939
+ ):
940
+ return _token_error("invalid_grant", "Expired refresh token")
941
+ resource = args.get("resource")
942
+ if resource and resource != self._resource:
943
+ return _token_error("invalid_target", "Unknown resource")
944
+
945
+ self._prune_families()
946
+ current = self._refresh_families.get(family)
947
+ # An unknown family (process restart) is accepted: the signature and
948
+ # expiry still gate it. A known family only accepts its latest token.
949
+ if current is not None and current[0] != generation:
950
+ # A rotated-out token was presented: assume the grant is
951
+ # compromised and revoke the whole family.
952
+ self._refresh_families[family] = (_REVOKED, current[1])
953
+ return _token_error("invalid_grant", "Refresh token reuse; grant revoked")
954
+
955
+ requested = args.get("scope", "").split()
956
+ if not set(requested) <= set(grant_scope.split()):
957
+ # https://datatracker.ietf.org/doc/html/rfc6749#section-6
958
+ return _token_error("invalid_scope", "Scope exceeds the original grant")
959
+ subject = payload.get("sub")
960
+ return self._issue_grant(
961
+ subject=subject if isinstance(subject, str) else None,
962
+ grant_scope=grant_scope,
963
+ access_scope=" ".join(requested) if requested else grant_scope,
964
+ family=family,
965
+ generation=generation + 1,
966
+ family_exp=expiry,
967
+ )
968
+
969
+ def _prune_families(self) -> None:
970
+ now = time.time()
971
+ while self._family_heap and self._family_heap[0][0] <= now:
972
+ _, family = heapq.heappop(self._family_heap)
973
+ del self._refresh_families[family]
974
+
975
+ def _issue_grant(
976
+ self,
977
+ *,
978
+ subject: str | None,
979
+ grant_scope: str,
980
+ access_scope: str,
981
+ family: str,
982
+ generation: int,
983
+ family_exp: float,
984
+ ) -> web.Response:
985
+ now = time.time()
986
+ token = self._signer.sign(
987
+ {
988
+ "t": "token",
989
+ "sub": subject,
990
+ "aud": self._resource,
991
+ "scope": access_scope,
992
+ "iat": now,
993
+ "exp": now + self._token_ttl,
994
+ }
995
+ )
996
+ # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-4.1.3
997
+ grant: dict[str, object] = {
998
+ "access_token": token,
999
+ "token_type": "Bearer",
1000
+ "expires_in": int(self._token_ttl),
1001
+ "scope": access_scope,
1002
+ }
1003
+ if self._refresh_ttl is not None:
1004
+ grant["refresh_token"] = self._signer.sign(
1005
+ {
1006
+ "t": "refresh",
1007
+ "fam": family,
1008
+ "gen": generation,
1009
+ "sub": subject,
1010
+ "aud": self._resource,
1011
+ "scope": grant_scope,
1012
+ "exp": family_exp,
1013
+ }
1014
+ )
1015
+ if family not in self._refresh_families:
1016
+ heapq.heappush(self._family_heap, (family_exp, family))
1017
+ self._refresh_families[family] = (generation, family_exp)
1018
+ return web.json_response(grant, headers={"Cache-Control": "no-store"})
1019
+
1020
+ async def _handle_as_metadata(self, request: web.Request) -> web.Response:
1021
+ return web.json_response(self._as_metadata)