semiont 0.6.12.dev2__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 (66) hide show
  1. semiont/__init__.py +20 -0
  2. semiont/bus.py +236 -0
  3. semiont/cache.py +345 -0
  4. semiont/cached.py +152 -0
  5. semiont/channel.py +81 -0
  6. semiont/channels.py +1306 -0
  7. semiont/client.py +161 -0
  8. semiont/error_codes.py +232 -0
  9. semiont/errors.py +147 -0
  10. semiont/event_bus.py +108 -0
  11. semiont/events.py +101 -0
  12. semiont/http/__init__.py +24 -0
  13. semiont/http/agent.py +232 -0
  14. semiont/http/content.py +234 -0
  15. semiont/http/exchange.py +200 -0
  16. semiont/http/oauth.py +346 -0
  17. semiont/http/session.py +191 -0
  18. semiont/http/sse.py +71 -0
  19. semiont/http/stream.py +683 -0
  20. semiont/http/transport.py +326 -0
  21. semiont/identifiers.py +114 -0
  22. semiont/identity.py +59 -0
  23. semiont/media_types.py +69 -0
  24. semiont/media_types_table.py +89 -0
  25. semiont/model.py +33 -0
  26. semiont/namespaces/__init__.py +19 -0
  27. semiont/namespaces/auth.py +32 -0
  28. semiont/namespaces/beckon.py +47 -0
  29. semiont/namespaces/bind.py +38 -0
  30. semiont/namespaces/browse.py +338 -0
  31. semiont/namespaces/follow.py +238 -0
  32. semiont/namespaces/frame.py +35 -0
  33. semiont/namespaces/gather.py +82 -0
  34. semiont/namespaces/job.py +94 -0
  35. semiont/namespaces/links.py +66 -0
  36. semiont/namespaces/live.py +301 -0
  37. semiont/namespaces/mark.py +174 -0
  38. semiont/namespaces/match.py +67 -0
  39. semiont/namespaces/refresher.py +283 -0
  40. semiont/namespaces/system.py +24 -0
  41. semiont/namespaces/yield_.py +101 -0
  42. semiont/oauth_clients.py +27 -0
  43. semiont/operations.py +337 -0
  44. semiont/py.typed +0 -0
  45. semiont/refresh.py +188 -0
  46. semiont/resume.py +95 -0
  47. semiont/retry.py +164 -0
  48. semiont/running.py +110 -0
  49. semiont/session.py +493 -0
  50. semiont/sign_in.py +54 -0
  51. semiont/sign_in_store.py +283 -0
  52. semiont/storage.py +139 -0
  53. semiont/telemetry.py +116 -0
  54. semiont/telemetry_table.py +121 -0
  55. semiont/testing/__init__.py +62 -0
  56. semiont/testing/client.py +53 -0
  57. semiont/testing/content.py +124 -0
  58. semiont/testing/gateway.py +85 -0
  59. semiont/testing/transport.py +341 -0
  60. semiont/timing.py +187 -0
  61. semiont/transport.py +531 -0
  62. semiont/types.py +5902 -0
  63. semiont/watched.py +111 -0
  64. semiont-0.6.12.dev2.dist-info/METADATA +533 -0
  65. semiont-0.6.12.dev2.dist-info/RECORD +66 -0
  66. semiont-0.6.12.dev2.dist-info/WHEEL +4 -0
