stwrd-auth 0.1.0__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.
- stwrd/__init__.py +49 -0
- stwrd/client.py +466 -0
- stwrd/config.py +240 -0
- stwrd/fastapi.py +733 -0
- stwrd/management.py +539 -0
- stwrd/management_generated.py +2180 -0
- stwrd/oidc.py +537 -0
- stwrd/organizations.py +79 -0
- stwrd/postgres.py +336 -0
- stwrd/py.typed +0 -0
- stwrd/sessions.py +362 -0
- stwrd/webhooks.py +251 -0
- stwrd_auth-0.1.0.dist-info/METADATA +577 -0
- stwrd_auth-0.1.0.dist-info/RECORD +16 -0
- stwrd_auth-0.1.0.dist-info/WHEEL +4 -0
- stwrd_auth-0.1.0.dist-info/licenses/LICENSE +21 -0
stwrd/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""stwrd — Python SDK for the stwrd identity provider (BFF profile).
|
|
2
|
+
|
|
3
|
+
Installed as `stwrd-auth`, imported as `stwrd`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .client import ConfigError, Stwrd, connect_to_hook
|
|
9
|
+
from .config import StwrdConfig
|
|
10
|
+
from .management import ApiResult, ManagementError, ManagementOptions, WriteOptions
|
|
11
|
+
from .management_generated import ManagementClient, create_management
|
|
12
|
+
from .oidc import IdpUnavailable, OidcError, RefreshUncertain
|
|
13
|
+
from .sessions import MemoryStore, SessionStore, StwrdOrganization, StwrdSession, StwrdUser, Tokens
|
|
14
|
+
from .webhooks import (
|
|
15
|
+
DuplicateEventError,
|
|
16
|
+
InvalidSignatureError,
|
|
17
|
+
WebhookEvent,
|
|
18
|
+
safe_equal,
|
|
19
|
+
verify_webhook,
|
|
20
|
+
verify_webhook_signature,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ApiResult",
|
|
25
|
+
"ManagementClient",
|
|
26
|
+
"ManagementError",
|
|
27
|
+
"ManagementOptions",
|
|
28
|
+
"WriteOptions",
|
|
29
|
+
"create_management",
|
|
30
|
+
"connect_to_hook",
|
|
31
|
+
"ConfigError",
|
|
32
|
+
"DuplicateEventError",
|
|
33
|
+
"IdpUnavailable",
|
|
34
|
+
"InvalidSignatureError",
|
|
35
|
+
"MemoryStore",
|
|
36
|
+
"OidcError",
|
|
37
|
+
"RefreshUncertain",
|
|
38
|
+
"SessionStore",
|
|
39
|
+
"Stwrd",
|
|
40
|
+
"StwrdConfig",
|
|
41
|
+
"StwrdOrganization",
|
|
42
|
+
"StwrdSession",
|
|
43
|
+
"StwrdUser",
|
|
44
|
+
"Tokens",
|
|
45
|
+
"WebhookEvent",
|
|
46
|
+
"safe_equal",
|
|
47
|
+
"verify_webhook",
|
|
48
|
+
"verify_webhook_signature",
|
|
49
|
+
]
|
stwrd/client.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""`Stwrd` — the object an app builds once and hands to `auth_router`.
|
|
2
|
+
|
|
3
|
+
Ties `StwrdConfig`, the `OidcClient`, a `SessionStore` and the webhook dedup
|
|
4
|
+
set together, and owns the cookie sealing that keeps every secret (tokens,
|
|
5
|
+
`id_token` claims) on the server side — the browser only ever sees an opaque,
|
|
6
|
+
HMAC-signed cookie value.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import secrets
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from .config import ConfigError, StwrdConfig
|
|
27
|
+
from .oidc import (
|
|
28
|
+
IdpUnavailable,
|
|
29
|
+
OidcClient,
|
|
30
|
+
OidcError,
|
|
31
|
+
RefreshUncertain,
|
|
32
|
+
generate_verifier,
|
|
33
|
+
new_nonce,
|
|
34
|
+
new_state,
|
|
35
|
+
)
|
|
36
|
+
from .organizations import OwnOrganization, list_own_organizations
|
|
37
|
+
from .sessions import (
|
|
38
|
+
AUTHORITY_KEYS,
|
|
39
|
+
MemoryStore,
|
|
40
|
+
RefreshClaim,
|
|
41
|
+
SessionStore,
|
|
42
|
+
StwrdSession,
|
|
43
|
+
Tokens,
|
|
44
|
+
merge_userinfo,
|
|
45
|
+
)
|
|
46
|
+
from .webhooks import DuplicateEventError, InvalidSignatureError, SeenWebhookIds, WebhookEvent
|
|
47
|
+
from .webhooks import safe_equal as _safe_equal
|
|
48
|
+
from .webhooks import verify_webhook as _verify_webhook
|
|
49
|
+
|
|
50
|
+
# Longer than the IdP HTTP timeout so a live exchange never loses its lease; a
|
|
51
|
+
# waiting worker polls for the lease holder's result for at most REFRESH_WAIT_S.
|
|
52
|
+
REFRESH_LEASE_S = 30.0
|
|
53
|
+
REFRESH_WAIT_S = 10.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _finite(value: float) -> float:
|
|
57
|
+
if not math.isfinite(value):
|
|
58
|
+
raise ValueError("Non-finite expiry.")
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class AuthorizationState:
|
|
64
|
+
"""The pending-login transaction: `state`, `nonce` and the PKCE verifier
|
|
65
|
+
are kept server-side, and the browser only carries an opaque blob. This
|
|
66
|
+
is that state, sealed into the `tx_cookie`; the browser round-trips only
|
|
67
|
+
the sealed value.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
state: str
|
|
71
|
+
nonce: str
|
|
72
|
+
code_verifier: str
|
|
73
|
+
return_to: str
|
|
74
|
+
created_at: float
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def connect_to_hook(connect_to: str) -> Callable[[httpx.Request], Awaitable[None]]:
|
|
78
|
+
"""The `httpx` request hook behind `StwrdConfig.connect_to`: every
|
|
79
|
+
request goes to `connect_to`'s scheme/host/port while its path, query
|
|
80
|
+
and `Host` header stay the issuer's — the IdP routes the tenant by
|
|
81
|
+
`Host`, so this is how an app talks to an IdP on `127.0.0.1` AS
|
|
82
|
+
`app.example.com` without DNS or `/etc/hosts`. Public so that an app
|
|
83
|
+
that builds its own `httpx.AsyncClient` (a proxy, a custom transport)
|
|
84
|
+
can install the same hook instead of re-deriving it.
|
|
85
|
+
"""
|
|
86
|
+
parts = urlsplit(connect_to)
|
|
87
|
+
scheme = parts.scheme
|
|
88
|
+
host = parts.hostname or ""
|
|
89
|
+
port = parts.port
|
|
90
|
+
|
|
91
|
+
async def to_target(request: httpx.Request) -> None:
|
|
92
|
+
# `httpx` fixed `Host` from the original URL when it built the
|
|
93
|
+
# request; `copy_with` on the URL leaves the header alone.
|
|
94
|
+
request.url = request.url.copy_with(scheme=scheme, host=host, port=port)
|
|
95
|
+
|
|
96
|
+
return to_target
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _own_http_client(config: StwrdConfig) -> httpx.AsyncClient:
|
|
100
|
+
if config.connect_to is None:
|
|
101
|
+
return httpx.AsyncClient()
|
|
102
|
+
return httpx.AsyncClient(event_hooks={"request": [connect_to_hook(config.connect_to)]})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Stwrd:
|
|
106
|
+
"""`Stwrd(config, *, sessions=None, http_client=None)`.
|
|
107
|
+
|
|
108
|
+
`http_client` is injected — an `httpx.AsyncClient` — which is what makes
|
|
109
|
+
the end-to-end flow testable without sockets: a test mounts it against an
|
|
110
|
+
in-process ASGI app with `ASGITransport` and every back-channel call
|
|
111
|
+
(discovery, token, JWKS, userinfo) goes through it with no network
|
|
112
|
+
involved. It is an `AsyncClient` and not the sync `httpx.Client` because
|
|
113
|
+
`ASGITransport` only implements async requests.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
config: StwrdConfig,
|
|
119
|
+
*,
|
|
120
|
+
sessions: SessionStore | None = None,
|
|
121
|
+
http_client: httpx.AsyncClient | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self.config = config
|
|
124
|
+
self.sessions: SessionStore = sessions if sessions is not None else MemoryStore()
|
|
125
|
+
self._http_owned = http_client is None
|
|
126
|
+
self._http: httpx.AsyncClient = (
|
|
127
|
+
http_client if http_client is not None else _own_http_client(config)
|
|
128
|
+
)
|
|
129
|
+
self.oidc = OidcClient(self._http, config.oidc())
|
|
130
|
+
self.seen_webhook_ids = SeenWebhookIds()
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_env(
|
|
134
|
+
cls,
|
|
135
|
+
environ: Mapping[str, str] | None = None,
|
|
136
|
+
**overrides: object,
|
|
137
|
+
) -> Stwrd:
|
|
138
|
+
"""`StwrdConfig.from_env` builds the config; `sessions`/`http_client`
|
|
139
|
+
are runtime objects, not environment values, so they are pulled out
|
|
140
|
+
of `overrides` before the rest reaches `StwrdConfig.from_env`."""
|
|
141
|
+
sessions = overrides.pop("sessions", None) # type: ignore[assignment]
|
|
142
|
+
http_client = overrides.pop("http_client", None) # type: ignore[assignment]
|
|
143
|
+
config = StwrdConfig.from_env(environ, **overrides) # type: ignore[arg-type]
|
|
144
|
+
return cls(config, sessions=sessions, http_client=http_client) # type: ignore[arg-type]
|
|
145
|
+
|
|
146
|
+
async def close(self) -> None:
|
|
147
|
+
"""Close the HTTP client, but only if this instance created it.
|
|
148
|
+
|
|
149
|
+
Closing an injected client would surprise whoever owns it.
|
|
150
|
+
"""
|
|
151
|
+
if self._http_owned:
|
|
152
|
+
await self._http.aclose()
|
|
153
|
+
|
|
154
|
+
# --- cookie sealing -------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def _sign(self, data: str) -> str:
|
|
157
|
+
key = self.config.cookie_secret.encode("utf-8")
|
|
158
|
+
mac = hmac.new(key, data.encode("utf-8"), hashlib.sha256)
|
|
159
|
+
return base64.urlsafe_b64encode(mac.digest()).rstrip(b"=").decode("ascii")
|
|
160
|
+
|
|
161
|
+
def seal(self, value: Any) -> str:
|
|
162
|
+
"""HMAC-signed, base64url cookie value. Not encryption — nothing
|
|
163
|
+
sealed carries a secret the browser must not read (session ids,
|
|
164
|
+
`state`/`nonce`/PKCE verifier are all opaque already); what this
|
|
165
|
+
buys is tamper-evidence, so a forged cookie unseals to nothing."""
|
|
166
|
+
payload = json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
167
|
+
payload_b64 = base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii")
|
|
168
|
+
return f"{payload_b64}.{self._sign(payload_b64)}"
|
|
169
|
+
|
|
170
|
+
def unseal(self, cookie: str | None) -> Any | None:
|
|
171
|
+
"""`None` on anything wrong — missing cookie, bad signature, bad
|
|
172
|
+
JSON. The caller (the router) always treats that the same as "no
|
|
173
|
+
session"/"no transaction", never as an error to surface."""
|
|
174
|
+
if not cookie or "." not in cookie:
|
|
175
|
+
return None
|
|
176
|
+
payload_b64, _, signature = cookie.rpartition(".")
|
|
177
|
+
if not signature or not _safe_equal(signature, self._sign(payload_b64)):
|
|
178
|
+
return None
|
|
179
|
+
try:
|
|
180
|
+
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
|
|
181
|
+
return json.loads(base64.urlsafe_b64decode(padded))
|
|
182
|
+
except (ValueError, UnicodeDecodeError):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def session_id_for_sid(self, sid: str) -> str:
|
|
186
|
+
"""The local session id for an IdP session — derived, never random.
|
|
187
|
+
|
|
188
|
+
This is what makes back-channel logout able to actually end the local
|
|
189
|
+
session. The IdP's notice only carries `sid`; the `SessionStore`
|
|
190
|
+
protocol looks sessions up by the BFF's own opaque id. Deriving one
|
|
191
|
+
from the other closes that gap **without extending the protocol**:
|
|
192
|
+
logout becomes `store.delete(session_id_for_sid(sid))` using the
|
|
193
|
+
`delete(id)` every store already implements, including third-party
|
|
194
|
+
ones. The alternative — adding `delete_by_sid` — would break existing
|
|
195
|
+
stores and cost a secondary index (and one extra write per login) in
|
|
196
|
+
every real store.
|
|
197
|
+
|
|
198
|
+
The derivation is keyed, so the id is not guessable from a `sid` that
|
|
199
|
+
leaked. Rotating `cookie_secret` changes it and orphans the mapping —
|
|
200
|
+
which costs nothing, because that same rotation already invalidates
|
|
201
|
+
every sealed cookie, so those sessions are unreachable anyway.
|
|
202
|
+
"""
|
|
203
|
+
return self._sign(f"sid:{sid}")
|
|
204
|
+
|
|
205
|
+
def csrf(self, session_id: str) -> str:
|
|
206
|
+
"""A CSRF token derived from the session id, not stored separately —
|
|
207
|
+
recomputing it is a comparison, never a lookup. CSRF protection is mandatory."""
|
|
208
|
+
return self._sign(f"csrf:{session_id}")
|
|
209
|
+
|
|
210
|
+
# --- authorization state (the sign-in transaction) -------------------
|
|
211
|
+
|
|
212
|
+
def new_authorization_state(self, *, return_to: str = "/") -> AuthorizationState:
|
|
213
|
+
return AuthorizationState(
|
|
214
|
+
state=new_state(),
|
|
215
|
+
nonce=new_nonce(),
|
|
216
|
+
code_verifier=generate_verifier(),
|
|
217
|
+
return_to=return_to,
|
|
218
|
+
created_at=time.time(),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# --- organizations -----------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def organization_selection_enabled(self) -> bool:
|
|
224
|
+
"""Organization selection needs the `org` scope to be requested
|
|
225
|
+
explicitly; it is never added automatically."""
|
|
226
|
+
return "org" in self.config.scope.split()
|
|
227
|
+
|
|
228
|
+
async def list_organizations(self, session: StwrdSession) -> list[OwnOrganization]:
|
|
229
|
+
"""The person's own usable organizations, read with their access token."""
|
|
230
|
+
discovery = await self.oidc.discover()
|
|
231
|
+
return await list_own_organizations(
|
|
232
|
+
self._http, self.config.issuer, discovery, session.tokens.access_token
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# --- sessions ----------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
async def resolve_session(self, cookie: str | None) -> StwrdSession | None:
|
|
238
|
+
"""The session ready to authorize with — renews tokens if the store
|
|
239
|
+
and the session support it.
|
|
240
|
+
|
|
241
|
+
**The refresh branch.** With no `refresh_token` on the
|
|
242
|
+
session (no `offline_access` at login), the credential's lifetime is
|
|
243
|
+
still the access token's — unchanged. With one, an
|
|
244
|
+
expired access token is renewed against the token endpoint instead
|
|
245
|
+
of ending the session outright; a **rejection** of that renewal
|
|
246
|
+
(revoked, reused, a dead family) still ends the local session,
|
|
247
|
+
because at that point the SDK can no longer vouch for the claims it
|
|
248
|
+
is holding; carrying on with the old ones would be the hole.
|
|
249
|
+
|
|
250
|
+
**An IdP that does not answer is not a rejection, so it ends
|
|
251
|
+
nothing.** `IdpUnavailable` propagates to the caller instead of
|
|
252
|
+
becoming `None`: the IdP did not say the credential is invalid, it
|
|
253
|
+
said nothing. Turning silence into "no session" would sign out
|
|
254
|
+
everyone who was renewing every time the IdP restarted, and would buy
|
|
255
|
+
no security — the hole is serving stale claims, and this does not:
|
|
256
|
+
it returns no session, it raises. The caller answers 503 and the
|
|
257
|
+
session stays for the next attempt.
|
|
258
|
+
"""
|
|
259
|
+
payload = self.unseal(cookie)
|
|
260
|
+
if not payload or not isinstance(payload, dict) or "sid" not in payload:
|
|
261
|
+
return None
|
|
262
|
+
session = await self.sessions.get(payload["sid"])
|
|
263
|
+
if session is None:
|
|
264
|
+
return None
|
|
265
|
+
if session.is_expired():
|
|
266
|
+
await self.sessions.delete(session.id)
|
|
267
|
+
return None
|
|
268
|
+
if session.access_token_expired():
|
|
269
|
+
if not session.tokens.refresh_token:
|
|
270
|
+
# Without `offline_access` there is no renewal: the
|
|
271
|
+
# credential's lifetime is the access token's.
|
|
272
|
+
await self.sessions.delete(session.id)
|
|
273
|
+
return None
|
|
274
|
+
# `_renew` raises `IdpUnavailable` and it is deliberately NOT
|
|
275
|
+
# caught here: that exception is the difference between "no
|
|
276
|
+
# session" and "cannot tell right now", and catching it would
|
|
277
|
+
# delete the session.
|
|
278
|
+
renewed = await self._renew(session)
|
|
279
|
+
if renewed is None:
|
|
280
|
+
await self.sessions.delete(session.id)
|
|
281
|
+
return None
|
|
282
|
+
return renewed
|
|
283
|
+
return session
|
|
284
|
+
|
|
285
|
+
async def _renew(self, session: StwrdSession) -> StwrdSession | None:
|
|
286
|
+
"""Refresh `session` under the store's lease: across every worker the
|
|
287
|
+
refresh token is exchanged at most once.
|
|
288
|
+
|
|
289
|
+
`None` when the IdP REJECTED the renewal (revoked, reused, dead
|
|
290
|
+
family, unreadable answer) or the session was deleted meanwhile;
|
|
291
|
+
`resolve_session` then ends the local session.
|
|
292
|
+
|
|
293
|
+
`IdpUnavailable` propagates when nothing can be vouched for right now:
|
|
294
|
+
the IdP is silent, another worker holds the lease for too long, or an
|
|
295
|
+
earlier exchange may have rotated the refresh token without its result
|
|
296
|
+
being stored (`uncertain`). The last case is permanent for that
|
|
297
|
+
session: the consumed token is never replayed, so the user signs in
|
|
298
|
+
again. A request that provably never left (`request_sent=False`)
|
|
299
|
+
releases the lease and the next request simply retries.
|
|
300
|
+
"""
|
|
301
|
+
owner = secrets.token_urlsafe(16)
|
|
302
|
+
deadline = time.monotonic() + REFRESH_WAIT_S
|
|
303
|
+
current = session
|
|
304
|
+
while True:
|
|
305
|
+
claim = await self.sessions.claim_refresh(current.id, owner, REFRESH_LEASE_S)
|
|
306
|
+
if claim.status == "granted":
|
|
307
|
+
return await self._renew_owned(claim)
|
|
308
|
+
if claim.status == "gone":
|
|
309
|
+
return None
|
|
310
|
+
if claim.status == "uncertain":
|
|
311
|
+
raise RefreshUncertain(
|
|
312
|
+
"A refresh may have been processed without its result; sign in again."
|
|
313
|
+
)
|
|
314
|
+
if time.monotonic() >= deadline:
|
|
315
|
+
raise IdpUnavailable("Another worker is still refreshing this session.")
|
|
316
|
+
await asyncio.sleep(0.05)
|
|
317
|
+
latest = await self.sessions.get(current.id)
|
|
318
|
+
if latest is None or latest.is_expired():
|
|
319
|
+
return None
|
|
320
|
+
if not latest.access_token_expired():
|
|
321
|
+
return latest
|
|
322
|
+
current = latest
|
|
323
|
+
|
|
324
|
+
async def _renew_owned(self, claim: RefreshClaim) -> StwrdSession | None:
|
|
325
|
+
assert claim.session is not None
|
|
326
|
+
session, fence = claim.session, claim.fence
|
|
327
|
+
if claim.phase == "acquired":
|
|
328
|
+
if not session.access_token_expired():
|
|
329
|
+
await self.sessions.release_refresh(session.id, fence, sent=False)
|
|
330
|
+
return session
|
|
331
|
+
if not session.tokens.refresh_token:
|
|
332
|
+
await self.sessions.release_refresh(session.id, fence, sent=False)
|
|
333
|
+
return None
|
|
334
|
+
try:
|
|
335
|
+
# Discovery is read before anything is marked as sent: a failure
|
|
336
|
+
# here proves nothing left for the token endpoint.
|
|
337
|
+
await self.oidc.discover()
|
|
338
|
+
except IdpUnavailable:
|
|
339
|
+
await self.sessions.release_refresh(session.id, fence, sent=False)
|
|
340
|
+
raise
|
|
341
|
+
except OidcError:
|
|
342
|
+
await self.sessions.release_refresh(session.id, fence, sent=False)
|
|
343
|
+
raise IdpUnavailable("The IdP discovery document is unusable.") from None
|
|
344
|
+
if not await self.sessions.mark_refresh_sent(session.id, fence):
|
|
345
|
+
raise IdpUnavailable("The refresh lease was lost; try again.")
|
|
346
|
+
try:
|
|
347
|
+
token_response = await self.oidc.exchange_refresh_token(
|
|
348
|
+
session.tokens.refresh_token
|
|
349
|
+
)
|
|
350
|
+
except IdpUnavailable as exc:
|
|
351
|
+
await self.sessions.release_refresh(session.id, fence, sent=exc.request_sent)
|
|
352
|
+
raise
|
|
353
|
+
except (OidcError, KeyError):
|
|
354
|
+
return None
|
|
355
|
+
now = time.time()
|
|
356
|
+
try:
|
|
357
|
+
tokens = Tokens(
|
|
358
|
+
access_token=token_response["access_token"],
|
|
359
|
+
id_token=token_response.get("id_token", session.tokens.id_token),
|
|
360
|
+
token_type=token_response.get("token_type", "Bearer"),
|
|
361
|
+
expires_at=_finite(now + float(token_response.get("expires_in", 3600))),
|
|
362
|
+
refresh_token=token_response.get("refresh_token"),
|
|
363
|
+
)
|
|
364
|
+
except (KeyError, TypeError, ValueError):
|
|
365
|
+
return None
|
|
366
|
+
fresh_id_token = bool(token_response.get("id_token"))
|
|
367
|
+
# The exchange happened: the old refresh token is consumed. Store
|
|
368
|
+
# the rotation before any further network call. If the lease was
|
|
369
|
+
# lost (logout, takeover) the session ends instead of resurrecting.
|
|
370
|
+
if not await self.sessions.checkpoint_refresh(
|
|
371
|
+
session.id, fence, tokens, fresh_id_token=fresh_id_token
|
|
372
|
+
):
|
|
373
|
+
return await self._lease_lost(session.id)
|
|
374
|
+
access_token = tokens.access_token
|
|
375
|
+
else:
|
|
376
|
+
tokens = session.tokens
|
|
377
|
+
fresh_id_token = claim.fresh_id_token
|
|
378
|
+
access_token = tokens.access_token
|
|
379
|
+
now = time.time()
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
userinfo_claims = await self.oidc.userinfo(access_token)
|
|
383
|
+
id_claims: dict[str, Any] = {}
|
|
384
|
+
if fresh_id_token:
|
|
385
|
+
id_claims = await self.oidc.validate_id_token(
|
|
386
|
+
tokens.id_token, nonce=None, access_token=access_token
|
|
387
|
+
)
|
|
388
|
+
except IdpUnavailable:
|
|
389
|
+
# Tokens are checkpointed: a later claim retries userinfo only.
|
|
390
|
+
await self.sessions.release_refresh(session.id, fence, sent=True)
|
|
391
|
+
raise
|
|
392
|
+
except (OidcError, KeyError):
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
if id_claims and id_claims.get("sub") != session.sub:
|
|
396
|
+
return None
|
|
397
|
+
base = (
|
|
398
|
+
id_claims
|
|
399
|
+
if fresh_id_token
|
|
400
|
+
else {k: v for k, v in session.claims.items() if k not in AUTHORITY_KEYS}
|
|
401
|
+
)
|
|
402
|
+
try:
|
|
403
|
+
claims = merge_userinfo(base, userinfo_claims)
|
|
404
|
+
except OidcError:
|
|
405
|
+
return None
|
|
406
|
+
renewed = StwrdSession(
|
|
407
|
+
id=session.id,
|
|
408
|
+
sid_idp=claims.get("sid", session.sid_idp),
|
|
409
|
+
sub=session.sub,
|
|
410
|
+
claims=claims,
|
|
411
|
+
tokens=tokens,
|
|
412
|
+
expires_at=now + self.config.session_ttl_s,
|
|
413
|
+
access_expires_at=tokens.expires_at,
|
|
414
|
+
)
|
|
415
|
+
if not await self.sessions.complete_refresh(session.id, fence, renewed):
|
|
416
|
+
return await self._lease_lost(session.id)
|
|
417
|
+
return renewed
|
|
418
|
+
|
|
419
|
+
async def _lease_lost(self, session_id: str) -> None:
|
|
420
|
+
"""A lost lease is not a rejection: only a session that no longer
|
|
421
|
+
exists ends (logout). One that is still there, possibly re-created by a
|
|
422
|
+
new sign-in, must not be deleted by a stale owner."""
|
|
423
|
+
if await self.sessions.get(session_id) is None:
|
|
424
|
+
return None
|
|
425
|
+
raise IdpUnavailable("The refresh lease was lost; try again.")
|
|
426
|
+
|
|
427
|
+
async def session_from_cookie(self, cookie: str | None) -> StwrdSession | None:
|
|
428
|
+
"""Raw store read: unseals the cookie and looks the session up, with
|
|
429
|
+
no renewal and no expiry side effects.
|
|
430
|
+
|
|
431
|
+
This is async like the rest of `SessionStore`, so a real store — Redis,
|
|
432
|
+
a database table — can do I/O.
|
|
433
|
+
"""
|
|
434
|
+
payload = self.unseal(cookie)
|
|
435
|
+
if not payload or not isinstance(payload, dict) or "sid" not in payload:
|
|
436
|
+
return None
|
|
437
|
+
return await self.sessions.get(payload["sid"])
|
|
438
|
+
|
|
439
|
+
# --- webhooks -----------------------------------------------------
|
|
440
|
+
|
|
441
|
+
def verify_webhook(self, body: bytes, headers: Mapping[str, str]) -> WebhookEvent:
|
|
442
|
+
"""`POST /auth/webhook`. Deduplicates against
|
|
443
|
+
`self.seen_webhook_ids` (a 30-hour window). Raises `ConfigError` if no
|
|
444
|
+
`webhook_secret` is configured — the router turns that into a 503."""
|
|
445
|
+
if not self.config.webhook_secret:
|
|
446
|
+
raise ConfigError(
|
|
447
|
+
"STWRD_WEBHOOK_SECRET is not configured: no webhook can be "
|
|
448
|
+
"verified without a secret."
|
|
449
|
+
)
|
|
450
|
+
return _verify_webhook(
|
|
451
|
+
body,
|
|
452
|
+
headers,
|
|
453
|
+
self.config.webhook_secret,
|
|
454
|
+
seen=self.seen_webhook_ids,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
__all__ = [
|
|
459
|
+
"AuthorizationState",
|
|
460
|
+
"ConfigError",
|
|
461
|
+
"DuplicateEventError",
|
|
462
|
+
"InvalidSignatureError",
|
|
463
|
+
"OidcError",
|
|
464
|
+
"Stwrd",
|
|
465
|
+
"connect_to_hook",
|
|
466
|
+
]
|