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.
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/PKG-INFO +4 -4
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/README.md +3 -3
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/pyproject.toml +1 -1
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/pyproject.toml.orig +1 -1
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/__init__.py +7 -0
- aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/auth.py +274 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/console/__init__.py +21 -1
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/console/console.css +9 -2
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/console/console.js +420 -18
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/console/index.html +36 -1
- aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/console/oauth-callback.html +14 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/endpoint.py +64 -36
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/http_sse.py +44 -9
- aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/metadata.py +44 -0
- aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/oauth/__init__.py +15 -0
- aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/oauth/facade.py +551 -0
- aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/oauth/github.py +45 -0
- aiohttp_tiny_mcp-0.4.1/src/aiohttp_tiny_mcp/oauth/upstream.py +117 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/registry.py +28 -4
- aiohttp_tiny_mcp-0.3.7/src/aiohttp_tiny_mcp/auth.py +0 -129
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/LICENSE +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/adapter.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/client.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/client_base.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/core.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/exchange.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/extensions.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/hub.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/models.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/postgres.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/py.typed +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/redis.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/request_state.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/schema.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/sessions.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/skills.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/specs.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/stdio.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
- {aiohttp_tiny_mcp-0.3.7 → aiohttp_tiny_mcp-0.4.1}/src/aiohttp_tiny_mcp/tasks.py +0 -0
- {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
|
+
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.
|
|
177
|
-
this package
|
|
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):
|
|
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.
|
|
135
|
-
this package
|
|
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):
|
|
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
|
|
|
@@ -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,
|
|
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); }
|