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
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