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/session.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""A session with one knowledge base: its client, the token the client's transport sends, and who is signed in.
|
|
2
|
+
|
|
3
|
+
Headless: it runs in an application, a script, a daemon and a test alike, and
|
|
4
|
+
shows nobody anything. What it needs of its surroundings it is given:
|
|
5
|
+
|
|
6
|
+
- `refresh` renews the token: before it expires, and when the gateway refuses
|
|
7
|
+
it. It answers the new token, or nothing when there is nothing to renew
|
|
8
|
+
with, and raises `SignInError` when the renewal failed.
|
|
9
|
+
- `validate` asks the gateway who a token is: when the session starts, and of
|
|
10
|
+
a token renewed because the gateway refused the one before.
|
|
11
|
+
- `on_auth_failed` is told when the session is over, and why: it could not be
|
|
12
|
+
renewed (`expired`), or the gateway refused a token the issuer had just
|
|
13
|
+
issued (`refused`). A reason is not a sentence: what a person reads is the
|
|
14
|
+
host's to say. `on_error` is told of every failure that makes the session
|
|
15
|
+
unusable.
|
|
16
|
+
|
|
17
|
+
**How a token is held** is one rule for every SDK
|
|
18
|
+
(`specs/src/session/cases.json`, which this package's tests run): when a
|
|
19
|
+
token expires, read from its own claims, and how long to wait before renewing
|
|
20
|
+
it. The margin before expiry is half the token's lifetime, capped at
|
|
21
|
+
`REFRESH_BEFORE_EXP_MS`. A fixed margin can equal the lifetime an issuer
|
|
22
|
+
mints, and then every renewal is due the moment it is issued; half a lifetime
|
|
23
|
+
cannot, for any lifetime. The wait is never under `MIN_REFRESH_DELAY_MS`, so a
|
|
24
|
+
token already past its renewal point is renewed once per interval, not in a
|
|
25
|
+
loop.
|
|
26
|
+
|
|
27
|
+
**What a session does at its start, and when the gateway refuses its token**,
|
|
28
|
+
is the same table's `startup` and `refusal`: a token the gateway refuses is
|
|
29
|
+
renewed once and asked about once more, and a token the issuer has just
|
|
30
|
+
issued and the gateway refuses is final. So one refusal costs at most one
|
|
31
|
+
renewal and one ask, whatever the gateway and the issuer answer.
|
|
32
|
+
|
|
33
|
+
A session that cannot be renewed clears its token and what it kept, so a dead
|
|
34
|
+
credential is never used again. One that never had a credential is only
|
|
35
|
+
signed out: there is nothing to end, and nothing is said.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import base64
|
|
40
|
+
import binascii
|
|
41
|
+
import time
|
|
42
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from types import TracebackType
|
|
45
|
+
from typing import Final, Literal, Protocol, Self, final, override
|
|
46
|
+
|
|
47
|
+
from pydantic import JsonValue, TypeAdapter, ValidationError
|
|
48
|
+
|
|
49
|
+
from semiont.client import SemiontClient
|
|
50
|
+
from semiont.errors import SemiontError, SessionError, SignInError, TransportError
|
|
51
|
+
from semiont.timing import MIN_REFRESH_DELAY_MS, REFRESH_BEFORE_EXP_MS
|
|
52
|
+
from semiont.transport import Transport
|
|
53
|
+
from semiont.types import UserResponse
|
|
54
|
+
from semiont.watched import Variable, Watched
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"HeldSignIn",
|
|
58
|
+
"MemorySignIn",
|
|
59
|
+
"Refresh",
|
|
60
|
+
"SemiontSession",
|
|
61
|
+
"SessionEndReason",
|
|
62
|
+
"SignInKept",
|
|
63
|
+
"Validate",
|
|
64
|
+
"is_token_expired",
|
|
65
|
+
"refresh_delay",
|
|
66
|
+
"renew_when_due",
|
|
67
|
+
"renewal_delay",
|
|
68
|
+
"text_claim",
|
|
69
|
+
"token_expiry",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
type SessionEndReason = Literal["expired", "refused"]
|
|
73
|
+
"""Why a session ended: its token could not be renewed (`expired`), or the
|
|
74
|
+
gateway refused a token its issuer had just issued (`refused`)."""
|
|
75
|
+
|
|
76
|
+
type Refresh = Callable[[], Awaitable[str | None]]
|
|
77
|
+
"""Renew the token: the new one, or nothing when there is nothing to renew with. Raises `SignInError` when the renewal failed."""
|
|
78
|
+
|
|
79
|
+
type Validate = Callable[[str], Awaitable[UserResponse]]
|
|
80
|
+
"""Ask the gateway who a token is. Raises `TransportError` when it refuses, or cannot be asked."""
|
|
81
|
+
|
|
82
|
+
_CLAIMS: Final = TypeAdapter[dict[str, JsonValue]](dict[str, JsonValue])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── How a token is held ─────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _claims(token: str) -> Mapping[str, JsonValue] | None:
|
|
89
|
+
"""The claims of a JWT: its second segment of three, base64url without padding, UTF-8 JSON."""
|
|
90
|
+
segments = token.split(".")
|
|
91
|
+
if len(segments) != 3:
|
|
92
|
+
return None
|
|
93
|
+
payload = segments[1]
|
|
94
|
+
try:
|
|
95
|
+
return _CLAIMS.validate_json(base64.b64decode(payload + "=" * (-len(payload) % 4), altchars=b"-_", validate=True))
|
|
96
|
+
except (binascii.Error, ValidationError):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _seconds(claims: Mapping[str, JsonValue], name: str) -> float | None:
|
|
101
|
+
"""A numeric claim, in seconds. Absent, or zero, is none."""
|
|
102
|
+
value = claims.get(name)
|
|
103
|
+
if isinstance(value, bool) or not isinstance(value, int | float) or value == 0:
|
|
104
|
+
return None
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def text_claim(token: str, name: str) -> str | None:
|
|
109
|
+
"""A claim of a token that is text, when the token carries one."""
|
|
110
|
+
claims = _claims(token)
|
|
111
|
+
value = None if claims is None else claims.get(name)
|
|
112
|
+
return value if isinstance(value, str) else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def token_expiry(token: str) -> float | None:
|
|
116
|
+
"""When a token expires, by its `exp` claim, in seconds since the epoch. Nothing when it names no time."""
|
|
117
|
+
claims = _claims(token)
|
|
118
|
+
return None if claims is None else _seconds(claims, "exp")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_token_expired(token: str, *, now: float) -> bool:
|
|
122
|
+
"""Whether a token is past its expiry at `now`. One that names no expiry is read as expired: nothing says it is still good."""
|
|
123
|
+
expiry = token_expiry(token)
|
|
124
|
+
return expiry is None or expiry < now
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def renewal_delay(lifetime: float, remaining: float) -> float:
|
|
128
|
+
"""How long to wait, in seconds, before renewing a token that lives `lifetime` in all and has `remaining` of it left."""
|
|
129
|
+
margin = min(REFRESH_BEFORE_EXP_MS / 1000, lifetime / 2)
|
|
130
|
+
return max(remaining - margin, MIN_REFRESH_DELAY_MS / 1000)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def refresh_delay(token: str, *, now: float) -> float | None:
|
|
134
|
+
"""How long to wait, in seconds from `now`, before renewing `token`.
|
|
135
|
+
|
|
136
|
+
Nothing when it has no readable `exp`: nothing is scheduled. The lifetime
|
|
137
|
+
is the one the issuer chose (`exp - iat`); with no `iat`, what remains of
|
|
138
|
+
it.
|
|
139
|
+
"""
|
|
140
|
+
claims = _claims(token)
|
|
141
|
+
expiry = None if claims is None else _seconds(claims, "exp")
|
|
142
|
+
if claims is None or expiry is None:
|
|
143
|
+
return None
|
|
144
|
+
remaining = max(expiry - now, 0)
|
|
145
|
+
issued = _seconds(claims, "iat")
|
|
146
|
+
return renewal_delay(remaining if issued is None else max(expiry - issued, 0), remaining)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def renew_when_due(token: Watched[str | None], renew: Callable[[], Awaitable[object]]) -> None:
|
|
150
|
+
"""Renew a token whenever it is due, for as long as it can change.
|
|
151
|
+
|
|
152
|
+
Waits what `refresh_delay` says of the token as it is, and calls `renew`.
|
|
153
|
+
The wait is counted from each token as it arrives, so one renewed some
|
|
154
|
+
other way starts a wait of its own, and a renewal that changed nothing is
|
|
155
|
+
due again a floor later. A token that names no expiry schedules nothing:
|
|
156
|
+
it is renewed when whoever it is shown to refuses it.
|
|
157
|
+
"""
|
|
158
|
+
changes = aiter(token)
|
|
159
|
+
try:
|
|
160
|
+
current = await anext(changes)
|
|
161
|
+
while True:
|
|
162
|
+
due = None if not current else refresh_delay(current, now=time.time())
|
|
163
|
+
try:
|
|
164
|
+
async with asyncio.timeout(due):
|
|
165
|
+
current = await anext(changes)
|
|
166
|
+
continue
|
|
167
|
+
except TimeoutError:
|
|
168
|
+
pass
|
|
169
|
+
await renew()
|
|
170
|
+
current = token.value
|
|
171
|
+
except StopAsyncIteration:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── What a session keeps ────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@final
|
|
179
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
180
|
+
class HeldSignIn:
|
|
181
|
+
"""What is kept of a sign-in.
|
|
182
|
+
|
|
183
|
+
The tokens an issuer issued, the client they were issued to, and the
|
|
184
|
+
issuer's endpoints a renewal and a sign-out need, learned once at sign-in
|
|
185
|
+
so no session asks again.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
access: str
|
|
189
|
+
refresh: str
|
|
190
|
+
client_id: str
|
|
191
|
+
token_endpoint: str
|
|
192
|
+
revocation_endpoint: str | None = None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SignInKept(Protocol):
|
|
196
|
+
"""Where one knowledge base's sign-in is kept."""
|
|
197
|
+
|
|
198
|
+
async def held(self) -> HeldSignIn | None:
|
|
199
|
+
"""The sign-in as it is kept now, when there is one."""
|
|
200
|
+
...
|
|
201
|
+
|
|
202
|
+
async def keep(self, sign_in: HeldSignIn) -> None:
|
|
203
|
+
"""Keep a sign-in, in place of whatever was kept."""
|
|
204
|
+
...
|
|
205
|
+
|
|
206
|
+
async def renewed(self, access: str, refresh: str) -> bool:
|
|
207
|
+
"""Keep the tokens a renewal issued, against what is kept now.
|
|
208
|
+
|
|
209
|
+
False when nothing is: a sign-in that was signed out, or ended, while
|
|
210
|
+
it was being renewed is gone, and is not written back.
|
|
211
|
+
"""
|
|
212
|
+
...
|
|
213
|
+
|
|
214
|
+
async def forget(self) -> None:
|
|
215
|
+
"""Keep the sign-in no more."""
|
|
216
|
+
...
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@final
|
|
220
|
+
class MemorySignIn(SignInKept):
|
|
221
|
+
"""A sign-in kept for as long as the process lives, and no longer."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, sign_in: HeldSignIn | None = None) -> None:
|
|
224
|
+
self._sign_in = sign_in
|
|
225
|
+
|
|
226
|
+
@override
|
|
227
|
+
async def held(self) -> HeldSignIn | None:
|
|
228
|
+
return self._sign_in
|
|
229
|
+
|
|
230
|
+
@override
|
|
231
|
+
async def keep(self, sign_in: HeldSignIn) -> None:
|
|
232
|
+
self._sign_in = sign_in
|
|
233
|
+
|
|
234
|
+
@override
|
|
235
|
+
async def renewed(self, access: str, refresh: str) -> bool:
|
|
236
|
+
if self._sign_in is None:
|
|
237
|
+
return False
|
|
238
|
+
self._sign_in = HeldSignIn(
|
|
239
|
+
access=access,
|
|
240
|
+
refresh=refresh,
|
|
241
|
+
client_id=self._sign_in.client_id,
|
|
242
|
+
token_endpoint=self._sign_in.token_endpoint,
|
|
243
|
+
revocation_endpoint=self._sign_in.revocation_endpoint,
|
|
244
|
+
)
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
@override
|
|
248
|
+
async def forget(self) -> None:
|
|
249
|
+
self._sign_in = None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── The session ─────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _not_renewed(failure: str | None) -> str:
|
|
256
|
+
"""Why a renewal gave no token, for whoever reads the error."""
|
|
257
|
+
return "Token refresh failed" if failure is None else f"Token refresh failed: {failure}"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
_JUST_ISSUED_AND_REFUSED: Final = "The gateway refused a token its issuer had just issued"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@final
|
|
264
|
+
class SemiontSession[T: Transport]:
|
|
265
|
+
"""See the module's documentation.
|
|
266
|
+
|
|
267
|
+
Held with `async with`: it starts inside, and `ready` says when it has
|
|
268
|
+
done what it does at its start. `client` is the one whose transport sends
|
|
269
|
+
its token, typed by the kind of transport that is; the session does not
|
|
270
|
+
open or close either.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(
|
|
274
|
+
self,
|
|
275
|
+
*,
|
|
276
|
+
kb_id: str,
|
|
277
|
+
client: SemiontClient[T],
|
|
278
|
+
token: Variable[str | None],
|
|
279
|
+
kept: SignInKept,
|
|
280
|
+
refresh: Refresh | None = None,
|
|
281
|
+
validate: Validate | None = None,
|
|
282
|
+
on_auth_failed: Callable[[SessionEndReason], None] | None = None,
|
|
283
|
+
on_error: Callable[[SessionError], None] | None = None,
|
|
284
|
+
) -> None:
|
|
285
|
+
self.kb_id: Final = kb_id
|
|
286
|
+
self.client: Final[SemiontClient[T]] = client
|
|
287
|
+
self._token: Final = token
|
|
288
|
+
self._kept: Final = kept
|
|
289
|
+
self._refresh: Final = refresh
|
|
290
|
+
self._validate: Final = validate
|
|
291
|
+
self._on_auth_failed: Final = on_auth_failed
|
|
292
|
+
self._on_error: Final = on_error
|
|
293
|
+
self._user: Final[Variable[UserResponse | None]] = Variable(None)
|
|
294
|
+
self._ready: Final = asyncio.Event()
|
|
295
|
+
self._closing: Final = asyncio.Event()
|
|
296
|
+
# Held while a refusal is being answered, so that the refusals that arrive during it are answered by it.
|
|
297
|
+
self._answering: Final = asyncio.Lock()
|
|
298
|
+
self._running: asyncio.Task[None] | None = None
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def token(self) -> Watched[str | None]:
|
|
302
|
+
"""The token the transport sends: the present one, and each after it."""
|
|
303
|
+
return self._token
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def user(self) -> Watched[UserResponse | None]:
|
|
307
|
+
"""Who is signed in, once the gateway has said."""
|
|
308
|
+
return self._user
|
|
309
|
+
|
|
310
|
+
def expires_at(self) -> float | None:
|
|
311
|
+
"""When the token the session holds expires, in seconds since the epoch."""
|
|
312
|
+
token = self._token.value
|
|
313
|
+
return None if token is None else token_expiry(token)
|
|
314
|
+
|
|
315
|
+
async def ready(self) -> None:
|
|
316
|
+
"""Wait until the session has done what it does at its start. It returns whatever that came to."""
|
|
317
|
+
await self._ready.wait()
|
|
318
|
+
|
|
319
|
+
async def __aenter__(self) -> Self:
|
|
320
|
+
if self._running is not None or self._closing.is_set():
|
|
321
|
+
raise RuntimeError("a session is started once")
|
|
322
|
+
# A kept token that is still good is what the transport sends first, unless it was given one already.
|
|
323
|
+
kept = await self._kept.held()
|
|
324
|
+
if kept is not None and not is_token_expired(kept.access, now=time.time()) and self._token.value is None:
|
|
325
|
+
self._token.set(kept.access)
|
|
326
|
+
self._running = asyncio.create_task(self._run(kept))
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
async def __aexit__(self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None) -> None:
|
|
330
|
+
await self.close()
|
|
331
|
+
|
|
332
|
+
async def close(self) -> None:
|
|
333
|
+
"""End the session: it renews nothing more. Closing twice is closing once."""
|
|
334
|
+
self._closing.set()
|
|
335
|
+
self._ready.set()
|
|
336
|
+
# The token and who is signed in change no more: whoever reads them is told so.
|
|
337
|
+
self._token.end()
|
|
338
|
+
self._user.end()
|
|
339
|
+
running = self._running
|
|
340
|
+
if running is None:
|
|
341
|
+
return
|
|
342
|
+
running.cancel()
|
|
343
|
+
await asyncio.gather(running, return_exceptions=True)
|
|
344
|
+
# Whatever ended the session's task that was not its closing is raised here.
|
|
345
|
+
if not running.cancelled():
|
|
346
|
+
await running
|
|
347
|
+
|
|
348
|
+
async def _run(self, kept: HeldSignIn | None) -> None:
|
|
349
|
+
async with asyncio.TaskGroup() as tasks:
|
|
350
|
+
tasks.create_task(renew_when_due(self._token, self._renew))
|
|
351
|
+
try:
|
|
352
|
+
await self._start(kept)
|
|
353
|
+
finally:
|
|
354
|
+
self._ready.set()
|
|
355
|
+
|
|
356
|
+
async def refresh(self) -> str | None:
|
|
357
|
+
"""The gateway refused the session's token: renew it, and ask the gateway who the new one is.
|
|
358
|
+
|
|
359
|
+
Returns the token the session then holds, or nothing: it holds no
|
|
360
|
+
token, and if it had a kept sign-in it is over. A transport asks this
|
|
361
|
+
when the gateway refuses it.
|
|
362
|
+
|
|
363
|
+
One refusal is answered at a time, so however many requests were
|
|
364
|
+
refused at once, they cost one renewal and one ask. A gateway that
|
|
365
|
+
cannot be asked refuses nothing: the renewed token is given.
|
|
366
|
+
"""
|
|
367
|
+
refused_with = self._token.value
|
|
368
|
+
async with self._answering:
|
|
369
|
+
# Refusals that arrive together are answered together: the first
|
|
370
|
+
# renews and asks, and the rest find that the session's token is no
|
|
371
|
+
# longer the one they were refused with.
|
|
372
|
+
if self._token.value != refused_with:
|
|
373
|
+
return self._token.value
|
|
374
|
+
renewed = await self._renew()
|
|
375
|
+
if renewed is None or self._validate is None:
|
|
376
|
+
return renewed
|
|
377
|
+
# While the session is starting, its start is what asks: it finds
|
|
378
|
+
# the token renewed here and takes it up as the just-issued one.
|
|
379
|
+
if not self._ready.is_set():
|
|
380
|
+
return renewed
|
|
381
|
+
try:
|
|
382
|
+
await self._validate(renewed)
|
|
383
|
+
except SemiontError as refusal:
|
|
384
|
+
# A gateway that could not be asked refused nothing. And only a
|
|
385
|
+
# token the session still holds ends it: one replaced while the
|
|
386
|
+
# gateway was being asked is no longer the session's to be
|
|
387
|
+
# refused.
|
|
388
|
+
refused = isinstance(refusal, TransportError) and refusal.code == "unauthorized"
|
|
389
|
+
if refused and not self._closing.is_set() and self._token.value == renewed:
|
|
390
|
+
await self._signed_out(
|
|
391
|
+
"refused", SessionError("session.credential-refused", _JUST_ISSUED_AND_REFUSED, kb_id=self.kb_id)
|
|
392
|
+
)
|
|
393
|
+
return None if self._closing.is_set() else self._token.value
|
|
394
|
+
|
|
395
|
+
async def _try_refresh(self) -> tuple[str | None, str | None]:
|
|
396
|
+
"""Ask `refresh`. A renewal that could not be made and one that was refused both leave the session without a token.
|
|
397
|
+
|
|
398
|
+
Returns the token, and why there is none, for whoever reads why.
|
|
399
|
+
"""
|
|
400
|
+
if self._refresh is None:
|
|
401
|
+
return None, None
|
|
402
|
+
try:
|
|
403
|
+
return await self._refresh(), None
|
|
404
|
+
except SignInError as failure:
|
|
405
|
+
return None, failure.message
|
|
406
|
+
|
|
407
|
+
async def _renew(self) -> str | None:
|
|
408
|
+
"""Renew the token at the issuer, with nobody asked afterwards: what the session does on its own schedule."""
|
|
409
|
+
if self._closing.is_set() or self._refresh is None:
|
|
410
|
+
return None
|
|
411
|
+
renewed, failure = await self._try_refresh()
|
|
412
|
+
if self._closing.is_set():
|
|
413
|
+
return None
|
|
414
|
+
if renewed is not None:
|
|
415
|
+
self._token.set(renewed)
|
|
416
|
+
return renewed
|
|
417
|
+
self._token.set(None)
|
|
418
|
+
# A session that never had a credential cannot expire: it is signed
|
|
419
|
+
# out, which is no failure. Ending the session below also forgets the
|
|
420
|
+
# credential, so the refusals that follow it are quiet too.
|
|
421
|
+
if await self._kept.held() is not None:
|
|
422
|
+
await self._signed_out("expired", SessionError("session.refresh-exhausted", _not_renewed(failure), kb_id=self.kb_id))
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
async def _signed_out(self, told: SessionEndReason, error: SessionError) -> None:
|
|
426
|
+
"""The session is over: its credential is forgotten, and its host is told why. The one teardown, whichever way it ended."""
|
|
427
|
+
self._token.set(None)
|
|
428
|
+
self._user.set(None)
|
|
429
|
+
await self._kept.forget()
|
|
430
|
+
if self._on_auth_failed is not None:
|
|
431
|
+
self._on_auth_failed(told)
|
|
432
|
+
self._failed(error)
|
|
433
|
+
|
|
434
|
+
def _failed(self, error: SessionError) -> None:
|
|
435
|
+
if self._on_error is not None:
|
|
436
|
+
self._on_error(error)
|
|
437
|
+
|
|
438
|
+
async def _start(self, kept: HeldSignIn | None) -> None:
|
|
439
|
+
"""What a session does at its start, with the sign-in it found kept.
|
|
440
|
+
|
|
441
|
+
Renew it if it has expired, then ask the gateway who it is. A token
|
|
442
|
+
the gateway refuses is renewed once and asked about once more. A token
|
|
443
|
+
the issuer has just issued and the gateway refuses is final: renewing
|
|
444
|
+
again cannot change the answer. So the gateway is asked at most twice
|
|
445
|
+
and the issuer at most once.
|
|
446
|
+
"""
|
|
447
|
+
if kept is None:
|
|
448
|
+
return
|
|
449
|
+
just_issued = is_token_expired(kept.access, now=time.time())
|
|
450
|
+
token = kept.access
|
|
451
|
+
if just_issued:
|
|
452
|
+
renewed, _ = await self._try_refresh()
|
|
453
|
+
if renewed is None:
|
|
454
|
+
# There was no session to end: the sign-in is forgotten, and nothing is said.
|
|
455
|
+
await self._kept.forget()
|
|
456
|
+
return
|
|
457
|
+
token = renewed
|
|
458
|
+
self._token.set(token)
|
|
459
|
+
if self._validate is None:
|
|
460
|
+
return
|
|
461
|
+
while not self._closing.is_set():
|
|
462
|
+
try:
|
|
463
|
+
self._user.set(await self._validate(token))
|
|
464
|
+
return
|
|
465
|
+
except TransportError as refusal:
|
|
466
|
+
if refusal.code != "unauthorized":
|
|
467
|
+
self._failed(SessionError("session.auth-failed", refusal.message, kb_id=self.kb_id))
|
|
468
|
+
return
|
|
469
|
+
except SemiontError as other:
|
|
470
|
+
self._failed(SessionError("session.auth-failed", other.message, kb_id=self.kb_id))
|
|
471
|
+
return
|
|
472
|
+
if self._closing.is_set():
|
|
473
|
+
return
|
|
474
|
+
if just_issued:
|
|
475
|
+
await self._signed_out("refused", SessionError("session.credential-refused", _JUST_ISSUED_AND_REFUSED, kb_id=self.kb_id))
|
|
476
|
+
return
|
|
477
|
+
# The session's token is no longer the one asked about: the stream
|
|
478
|
+
# was refused with it too, and `refresh` has renewed it meanwhile.
|
|
479
|
+
# That token is the just-issued one, and is not renewed again.
|
|
480
|
+
current = self._token.value
|
|
481
|
+
if current != token:
|
|
482
|
+
if current is None:
|
|
483
|
+
return
|
|
484
|
+
token, just_issued = current, True
|
|
485
|
+
continue
|
|
486
|
+
renewed, failure = await self._try_refresh()
|
|
487
|
+
if self._closing.is_set():
|
|
488
|
+
return
|
|
489
|
+
if renewed is None:
|
|
490
|
+
await self._signed_out("expired", SessionError("session.refresh-exhausted", _not_renewed(failure), kb_id=self.kb_id))
|
|
491
|
+
return
|
|
492
|
+
self._token.set(renewed)
|
|
493
|
+
token, just_issued = renewed, True
|
semiont/sign_in.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Generated from specs/src/sign-in-store/SignIn.json; do not edit.
|
|
2
|
+
# Regenerate: node scripts/spec/generate-sign-in-python.mjs
|
|
3
|
+
|
|
4
|
+
"""One entry of the sign-in store (`specs/src/sign-in-store`): the file where
|
|
5
|
+
`semiont login` keeps the sign-in to each stack, and where an application
|
|
6
|
+
finds it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Annotated, final
|
|
10
|
+
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
from semiont.model import WireModel
|
|
14
|
+
|
|
15
|
+
__all__ = ["SignIn"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class SignIn(WireModel, frozen=True):
|
|
20
|
+
"""One sign-in to a running stack's knowledge base, as the sign-in store keeps it: the tokens
|
|
21
|
+
an issuer issued to the script client, and what a renewal and a sign-out need of that
|
|
22
|
+
issuer, learned once at sign-in. TOKENS, never a password. An entry of
|
|
23
|
+
<stateDir>/tokens.json (see README.md).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# The access token: short-lived, sent as a Bearer token.
|
|
27
|
+
token: str
|
|
28
|
+
|
|
29
|
+
# The refresh token: long-lived, and what renews the access token at the issuer without
|
|
30
|
+
# another sign-in. An entry with none cannot be renewed.
|
|
31
|
+
refresh_token: Annotated[str | None, Field(alias="refreshToken")] = None
|
|
32
|
+
|
|
33
|
+
# Who signed in: the address the access token named when the sign-in was made, which is the
|
|
34
|
+
# one the gateway answers for it. For display. A writer that renews an entry keeps it.
|
|
35
|
+
email: str
|
|
36
|
+
|
|
37
|
+
# When the access token was issued to this machine, RFC 3339 in UTC.
|
|
38
|
+
obtained_at: Annotated[str, Field(alias="obtainedAt")]
|
|
39
|
+
|
|
40
|
+
# When the access token stops working, RFC 3339 in UTC, so a reader can renew before sending
|
|
41
|
+
# a token it knows is dead. Absent when the issuer named no lifetime: the gateway's refusal
|
|
42
|
+
# is then the signal.
|
|
43
|
+
expires_at: Annotated[str | None, Field(alias="expiresAt")] = None
|
|
44
|
+
|
|
45
|
+
# The issuer the sign-in came from: the one the access token named when the sign-in was
|
|
46
|
+
# made. A writer that renews an entry keeps it.
|
|
47
|
+
issuer: str
|
|
48
|
+
|
|
49
|
+
# The issuer's token endpoint, where the refresh grant is made.
|
|
50
|
+
token_endpoint: Annotated[str, Field(alias="tokenEndpoint")]
|
|
51
|
+
|
|
52
|
+
# The issuer's revocation endpoint (RFC 7009), where a sign-out tells it to forget the
|
|
53
|
+
# refresh token. Absent when the issuer has none.
|
|
54
|
+
revocation_endpoint: Annotated[str | None, Field(alias="revocationEndpoint")] = None
|