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.
Files changed (67) hide show
  1. zenture/__init__.py +9 -0
  2. zenture/_auth/__init__.py +1 -0
  3. zenture/_auth/browser.py +262 -0
  4. zenture/_auth/device.py +224 -0
  5. zenture/_auth/discovery.py +93 -0
  6. zenture/_auth/errors.py +59 -0
  7. zenture/_auth/http.py +59 -0
  8. zenture/_auth/http_auth.py +75 -0
  9. zenture/_auth/lock.py +106 -0
  10. zenture/_auth/login.py +292 -0
  11. zenture/_auth/model.py +81 -0
  12. zenture/_auth/session.py +249 -0
  13. zenture/_auth/store.py +236 -0
  14. zenture/_auth/stored.py +114 -0
  15. zenture/_auth/tokens.py +186 -0
  16. zenture/_contract/__init__.py +138 -0
  17. zenture/_contract/headers.py +17 -0
  18. zenture/_contract/models.py +1254 -0
  19. zenture/_contract/run_references.py +43 -0
  20. zenture/_mcp/__init__.py +6 -0
  21. zenture/_mcp/catalog.py +136 -0
  22. zenture/_mcp/client.py +1153 -0
  23. zenture/_mcp/contracts.py +253 -0
  24. zenture/_mcp/transport.py +230 -0
  25. zenture/_mcp/wait.py +134 -0
  26. zenture/_request_validation.py +46 -0
  27. zenture/_resources/__init__.py +43 -0
  28. zenture/_resources/_utils.py +110 -0
  29. zenture/_resources/account.py +94 -0
  30. zenture/_resources/async_runs.py +782 -0
  31. zenture/_resources/chat.py +521 -0
  32. zenture/_resources/evaluations.py +368 -0
  33. zenture/_resources/helloworld.py +32 -0
  34. zenture/_resources/input_wizard.py +136 -0
  35. zenture/_resources/models.py +47 -0
  36. zenture/_resources/operations.py +141 -0
  37. zenture/_resources/runs.py +1074 -0
  38. zenture/_transport/__init__.py +8 -0
  39. zenture/_transport/async_.py +338 -0
  40. zenture/_transport/base.py +143 -0
  41. zenture/_transport/sync.py +337 -0
  42. zenture/_version.py +5 -0
  43. zenture/async_client.py +142 -0
  44. zenture/auth/__init__.py +58 -0
  45. zenture/cli.py +294 -0
  46. zenture/client.py +142 -0
  47. zenture/config.py +184 -0
  48. zenture/errors.py +266 -0
  49. zenture/idempotency.py +53 -0
  50. zenture/mcp/__init__.py +13 -0
  51. zenture/models.py +49 -0
  52. zenture/polling.py +108 -0
  53. zenture/py.typed +1 -0
  54. zenture/rate_limits.py +53 -0
  55. zenture/redaction.py +38 -0
  56. zenture/retries.py +57 -0
  57. zenture/testing/__init__.py +9 -0
  58. zenture/testing/product_runs.py +158 -0
  59. zenture/types.py +75 -0
  60. zenture/validation_issues.py +168 -0
  61. zenture/validation_projection.py +168 -0
  62. zenture-1.0.1.dist-info/METADATA +804 -0
  63. zenture-1.0.1.dist-info/RECORD +67 -0
  64. zenture-1.0.1.dist-info/WHEEL +4 -0
  65. zenture-1.0.1.dist-info/entry_points.txt +2 -0
  66. zenture-1.0.1.dist-info/licenses/LICENSE +201 -0
  67. 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."""
@@ -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)
@@ -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
+ )
@@ -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"