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