@@ -0,0 +1,200 @@
1
+ """One request of a gateway and its answer: how every request of this transport is made that is neither its stream nor an emit.
2
+
3
+ A request carries the token the transport has now, and the trace it is made
4
+ in. It is made a second time when that is worth it and safe
5
+ (`semiont.retry.TRANSPORT`): a `401` once a renewed token is in hand, on any
6
+ method; a status that promises recovery, or no answer at all, on a method that
7
+ cannot cause a second effect. A request whose answer has not begun by its
8
+ deadline fails as one that got no answer, and is not made again: the gateway
9
+ has the request, and may yet act on it. Every failure is reported on the
10
+ transport's error stream as it is raised to its caller.
11
+
12
+ Once the transport is closing nothing is sent: a request made then fails at
13
+ once, and one in flight, which the closing ends, is not made again.
14
+ """
15
+
16
+ import asyncio
17
+ from collections.abc import AsyncIterable, Awaitable, Callable, Coroutine
18
+ from typing import Final, final
19
+
20
+ import httpx
21
+ from pydantic import ValidationError
22
+
23
+ from semiont import telemetry
24
+ from semiont.errors import SemiontError, TransportError
25
+ from semiont.events import Broadcast
26
+ from semiont.model import WireModel
27
+ from semiont.retry import TRANSPORT, RetryFacts, retry_after_ms
28
+ from semiont.types import ErrorResponse
29
+ from semiont.watched import Watched
30
+
31
+ __all__ = ["Exchange", "TokenRefresher"]
32
+
33
+ type TokenRefresher = Callable[[], Awaitable[str | None]]
34
+ """How a transport renews its token when the gateway refuses the one it has:
35
+ renew it at its source, and say what it is now. Nothing when it could not be
36
+ renewed. Whoever gives a transport one also feeds the transport's token, so
37
+ the two never hold different ones. It is asked once per outage when the stream
38
+ is refused `401`, and once per request refused `401`."""
39
+
40
+ # The wait before a request that is safe to repeat is made a second time.
41
+ _RETRY_PAUSE_MS: Final = 300
42
+
43
+
44
+ @final
45
+ class Exchange:
46
+ """A transport's requests of its gateway, and the tasks it runs for its callers."""
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ base_url: str,
52
+ http: httpx.AsyncClient,
53
+ token: Watched[str | None],
54
+ refresher: TokenRefresher | None,
55
+ failures: Broadcast[SemiontError],
56
+ deadline_ms: int,
57
+ closing: asyncio.Event,
58
+ ) -> None:
59
+ self._base_url = base_url
60
+ self._http = http
61
+ self._token = token
62
+ self._refresher = refresher
63
+ self._failures = failures
64
+ self._deadline_ms = deadline_ms
65
+ self._closing = closing
66
+ self._tasks: set[asyncio.Task[object]] = set()
67
+
68
+ def failed(self, error: TransportError) -> TransportError:
69
+ """Report a failure on the error stream, and hand it back for its caller."""
70
+ self._failures.deliver(error)
71
+ return error
72
+
73
+ def _unanswered(self, method: str, path: str) -> TransportError:
74
+ """A request the gateway did not answer by its deadline."""
75
+ return TransportError.without_response(f"{method} {path} got no answer within {self._deadline_ms}ms")
76
+
77
+ def _headers(self, token: str | None) -> dict[str, str]:
78
+ """What every request carries: its token, when it sends one, and the trace it is made in."""
79
+ headers = telemetry.trace_headers()
80
+ if token:
81
+ headers["Authorization"] = f"Bearer {token}"
82
+ return headers
83
+
84
+ async def _renewed(self) -> str | None:
85
+ """The token the refresher gives in place of one the gateway refused, when there is a refresher and it gives one."""
86
+ if self._refresher is None:
87
+ return None
88
+ try:
89
+ return await self._refresher()
90
+ except SemiontError:
91
+ return None
92
+
93
+ def run[T](self, work: Coroutine[object, object, T]) -> asyncio.Task[T]:
94
+ """Run `work` as a task of this transport's: one it has seen the end of by the time it has closed."""
95
+ task = asyncio.ensure_future(work)
96
+ self._tasks.add(task)
97
+ task.add_done_callback(self._tasks.discard)
98
+ return task
99
+
100
+ async def ended(self) -> None:
101
+ """Wait for every task `run` began. Called once the HTTP client is closed, which ends each of them."""
102
+ await asyncio.gather(*self._tasks, return_exceptions=True)
103
+
104
+ async def begin(
105
+ self,
106
+ method: str,
107
+ path: str,
108
+ *,
109
+ authenticated: bool = True,
110
+ headers: dict[str, str] | None = None,
111
+ content: bytes | Callable[[], AsyncIterable[bytes]] | None = None,
112
+ at_length: bool = False,
113
+ ) -> httpx.Response:
114
+ """Make a request, and return its answer once it has begun: the response, its body not yet read.
115
+
116
+ The caller reads the body and closes the response. A refusal is read
117
+ here and raised. The answer is to begin within the transport's
118
+ deadline, unless the request is made `at_length`: then it may take as
119
+ long as it takes. A body that is sent as it is made is given as what
120
+ makes it, so a second attempt sends it from its start.
121
+ """
122
+ repeatable = TRANSPORT.retryable(RetryFacts(status=503, method=method))
123
+ token = self._token.value if authenticated else None
124
+ retried = False
125
+ while True:
126
+ if self._closing.is_set():
127
+ raise self.failed(TransportError("error", f"{method} {path} was not sent: the transport is closed"))
128
+ request = self._http.build_request(
129
+ method,
130
+ f"{self._base_url}{path}",
131
+ headers={**self._headers(token), **(headers or {})},
132
+ content=content() if callable(content) else content,
133
+ )
134
+ try:
135
+ async with asyncio.timeout(None if at_length else self._deadline_ms / 1000):
136
+ response = await self._http.send(request, stream=True)
137
+ except TimeoutError:
138
+ raise self.failed(self._unanswered(method, path)) from None
139
+ except httpx.HTTPError as error:
140
+ if repeatable and not retried and not self._closing.is_set():
141
+ retried = True
142
+ await asyncio.sleep(_RETRY_PAUSE_MS / 1000)
143
+ continue
144
+ raise self.failed(TransportError.without_response(f"{method} {path} got no answer: {error!r}")) from error
145
+ if response.is_success:
146
+ return response
147
+
148
+ status = response.status_code
149
+ stated_wait_ms = retry_after_ms(response.headers.get("retry-after"))
150
+ try:
151
+ # The gateway's refusal, in its own words when its body states them.
152
+ said = ErrorResponse.model_validate_json(await response.aread()).error
153
+ except (ValidationError, httpx.HTTPError):
154
+ said = f"HTTP {status}: {response.reason_phrase}"
155
+ finally:
156
+ await response.aclose()
157
+ if not retried and not self._closing.is_set() and TRANSPORT.retryable(RetryFacts(status=status, method=method)):
158
+ if status != 401:
159
+ retried = True
160
+ await asyncio.sleep(max(_RETRY_PAUSE_MS, stated_wait_ms or 0) / 1000)
161
+ continue
162
+ # A 401 earns its second attempt only if a renewed token
163
+ # arrives: without one the same request gets the same answer.
164
+ renewed = await self._renewed() if authenticated else None
165
+ if renewed is not None:
166
+ token, retried = renewed, True
167
+ continue
168
+ raise self.failed(TransportError.of_status(said, status, stated_wait_ms))
169
+
170
+ async def answer[M: WireModel](
171
+ self,
172
+ declared: type[M],
173
+ method: str,
174
+ path: str,
175
+ *,
176
+ authenticated: bool = True,
177
+ headers: dict[str, str] | None = None,
178
+ content: bytes | Callable[[], AsyncIterable[bytes]] | None = None,
179
+ at_length: bool = False,
180
+ ) -> M:
181
+ """Make a request, and read its answer as the body the operation declares.
182
+
183
+ The answer, once begun, is read within the same deadline.
184
+ """
185
+ response = await self.begin(method, path, authenticated=authenticated, headers=headers, content=content, at_length=at_length)
186
+ try:
187
+ async with asyncio.timeout(None if at_length else self._deadline_ms / 1000):
188
+ body = await response.aread()
189
+ except TimeoutError:
190
+ raise self.failed(self._unanswered(method, path)) from None
191
+ except httpx.HTTPError as error:
192
+ raise self.failed(TransportError.without_response(f"{method} {path} ended before its answer did: {error!r}")) from error
193
+ finally:
194
+ await response.aclose()
195
+ try:
196
+ return declared.model_validate_json(body)
197
+ except ValidationError as error:
198
+ raise self.failed(
199
+ TransportError("error", f"{method} {path} answered what is not its declared body: {error}", status=response.status_code)
200
+ ) from error
semiont/http/oauth.py ADDED
@@ -0,0 +1,346 @@
1
+ """A script as an OAuth public client of the issuer a knowledge base trusts.
2
+
3
+ Finding the issuer from the knowledge base's resource metadata (RFC 9728), the
4
+ device authorization grant (RFC 8628) for a process with no browser, the
5
+ refresh grant, and revocation (RFC 7009).
6
+
7
+ Nothing here names a vendor, and no password passes through it. A client that
8
+ knows a gateway's address knows everything it needs: the knowledge base names
9
+ its issuer, and the issuer names its endpoints.
10
+
11
+ **A refusal and an outage are different events.** A `SignInError` that carries
12
+ a status is the issuer's answer. One that carries none got no answer. A kept
13
+ sign-in's renewal is tried again only on the second kind, and on an answer
14
+ that says "not now" (`semiont.retry.REFRESH`), inside a bounded budget: a
15
+ refused grant is final, because trying again only delays a sign-in the person
16
+ has to perform anyway.
17
+
18
+ Each request of an issuer is to be answered within `HTTP_REQUEST_TIMEOUT_MS`:
19
+ an issuer that accepts a connection and never answers would otherwise hold a
20
+ renewal, and the session waiting on it, for as long as the process lives.
21
+ """
22
+
23
+ import asyncio
24
+ from collections.abc import Callable, Mapping, Sequence
25
+ from dataclasses import dataclass
26
+ from typing import Final, final
27
+
28
+ import httpx
29
+ from pydantic import JsonValue, TypeAdapter, ValidationError
30
+
31
+ from semiont.errors import SignInError, TransportError
32
+ from semiont.http.transport import HttpTransport
33
+ from semiont.identity import encode_uri_component
34
+ from semiont.oauth_clients import SCRIPT_CLIENT_ID, SIGN_IN_SCOPE
35
+ from semiont.retry import REFRESH, RetryFacts, retry_with_backoff
36
+ from semiont.session import SignInKept
37
+ from semiont.timing import HTTP_REQUEST_TIMEOUT_MS, REFRESH_RETRY
38
+ from semiont.watched import Variable
39
+
40
+ __all__ = [
41
+ "Answer",
42
+ "DeviceCode",
43
+ "IssuedTokens",
44
+ "IssuerEndpoints",
45
+ "discover_issuer",
46
+ "issuer_client",
47
+ "object_in",
48
+ "post_form",
49
+ "refresh_at_issuer",
50
+ "renew_kept",
51
+ "revoke_at_issuer",
52
+ "seconds_in",
53
+ "sign_in_with_device_grant",
54
+ "text_in",
55
+ ]
56
+
57
+ _DEVICE_GRANT: Final = "urn:ietf:params:oauth:grant-type:device_code"
58
+ # How long a device code is good for when the issuer does not say.
59
+ _DEVICE_CODE_LIFETIME_S: Final = 600.0
60
+ # How often the token endpoint is asked when the issuer does not say, and how
61
+ # much longer it is left between asks each time it says to slow down.
62
+ _DEVICE_POLL_S: Final = 5.0
63
+
64
+ _OBJECT: Final = TypeAdapter[dict[str, JsonValue]](dict[str, JsonValue])
65
+
66
+ type Answer = Mapping[str, JsonValue] | None
67
+ """What an issuer answered, when that was a JSON object."""
68
+
69
+
70
+ @final
71
+ @dataclass(frozen=True, slots=True, kw_only=True)
72
+ class IssuerEndpoints:
73
+ """An issuer, and the endpoints of it a client uses."""
74
+
75
+ issuer: str
76
+ authorization: str
77
+ token: str
78
+ device: str | None = None
79
+ revocation: str | None = None
80
+
81
+
82
+ @final
83
+ @dataclass(frozen=True, slots=True, kw_only=True)
84
+ class IssuedTokens:
85
+ """What a grant issued."""
86
+
87
+ access: str
88
+ refresh: str
89
+
90
+
91
+ @final
92
+ @dataclass(frozen=True, slots=True, kw_only=True)
93
+ class DeviceCode:
94
+ """What a person is shown to approve a sign-in from wherever they have a browser."""
95
+
96
+ user_code: str
97
+ verification_uri: str
98
+ verification_uri_complete: str | None
99
+ expires_in: float
100
+ """How long the code is good for, in seconds."""
101
+
102
+
103
+ def issuer_client() -> httpx.AsyncClient:
104
+ """An HTTP client for requests of an issuer. It reads no environment variable, as a transport's reads none."""
105
+ return httpx.AsyncClient(timeout=None, trust_env=False)
106
+
107
+
108
+ def _within() -> asyncio.Timeout:
109
+ return asyncio.timeout(HTTP_REQUEST_TIMEOUT_MS / 1000)
110
+
111
+
112
+ def object_in(body: bytes) -> Answer:
113
+ """A body, when it is a JSON object."""
114
+ try:
115
+ return _OBJECT.validate_json(body)
116
+ except ValidationError:
117
+ return None
118
+
119
+
120
+ def text_in(answer: Answer, name: str) -> str | None:
121
+ """A member of an answer that is text, when it has one."""
122
+ value = None if answer is None else answer.get(name)
123
+ return value if isinstance(value, str) else None
124
+
125
+
126
+ def seconds_in(answer: Answer, name: str) -> float | None:
127
+ """A span of time an answer states in seconds, when it states one that can be waited."""
128
+ value = None if answer is None else answer.get(name)
129
+ if isinstance(value, bool) or not isinstance(value, int | float) or value < 0 or value == float("inf"):
130
+ return None
131
+ return float(value)
132
+
133
+
134
+ def _no_issuer() -> SignInError:
135
+ return SignInError("no-issuer", "The knowledge base trusts no external issuer")
136
+
137
+
138
+ async def discover_issuer(base_url: str) -> IssuerEndpoints:
139
+ """Ask the knowledge base at `base_url` which issuer it trusts, and the issuer where its endpoints are."""
140
+ # Its resource metadata is public: asked with no token, by a transport that opens no stream.
141
+ asking = HttpTransport(base_url, token=Variable[str | None](None), channels=())
142
+ try:
143
+ metadata = await asking.get_protected_resource_metadata()
144
+ except TransportError as error:
145
+ if error.status == 404:
146
+ raise _no_issuer() from error
147
+ raise SignInError(
148
+ "discovery", f"The knowledge base did not answer its resource metadata: {error.message}", status=error.status
149
+ ) from error
150
+ finally:
151
+ await asking.close()
152
+ if not metadata.authorization_servers:
153
+ raise _no_issuer()
154
+ issuer = metadata.authorization_servers[0]
155
+
156
+ url = f"{issuer.rstrip('/')}/.well-known/openid-configuration"
157
+ try:
158
+ async with issuer_client() as http, _within():
159
+ response = await http.get(url, headers={"Accept": "application/json"})
160
+ except (TimeoutError, httpx.HTTPError) as error:
161
+ raise SignInError("discovery", f"Issuer {issuer}: discovery was not answered: {error!r}") from error
162
+ status = response.status_code
163
+ if not response.is_success:
164
+ raise SignInError("discovery", f"Issuer {issuer}: discovery answered HTTP {status}", status=status)
165
+ document = object_in(response.content)
166
+ authorization, token = text_in(document, "authorization_endpoint"), text_in(document, "token_endpoint")
167
+ if text_in(document, "issuer") != issuer or authorization is None or token is None:
168
+ raise SignInError("discovery", f"Issuer {issuer}: its discovery document is not an OpenID configuration for it", status=status)
169
+ return IssuerEndpoints(
170
+ issuer=issuer,
171
+ authorization=authorization,
172
+ token=token,
173
+ device=text_in(document, "device_authorization_endpoint"),
174
+ revocation=text_in(document, "revocation_endpoint"),
175
+ )
176
+
177
+
178
+ # ── The token endpoint ──────────────────────────────────────────────────
179
+
180
+
181
+ def _form(pairs: Sequence[tuple[str, str]]) -> bytes:
182
+ return "&".join(f"{name}={encode_uri_component(value)}" for name, value in pairs).encode()
183
+
184
+
185
+ async def post_form(http: httpx.AsyncClient, endpoint: str, form: Sequence[tuple[str, str]]) -> tuple[int, Answer]:
186
+ """One form posted to the issuer: the status it answered, and its answer when that was a JSON object.
187
+
188
+ No answer at all is an error with no status, and so is one that has not
189
+ come by the request's deadline.
190
+ """
191
+ try:
192
+ async with _within():
193
+ response = await http.post(
194
+ endpoint,
195
+ content=_form(form),
196
+ headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
197
+ )
198
+ except (TimeoutError, httpx.HTTPError) as error:
199
+ raise SignInError("exchange", f"The issuer did not answer: {error!r}") from error
200
+ return response.status_code, object_in(response.content)
201
+
202
+
203
+ def _refusal(status: int, answer: Answer) -> str:
204
+ """What the issuer said of a refusal: its error and description, or the status."""
205
+ error, description = text_in(answer, "error"), text_in(answer, "error_description")
206
+ if error is None:
207
+ return f"HTTP {status}"
208
+ return error if description is None else f"{error}: {description}"
209
+
210
+
211
+ def _no_refresh_token() -> SignInError:
212
+ return SignInError("exchange", "The issuer returned no refresh token: the session could not outlive its first access token")
213
+
214
+
215
+ async def refresh_at_issuer(http: httpx.AsyncClient, token_endpoint: str, client_id: str, refresh_token: str) -> IssuedTokens:
216
+ """The refresh grant. An issuer that does not rotate refresh tokens returns none, and the one held stays current."""
217
+ status, answer = await post_form(
218
+ http, token_endpoint, [("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", client_id)]
219
+ )
220
+ access = text_in(answer, "access_token")
221
+ if status != 200 or access is None:
222
+ raise SignInError("exchange", f"The issuer refused the token request ({_refusal(status, answer)})", status=status)
223
+ return IssuedTokens(access=access, refresh=text_in(answer, "refresh_token") or refresh_token)
224
+
225
+
226
+ async def renew_kept(kept: SignInKept) -> str | None:
227
+ """Renew a kept sign-in at its issuer, and keep what it rotated.
228
+
229
+ The new access token; nothing when no sign-in is kept, which is an absence
230
+ and not a failure. A renewal that fails says why in the issuer's own
231
+ words, and, when the retry budget ran out, how many attempts it took to
232
+ give up.
233
+
234
+ What is kept is written against what is kept then: a sign-in that was
235
+ signed out, or ended, while it was being renewed is gone. It is not
236
+ written back, and its new token is given to nobody: a session that is over
237
+ is not handed a credential again.
238
+ """
239
+ held = await kept.held()
240
+ if held is None:
241
+ return None
242
+ attempts = 0
243
+ async with issuer_client() as http:
244
+
245
+ async def attempt() -> IssuedTokens:
246
+ nonlocal attempts
247
+ attempts += 1
248
+ return await refresh_at_issuer(http, held.token_endpoint, held.client_id, held.refresh)
249
+
250
+ try:
251
+ tokens = await retry_with_backoff(
252
+ REFRESH_RETRY,
253
+ attempt,
254
+ retryable=lambda error: REFRESH.retryable(RetryFacts(status=error.status, method="POST")),
255
+ give_up=asyncio.Event(),
256
+ )
257
+ except SignInError as error:
258
+ # One attempt: the issuer answered, and its answer was final.
259
+ if attempts <= 1:
260
+ raise
261
+ raise SignInError(
262
+ "exchange", f"The session could not be renewed after {attempts} attempts: {error.message}", status=error.status
263
+ ) from error
264
+ return tokens.access if await kept.renewed(tokens.access, tokens.refresh) else None
265
+
266
+
267
+ async def revoke_at_issuer(http: httpx.AsyncClient, revocation_endpoint: str, client_id: str, refresh_token: str) -> None:
268
+ """RFC 7009. The issuer answers 200 for a token it has already forgotten, so revoking twice is revoking once."""
269
+ status, _ = await post_form(
270
+ http, revocation_endpoint, [("token", refresh_token), ("token_type_hint", "refresh_token"), ("client_id", client_id)]
271
+ )
272
+ if status != 200:
273
+ raise SignInError("exchange", f"The issuer refused the revocation (HTTP {status})", status=status)
274
+
275
+
276
+ # ── The device authorization grant ──────────────────────────────────────
277
+
278
+
279
+ def _expired() -> SignInError:
280
+ return SignInError("expired", "The code expired before it was approved")
281
+
282
+
283
+ async def sign_in_with_device_grant(base_url: str, on_code: Callable[[DeviceCode], None]) -> tuple[IssuerEndpoints, IssuedTokens]:
284
+ """Sign in as a person from a process with no browser.
285
+
286
+ The issuer the knowledge base at `base_url` trusts mints a code, `on_code`
287
+ shows the person where to approve it, and the token endpoint is asked at
288
+ the issuer's interval until the tokens arrive. Cancelling it abandons the
289
+ sign-in.
290
+ """
291
+ issuer = await discover_issuer(base_url)
292
+ if issuer.device is None:
293
+ raise SignInError(
294
+ "discovery",
295
+ f"Issuer {issuer.issuer} offers no device authorization endpoint: "
296
+ f"the device grant needs one enabled for client {SCRIPT_CLIENT_ID}",
297
+ )
298
+ async with issuer_client() as http:
299
+ status, body = await post_form(http, issuer.device, [("client_id", SCRIPT_CLIENT_ID), ("scope", SIGN_IN_SCOPE)])
300
+ device_code, user_code, verification_uri = (
301
+ text_in(body, "device_code"),
302
+ text_in(body, "user_code"),
303
+ text_in(body, "verification_uri"),
304
+ )
305
+ if status != 200 or device_code is None or user_code is None or verification_uri is None:
306
+ raise SignInError("exchange", f"The issuer refused the device authorization request ({_refusal(status, body)})", status=status)
307
+ expires_in = seconds_in(body, "expires_in")
308
+ if expires_in is None:
309
+ expires_in = _DEVICE_CODE_LIFETIME_S
310
+ on_code(
311
+ DeviceCode(
312
+ user_code=user_code,
313
+ verification_uri=verification_uri,
314
+ verification_uri_complete=text_in(body, "verification_uri_complete"),
315
+ expires_in=expires_in,
316
+ )
317
+ )
318
+
319
+ stated = seconds_in(body, "interval")
320
+ interval = max(_DEVICE_POLL_S if stated is None else stated, 1.0)
321
+ loop = asyncio.get_running_loop()
322
+ deadline = loop.time() + expires_in
323
+ while True:
324
+ await asyncio.sleep(interval)
325
+ if loop.time() > deadline:
326
+ raise _expired()
327
+ status, answer = await post_form(
328
+ http, issuer.token, [("grant_type", _DEVICE_GRANT), ("device_code", device_code), ("client_id", SCRIPT_CLIENT_ID)]
329
+ )
330
+ access = text_in(answer, "access_token")
331
+ if status == 200 and access is not None:
332
+ refresh = text_in(answer, "refresh_token")
333
+ if refresh is None:
334
+ raise _no_refresh_token()
335
+ return issuer, IssuedTokens(access=access, refresh=refresh)
336
+ match text_in(answer, "error"):
337
+ case "authorization_pending":
338
+ pass
339
+ case "slow_down":
340
+ interval += _DEVICE_POLL_S
341
+ case "access_denied":
342
+ raise SignInError("denied", "The sign-in was denied at the issuer", status=status)
343
+ case "expired_token":
344
+ raise _expired()
345
+ case _:
346
+ raise SignInError("exchange", f"The issuer refused the token request ({_refusal(status, answer)})", status=status)