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.
- aiohttp_mcp_server/__init__.py +249 -0
- aiohttp_mcp_server/_auth.py +291 -0
- aiohttp_mcp_server/_auth_server.py +1021 -0
- aiohttp_mcp_server/_content.py +252 -0
- aiohttp_mcp_server/_elicitation.py +656 -0
- aiohttp_mcp_server/_handlers.py +132 -0
- aiohttp_mcp_server/_prompts.py +211 -0
- aiohttp_mcp_server/_protocol.py +592 -0
- aiohttp_mcp_server/_resources.py +442 -0
- aiohttp_mcp_server/_secure_form.py +223 -0
- aiohttp_mcp_server/_server.py +251 -0
- aiohttp_mcp_server/_tools.py +666 -0
- aiohttp_mcp_server/_transport.py +506 -0
- aiohttp_mcp_server/_validators.py +207 -0
- aiohttp_mcp_server/jwt.py +160 -0
- aiohttp_mcp_server/py.typed +0 -0
- aiohttp_mcp_server-0.1.0.dist-info/METADATA +102 -0
- aiohttp_mcp_server-0.1.0.dist-info/RECORD +21 -0
- aiohttp_mcp_server-0.1.0.dist-info/WHEEL +5 -0
- aiohttp_mcp_server-0.1.0.dist-info/licenses/LICENSE +201 -0
- aiohttp_mcp_server-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|