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.
- semiont/__init__.py +20 -0
- semiont/bus.py +236 -0
- semiont/cache.py +345 -0
- semiont/cached.py +152 -0
- semiont/channel.py +81 -0
- semiont/channels.py +1306 -0
- semiont/client.py +161 -0
- semiont/error_codes.py +232 -0
- semiont/errors.py +147 -0
- semiont/event_bus.py +108 -0
- semiont/events.py +101 -0
- semiont/http/__init__.py +24 -0
- semiont/http/agent.py +232 -0
- semiont/http/content.py +234 -0
- semiont/http/exchange.py +200 -0
- semiont/http/oauth.py +346 -0
- semiont/http/session.py +191 -0
- semiont/http/sse.py +71 -0
- semiont/http/stream.py +683 -0
- semiont/http/transport.py +326 -0
- semiont/identifiers.py +114 -0
- semiont/identity.py +59 -0
- semiont/media_types.py +69 -0
- semiont/media_types_table.py +89 -0
- semiont/model.py +33 -0
- semiont/namespaces/__init__.py +19 -0
- semiont/namespaces/auth.py +32 -0
- semiont/namespaces/beckon.py +47 -0
- semiont/namespaces/bind.py +38 -0
- semiont/namespaces/browse.py +338 -0
- semiont/namespaces/follow.py +238 -0
- semiont/namespaces/frame.py +35 -0
- semiont/namespaces/gather.py +82 -0
- semiont/namespaces/job.py +94 -0
- semiont/namespaces/links.py +66 -0
- semiont/namespaces/live.py +301 -0
- semiont/namespaces/mark.py +174 -0
- semiont/namespaces/match.py +67 -0
- semiont/namespaces/refresher.py +283 -0
- semiont/namespaces/system.py +24 -0
- semiont/namespaces/yield_.py +101 -0
- semiont/oauth_clients.py +27 -0
- semiont/operations.py +337 -0
- semiont/py.typed +0 -0
- semiont/refresh.py +188 -0
- semiont/resume.py +95 -0
- semiont/retry.py +164 -0
- semiont/running.py +110 -0
- semiont/session.py +493 -0
- semiont/sign_in.py +54 -0
- semiont/sign_in_store.py +283 -0
- semiont/storage.py +139 -0
- semiont/telemetry.py +116 -0
- semiont/telemetry_table.py +121 -0
- semiont/testing/__init__.py +62 -0
- semiont/testing/client.py +53 -0
- semiont/testing/content.py +124 -0
- semiont/testing/gateway.py +85 -0
- semiont/testing/transport.py +341 -0
- semiont/timing.py +187 -0
- semiont/transport.py +531 -0
- semiont/types.py +5902 -0
- semiont/watched.py +111 -0
- semiont-0.6.12.dev2.dist-info/METADATA +533 -0
- semiont-0.6.12.dev2.dist-info/RECORD +66 -0
- semiont-0.6.12.dev2.dist-info/WHEEL +4 -0
semiont/http/exchange.py
ADDED
|
@@ -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)
|