aiohttp-tiny-mcp 0.3.7__tar.gz → 0.4.1__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.
Files changed (53) hide show
  1. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/PKG-INFO +4 -4
  2. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/README.md +3 -3
  3. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/pyproject.toml +1 -1
  4. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/pyproject.toml.orig +1 -1
  5. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/__init__.py +7 -0
  6. aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/auth.py +274 -0
  7. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/console/__init__.py +21 -1
  8. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/console/console.css +9 -2
  9. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/console/console.js +420 -18
  10. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/console/index.html +36 -1
  11. aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/console/oauth-callback.html +14 -0
  12. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/endpoint.py +64 -36
  13. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/http_sse.py +44 -9
  14. aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/metadata.py +44 -0
  15. aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/oauth/__init__.py +15 -0
  16. aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/oauth/facade.py +551 -0
  17. aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/oauth/github.py +45 -0
  18. aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/oauth/upstream.py +117 -0
  19. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/registry.py +28 -4
  20. aiohttp_tiny_mcp-0.3.7/src/aiohttp_tiny_mcp/auth.py +0 -129
  21. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/LICENSE +0 -0
  22. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/adapter.py +0 -0
  23. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/client.py +0 -0
  24. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/client_base.py +0 -0
  25. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/core.py +0 -0
  26. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
  27. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/exchange.py +0 -0
  28. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/extensions.py +0 -0
  29. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/hub.py +0 -0
  30. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/models.py +0 -0
  31. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  32. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/postgres.py +0 -0
  33. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  34. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  35. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  36. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  37. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  38. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  39. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
  40. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/py.typed +0 -0
  41. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/redis.py +0 -0
  42. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  43. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/schema.py +0 -0
  44. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/sessions.py +0 -0
  45. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/skills.py +0 -0
  46. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/specs.py +0 -0
  47. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
  48. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/sse.py +0 -0
  49. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/stdio.py +0 -0
  50. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  51. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
  52. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/tasks.py +0 -0
  53. {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohttp-tiny-mcp
3
- Version: 0.3.7
3
+ Version: 0.4.1
4
4
  Summary: Remote MCP server and client library for aiohttp
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -173,8 +173,8 @@ Console("/mcp", title="Demo").setup(app, "/console")
173
173
 
174
174
  Open `http://127.0.0.1:8080/console`. It speaks the protocol itself on any of
175
175
  the five revisions, builds a form from each tool's schema, answers the
176
- questions a handler asks, and shows every message either way. Three files from
177
- this package, no build step and no second process.
176
+ questions a handler asks, and shows every message either way. Static files from
177
+ this package need no build step or second process.
178
178
 
179
179
  An MCP host that supports Streamable HTTP can connect to
180
180
  `http://127.0.0.1:8080/mcp`. In an existing aiohttp service, use
@@ -235,7 +235,7 @@ Start with [the documentation overview](https://mosquito.github.io/aiohttp-tiny-
235
235
  2. [Quickstart](https://mosquito.github.io/aiohttp-tiny-mcp/quickstart.html): a complete server, launch command, and client call.
236
236
  3. [How the server fits together](https://mosquito.github.io/aiohttp-tiny-mcp/pieces.html): a conversation across two workers and each object's lifetime.
237
237
  4. [Using Exchange](https://mosquito.github.io/aiohttp-tiny-mcp/guide/exchange.html): request context, progress, questions, and state.
238
- 5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): bearer-token verification and OAuth resource metadata.
238
+ 5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): Basic, Bearer, GitHub sign-in, custom policies, and permissions.
239
239
  6. [Stores and hubs](https://mosquito.github.io/aiohttp-tiny-mcp/deployment/stores.html): shared backend contracts and deployment requirements.
240
240
  7. [Extensions and skills](https://mosquito.github.io/aiohttp-tiny-mcp/guide/extensions.html): custom methods, skill directories, and resources for older clients.
241
241
 
@@ -131,8 +131,8 @@ Console("/mcp", title="Demo").setup(app, "/console")
131
131
 
132
132
  Open `http://127.0.0.1:8080/console`. It speaks the protocol itself on any of
133
133
  the five revisions, builds a form from each tool's schema, answers the
134
- questions a handler asks, and shows every message either way. Three files from
135
- this package, no build step and no second process.
134
+ questions a handler asks, and shows every message either way. Static files from
135
+ this package need no build step or second process.
136
136
 
137
137
  An MCP host that supports Streamable HTTP can connect to
138
138
  `http://127.0.0.1:8080/mcp`. In an existing aiohttp service, use
@@ -193,7 +193,7 @@ Start with [the documentation overview](https://mosquito.github.io/aiohttp-tiny-
193
193
  2. [Quickstart](https://mosquito.github.io/aiohttp-tiny-mcp/quickstart.html): a complete server, launch command, and client call.
194
194
  3. [How the server fits together](https://mosquito.github.io/aiohttp-tiny-mcp/pieces.html): a conversation across two workers and each object's lifetime.
195
195
  4. [Using Exchange](https://mosquito.github.io/aiohttp-tiny-mcp/guide/exchange.html): request context, progress, questions, and state.
196
- 5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): bearer-token verification and OAuth resource metadata.
196
+ 5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): Basic, Bearer, GitHub sign-in, custom policies, and permissions.
197
197
  6. [Stores and hubs](https://mosquito.github.io/aiohttp-tiny-mcp/deployment/stores.html): shared backend contracts and deployment requirements.
198
198
  7. [Extensions and skills](https://mosquito.github.io/aiohttp-tiny-mcp/guide/extensions.html): custom methods, skill directories, and resources for older clients.
199
199
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.7"
3
+ version = "0.4.1"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.7"
3
+ version = "0.4.1"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from .auth import Authentication, Authorization, BasicAuth, Principal, StaticBasicAuth, Unauthorized
5
6
  from .client import Client
6
7
  from .client_base import ClientError, Elicitor
7
8
  from .core import (
@@ -47,6 +48,9 @@ __all__ = [
47
48
  "Answer",
48
49
  "AnswerAction",
49
50
  "AudioContent",
51
+ "Authentication",
52
+ "Authorization",
53
+ "BasicAuth",
50
54
  "BlobResourceContents",
51
55
  "CallToolResult",
52
56
  "Client",
@@ -68,6 +72,7 @@ __all__ = [
68
72
  "NeedInput",
69
73
  "namespace",
70
74
  "PromptMessage",
75
+ "Principal",
71
76
  "Registry",
72
77
  "SseEndpoint",
73
78
  "RequestStates",
@@ -75,8 +80,10 @@ __all__ = [
75
80
  "SessionRecord",
76
81
  "SessionStore",
77
82
  "StdioClient",
83
+ "StaticBasicAuth",
78
84
  "TextContent",
79
85
  "TextResourceContents",
86
+ "Unauthorized",
80
87
  "elicit",
81
88
  "elicit_accept",
82
89
  "elicit_cancel",
@@ -0,0 +1,274 @@
1
+ """Extensible HTTP authentication, Basic credentials, and OAuth Bearer tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from abc import ABC, abstractmethod
7
+ from base64 import b64decode
8
+ from binascii import Error as Base64Error
9
+ from collections.abc import Iterable, Mapping, Sequence
10
+ from dataclasses import dataclass, field
11
+ from secrets import compare_digest
12
+ from typing import Any, Protocol, runtime_checkable
13
+ from urllib.parse import urlsplit
14
+
15
+ from aiohttp import web
16
+
17
+ WELL_KNOWN = "/.well-known/oauth-protected-resource"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Principal:
22
+ """Identity and claims returned by a token verifier."""
23
+
24
+ subject: str | None = None
25
+ client_id: str = ""
26
+ issuer: str | None = None
27
+ scopes: frozenset[str] = frozenset()
28
+ expires_at: float | None = None
29
+ claims: Mapping[str, Any] = field(default_factory=dict)
30
+ namespace: str | None = None
31
+
32
+ def holds(self, wanted: Iterable[str]) -> frozenset[str]:
33
+ """Return required scopes absent from this principal."""
34
+ return frozenset(wanted) - self.scopes
35
+
36
+ @property
37
+ def expired(self) -> bool:
38
+ return self.expires_at is not None and self.expires_at < time.time()
39
+
40
+ @property
41
+ def identity(self) -> str:
42
+ """Return an issuer-qualified subject or client identifier."""
43
+ if self.subject:
44
+ return f"{self.issuer or ''}|{self.subject}"
45
+ return f"{self.issuer or ''}|client:{self.client_id}"
46
+
47
+
48
+ @runtime_checkable
49
+ class TokenVerifier(Protocol):
50
+ """Verify a bearer token for this resource."""
51
+
52
+ async def verify(self, token: str) -> Principal | None: ...
53
+
54
+
55
+ class Unauthorized(Exception):
56
+ """Authentication or authorization failed."""
57
+
58
+ def __init__(self, error: str, description: str, status: int = 401) -> None:
59
+ super().__init__(description)
60
+ self.error = error
61
+ self.description = description
62
+ self.status = status
63
+
64
+
65
+ class Authentication(ABC):
66
+ """Subclass this policy and pass an instance to `Registry(auth=...)`.
67
+
68
+ Implement `authenticate` and `challenge`. Return a verified `Principal`
69
+ or `None` for invalid credentials. Raise `Unauthorized` for a specific
70
+ refusal. HTTP endpoints enforce expiry, required scopes, session ownership,
71
+ and namespaces after authentication. Policies publish no metadata by default.
72
+ """
73
+
74
+ bind_sessions: bool = True
75
+ namespace_from_token: bool = True
76
+ required_scopes: Sequence[str] = ()
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ bind_sessions: bool = True,
82
+ namespace_from_token: bool = True,
83
+ required_scopes: Sequence[str] = (),
84
+ ) -> None:
85
+ self.bind_sessions = bind_sessions
86
+ self.namespace_from_token = namespace_from_token
87
+ self.required_scopes = tuple(required_scopes)
88
+
89
+ @abstractmethod
90
+ async def authenticate(self, request: web.Request) -> Principal | None:
91
+ """Verify this HTTP request. The full request is available to the policy."""
92
+
93
+ @abstractmethod
94
+ def challenge(self, refusal: Unauthorized) -> str:
95
+ """Return the WWW-Authenticate value for this policy."""
96
+
97
+ def check(self, principal: Principal | None) -> Principal:
98
+ """Reject invalid identities and missing server-wide scopes."""
99
+ if principal is None or principal.expired:
100
+ raise Unauthorized("invalid_credentials", "valid credentials required")
101
+ missing = principal.holds(self.required_scopes)
102
+ if missing:
103
+ raise Unauthorized(
104
+ "insufficient_scope",
105
+ f"missing scope: {', '.join(sorted(missing))}",
106
+ status=403,
107
+ )
108
+ return principal
109
+
110
+ @property
111
+ def metadata_path(self) -> str | None:
112
+ """Return an optional metadata route, relative to the site root."""
113
+ return None
114
+
115
+ def metadata(self) -> dict[str, Any]:
116
+ """Return metadata when the policy declares a metadata path."""
117
+ return {}
118
+
119
+
120
+ class BasicAuth(Authentication):
121
+ """Subclass `verify` to authenticate Basic credentials against your user store.
122
+
123
+ Credentials use standard Base64 and UTF-8. Query credentials are not read.
124
+ Use `StaticBasicAuth` for one configured username and password.
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ *,
130
+ realm: str = "mcp",
131
+ bind_sessions: bool = True,
132
+ namespace_from_token: bool = True,
133
+ required_scopes: Sequence[str] = (),
134
+ ) -> None:
135
+ super().__init__(
136
+ bind_sessions=bind_sessions,
137
+ namespace_from_token=namespace_from_token,
138
+ required_scopes=required_scopes,
139
+ )
140
+ if not realm.isascii() or any(ord(char) < 32 or ord(char) == 127 for char in realm):
141
+ raise ValueError("realm must contain only printable ASCII characters")
142
+ self.realm = realm
143
+
144
+ @abstractmethod
145
+ async def verify(self, username: str, password: str) -> Principal | None:
146
+ """Return the verified identity, or None when credentials do not match."""
147
+
148
+ async def authenticate(self, request: web.Request) -> Principal | None:
149
+ header = request.headers.get("Authorization", "")
150
+ parts = header.split()
151
+ if len(parts) != 2 or parts[0].lower() != "basic":
152
+ return None
153
+ try:
154
+ decoded = b64decode(parts[1], validate=True).decode("utf-8")
155
+ except (ValueError, Base64Error, UnicodeDecodeError):
156
+ return None
157
+ username, separator, password = decoded.partition(":")
158
+ if not separator or any(ord(char) < 32 or ord(char) == 127 for char in decoded):
159
+ return None
160
+ return await self.verify(username, password)
161
+
162
+ def challenge(self, refusal: Unauthorized) -> str:
163
+ realm = self.realm.replace("\\", "\\\\").replace('"', '\\"')
164
+ return f'Basic realm="{realm}", charset="UTF-8"'
165
+
166
+
167
+ class StaticBasicAuth(BasicAuth):
168
+ """Basic authentication for one configured account. Use HTTPS in production."""
169
+
170
+ def __init__(
171
+ self,
172
+ username: str,
173
+ password: str,
174
+ *,
175
+ realm: str = "mcp",
176
+ scopes: Iterable[str] = (),
177
+ bind_sessions: bool = True,
178
+ namespace_from_token: bool = True,
179
+ required_scopes: Sequence[str] = (),
180
+ ) -> None:
181
+ super().__init__(
182
+ realm=realm,
183
+ bind_sessions=bind_sessions,
184
+ namespace_from_token=namespace_from_token,
185
+ required_scopes=required_scopes,
186
+ )
187
+ if ":" in username or any(
188
+ ord(char) < 32 or ord(char) == 127 for char in username + password
189
+ ):
190
+ raise ValueError("Basic credentials cannot contain controls or a colon in the username")
191
+ if not username or not password:
192
+ raise ValueError("username and password must be non-empty")
193
+ self._username = username.encode("utf-8")
194
+ self._password = password.encode("utf-8")
195
+ self._principal = Principal(subject=username, scopes=frozenset(scopes))
196
+
197
+ async def verify(self, username: str, password: str) -> Principal | None:
198
+ matched = compare_digest(username.encode("utf-8"), self._username)
199
+ matched &= compare_digest(password.encode("utf-8"), self._password)
200
+ return self._principal if matched else None
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class Authorization(Authentication):
205
+ """Configuration for an OAuth protected resource server."""
206
+
207
+ verifier: TokenVerifier
208
+ resource: str
209
+ authorization_servers: Sequence[str] = ()
210
+ scopes_supported: Sequence[str] | None = None
211
+ required_scopes: Sequence[str] = ()
212
+ resource_name: str | None = None
213
+ documentation: str | None = None
214
+ bind_sessions: bool = True
215
+ namespace_from_token: bool = True
216
+
217
+ @property
218
+ def metadata_path(self) -> str:
219
+ """Return this resource's RFC 9728 metadata path."""
220
+ path = urlsplit(self.resource).path.rstrip("/")
221
+ return f"{WELL_KNOWN}{path}"
222
+
223
+ def metadata(self) -> dict[str, Any]:
224
+ """Build this resource's RFC 9728 metadata document."""
225
+ found: dict[str, Any] = {"resource": self.resource}
226
+ if self.authorization_servers:
227
+ found["authorization_servers"] = list(self.authorization_servers)
228
+ if self.scopes_supported is not None:
229
+ found["scopes_supported"] = list(self.scopes_supported)
230
+ if self.resource_name:
231
+ found["resource_name"] = self.resource_name
232
+ if self.documentation:
233
+ found["resource_documentation"] = self.documentation
234
+ found["bearer_methods_supported"] = ["header"]
235
+ return found
236
+
237
+ def challenge(self, refusal: Unauthorized) -> str:
238
+ """Build a Bearer challenge with a metadata URL."""
239
+ parts = [
240
+ f'error="{refusal.error}"',
241
+ f'error_description="{refusal.description}"',
242
+ f'resource_metadata="{self.metadata_url}"',
243
+ ]
244
+ return "Bearer " + ", ".join(parts)
245
+
246
+ @property
247
+ def metadata_url(self) -> str:
248
+ split = urlsplit(self.resource)
249
+ return f"{split.scheme}://{split.netloc}{self.metadata_path}"
250
+
251
+ async def principal(self, authorization: str | None) -> Principal:
252
+ """Verify an Authorization header and return its principal."""
253
+ if not authorization or not authorization.lower().startswith("bearer "):
254
+ raise Unauthorized("invalid_request", "authorization required")
255
+ found = await self.verifier.verify(authorization[len("bearer ") :].strip())
256
+ if found is None or found.expired:
257
+ raise Unauthorized("invalid_token", "the token is not valid for this resource")
258
+ return self.check(found)
259
+
260
+ async def authenticate(self, request: web.Request) -> Principal:
261
+ """Verify the request's Bearer header using the configured token verifier."""
262
+ return await self.principal(request.headers.get("Authorization"))
263
+
264
+
265
+ __all__ = [
266
+ "WELL_KNOWN",
267
+ "Authentication",
268
+ "Authorization",
269
+ "BasicAuth",
270
+ "Principal",
271
+ "StaticBasicAuth",
272
+ "TokenVerifier",
273
+ "Unauthorized",
274
+ ]
@@ -17,6 +17,7 @@ FILES = {
17
17
  "": ("index.html", "text/html"),
18
18
  "console.js": ("console.js", "application/javascript"),
19
19
  "console.css": ("console.css", "text/css"),
20
+ "oauth-callback": ("oauth-callback.html", "text/html"),
20
21
  }
21
22
 
22
23
 
@@ -31,12 +32,14 @@ class Console:
31
32
  description: str = "",
32
33
  template: str | Path | None = None,
33
34
  variables: Mapping[str, str] | None = None,
35
+ oauth_client_id: str | None = None,
34
36
  ) -> None:
35
37
  self.endpoint_path = endpoint_path
36
38
  self.title = title
37
39
  self.description = description
38
40
  self.template = Path(template) if template else HERE / "index.html"
39
41
  self.variables = dict(variables or {})
42
+ self.oauth_client_id = oauth_client_id
40
43
 
41
44
  def substitutions(self, base: str) -> dict[str, str]:
42
45
  return {
@@ -44,6 +47,7 @@ class Console:
44
47
  "BASE": base,
45
48
  "TITLE": self.title,
46
49
  "DESCRIPTION": self.description,
50
+ "OAUTH_CLIENT_ID": self.oauth_client_id or "",
47
51
  **self.variables,
48
52
  }
49
53
 
@@ -80,7 +84,7 @@ class Console:
80
84
  )
81
85
 
82
86
  def routes(self, path: str = "/console") -> list[web.RouteDef]:
83
- """The page, either way it is addressed, and its two files."""
87
+ """The page, its assets, and the OAuth callback page."""
84
88
  base = path.rstrip("/")
85
89
  log.debug("console at %s, for the endpoint at %s", base, self.endpoint_path)
86
90
  return [
@@ -89,6 +93,22 @@ class Console:
89
93
  web.get(f"{base}/{{file}}", self.handle),
90
94
  ]
91
95
 
96
+ @staticmethod
97
+ def is_public(request: web.Request) -> bool:
98
+ """Identify a mounted console asset for application authentication middleware.
99
+
100
+ Check the resolved handler, method, and file allowlist. No path prefix
101
+ is trusted, and an unmounted console grants no exemptions.
102
+ """
103
+ handler = request.match_info.handler
104
+ console = getattr(handler, "__self__", None)
105
+ return (
106
+ request.method in {"GET", "HEAD"}
107
+ and isinstance(console, Console)
108
+ and handler == console.handle
109
+ and request.match_info.get("file", "") in FILES
110
+ )
111
+
92
112
  def setup(self, app: web.Application, path: str = "/console") -> web.Application:
93
113
  log.debug("adding the console routes to %r", app)
94
114
  app.add_routes(self.routes(path))
@@ -75,7 +75,7 @@ body {
75
75
  .field.grow > input { flex: 1 1 auto; min-width: 8em; }
76
76
  .field > span { color: var(--ink-soft); font-size: 12px; }
77
77
 
78
- input[type="text"], input[type="number"], select, textarea {
78
+ input[type="text"], input[type="password"], input[type="number"], select, textarea {
79
79
  font: inherit;
80
80
  color: inherit;
81
81
  background: var(--page);
@@ -84,7 +84,7 @@ input[type="text"], input[type="number"], select, textarea {
84
84
  padding: 5px 8px;
85
85
  }
86
86
 
87
- input[type="text"]:focus, select:focus, textarea:focus, input[type="number"]:focus {
87
+ input[type="text"]:focus, input[type="password"]:focus, select:focus, textarea:focus, input[type="number"]:focus {
88
88
  outline: 2px solid var(--accent);
89
89
  outline-offset: -1px;
90
90
  }
@@ -532,3 +532,10 @@ dialog.question #question-text p { margin: 0 0 8px; }
532
532
  dialog.question #question-text :last-child { margin-bottom: 0; }
533
533
  dialog.question menu { display: flex; justify-content: flex-end; gap: 8px; margin: 18px 0 0; padding: 0; }
534
534
  dialog.question .argument { margin-bottom: 10px; }
535
+
536
+ .auth-field { display: flex; flex-direction: column; gap: 4px; margin: 12px 0; }
537
+ .auth-field[hidden] { display: none; }
538
+ .auth-field > input, .auth-field > select { width: 100%; min-width: 0; }
539
+ .auth-endpoint { overflow-wrap: anywhere; font-family: var(--mono); }
540
+ .auth-help { color: var(--ink-soft); font-size: 12px; }
541
+ .auth-error { color: var(--bad); }