zenture 1.0.1__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.
- zenture/__init__.py +9 -0
- zenture/_auth/__init__.py +1 -0
- zenture/_auth/browser.py +262 -0
- zenture/_auth/device.py +224 -0
- zenture/_auth/discovery.py +93 -0
- zenture/_auth/errors.py +59 -0
- zenture/_auth/http.py +59 -0
- zenture/_auth/http_auth.py +75 -0
- zenture/_auth/lock.py +106 -0
- zenture/_auth/login.py +292 -0
- zenture/_auth/model.py +81 -0
- zenture/_auth/session.py +249 -0
- zenture/_auth/store.py +236 -0
- zenture/_auth/stored.py +114 -0
- zenture/_auth/tokens.py +186 -0
- zenture/_contract/__init__.py +138 -0
- zenture/_contract/headers.py +17 -0
- zenture/_contract/models.py +1254 -0
- zenture/_contract/run_references.py +43 -0
- zenture/_mcp/__init__.py +6 -0
- zenture/_mcp/catalog.py +136 -0
- zenture/_mcp/client.py +1153 -0
- zenture/_mcp/contracts.py +253 -0
- zenture/_mcp/transport.py +230 -0
- zenture/_mcp/wait.py +134 -0
- zenture/_request_validation.py +46 -0
- zenture/_resources/__init__.py +43 -0
- zenture/_resources/_utils.py +110 -0
- zenture/_resources/account.py +94 -0
- zenture/_resources/async_runs.py +782 -0
- zenture/_resources/chat.py +521 -0
- zenture/_resources/evaluations.py +368 -0
- zenture/_resources/helloworld.py +32 -0
- zenture/_resources/input_wizard.py +136 -0
- zenture/_resources/models.py +47 -0
- zenture/_resources/operations.py +141 -0
- zenture/_resources/runs.py +1074 -0
- zenture/_transport/__init__.py +8 -0
- zenture/_transport/async_.py +338 -0
- zenture/_transport/base.py +143 -0
- zenture/_transport/sync.py +337 -0
- zenture/_version.py +5 -0
- zenture/async_client.py +142 -0
- zenture/auth/__init__.py +58 -0
- zenture/cli.py +294 -0
- zenture/client.py +142 -0
- zenture/config.py +184 -0
- zenture/errors.py +266 -0
- zenture/idempotency.py +53 -0
- zenture/mcp/__init__.py +13 -0
- zenture/models.py +49 -0
- zenture/polling.py +108 -0
- zenture/py.typed +1 -0
- zenture/rate_limits.py +53 -0
- zenture/redaction.py +38 -0
- zenture/retries.py +57 -0
- zenture/testing/__init__.py +9 -0
- zenture/testing/product_runs.py +158 -0
- zenture/types.py +75 -0
- zenture/validation_issues.py +168 -0
- zenture/validation_projection.py +168 -0
- zenture-1.0.1.dist-info/METADATA +804 -0
- zenture-1.0.1.dist-info/RECORD +67 -0
- zenture-1.0.1.dist-info/WHEEL +4 -0
- zenture-1.0.1.dist-info/entry_points.txt +2 -0
- zenture-1.0.1.dist-info/licenses/LICENSE +201 -0
- zenture-1.0.1.dist-info/licenses/NOTICE +7 -0
zenture/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Official Python SDK package for the zenture Public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from zenture._version import __version__
|
|
6
|
+
from zenture.async_client import AsyncZentureClient
|
|
7
|
+
from zenture.client import ZentureClient
|
|
8
|
+
|
|
9
|
+
__all__ = ("AsyncZentureClient", "ZentureClient", "__version__")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal native authorization core."""
|
zenture/_auth/browser.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""RFC 8252 loopback browser authorization (Authorization Code + PKCE S256)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import secrets
|
|
10
|
+
import select
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
from urllib.parse import parse_qs, quote, urlsplit
|
|
18
|
+
|
|
19
|
+
from zenture._auth.errors import AuthUnavailable, LoginCancelled
|
|
20
|
+
from zenture._auth.model import CLIENT_ID, SCOPE
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import threading
|
|
24
|
+
|
|
25
|
+
from zenture._auth.discovery import Discovery
|
|
26
|
+
|
|
27
|
+
CALLBACK_PATH = "/oauth/callback"
|
|
28
|
+
LOGIN_TIMEOUT_SECONDS = 600.0
|
|
29
|
+
_MAX_REQUEST_BYTES = 16 * 1024
|
|
30
|
+
_CONNECTION_READ_SECONDS = 0.5
|
|
31
|
+
_REQUEST_READ_TOTAL_SECONDS = 2.0
|
|
32
|
+
_SLICE_SECONDS = 0.2
|
|
33
|
+
_DRAIN_SECONDS = 0.25
|
|
34
|
+
_MAX_DRAIN_BYTES = 64 * 1024
|
|
35
|
+
_PAGE = (
|
|
36
|
+
b"<!doctype html><html lang=en><meta charset=utf-8><title>zenture</title>"
|
|
37
|
+
b"<body><p>%s</p></body></html>"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
BrowserOpener = Callable[[str], object]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
44
|
+
class CallbackResult:
|
|
45
|
+
"""The validated authorization response; ``code`` is a bearer-like secret."""
|
|
46
|
+
|
|
47
|
+
code: str
|
|
48
|
+
redirect_uri: str
|
|
49
|
+
verifier: str
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
return "CallbackResult()"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def bind_loopback() -> tuple[socket.socket, str]:
|
|
56
|
+
"""Listen on 127.0.0.1 (fallback ::1) on an ephemeral port; never a LAN address."""
|
|
57
|
+
|
|
58
|
+
last_error: OSError | None = None
|
|
59
|
+
for family, host, display in (
|
|
60
|
+
(socket.AF_INET, "127.0.0.1", "127.0.0.1"),
|
|
61
|
+
(socket.AF_INET6, "::1", "[::1]"),
|
|
62
|
+
):
|
|
63
|
+
try:
|
|
64
|
+
listener = socket.socket(family, socket.SOCK_STREAM)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
last_error = exc
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
if sys.platform == "win32":
|
|
70
|
+
# Windows lets a second local process share a port unless it is claimed exclusively.
|
|
71
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
72
|
+
listener.bind((host, 0))
|
|
73
|
+
listener.listen(4)
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
listener.close()
|
|
76
|
+
last_error = exc
|
|
77
|
+
continue
|
|
78
|
+
port = listener.getsockname()[1]
|
|
79
|
+
return listener, f"http://{display}:{port}{CALLBACK_PATH}"
|
|
80
|
+
raise AuthUnavailable("loopback_unavailable", next_action="use_device_login") from last_error
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pkce_pair() -> tuple[str, str]:
|
|
84
|
+
verifier = secrets.token_urlsafe(32)
|
|
85
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
86
|
+
return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def authorization_url(
|
|
90
|
+
discovery: Discovery, *, redirect_uri: str, state: str, challenge: str
|
|
91
|
+
) -> str:
|
|
92
|
+
query = {
|
|
93
|
+
"response_type": "code",
|
|
94
|
+
"client_id": CLIENT_ID,
|
|
95
|
+
"redirect_uri": redirect_uri,
|
|
96
|
+
"scope": SCOPE,
|
|
97
|
+
"state": state,
|
|
98
|
+
"code_challenge": challenge,
|
|
99
|
+
"code_challenge_method": "S256",
|
|
100
|
+
"resource": discovery.resource,
|
|
101
|
+
}
|
|
102
|
+
encoded = "&".join(f"{key}={quote(value, safe='')}" for key, value in query.items())
|
|
103
|
+
return f"{discovery.authorization_endpoint}?{encoded}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_request(connection: socket.socket) -> tuple[str, str, dict[str, str]] | None:
|
|
107
|
+
deadline = time.monotonic() + _REQUEST_READ_TOTAL_SECONDS
|
|
108
|
+
received = bytearray()
|
|
109
|
+
try:
|
|
110
|
+
while b"\r\n\r\n" not in received:
|
|
111
|
+
remaining = deadline - time.monotonic()
|
|
112
|
+
if remaining <= 0:
|
|
113
|
+
return None
|
|
114
|
+
connection.settimeout(min(_CONNECTION_READ_SECONDS, remaining))
|
|
115
|
+
chunk = connection.recv(4096)
|
|
116
|
+
if not chunk:
|
|
117
|
+
return None
|
|
118
|
+
received.extend(chunk)
|
|
119
|
+
if len(received) > _MAX_REQUEST_BYTES:
|
|
120
|
+
return None
|
|
121
|
+
except OSError:
|
|
122
|
+
return None
|
|
123
|
+
head = bytes(received).split(b"\r\n\r\n", 1)[0].decode("latin-1")
|
|
124
|
+
lines = head.split("\r\n")
|
|
125
|
+
parts = lines[0].split(" ")
|
|
126
|
+
if len(parts) != 3:
|
|
127
|
+
return None
|
|
128
|
+
headers: dict[str, str] = {}
|
|
129
|
+
for line in lines[1:]:
|
|
130
|
+
name, separator, value = line.partition(":")
|
|
131
|
+
if separator:
|
|
132
|
+
headers[name.strip().lower()] = value.strip()
|
|
133
|
+
return parts[0], parts[1], headers
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _respond(connection: socket.socket, status: str, message: str) -> None:
|
|
137
|
+
body = _PAGE % message.encode("ascii")
|
|
138
|
+
head = (
|
|
139
|
+
f"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\n"
|
|
140
|
+
f"Content-Length: {len(body)}\r\nCache-Control: no-store\r\n"
|
|
141
|
+
"Referrer-Policy: no-referrer\r\nConnection: close\r\n\r\n"
|
|
142
|
+
).encode("ascii")
|
|
143
|
+
with contextlib.suppress(OSError):
|
|
144
|
+
connection.sendall(head + body)
|
|
145
|
+
_drain_before_close(connection)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _drain_before_close(connection: socket.socket) -> None:
|
|
149
|
+
"""Half-close writes and discard bounded unread request bytes before close."""
|
|
150
|
+
|
|
151
|
+
connection.shutdown(socket.SHUT_WR)
|
|
152
|
+
deadline = time.monotonic() + _DRAIN_SECONDS
|
|
153
|
+
remaining = _MAX_DRAIN_BYTES
|
|
154
|
+
while remaining > 0:
|
|
155
|
+
seconds_left = deadline - time.monotonic()
|
|
156
|
+
if seconds_left <= 0:
|
|
157
|
+
return
|
|
158
|
+
connection.settimeout(seconds_left)
|
|
159
|
+
try:
|
|
160
|
+
chunk = connection.recv(4096)
|
|
161
|
+
except TimeoutError:
|
|
162
|
+
return
|
|
163
|
+
if not chunk:
|
|
164
|
+
return
|
|
165
|
+
remaining -= len(chunk)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _single(query: dict[str, list[str]], key: str) -> str | None:
|
|
169
|
+
values = query.get(key)
|
|
170
|
+
if values is None or len(values) != 1 or not values[0]:
|
|
171
|
+
return None
|
|
172
|
+
return values[0]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _classify(
|
|
176
|
+
request: tuple[str, str, dict[str, str]] | None, *, host_header: str, state: str
|
|
177
|
+
) -> dict[str, list[str]] | None:
|
|
178
|
+
"""Return the query of an acceptable callback, else ``None`` (request is ignored)."""
|
|
179
|
+
|
|
180
|
+
if request is None:
|
|
181
|
+
return None
|
|
182
|
+
method, target, headers = request
|
|
183
|
+
if method != "GET" or headers.get("host") != host_header:
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
parsed = urlsplit(target)
|
|
187
|
+
if parsed.path != CALLBACK_PATH or parsed.fragment:
|
|
188
|
+
return None
|
|
189
|
+
query = parse_qs(parsed.query, keep_blank_values=True, max_num_fields=16)
|
|
190
|
+
except ValueError:
|
|
191
|
+
return None
|
|
192
|
+
received_state = _single(query, "state")
|
|
193
|
+
if (
|
|
194
|
+
received_state is None
|
|
195
|
+
or not received_state.isascii()
|
|
196
|
+
or not hmac.compare_digest(received_state, state)
|
|
197
|
+
):
|
|
198
|
+
return None
|
|
199
|
+
return query
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def wait_for_callback(
|
|
203
|
+
listener: socket.socket,
|
|
204
|
+
*,
|
|
205
|
+
redirect_uri: str,
|
|
206
|
+
state: str,
|
|
207
|
+
verifier: str,
|
|
208
|
+
issuer: str,
|
|
209
|
+
timeout: float = LOGIN_TIMEOUT_SECONDS,
|
|
210
|
+
cancel: threading.Event | None = None,
|
|
211
|
+
) -> CallbackResult:
|
|
212
|
+
"""Accept exactly one valid callback, then stop listening."""
|
|
213
|
+
|
|
214
|
+
host_header = urlsplit(redirect_uri).netloc
|
|
215
|
+
deadline = time.monotonic() + timeout
|
|
216
|
+
try:
|
|
217
|
+
while True:
|
|
218
|
+
if cancel is not None and cancel.is_set():
|
|
219
|
+
raise LoginCancelled
|
|
220
|
+
remaining = deadline - time.monotonic()
|
|
221
|
+
if remaining <= 0:
|
|
222
|
+
raise AuthUnavailable("login_timed_out", next_action="login")
|
|
223
|
+
ready, _, _ = select.select([listener], [], [], min(_SLICE_SECONDS, remaining))
|
|
224
|
+
if not ready:
|
|
225
|
+
continue
|
|
226
|
+
try:
|
|
227
|
+
connection, _ = listener.accept()
|
|
228
|
+
except OSError:
|
|
229
|
+
continue
|
|
230
|
+
with connection:
|
|
231
|
+
query = _classify(_read_request(connection), host_header=host_header, state=state)
|
|
232
|
+
if query is None:
|
|
233
|
+
_respond(connection, "400 Bad Request", "Invalid request.")
|
|
234
|
+
continue
|
|
235
|
+
return _finish(connection, query, issuer, redirect_uri, verifier)
|
|
236
|
+
finally:
|
|
237
|
+
listener.close()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _finish(
|
|
241
|
+
connection: socket.socket,
|
|
242
|
+
query: dict[str, list[str]],
|
|
243
|
+
issuer: str,
|
|
244
|
+
redirect_uri: str,
|
|
245
|
+
verifier: str,
|
|
246
|
+
) -> CallbackResult:
|
|
247
|
+
if _single(query, "iss") != issuer:
|
|
248
|
+
_respond(connection, "400 Bad Request", "Authorization response rejected.")
|
|
249
|
+
raise AuthUnavailable("authorization_response_invalid")
|
|
250
|
+
error = _single(query, "error")
|
|
251
|
+
if error is not None:
|
|
252
|
+
if error == "access_denied":
|
|
253
|
+
_respond(connection, "200 OK", "Authorization was cancelled. You can close this tab.")
|
|
254
|
+
raise LoginCancelled
|
|
255
|
+
_respond(connection, "200 OK", "Authorization failed. You can close this tab.")
|
|
256
|
+
raise AuthUnavailable("authorization_failed")
|
|
257
|
+
code = _single(query, "code")
|
|
258
|
+
if code is None:
|
|
259
|
+
_respond(connection, "400 Bad Request", "Authorization response rejected.")
|
|
260
|
+
raise AuthUnavailable("authorization_response_invalid")
|
|
261
|
+
_respond(connection, "200 OK", "Authorization received. Return to your terminal.")
|
|
262
|
+
return CallbackResult(code=code, redirect_uri=redirect_uri, verifier=verifier)
|
zenture/_auth/device.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""RFC 8628 device authorization: present a code, poll the token endpoint, verify tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from zenture._auth.errors import AuthorizationRequired, AuthUnavailable, LoginCancelled
|
|
15
|
+
from zenture._auth.http import PRE_SEND_ERRORS, JsonResponse, request_json
|
|
16
|
+
from zenture._auth.model import CLIENT_ID, SCOPE, is_secure_origin, origin
|
|
17
|
+
from zenture._auth.tokens import KeyCache, TokenRejected, TokenSet, parse_token_response
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import threading
|
|
21
|
+
|
|
22
|
+
from zenture._auth.discovery import Discovery
|
|
23
|
+
|
|
24
|
+
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
25
|
+
DEVICE_TIMEOUT_SECONDS = 600.0
|
|
26
|
+
_DEFAULT_INTERVAL = 5.0
|
|
27
|
+
_MAX_INTERVAL = 60.0
|
|
28
|
+
_SLOW_DOWN_STEP = 5.0
|
|
29
|
+
_USER_CODE = re.compile(r"^[A-Za-z0-9]{8}$")
|
|
30
|
+
_MAX_FIELD = 2048
|
|
31
|
+
_WAIT_SLICE = 1.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
35
|
+
class DevicePrompt:
|
|
36
|
+
"""What the user must see: the plain verification URI and the code as ``XXXX-XXXX``."""
|
|
37
|
+
|
|
38
|
+
verification_uri: str
|
|
39
|
+
user_code: str
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return "DevicePrompt()"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
Presenter = Callable[[DevicePrompt], None]
|
|
46
|
+
Clock = Callable[[], float]
|
|
47
|
+
Sleep = Callable[[float], None]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def terminal_presenter(prompt: DevicePrompt) -> None:
|
|
51
|
+
"""Show the prompt on the terminal only (never through logging)."""
|
|
52
|
+
|
|
53
|
+
print(
|
|
54
|
+
f"To authorize this device, open {prompt.verification_uri}\n"
|
|
55
|
+
f"and enter the code {prompt.user_code}",
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
flush=True,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
62
|
+
class _Authorization:
|
|
63
|
+
device_code: str
|
|
64
|
+
prompt: DevicePrompt
|
|
65
|
+
expires_in: float
|
|
66
|
+
interval: float
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return "_Authorization()"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _invalid() -> AuthUnavailable:
|
|
73
|
+
return AuthUnavailable("device_authorization_invalid", next_action="retry_later")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_plain_text(value: str) -> bool:
|
|
77
|
+
"""Printable and free of whitespace: nothing that could rewrite the terminal."""
|
|
78
|
+
|
|
79
|
+
return value.isprintable() and not any(ch.isspace() for ch in value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _positive(value: object) -> float | None:
|
|
83
|
+
if isinstance(value, bool) or not isinstance(value, int | float) or value <= 0:
|
|
84
|
+
return None
|
|
85
|
+
return float(value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _start(client: httpx.Client, discovery: Discovery) -> _Authorization:
|
|
89
|
+
endpoint = discovery.device_authorization_endpoint
|
|
90
|
+
if endpoint is None:
|
|
91
|
+
raise AuthUnavailable("device_login_unsupported", next_action="check_endpoint")
|
|
92
|
+
form = {"client_id": CLIENT_ID, "scope": SCOPE, "resource": discovery.resource}
|
|
93
|
+
try:
|
|
94
|
+
response = request_json(client, "POST", endpoint, form=form)
|
|
95
|
+
except httpx.HTTPError as exc:
|
|
96
|
+
raise AuthUnavailable("device_authorization_unreachable") from exc
|
|
97
|
+
body = response.body
|
|
98
|
+
if response.status != 200 or body is None:
|
|
99
|
+
raise _invalid()
|
|
100
|
+
device_code = body.get("device_code")
|
|
101
|
+
user_code = body.get("user_code")
|
|
102
|
+
verification_uri = body.get("verification_uri")
|
|
103
|
+
expires_in = _positive(body.get("expires_in"))
|
|
104
|
+
interval = _positive(body.get("interval", _DEFAULT_INTERVAL))
|
|
105
|
+
if not (
|
|
106
|
+
isinstance(device_code, str)
|
|
107
|
+
and device_code
|
|
108
|
+
and len(device_code) <= _MAX_FIELD
|
|
109
|
+
and isinstance(user_code, str)
|
|
110
|
+
and _USER_CODE.fullmatch(user_code.replace("-", ""))
|
|
111
|
+
and isinstance(verification_uri, str)
|
|
112
|
+
and len(verification_uri) <= _MAX_FIELD
|
|
113
|
+
and _is_plain_text(verification_uri)
|
|
114
|
+
and is_secure_origin(verification_uri)
|
|
115
|
+
and origin(verification_uri) == origin(discovery.issuer)
|
|
116
|
+
and expires_in is not None
|
|
117
|
+
and interval is not None
|
|
118
|
+
):
|
|
119
|
+
raise _invalid()
|
|
120
|
+
code = user_code.replace("-", "").upper()
|
|
121
|
+
return _Authorization(
|
|
122
|
+
device_code=device_code,
|
|
123
|
+
prompt=DevicePrompt(verification_uri, f"{code[:4]}-{code[4:]}"),
|
|
124
|
+
expires_in=expires_in,
|
|
125
|
+
interval=min(interval, _MAX_INTERVAL),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _poll_error(response: JsonResponse) -> str | None:
|
|
130
|
+
if response.status == 400 and response.body is not None:
|
|
131
|
+
error = response.body.get("error")
|
|
132
|
+
if isinstance(error, str):
|
|
133
|
+
return error
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _unknown_outcome() -> AuthorizationRequired:
|
|
138
|
+
return AuthorizationRequired("device_outcome_unknown", next_action="login")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run_device_login(
|
|
142
|
+
client: httpx.Client,
|
|
143
|
+
discovery: Discovery,
|
|
144
|
+
keys: KeyCache,
|
|
145
|
+
*,
|
|
146
|
+
present: Presenter,
|
|
147
|
+
clock: Clock = time.monotonic,
|
|
148
|
+
sleep: Sleep = time.sleep,
|
|
149
|
+
cancel: threading.Event | None = None,
|
|
150
|
+
) -> TokenSet:
|
|
151
|
+
"""Authorize through the device grant; nothing persists before token verification."""
|
|
152
|
+
|
|
153
|
+
authorization = _start(client, discovery)
|
|
154
|
+
deadline = clock() + min(authorization.expires_in, DEVICE_TIMEOUT_SECONDS)
|
|
155
|
+
present(authorization.prompt)
|
|
156
|
+
interval = authorization.interval
|
|
157
|
+
form = {
|
|
158
|
+
"grant_type": DEVICE_GRANT,
|
|
159
|
+
"device_code": authorization.device_code,
|
|
160
|
+
"client_id": CLIENT_ID,
|
|
161
|
+
"resource": discovery.resource,
|
|
162
|
+
}
|
|
163
|
+
try:
|
|
164
|
+
while True:
|
|
165
|
+
_wait(interval, sleep, clock, deadline, cancel)
|
|
166
|
+
if cancel is not None and cancel.is_set():
|
|
167
|
+
raise LoginCancelled("device_login_interrupted")
|
|
168
|
+
if clock() >= deadline:
|
|
169
|
+
raise AuthorizationRequired("device_code_expired", next_action="login")
|
|
170
|
+
response = _poll(client, discovery, form)
|
|
171
|
+
if response.status == 200:
|
|
172
|
+
return _tokens(response, discovery, keys, client)
|
|
173
|
+
error = _poll_error(response)
|
|
174
|
+
if error == "authorization_pending":
|
|
175
|
+
continue
|
|
176
|
+
if error == "slow_down":
|
|
177
|
+
interval = min(interval + _SLOW_DOWN_STEP, _MAX_INTERVAL)
|
|
178
|
+
elif error == "access_denied":
|
|
179
|
+
raise LoginCancelled
|
|
180
|
+
elif error == "expired_token":
|
|
181
|
+
raise AuthorizationRequired("device_code_expired", next_action="login")
|
|
182
|
+
elif error == "invalid_grant":
|
|
183
|
+
raise AuthorizationRequired("device_code_invalid", next_action="login")
|
|
184
|
+
else:
|
|
185
|
+
# The code may already be consumed: never poll it again.
|
|
186
|
+
raise _unknown_outcome()
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
raise LoginCancelled("device_login_interrupted") from None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _wait(
|
|
192
|
+
interval: float,
|
|
193
|
+
sleep: Sleep,
|
|
194
|
+
clock: Clock,
|
|
195
|
+
deadline: float,
|
|
196
|
+
cancel: threading.Event | None,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Wait in short slices so a cancellation or the deadline stops polling within a second."""
|
|
199
|
+
|
|
200
|
+
remaining = interval
|
|
201
|
+
while remaining > 0:
|
|
202
|
+
step = min(_WAIT_SLICE, remaining)
|
|
203
|
+
sleep(step)
|
|
204
|
+
remaining -= step
|
|
205
|
+
if (cancel is not None and cancel.is_set()) or clock() >= deadline:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _poll(client: httpx.Client, discovery: Discovery, form: dict[str, str]) -> JsonResponse:
|
|
210
|
+
try:
|
|
211
|
+
return request_json(client, "POST", discovery.token_endpoint, form=form)
|
|
212
|
+
except PRE_SEND_ERRORS as exc:
|
|
213
|
+
raise AuthUnavailable("token_endpoint_unreachable") from exc
|
|
214
|
+
except httpx.HTTPError as exc:
|
|
215
|
+
raise _unknown_outcome() from exc
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _tokens(
|
|
219
|
+
response: JsonResponse, discovery: Discovery, keys: KeyCache, client: httpx.Client
|
|
220
|
+
) -> TokenSet:
|
|
221
|
+
try:
|
|
222
|
+
return parse_token_response(response, discovery=discovery, keys=keys, client=client)
|
|
223
|
+
except TokenRejected as exc:
|
|
224
|
+
raise _unknown_outcome() from exc
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Protected-resource and authorization-server discovery with exact-match checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from zenture._auth.errors import AuthUnavailable
|
|
11
|
+
from zenture._auth.http import request_json
|
|
12
|
+
from zenture._auth.model import Target, is_secure_origin, origin
|
|
13
|
+
|
|
14
|
+
_PRM_PATH = "/.well-known/oauth-protected-resource"
|
|
15
|
+
_AS_PATH = "/.well-known/oauth-authorization-server"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Discovery:
|
|
20
|
+
"""Verified endpoints of the issuer that protects one MCP resource."""
|
|
21
|
+
|
|
22
|
+
resource: str
|
|
23
|
+
issuer: str
|
|
24
|
+
authorization_endpoint: str
|
|
25
|
+
token_endpoint: str
|
|
26
|
+
jwks_uri: str
|
|
27
|
+
device_authorization_endpoint: str | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _contract_error() -> AuthUnavailable:
|
|
31
|
+
return AuthUnavailable("discovery_contract_violation", next_action="check_endpoint")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _endpoint(metadata: dict[str, object], key: str, issuer: str, *, required: bool) -> str | None:
|
|
35
|
+
value = metadata.get(key)
|
|
36
|
+
if value is None and not required:
|
|
37
|
+
return None
|
|
38
|
+
if not isinstance(value, str) or not is_secure_origin(value) or origin(value) != issuer:
|
|
39
|
+
raise _contract_error()
|
|
40
|
+
if urlsplit(value).query:
|
|
41
|
+
raise _contract_error()
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _issuer_metadata_url(issuer: str) -> str:
|
|
46
|
+
parsed = urlsplit(issuer)
|
|
47
|
+
path = parsed.path.rstrip("/")
|
|
48
|
+
return f"{origin(issuer)}{_AS_PATH}{path}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def discover(client: httpx.Client, target: Target) -> Discovery:
|
|
52
|
+
"""Discover and verify the issuer; raises before any browser can open."""
|
|
53
|
+
|
|
54
|
+
resource = target.resource
|
|
55
|
+
try:
|
|
56
|
+
prm = request_json(client, "GET", f"{resource}{_PRM_PATH}")
|
|
57
|
+
if prm.status != 200 or prm.body is None:
|
|
58
|
+
raise _contract_error()
|
|
59
|
+
if prm.body.get("resource") != resource:
|
|
60
|
+
raise _contract_error()
|
|
61
|
+
servers = prm.body.get("authorization_servers")
|
|
62
|
+
if not isinstance(servers, list) or not servers or not isinstance(servers[0], str):
|
|
63
|
+
raise _contract_error()
|
|
64
|
+
issuer = servers[0]
|
|
65
|
+
if not is_secure_origin(issuer) or urlsplit(issuer).query or issuer.endswith("/"):
|
|
66
|
+
raise _contract_error()
|
|
67
|
+
meta = request_json(client, "GET", _issuer_metadata_url(issuer))
|
|
68
|
+
except httpx.HTTPError as exc:
|
|
69
|
+
raise AuthUnavailable("discovery_unreachable") from exc
|
|
70
|
+
if meta.status != 200 or meta.body is None:
|
|
71
|
+
raise _contract_error()
|
|
72
|
+
metadata = meta.body
|
|
73
|
+
if metadata.get("issuer") != issuer:
|
|
74
|
+
raise _contract_error()
|
|
75
|
+
methods = metadata.get("code_challenge_methods_supported")
|
|
76
|
+
if not isinstance(methods, list) or "S256" not in methods:
|
|
77
|
+
raise _contract_error()
|
|
78
|
+
if metadata.get("authorization_response_iss_parameter_supported") is not True:
|
|
79
|
+
raise _contract_error()
|
|
80
|
+
authorization = _endpoint(metadata, "authorization_endpoint", origin(issuer), required=True)
|
|
81
|
+
token = _endpoint(metadata, "token_endpoint", origin(issuer), required=True)
|
|
82
|
+
jwks = _endpoint(metadata, "jwks_uri", origin(issuer), required=True)
|
|
83
|
+
device = _endpoint(metadata, "device_authorization_endpoint", origin(issuer), required=False)
|
|
84
|
+
if authorization is None or token is None or jwks is None:
|
|
85
|
+
raise _contract_error()
|
|
86
|
+
return Discovery(
|
|
87
|
+
resource=resource,
|
|
88
|
+
issuer=issuer,
|
|
89
|
+
authorization_endpoint=authorization,
|
|
90
|
+
token_endpoint=token,
|
|
91
|
+
jwks_uri=jwks,
|
|
92
|
+
device_authorization_endpoint=device,
|
|
93
|
+
)
|
zenture/_auth/errors.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Public error taxonomy of the native authorization core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from zenture.errors import ZentureError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthError(ZentureError):
|
|
9
|
+
"""Base class for native authorization failures.
|
|
10
|
+
|
|
11
|
+
Messages carry a fixed machine code only; they never contain tokens,
|
|
12
|
+
authorization codes, state values or server response bodies.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
default_code = "auth_error"
|
|
16
|
+
default_action = "check_request"
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, code: str | None = None, *, next_action: str | None = None, hint: str | None = None
|
|
20
|
+
) -> None:
|
|
21
|
+
self.code = code or self.default_code
|
|
22
|
+
self.next_action = next_action or self.default_action
|
|
23
|
+
message = f"{type(self).__name__}: {self.code}"
|
|
24
|
+
super().__init__(f"{message} ({hint})" if hint else message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthorizationRequired(AuthError):
|
|
28
|
+
"""The stored or presented authorization can no longer be used; log in again."""
|
|
29
|
+
|
|
30
|
+
default_code = "authorization_required"
|
|
31
|
+
default_action = "login"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthUnavailable(AuthError):
|
|
35
|
+
"""Authorization could not be completed now; the stored authorization is unchanged."""
|
|
36
|
+
|
|
37
|
+
default_code = "auth_unavailable"
|
|
38
|
+
default_action = "retry_later"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SecureStoreUnavailable(AuthError):
|
|
42
|
+
"""No protected native credential store is usable; session-only login is possible."""
|
|
43
|
+
|
|
44
|
+
default_code = "secure_store_unavailable"
|
|
45
|
+
default_action = "use_session_only"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LoginCancelled(AuthError):
|
|
49
|
+
"""The user declined or cancelled the browser authorization."""
|
|
50
|
+
|
|
51
|
+
default_code = "login_cancelled"
|
|
52
|
+
default_action = "none"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PermissionDenied(AuthError):
|
|
56
|
+
"""The server refused the request for the authorized client (HTTP 403); no scope uplift."""
|
|
57
|
+
|
|
58
|
+
default_code = "permission_denied"
|
|
59
|
+
default_action = "contact_owner"
|