fastcas-sdk 0.1.1__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.
fastcas/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Optional FastCAS authentication. Applications retain local accounts and ACLs."""
2
+ from .client import AsyncFastCAS, Configuration, FastCAS, FastCASError
3
+ from .store import SQLiteTransactionStore, TransactionStore
4
+
5
+ __all__ = ["AsyncFastCAS", "Configuration", "FastCAS", "FastCASError", "SQLiteTransactionStore", "TransactionStore"]
fastcas/client.py ADDED
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import hmac
7
+ import secrets
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass
11
+ from typing import Awaitable, Callable
12
+ from urllib.parse import urlencode, urlparse, parse_qs, quote
13
+
14
+ import httpx
15
+ from authlib.jose import JsonWebToken
16
+ from authlib.oidc.core import CodeIDToken
17
+
18
+ from .store import TransactionStore
19
+
20
+
21
+ class FastCASError(Exception):
22
+ def __init__(self, code: str, message: str, status: int = 400):
23
+ super().__init__(message)
24
+ self.code, self.status = code, status
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Configuration:
29
+ issuer: str
30
+ client_id: str
31
+ redirect_uri: str
32
+ client_secret: str = ""
33
+ allow_loopback_http: bool = False
34
+ timeout: float = 10
35
+
36
+
37
+ def _hash(value: str) -> str:
38
+ return hashlib.sha256(value.encode()).hexdigest()
39
+
40
+
41
+ def _url(value: str, dev: bool):
42
+ u = urlparse(value)
43
+ if not u.netloc or u.username or u.password or u.fragment:
44
+ raise FastCASError("invalid_url", "Invalid endpoint URL")
45
+ if u.scheme != "https" and not (dev and u.scheme == "http" and u.hostname in {"localhost", "127.0.0.1", "::1"}):
46
+ raise FastCASError("https_required", "HTTPS required outside explicit loopback development")
47
+ return u
48
+
49
+
50
+ class FastCAS:
51
+ def __init__(self, configuration: Configuration, transactions: TransactionStore, *, http: httpx.Client | None = None):
52
+ self.config, self.transactions = configuration, transactions
53
+ issuer = _url(configuration.issuer, configuration.allow_loopback_http)
54
+ if issuer.path or issuer.query or not configuration.client_id:
55
+ raise FastCASError("invalid_configuration", "Issuer origin and client ID required")
56
+ _url(configuration.redirect_uri, configuration.allow_loopback_http)
57
+ self.http = http or httpx.Client(timeout=configuration.timeout, follow_redirects=False)
58
+ self._owns_http = http is None
59
+ self._metadata: dict | None = None
60
+ self._keys: dict | None = None
61
+ self._keys_at = 0.0
62
+ self._keys_lock = threading.Lock()
63
+ self._jwt = JsonWebToken(["RS256"])
64
+
65
+ def reset_verification_cache(self):
66
+ """Trusted operations only, after draining in-flight authentication.
67
+
68
+ This does not revoke existing application sessions.
69
+ """
70
+ with self._keys_lock:
71
+ self._keys = None
72
+ self._keys_at = 0.0
73
+ self._metadata = None
74
+
75
+ def close(self):
76
+ if self._owns_http:
77
+ self.http.close()
78
+
79
+ def _json(self, response: httpx.Response) -> dict:
80
+ if not response.is_success:
81
+ try:
82
+ error = response.json()
83
+ except ValueError:
84
+ error = {}
85
+ raise FastCASError(error.get("code") or error.get("error") or "request_failed", error.get("message") or "FastCAS request failed", response.status_code)
86
+ result = response.json()
87
+ if not isinstance(result, dict):
88
+ raise FastCASError("invalid_response", "Expected an object response")
89
+ return result
90
+
91
+ def discovery(self) -> dict:
92
+ if self._metadata is None:
93
+ metadata = self._json(self.http.get(self.config.issuer + "/.well-known/openid-configuration"))
94
+ if metadata.get("issuer") != self.config.issuer:
95
+ raise FastCASError("issuer_mismatch", "Unexpected discovery issuer")
96
+ for field in ("authorization_endpoint", "token_endpoint", "jwks_uri", "userinfo_endpoint"):
97
+ endpoint = _url(metadata.get(field, ""), self.config.allow_loopback_http)
98
+ if f"{endpoint.scheme}://{endpoint.netloc}" != self.config.issuer:
99
+ raise FastCASError("endpoint_mismatch", "FastCAS endpoints must belong to the configured issuer")
100
+ self._metadata = metadata
101
+ return dict(self._metadata)
102
+
103
+ def _jwks(self, force=False) -> dict:
104
+ with self._keys_lock:
105
+ age = time.monotonic() - self._keys_at
106
+ if self._keys is None or age > 300 or (force and age > 30):
107
+ self._keys = self._json(self.http.get(self.discovery()["jwks_uri"]))
108
+ self._keys_at = time.monotonic()
109
+ return self._keys
110
+
111
+ def _claims(self, token: str, audience: str, *, nonce: str | None = None, access_token: str = "", event: bool = False, logout: bool = False) -> dict:
112
+ options = {"iss": {"essential": True, "value": self.config.issuer}, "aud": {"essential": True, "value": audience}, "sub": {"essential": True}, "iat": {"essential": True}, "exp": {"essential": True}}
113
+ if event:
114
+ options.pop("sub")
115
+ options["jti"] = {"essential": True}
116
+ kwargs = {"claims_options": options}
117
+ if nonce is not None:
118
+ kwargs.update(claims_cls=CodeIDToken, claims_params={"client_id": audience, "nonce": nonce, "access_token": access_token})
119
+ try:
120
+ # Only the configured JWKS is used. JWT jku/x5u never selects a URL.
121
+ try:
122
+ claims = self._jwt.decode(token, self._jwks(), **kwargs)
123
+ except (ValueError, KeyError):
124
+ claims = self._jwt.decode(token, self._jwks(force=True), **kwargs)
125
+ claims.validate(leeway=60)
126
+ if event and claims.header.get("typ") != "fastcas-event+jwt":
127
+ raise ValueError("invalid event type")
128
+ if logout and claims.header.get("typ") != "logout+jwt":
129
+ raise ValueError("invalid logout type")
130
+ if nonce is not None and not hmac.compare_digest(str(claims.get("nonce", "")), nonce):
131
+ raise ValueError("nonce mismatch")
132
+ return dict(claims)
133
+ except Exception as exc:
134
+ if isinstance(exc, (httpx.HTTPError, FastCASError)):
135
+ raise
136
+ raise FastCASError("invalid_token", "Token validation failed", 401) from None
137
+
138
+ def begin_login(self, browser_binding: str, *, return_to: str = "/", scopes: list[str] | None = None) -> str:
139
+ return self._begin("login", browser_binding, return_to, scopes)
140
+
141
+ def begin_link(self, browser_binding: str, *, local_account_ref: str, local_session_id: str, return_to: str = "/", scopes: list[str] | None = None) -> str:
142
+ if not local_account_ref or not local_session_id:
143
+ raise FastCASError("local_proof_required", "Recent local account and session proof required")
144
+ return self._begin("link", browser_binding, return_to, scopes, local_account_ref, local_session_id)
145
+
146
+ def _begin(self, purpose, binding, return_to, scopes, local_ref="", session="") -> str:
147
+ if len(binding) < 32:
148
+ raise FastCASError("binding_required", "Fresh browser binding required")
149
+ if not return_to.startswith("/") or return_to.startswith("//") or any(c in return_to for c in "\\\r\n"):
150
+ raise FastCASError("invalid_return_to", "Application-relative return path required")
151
+ endpoint = self.discovery()["authorization_endpoint"]
152
+ tx = dict(state=secrets.token_urlsafe(32), nonce=secrets.token_urlsafe(32), verifier=secrets.token_urlsafe(32), binding_hash=_hash(binding), purpose=purpose, return_to=return_to, expires_at=time.time()+300)
153
+ if purpose in {"link", "register"}:
154
+ tx.update(local_account_ref=local_ref, local_session_id=session)
155
+ intent = self._api("POST", "/api/v1/link-intents", {"local_account_ref": local_ref}, {"Idempotency-Key": tx["state"]})
156
+ tx.update(intent=intent, nonce=intent["nonce"])
157
+ self.transactions.put(tx)
158
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(tx["verifier"].encode()).digest()).rstrip(b"=").decode()
159
+ return endpoint + "?" + urlencode(dict(client_id=self.config.client_id, redirect_uri=self.config.redirect_uri, response_type="code", scope=" ".join(scopes or ["openid", "profile", "email"]), state=tx["state"], nonce=tx["nonce"], code_challenge=challenge, code_challenge_method="S256"))
160
+
161
+ def begin_registration(self, browser_binding: str, *, new_local_account_ref: str, return_to: str = "/", scopes: list[str] | None = None) -> str:
162
+ """Use a newly reserved ID under the application's signup policy.
163
+ Atomically reject existing IDs when creating the user and pending link.
164
+ """
165
+ if not new_local_account_ref:
166
+ raise FastCASError("new_account_required", "New local account reference required")
167
+ return self._begin("register", browser_binding, return_to, scopes, new_local_account_ref)
168
+
169
+ def finish_login(self, callback: str, browser_binding: str, *, local_account_ref: str = "", local_session_id: str = "") -> dict:
170
+ actual, expected = urlparse(callback), urlparse(self.config.redirect_uri)
171
+ if (actual.scheme, actual.netloc, actual.path) != (expected.scheme, expected.netloc, expected.path):
172
+ raise FastCASError("callback_mismatch", "Unexpected callback URL")
173
+ query = parse_qs(actual.query)
174
+ state = query.get("state", [""])[0]
175
+ if not state:
176
+ raise FastCASError("state_missing", "Callback state required")
177
+ tx = self.transactions.take(state)
178
+ if not tx or tx["expires_at"] <= time.time() or not hmac.compare_digest(tx["binding_hash"], _hash(browser_binding)):
179
+ raise FastCASError("transaction_invalid", "Transaction expired, consumed, or belongs to another browser")
180
+ if tx["purpose"] == "link" and (tx["local_account_ref"] != local_account_ref or tx["local_session_id"] != local_session_id):
181
+ raise FastCASError("local_account_changed", "Local account changed during linking")
182
+ if "error" in query or not query.get("code"):
183
+ raise FastCASError("authorization_denied", "Authorization was not granted")
184
+ tokens = self._token(dict(grant_type="authorization_code", code=query["code"][0], redirect_uri=self.config.redirect_uri, code_verifier=tx["verifier"]))
185
+ claims = self._claims(tokens.get("id_token", ""), self.config.client_id, nonce=tx["nonce"], access_token=tokens["access_token"])
186
+ profile = self._json(self.http.get(self.discovery()["userinfo_endpoint"], headers={"Authorization": "Bearer "+tokens["access_token"]}))
187
+ if profile.get("sub") != claims["sub"]:
188
+ raise FastCASError("subject_mismatch", "Userinfo subject mismatch")
189
+ return {"identity": dict(issuer=claims["iss"], subject=claims["sub"], name=profile.get("name"), email=profile.get("email"), email_verified=profile.get("email_verified") is True, sid=claims.get("sid")), "transaction": tx, "tokens": tokens}
190
+
191
+ def prepare_link(self, result: dict) -> dict:
192
+ tx = result["transaction"]
193
+ if tx["purpose"] not in {"link", "register"} or not tx.get("intent"):
194
+ raise FastCASError("link_required", "Completed linking transaction required")
195
+ return self._api("POST", "/api/v1/link-intents/"+quote(tx["intent"]["id"], safe="")+"/prepare", dict(nonce=tx["intent"]["nonce"], subject=result["identity"]["subject"]))
196
+
197
+ def activate_link(self, link_id: str) -> dict:
198
+ """Call after the local pending link is committed; persist active afterwards."""
199
+ return self._api("POST", "/api/v1/link-intents/"+quote(link_id, safe="")+"/activate")
200
+
201
+ def get_link(self, link_id: str) -> dict:
202
+ return self._api("GET", "/api/v1/account-links/"+quote(link_id, safe=""))
203
+
204
+ def resolve_link(self, subject: str) -> dict:
205
+ return self._api("GET", "/api/v1/account-links/resolve?"+urlencode({"subject": subject}))
206
+
207
+ def revoke_link(self, link: dict) -> dict:
208
+ return self._api("POST", "/api/v1/account-links/"+quote(link["id"], safe="")+"/revoke", headers={"If-Match": '"'+str(link["version"])+'"'})
209
+
210
+ def verify_access_token(self, token: str, audience: str, scopes: list[str] | None = None) -> dict:
211
+ claims = self._claims(token, audience)
212
+ if claims.get("token_use") != "access" or not isinstance(claims.get("scope"), str):
213
+ raise FastCASError("wrong_token_type", "API access token required", 401)
214
+ if not set(scopes or []).issubset(set(claims["scope"].split())):
215
+ raise FastCASError("insufficient_scope", "Required permission missing", 403)
216
+ return claims
217
+
218
+ def introspect_token(self, token: str) -> dict:
219
+ """Check central revocation after local JWT/audience/scope validation."""
220
+ if not self.config.client_secret or not token:
221
+ raise FastCASError("confidential_client_required", "Confidential client credential and token required")
222
+ raw = self.discovery().get("introspection_endpoint", "")
223
+ parsed = _url(raw, self.config.allow_loopback_http)
224
+ if f"{parsed.scheme}://{parsed.netloc}" != self.config.issuer:
225
+ raise FastCASError("endpoint_mismatch", "Introspection endpoint must belong to the issuer")
226
+ result = self._json(self.http.post(raw, data={"token": token}, auth=self._auth(), follow_redirects=False))
227
+ if type(result.get("active")) is not bool:
228
+ raise FastCASError("invalid_response", "Invalid introspection response")
229
+ return result
230
+
231
+ def refresh(self, token: str) -> dict:
232
+ return self._token(dict(grant_type="refresh_token", refresh_token=token))
233
+
234
+ def client_credentials(self, scopes: list[str]) -> dict:
235
+ return self._token(dict(grant_type="client_credentials", scope=" ".join(scopes)))
236
+
237
+ def device_authorize(self, scopes: list[str]) -> dict:
238
+ """Start a public-client device flow; the caller displays both URI and code."""
239
+ if self.config.client_secret:
240
+ raise FastCASError("public_client_required", "Device pairing uses a public client")
241
+ endpoint = self.discovery().get("device_authorization_endpoint", "")
242
+ parsed = _url(endpoint, self.config.allow_loopback_http)
243
+ if f"{parsed.scheme}://{parsed.netloc}" != self.config.issuer:
244
+ raise FastCASError("endpoint_mismatch", "Device endpoint must belong to the issuer")
245
+ result = self._json(self.http.post(endpoint, data={"client_id": self.config.client_id, "scope": " ".join(scopes)}))
246
+ verification = _url(result.get("verification_uri", ""), self.config.allow_loopback_http)
247
+ if f"{verification.scheme}://{verification.netloc}" != self.config.issuer or not result.get("device_code") or not result.get("user_code"):
248
+ raise FastCASError("invalid_response", "Invalid device authorization response")
249
+ return result
250
+
251
+ def poll_device(self, device_code: str) -> dict:
252
+ """Poll once. authorization_pending and slow_down are raised as FastCASError."""
253
+ if self.config.client_secret:
254
+ raise FastCASError("public_client_required", "Device pairing uses a public client")
255
+ tokens = self._token({"grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code})
256
+ claims = self._claims(tokens.get("id_token", ""), self.config.client_id)
257
+ access = self.verify_access_token(tokens.get("access_token", ""), self.config.client_id, ["openid"])
258
+ if claims["sub"] != access["sub"] or access.get("service") is True:
259
+ raise FastCASError("identity_mismatch", "Device authorization did not return a user identity", 401)
260
+ return {"identity": {"issuer": claims["iss"], "subject": claims["sub"]}, "tokens": tokens}
261
+
262
+ def register_device_installation(self, access_token: str, installation_id: str) -> dict:
263
+ """Bind one approved device grant to a stable local installation."""
264
+ result = self._json(self.http.post(self.config.issuer + "/api/v1/device-installations",
265
+ json={"installation_id": installation_id}, headers={"Authorization": "Bearer " + access_token}))
266
+ installation = result.get("installation")
267
+ if not isinstance(installation, dict) or installation.get("installation_id") != installation_id or not isinstance(result.get("management_secret"), str):
268
+ raise FastCASError("invalid_response", "Invalid installation registration response")
269
+ return result
270
+
271
+ def device_installation_status(self, installation_record_id: str, management_secret: str) -> dict:
272
+ result = self._json(self.http.get(self.config.issuer + "/api/v1/device-installations/" + installation_record_id,
273
+ headers={"Authorization": "Bearer " + management_secret}))
274
+ if result.get("id") != installation_record_id:
275
+ raise FastCASError("invalid_response", "Invalid installation status response")
276
+ return result
277
+
278
+ def revoke_device_installation(self, installation_record_id: str, management_secret: str) -> None:
279
+ response = self.http.post(self.config.issuer + "/api/v1/device-installations/" + installation_record_id + "/revoke",
280
+ headers={"Authorization": "Bearer " + management_secret})
281
+ if response.status_code != 204:
282
+ self._json(response)
283
+ raise FastCASError("invalid_response", "Expected an empty revoke response")
284
+
285
+ def exchange_token(self, subject_token: str, resource: str, scopes: list[str]) -> dict:
286
+ return self._token(dict(grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token=subject_token, subject_token_type="urn:ietf:params:oauth:token-type:access_token", requested_token_type="urn:ietf:params:oauth:token-type:access_token", resource=resource, audience=resource, scope=" ".join(scopes)))
287
+
288
+ def verify_logout(self, token: str) -> dict:
289
+ claims = self._claims(token, self.config.client_id, logout=True)
290
+ events = claims.get("events")
291
+ if (not isinstance(events, dict) or set(events) != {"http://schemas.openid.net/event/backchannel-logout"}
292
+ or not isinstance(events["http://schemas.openid.net/event/backchannel-logout"], dict)
293
+ or "nonce" in claims or not claims.get("jti") or abs(time.time()-claims["iat"]) > 300):
294
+ raise FastCASError("logout_invalid", "Invalid logout token", 401)
295
+ return dict(id=claims["jti"], subject=claims["sub"], sid=claims.get("sid"), expires_at=claims["exp"])
296
+
297
+ def handle_logout(self, token: str, accept_replay_id: Callable[[str, float], bool]) -> dict:
298
+ result = self.verify_logout(token)
299
+ if not accept_replay_id(result["id"], result["expires_at"]):
300
+ raise FastCASError("logout_replayed", "Logout token already processed", 401)
301
+ return dict(subject=result["subject"], sid=result["sid"])
302
+
303
+ def diagnostics(self) -> dict:
304
+ self.discovery()
305
+ return dict(issuer=self.config.issuer, client_id=self.config.client_id, ready=True)
306
+
307
+ def handle_event(self, token: str, apply: Callable[[dict], None]) -> None:
308
+ def only_link(event):
309
+ if event["type"] != "account_link.revoked":
310
+ raise FastCASError("event_invalid", "Unsupported account-link event", 401)
311
+ apply(event)
312
+ self.handle_notification(token, only_link)
313
+
314
+ def handle_notification(self, token: str, apply: Callable[[dict], None]) -> None:
315
+ """Commit ID deduplication, version comparison and local updates in one
316
+ database transaction inside apply. A committed duplicate is successful.
317
+ A failed callback must cause the HTTP handler to return non-2xx.
318
+ """
319
+ if len(token) > 65536:
320
+ raise FastCASError("event_invalid", "Event too large", 401)
321
+ claims = self._claims(token, self.config.client_id, event=True)
322
+ event = claims.get("event")
323
+ link = event.get("link") if isinstance(event, dict) else None
324
+ if (not isinstance(claims.get("jti"), str) or not claims["jti"] or abs(time.time() - claims["iat"]) > 300):
325
+ raise FastCASError("event_invalid", "Invalid event claims", 401)
326
+ if isinstance(event, dict) and event.get("type") == "identity.status_changed":
327
+ if (not isinstance(event.get("subject"), str) or not event["subject"] or
328
+ event.get("status") not in ("active", "disabled") or
329
+ type(event.get("version")) is not int or event["version"] < 2 or "link" in event):
330
+ raise FastCASError("event_invalid", "Invalid identity status event", 401)
331
+ apply(dict(id=claims["jti"], type="identity.status_changed", subject=event["subject"], status=event["status"], version=event["version"]))
332
+ return
333
+ if (not isinstance(link, dict) or event.get("type") != "account_link.revoked" or
334
+ link.get("client_id") != self.config.client_id or link.get("state") != "revoked" or
335
+ any(not isinstance(link.get(key), str) or not link[key] for key in ("id", "subject", "local_account_ref")) or
336
+ type(link.get("version")) is not int or link["version"] < 1):
337
+ raise FastCASError("event_invalid", "Invalid account-link event", 401)
338
+ apply(dict(id=claims["jti"], type=event["type"], link=link))
339
+
340
+ def _auth(self):
341
+ # OAuth Basic authentication percent-encodes both components first.
342
+ return httpx.BasicAuth(quote(self.config.client_id, safe=""), quote(self.config.client_secret, safe=""))
343
+
344
+ def _token(self, form: dict) -> dict:
345
+ form = dict(form)
346
+ if not self.config.client_secret:
347
+ form["client_id"] = self.config.client_id
348
+ return self._json(self.http.post(self.discovery()["token_endpoint"], data=form, auth=self._auth() if self.config.client_secret else None))
349
+
350
+ def _api(self, method: str, path: str, body: dict | None = None, headers: dict | None = None) -> dict:
351
+ if not self.config.client_secret:
352
+ raise FastCASError("confidential_client_required", "Application server credential required")
353
+ return self._json(self.http.request(method, self.config.issuer+path, json=body, headers=headers, auth=self._auth()))
354
+
355
+
356
+ class AsyncFastCAS:
357
+ """Async facade for synchronous framework/store adapters.
358
+
359
+ Runs blocking HTTP and database work in worker threads, never on the event
360
+ loop. The injected store must be thread safe. HTTP timeouts bound cancelled
361
+ calls; cancelling a coroutine cannot undo a consumed login transaction.
362
+ """
363
+
364
+ def __init__(self, configuration: Configuration, transactions: TransactionStore, **kwargs):
365
+ self.sync = FastCAS(configuration, transactions, **kwargs)
366
+
367
+ async def handle_event(self, token: str, apply: Callable[[dict], Awaitable[None]]) -> None:
368
+ async def only_link(event):
369
+ if event["type"] != "account_link.revoked":
370
+ raise FastCASError("event_invalid", "Unsupported account-link event", 401)
371
+ await apply(event)
372
+ await self.handle_notification(token, only_link)
373
+
374
+ async def handle_notification(self, token: str, apply: Callable[[dict], Awaitable[None]]) -> None:
375
+ # Verify off the event loop, then await the application's async database
376
+ # transaction on its original loop before allowing HTTP acknowledgement.
377
+ def verified():
378
+ events = []
379
+ self.sync.handle_notification(token, events.append)
380
+ return events[0]
381
+ event = await asyncio.to_thread(verified)
382
+ await apply(event)
383
+
384
+ def __getattr__(self, name):
385
+ if name.startswith("_"):
386
+ raise AttributeError(name)
387
+ method = getattr(self.sync, name)
388
+ if not callable(method):
389
+ raise AttributeError(name)
390
+ async def call(*args, **kwargs):
391
+ return await asyncio.to_thread(method, *args, **kwargs)
392
+ return call
fastcas/fastapi.py ADDED
@@ -0,0 +1,96 @@
1
+ """Optional FastAPI transport for signed FastCAS notifications.
2
+
3
+ Importing the main fastcas package never imports FastAPI. Applications retain
4
+ their own routes, local accounts, transactions and authorization policy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ from typing import Callable
11
+ from urllib.parse import parse_qs
12
+
13
+ from fastapi import HTTPException, Request
14
+ from starlette.concurrency import run_in_threadpool
15
+
16
+ from .client import AsyncFastCAS, FastCAS, FastCASError
17
+
18
+ MAX_NOTIFICATION_BYTES = 65536
19
+
20
+
21
+ async def _body(request: Request, content_type: str) -> str:
22
+ if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() != content_type:
23
+ raise HTTPException(415, "signed FastCAS notification required")
24
+ body = bytearray()
25
+ async for chunk in request.stream():
26
+ body.extend(chunk)
27
+ if len(body) > MAX_NOTIFICATION_BYTES:
28
+ raise HTTPException(413, "FastCAS notification too large")
29
+ try:
30
+ return body.decode("ascii")
31
+ except UnicodeDecodeError:
32
+ raise HTTPException(400, "invalid FastCAS notification") from None
33
+
34
+
35
+ async def receive_event(request: Request, sdk: FastCAS | AsyncFastCAS, apply: Callable) -> None:
36
+ """Verify an application/jwt event, then await its local transaction.
37
+
38
+ apply must atomically deduplicate event.id and update only FastCAS-source
39
+ sessions. A failed apply returns 503 so the FastCAS outbox retries.
40
+ """
41
+ token = await _body(request, "application/jwt")
42
+ started = False
43
+ try:
44
+ if isinstance(sdk, AsyncFastCAS):
45
+ async def async_apply(event):
46
+ nonlocal started
47
+ started = True
48
+ result = apply(event)
49
+ if inspect.isawaitable(result):
50
+ await result
51
+ await sdk.handle_notification(token, async_apply)
52
+ else:
53
+ if inspect.iscoroutinefunction(apply):
54
+ raise TypeError("synchronous FastCAS needs a synchronous apply callback")
55
+ def sync_apply(event):
56
+ nonlocal started
57
+ started = True
58
+ result = apply(event)
59
+ if inspect.isawaitable(result):
60
+ raise TypeError("synchronous FastCAS apply callback returned an awaitable")
61
+ await run_in_threadpool(sdk.handle_notification, token, sync_apply)
62
+ except FastCASError:
63
+ raise HTTPException(503 if started else 401, "FastCAS notification was not committed" if started else "invalid FastCAS notification") from None
64
+ except Exception:
65
+ raise HTTPException(503, "FastCAS notification was not committed") from None
66
+
67
+
68
+ async def receive_logout(request: Request, sdk: FastCAS | AsyncFastCAS, apply: Callable) -> None:
69
+ """Verify a back-channel logout and commit replay ID plus session changes.
70
+
71
+ apply receives {id, subject, sid, expires_at} and must deduplicate the ID
72
+ and revoke only matching FastCAS-source sessions in one local transaction.
73
+ """
74
+ raw = await _body(request, "application/x-www-form-urlencoded")
75
+ try:
76
+ fields = parse_qs(raw, strict_parsing=True, keep_blank_values=True)
77
+ if set(fields) != {"logout_token"} or len(fields["logout_token"]) != 1 or not fields["logout_token"][0]:
78
+ raise ValueError("invalid logout form")
79
+ token = fields["logout_token"][0]
80
+ except ValueError:
81
+ raise HTTPException(400, "invalid FastCAS logout form") from None
82
+ try:
83
+ notice = await sdk.verify_logout(token) if isinstance(sdk, AsyncFastCAS) else await run_in_threadpool(sdk.verify_logout, token)
84
+ except FastCASError:
85
+ raise HTTPException(401, "invalid FastCAS logout token") from None
86
+ except Exception:
87
+ raise HTTPException(503, "FastCAS logout verification unavailable") from None
88
+ try:
89
+ if inspect.iscoroutinefunction(apply):
90
+ await apply(notice)
91
+ else:
92
+ result = await run_in_threadpool(apply, notice)
93
+ if inspect.isawaitable(result):
94
+ await result
95
+ except Exception:
96
+ raise HTTPException(503, "FastCAS logout was not committed") from None
fastcas/py.typed ADDED
File without changes
fastcas/store.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Protocol
9
+
10
+
11
+ class TransactionStore(Protocol):
12
+ def put(self, transaction: dict) -> None: ...
13
+ def take(self, state: str) -> dict | None:
14
+ """Atomically consume a transaction. Concurrent calls cannot both return it."""
15
+ ...
16
+
17
+
18
+ class SQLiteTransactionStore:
19
+ """Single-host persistent store, usable from multiple worker processes.
20
+
21
+ For multi-host deployments inject a shared transactional database adapter.
22
+ The dedicated database stores PKCE verifiers, so protect its directory too.
23
+ """
24
+
25
+ def __init__(self, path: str | Path):
26
+ self.path = Path(path)
27
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
28
+ fd = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o600)
29
+ os.close(fd)
30
+ if self.path.stat().st_mode & 0o077:
31
+ raise ValueError("Transaction database must have private file permissions")
32
+ with self._connect() as db:
33
+ db.execute("CREATE TABLE IF NOT EXISTS fastcas_transactions(state TEXT PRIMARY KEY, payload TEXT NOT NULL, expires REAL NOT NULL)")
34
+
35
+ def _connect(self):
36
+ return sqlite3.connect(str(self.path), timeout=10)
37
+
38
+ def put(self, transaction: dict) -> None:
39
+ with self._connect() as db:
40
+ db.execute("DELETE FROM fastcas_transactions WHERE expires < ?", (time.time(),))
41
+ db.execute("INSERT INTO fastcas_transactions VALUES(?,?,?)", (transaction["state"], json.dumps(transaction), transaction["expires_at"]))
42
+
43
+ def take(self, state: str) -> dict | None:
44
+ with self._connect() as db:
45
+ db.execute("BEGIN IMMEDIATE")
46
+ row = db.execute("SELECT payload FROM fastcas_transactions WHERE state=?", (state,)).fetchone()
47
+ db.execute("DELETE FROM fastcas_transactions WHERE state=?", (state,))
48
+ return json.loads(row[0]) if row else None
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastcas-sdk
3
+ Version: 0.1.1
4
+ Summary: Optional FastCAS login and account linking for Python applications
5
+ Author: FastR-D
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/FastR-D/FastCAS
8
+ Project-URL: Repository, https://github.com/FastR-D/FastCAS
9
+ Project-URL: Issues, https://github.com/FastR-D/FastCAS/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: Authlib<2,>=1.8
17
+ Requires-Dist: httpx<1,>=0.28
18
+ Provides-Extra: fastapi
19
+ Requires-Dist: fastapi<1,>=0.115; extra == "fastapi"
20
+ Dynamic: license-file
21
+
22
+ # fastcas-sdk
23
+
24
+ 发布后安装:`pip install fastcas-sdk==0.1.1`。版本发布与认证配置见[发布文档](../../docs/release.md)。
25
+
26
+ 可运行的服务身份调用见 [三语言示例](../../examples/service-tokens/README.md);它按资源受众验签并查询中心撤销状态,进程不会输出令牌。
27
+
28
+ 开发安装:`uv pip install -e sdk/python`。支持 Python 3.10+。`FastCAS` 提供同步 API;`AsyncFastCAS` 用工作线程适配异步调用,不阻塞事件循环,HTTP 超时限制后台调用时间。
29
+
30
+ 生产必须提供持久 `TransactionStore`;`SQLiteTransactionStore(path)` 适用于单机多进程,数据库权限应为 0600。多主机部署使用共享事务数据库适配器。回调原子消费 state,并校验浏览器绑定、PKCE、nonce、签名、issuer、audience 与 userinfo subject。
31
+
32
+ 已有账号认证使用 `begin_link/finish_login/prepare_link/activate_link`。应用在 prepare 与 activate 之间提交本地 pending 映射,之后持久化 active。SDK 不按邮箱合并,不改变本地 ID、权限和密码。
33
+
34
+ 显式新用户注册使用 `begin_registration(browser_binding, new_local_account_ref=...)`,需要应用检查注册政策、预留全新 ID,并在验证回调后于本地事务内拒绝已存在的 ID,创建普通用户和 pending 关系再 activate。此流程不能用于绑定原账号,原账号继续使用有本地证明的 `begin_link`。
35
+
36
+ `handle_notification(token, apply)` 验证签名绑定撤销与身份状态事件;旧 `handle_event` 仍仅接受绑定撤销。回调在本地事务内完成事件 ID 去重、绑定版本比较及状态更新,停用只影响 FastCAS 来源会话。`AsyncFastCAS.handle_notification` 接收异步回调并等待其提交,失败向上抛出。重复已提交事件返回成功,事务失败由 HTTP handler 返回非 2xx。详见 [事件契约](../../docs/events.md)。
37
+
38
+ 资源服务先调用 `verify_access_token(token, audience, scopes)` 做离线签名和权限验证,再调用 `introspect_token(token)` 查询中心撤销状态;异步 facade 可 `await introspect_token(token)`。资源客户端必须有自己的保密凭证和受众权限。真实提供方契约已验证 token 撤销后返回 `active=false`。仅离线验签不等于即时撤销。
39
+
40
+ 当前 Python 真服务契约、SQLite 并发消费、签名事件及设备授权/轮询测试已实现;原生异步 HTTP adapter 与完整发布仍在开发。
41
+
42
+ FastAPI 可选适配层位于 `fastcas.fastapi`,导入主包不加载 FastAPI。现有项目路由可 `await receive_event(request, sdk, apply_event)` 或 `await receive_logout(request, sdk, apply_logout)`;支持同步 `FastCAS` 与异步 `AsyncFastCAS`。它们限制通知体为 64 KiB,验证签名,并等待项目回调提交;回调须在本地数据库事务中完成 ID 去重、绑定版本处理以及只撤销 FastCAS 来源会话。验签失败返回 401,本地提交失败返回 503 供 outbox 重试。FastRead 已接入适配层,项目侧仍保留本地登录和工作区授权。
43
+
44
+ ## 签名公钥缓存与紧急撤销
45
+
46
+ 常规验证缓存公钥最多五分钟,未知 kid 刷新有三十秒冷却,短期请求可能需要重试。紧急撤销应先停止接收并排空认证请求,再由受信运维调用 `sdk.reset_verification_cache()(异步 facade 使用 await)`,然后恢复请求;同时处理全部进程与实例。方法清理 discovery/JWKS,本次后续验证必须重新取得公钥,网络失败不会放行。不要暴露为未认证 HTTP 接口。已建立的项目会话需要独立撤销。
47
+ ## 公开设备客户端
48
+
49
+ 使用 `Configuration(issuer, client_id, issuer + "/device")`(无 client secret)建立 SDK,调用 `device_authorize(["openid", "profile"])`,向用户显示返回的 `verification_uri` 和 `user_code`。每次按 `interval` 调用 `poll_device(device_code)`;`authorization_pending` 继续等待,`slow_down` 后增加间隔。成功后 SDK 验证 ID Token 与访问令牌的签名、issuer、audience 和 subject 一致。客户端仍需本机操作人确认,不能把设备令牌直接当作本机命令权限。FastLabs 示例见 [接入记录](../../../FastLabs/README.md)。
@@ -0,0 +1,10 @@
1
+ fastcas/__init__.py,sha256=gDSJmxGTrO4w0xc7LhcsFpnPfTcwVV8o6CU1YVWzNto,333
2
+ fastcas/client.py,sha256=HjDyAeOVHJawjFI8qJLLf8r6mihyxpK3wLYcA8c9gMU,23434
3
+ fastcas/fastapi.py,sha256=g7evaOOBbiiombNwsMem0tRIY9iSfwUrN2MhzODGkLM,4145
4
+ fastcas/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ fastcas/store.py,sha256=VBkFIFbeLa98d-mEu4AsmyD5_3RbhwlZeqzNvWU39XY,1971
6
+ fastcas_sdk-0.1.1.dist-info/licenses/LICENSE,sha256=-86_rPIHcuNSAzWpGQyvOL60Ms2t-Cy6CuhfNMic9qM,1064
7
+ fastcas_sdk-0.1.1.dist-info/METADATA,sha256=MwCIQmp1nr7ghmf5FkHywHp69hFWNcYFTw8v20ng5x0,4964
8
+ fastcas_sdk-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ fastcas_sdk-0.1.1.dist-info/top_level.txt,sha256=jt327WXoRYZk97BnSM_YBgaTdiVfE_HH78_TSr186zM,8
10
+ fastcas_sdk-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FastR-D
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ fastcas