fastapi-startkit-auth 0.3.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.
Files changed (57) hide show
  1. fastapi_startkit_auth/__init__.py +67 -0
  2. fastapi_startkit_auth/apitokens/__init__.py +13 -0
  3. fastapi_startkit_auth/apitokens/manager.py +122 -0
  4. fastapi_startkit_auth/apitokens/models.py +29 -0
  5. fastapi_startkit_auth/apitokens/repository.py +120 -0
  6. fastapi_startkit_auth/apitokens/sql.py +152 -0
  7. fastapi_startkit_auth/application.py +38 -0
  8. fastapi_startkit_auth/clients/__init__.py +4 -0
  9. fastapi_startkit_auth/clients/models.py +29 -0
  10. fastapi_startkit_auth/clients/repository.py +69 -0
  11. fastapi_startkit_auth/config.py +103 -0
  12. fastapi_startkit_auth/dependencies.py +93 -0
  13. fastapi_startkit_auth/exceptions.py +90 -0
  14. fastapi_startkit_auth/facade.py +107 -0
  15. fastapi_startkit_auth/grants/__init__.py +13 -0
  16. fastapi_startkit_auth/grants/authorization_code.py +83 -0
  17. fastapi_startkit_auth/grants/client_credentials.py +19 -0
  18. fastapi_startkit_auth/grants/password.py +32 -0
  19. fastapi_startkit_auth/grants/pkce.py +27 -0
  20. fastapi_startkit_auth/grants/refresh.py +13 -0
  21. fastapi_startkit_auth/guards/__init__.py +5 -0
  22. fastapi_startkit_auth/guards/guard.py +85 -0
  23. fastapi_startkit_auth/guards/session.py +81 -0
  24. fastapi_startkit_auth/guards/token.py +58 -0
  25. fastapi_startkit_auth/manager.py +320 -0
  26. fastapi_startkit_auth/middleware/__init__.py +4 -0
  27. fastapi_startkit_auth/middleware/csrf.py +160 -0
  28. fastapi_startkit_auth/middleware/session.py +117 -0
  29. fastapi_startkit_auth/passwords/__init__.py +4 -0
  30. fastapi_startkit_auth/passwords/broker.py +69 -0
  31. fastapi_startkit_auth/passwords/repository.py +44 -0
  32. fastapi_startkit_auth/provider.py +69 -0
  33. fastapi_startkit_auth/providers/__init__.py +4 -0
  34. fastapi_startkit_auth/providers/base.py +38 -0
  35. fastapi_startkit_auth/providers/memory.py +69 -0
  36. fastapi_startkit_auth/providers/model.py +70 -0
  37. fastapi_startkit_auth/publishable/cors.py +44 -0
  38. fastapi_startkit_auth/py.typed +0 -0
  39. fastapi_startkit_auth/routes.py +362 -0
  40. fastapi_startkit_auth/routes_spa.py +34 -0
  41. fastapi_startkit_auth/security/__init__.py +4 -0
  42. fastapi_startkit_auth/security/hashing.py +43 -0
  43. fastapi_startkit_auth/security/jwt.py +49 -0
  44. fastapi_startkit_auth/sessions/__init__.py +5 -0
  45. fastapi_startkit_auth/sessions/models.py +29 -0
  46. fastapi_startkit_auth/sessions/sql.py +158 -0
  47. fastapi_startkit_auth/sessions/state.py +11 -0
  48. fastapi_startkit_auth/sessions/store.py +100 -0
  49. fastapi_startkit_auth/startkit.py +38 -0
  50. fastapi_startkit_auth/tokens/__init__.py +9 -0
  51. fastapi_startkit_auth/tokens/models.py +68 -0
  52. fastapi_startkit_auth/tokens/repository.py +129 -0
  53. fastapi_startkit_auth/tokens/service.py +189 -0
  54. fastapi_startkit_auth-0.3.0.dist-info/METADATA +243 -0
  55. fastapi_startkit_auth-0.3.0.dist-info/RECORD +57 -0
  56. fastapi_startkit_auth-0.3.0.dist-info/WHEEL +4 -0
  57. fastapi_startkit_auth-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ from typing import Any
6
+ from urllib.parse import urlencode
7
+
8
+ from fastapi import APIRouter, Depends, Form, Request, Response
9
+ from pydantic import BaseModel
10
+
11
+ from .clients.models import Client
12
+ from .dependencies import current_user, get_auth_manager
13
+ from .exceptions import (
14
+ InvalidClient,
15
+ InvalidGrant,
16
+ InvalidRequest,
17
+ ThrottleException,
18
+ UnauthorizedClient,
19
+ UnsupportedGrantType,
20
+ )
21
+ from .manager import AuthManager
22
+
23
+
24
+ # --- request bodies ------------------------------------------------------
25
+ class ClientCreate(BaseModel):
26
+ name: str
27
+ redirect_uris: list[str] = []
28
+ confidential: bool = True
29
+ grant_types: list[str] = []
30
+
31
+
32
+ class AuthorizeRequest(BaseModel):
33
+ client_id: str
34
+ redirect_uri: str | None = None
35
+ scope: str = ""
36
+ state: str | None = None
37
+ code_challenge: str | None = None
38
+ code_challenge_method: str | None = "S256"
39
+
40
+
41
+ class PersonalAccessTokenCreate(BaseModel):
42
+ name: str
43
+ scopes: list[str] = []
44
+
45
+
46
+ class ForgotPasswordRequest(BaseModel):
47
+ email: str
48
+
49
+
50
+ class ResetPasswordRequest(BaseModel):
51
+ email: str
52
+ token: str
53
+ password: str
54
+
55
+
56
+ def _scopes(raw: str | None) -> list[str]:
57
+ return [s for s in (raw or "").split() if s]
58
+
59
+
60
+ def _client_credentials_from_request(
61
+ request: Request, client_id: str | None, client_secret: str | None
62
+ ) -> tuple[str | None, str | None]:
63
+ """Prefer HTTP Basic auth for client credentials, falling back to form fields."""
64
+ header = request.headers.get("authorization", "")
65
+ if header.lower().startswith("basic "):
66
+ try:
67
+ decoded = base64.b64decode(header[6:]).decode("utf-8")
68
+ cid, _, secret = decoded.partition(":")
69
+ return cid, secret
70
+ except (binascii.Error, UnicodeDecodeError):
71
+ raise InvalidClient("Malformed Basic authorization header.")
72
+ return client_id, client_secret
73
+
74
+
75
+ def _authenticate_client(manager: AuthManager, client_id: str, secret: str | None, grant: str) -> Client:
76
+ client = manager.client_repository.authenticate(client_id, secret)
77
+ if client is None:
78
+ raise InvalidClient("Client authentication failed.")
79
+ if not client.allows_grant(grant):
80
+ raise UnauthorizedClient(f"Client is not authorized for the {grant} grant.")
81
+ return client
82
+
83
+
84
+ def _require_authenticated_client(
85
+ manager: AuthManager, request: Request, client_id: str | None, client_secret: str | None
86
+ ) -> Client:
87
+ """Authenticate the calling client for the introspection/revocation endpoints."""
88
+ client_id, client_secret = _client_credentials_from_request(request, client_id, client_secret)
89
+ if not client_id:
90
+ raise InvalidClient("Client authentication is required.")
91
+ client = manager.client_repository.authenticate(client_id, client_secret)
92
+ if client is None:
93
+ raise InvalidClient("Client authentication failed.")
94
+ return client
95
+
96
+
97
+ def build_router(prefix: str = "") -> APIRouter:
98
+ """Build the OAuth2/Passport router. Mounted onto the app by AuthProvider."""
99
+ router = APIRouter(prefix=prefix)
100
+
101
+ # --- unified token endpoint (RFC 6749 §3.2) -----------------------
102
+ @router.post("/oauth/token")
103
+ async def token(
104
+ request: Request,
105
+ grant_type: str = Form(...),
106
+ username: str | None = Form(None),
107
+ password: str | None = Form(None),
108
+ scope: str | None = Form(None),
109
+ refresh_token: str | None = Form(None),
110
+ code: str | None = Form(None),
111
+ redirect_uri: str | None = Form(None),
112
+ code_verifier: str | None = Form(None),
113
+ client_id: str | None = Form(None),
114
+ client_secret: str | None = Form(None),
115
+ manager: AuthManager = Depends(get_auth_manager),
116
+ ) -> dict[str, Any]:
117
+ client_id, client_secret = _client_credentials_from_request(request, client_id, client_secret)
118
+ scopes = _scopes(scope)
119
+
120
+ if grant_type == "password":
121
+ if client_id:
122
+ _authenticate_client(manager, client_id, client_secret, "password")
123
+ issued = manager.password_grant().handle(
124
+ username=username or "", password=password or "", scopes=scopes, client_id=client_id
125
+ )
126
+ return issued.to_response()
127
+
128
+ if grant_type == "client_credentials":
129
+ if not client_id:
130
+ raise InvalidClient("client_id is required for the client_credentials grant.")
131
+ client = _authenticate_client(manager, client_id, client_secret, "client_credentials")
132
+ return manager.client_credentials_grant().handle(client=client, scopes=scopes).to_response()
133
+
134
+ if grant_type == "refresh_token":
135
+ if not refresh_token:
136
+ raise InvalidRequest("refresh_token is required.")
137
+ if client_id:
138
+ _authenticate_client(manager, client_id, client_secret, "refresh_token")
139
+ return manager.refresh_grant().handle(
140
+ refresh_token=refresh_token, scopes=scopes or None
141
+ ).to_response()
142
+
143
+ if grant_type == "authorization_code":
144
+ if not code:
145
+ raise InvalidRequest("code is required.")
146
+ if not client_id:
147
+ raise InvalidClient("client_id is required for the authorization_code grant.")
148
+ client = manager.client_repository.find(client_id)
149
+ if client is None:
150
+ raise InvalidClient("Unknown client.")
151
+ client_authenticated = False
152
+ if client.confidential:
153
+ _authenticate_client(manager, client_id, client_secret, "authorization_code")
154
+ client_authenticated = True
155
+ issued = manager.authorization_code_grant().handle(
156
+ client=client,
157
+ code=code,
158
+ redirect_uri=redirect_uri,
159
+ code_verifier=code_verifier,
160
+ client_authenticated=client_authenticated,
161
+ )
162
+ return issued.to_response()
163
+
164
+ raise UnsupportedGrantType(f"Unsupported grant_type: {grant_type!r}")
165
+
166
+ # --- simple password endpoint (FastAPI tutorial style) ------------
167
+ @router.post("/token")
168
+ async def simple_token(
169
+ username: str = Form(...),
170
+ password: str = Form(...),
171
+ scope: str | None = Form(None),
172
+ manager: AuthManager = Depends(get_auth_manager),
173
+ ) -> dict[str, Any]:
174
+ issued = manager.password_grant().handle(
175
+ username=username, password=password, scopes=_scopes(scope), client_id=None
176
+ )
177
+ return issued.to_response()
178
+
179
+ # --- authorization endpoint (code + PKCE) -------------------------
180
+ @router.post("/oauth/authorize")
181
+ async def authorize(
182
+ body: AuthorizeRequest,
183
+ user=Depends(current_user),
184
+ manager: AuthManager = Depends(get_auth_manager),
185
+ ) -> dict[str, Any]:
186
+ client = manager.client_repository.find(body.client_id)
187
+ if client is None:
188
+ raise InvalidClient("Unknown client.")
189
+ if body.redirect_uri is not None and not client.allows_redirect(body.redirect_uri):
190
+ raise InvalidRequest("redirect_uri is not registered for this client.")
191
+ user_id = manager.guard().provider.get_identifier(user)
192
+ code = manager.authorization_code_grant().issue_code(
193
+ client=client,
194
+ user_id=user_id,
195
+ scopes=_scopes(body.scope),
196
+ redirect_uri=body.redirect_uri,
197
+ code_challenge=body.code_challenge,
198
+ code_challenge_method=body.code_challenge_method,
199
+ )
200
+ result: dict[str, Any] = {"code": code, "state": body.state}
201
+ if body.redirect_uri:
202
+ sep = "&" if "?" in body.redirect_uri else "?"
203
+ params = {"code": code}
204
+ if body.state is not None:
205
+ params["state"] = body.state
206
+ result["redirect_to"] = f"{body.redirect_uri}{sep}{urlencode(params)}"
207
+ return result
208
+
209
+ # --- introspection (RFC 7662) -------------------------------------
210
+ @router.post("/oauth/introspect")
211
+ async def introspect(
212
+ request: Request,
213
+ token: str = Form(...),
214
+ client_id: str | None = Form(None),
215
+ client_secret: str | None = Form(None),
216
+ manager: AuthManager = Depends(get_auth_manager),
217
+ ) -> dict[str, Any]:
218
+ _require_authenticated_client(manager, request, client_id, client_secret)
219
+ return manager.token_service.introspect(token)
220
+
221
+ # --- revocation (RFC 7009) ----------------------------------------
222
+ @router.post("/oauth/revoke")
223
+ async def revoke(
224
+ request: Request,
225
+ token: str = Form(...),
226
+ token_type_hint: str | None = Form(None),
227
+ client_id: str | None = Form(None),
228
+ client_secret: str | None = Form(None),
229
+ manager: AuthManager = Depends(get_auth_manager),
230
+ ) -> dict[str, Any]:
231
+ _require_authenticated_client(manager, request, client_id, client_secret)
232
+ svc = manager.token_service
233
+ # Try refresh first when hinted; otherwise decode as an access token.
234
+ if token_type_hint == "refresh_token":
235
+ svc.revoke_refresh(token)
236
+ return {"revoked": True}
237
+ try:
238
+ claims = svc.encoder.decode(token, verify_exp=False)
239
+ svc.revoke_access(claims.get("jti", ""))
240
+ except Exception:
241
+ svc.revoke_refresh(token)
242
+ return {"revoked": True}
243
+
244
+ # --- client management --------------------------------------------
245
+ @router.post("/oauth/clients", status_code=201)
246
+ async def create_client(
247
+ body: ClientCreate,
248
+ manager: AuthManager = Depends(get_auth_manager),
249
+ ) -> dict[str, Any]:
250
+ client, secret = manager.client_repository.register(
251
+ name=body.name,
252
+ redirect_uris=body.redirect_uris,
253
+ confidential=body.confidential,
254
+ grant_types=body.grant_types,
255
+ )
256
+ return {
257
+ "id": client.id,
258
+ "name": client.name,
259
+ "secret": secret,
260
+ "redirect_uris": client.redirect_uris,
261
+ "confidential": client.confidential,
262
+ "grant_types": client.grant_types,
263
+ }
264
+
265
+ @router.get("/oauth/clients")
266
+ async def list_clients(manager: AuthManager = Depends(get_auth_manager)) -> list[dict[str, Any]]:
267
+ return [
268
+ {
269
+ "id": c.id,
270
+ "name": c.name,
271
+ "redirect_uris": c.redirect_uris,
272
+ "confidential": c.confidential,
273
+ "grant_types": c.grant_types,
274
+ }
275
+ for c in manager.client_repository.all()
276
+ ]
277
+
278
+ @router.delete("/oauth/clients/{client_id}", status_code=204)
279
+ async def delete_client(
280
+ client_id: str, manager: AuthManager = Depends(get_auth_manager)
281
+ ) -> Response:
282
+ manager.client_repository.delete(client_id)
283
+ return Response(status_code=204)
284
+
285
+ # --- personal access tokens ---------------------------------------
286
+ @router.post("/oauth/personal-access-tokens", status_code=201)
287
+ async def create_pat(
288
+ body: PersonalAccessTokenCreate,
289
+ user=Depends(current_user),
290
+ manager: AuthManager = Depends(get_auth_manager),
291
+ ) -> dict[str, Any]:
292
+ user_id = manager.guard().provider.get_identifier(user)
293
+ issued = manager.token_service.create_personal_access_token(
294
+ user_id=user_id, name=body.name, scopes=body.scopes
295
+ )
296
+ return {
297
+ "jti": issued.jti,
298
+ "name": body.name,
299
+ "access_token": issued.access_token,
300
+ "scopes": issued.scopes,
301
+ "expires_in": issued.expires_in,
302
+ }
303
+
304
+ @router.get("/oauth/personal-access-tokens")
305
+ async def list_pats(
306
+ user=Depends(current_user),
307
+ manager: AuthManager = Depends(get_auth_manager),
308
+ ) -> list[dict[str, Any]]:
309
+ user_id = manager.guard().provider.get_identifier(user)
310
+ records = manager.token_service.repository.list_access_tokens(user_id, personal_access=True)
311
+ return [
312
+ {"jti": r.jti, "name": r.name, "scopes": r.scopes, "revoked": r.revoked}
313
+ for r in records
314
+ if not r.revoked
315
+ ]
316
+
317
+ @router.delete("/oauth/personal-access-tokens/{jti}", status_code=204)
318
+ async def delete_pat(
319
+ jti: str,
320
+ user=Depends(current_user),
321
+ manager: AuthManager = Depends(get_auth_manager),
322
+ ) -> Response:
323
+ record = manager.token_service.repository.find_access_token(jti)
324
+ user_id = manager.guard().provider.get_identifier(user)
325
+ # Only allow owners to revoke their own tokens.
326
+ if record is not None and record.user_id == user_id:
327
+ manager.token_service.revoke_access(jti)
328
+ return Response(status_code=204)
329
+
330
+ # --- password reset -----------------------------------------------
331
+ @router.post("/password/email")
332
+ async def send_reset_link(
333
+ body: ForgotPasswordRequest,
334
+ manager: AuthManager = Depends(get_auth_manager),
335
+ ) -> dict[str, Any]:
336
+ # Always respond identically whether or not the email exists, so the
337
+ # endpoint can't be used to enumerate accounts. The token is delivered
338
+ # out-of-band via the configured notifier, never in the HTTP response
339
+ # (unless debug_expose_reset_token is explicitly enabled).
340
+ #
341
+ # A throttled request is also swallowed to a generic 200: throttling only
342
+ # fires for existing accounts, so surfacing a 429 would let two rapid
343
+ # requests distinguish registered from unregistered emails. The broker
344
+ # still enforces the throttle server-side (no extra notification is sent).
345
+ generic = {"status": "If that account exists, a reset link has been sent."}
346
+ try:
347
+ token = manager.broker().send_reset_link(body.email)
348
+ except (InvalidGrant, ThrottleException):
349
+ return generic
350
+ if manager.debug_expose_reset_token:
351
+ return {**generic, "token": token}
352
+ return generic
353
+
354
+ @router.post("/password/reset")
355
+ async def reset_password(
356
+ body: ResetPasswordRequest,
357
+ manager: AuthManager = Depends(get_auth_manager),
358
+ ) -> dict[str, Any]:
359
+ manager.broker().reset(body.email, body.token, body.password)
360
+ return {"status": "password reset"}
361
+
362
+ return router
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, Request, Response
4
+
5
+ from .dependencies import get_auth_manager
6
+ from .manager import AuthManager
7
+ from .sessions.state import FORGET_KEY, SESSION_KEY
8
+
9
+
10
+ def build_spa_router(prefix: str = "") -> APIRouter:
11
+ router = APIRouter(prefix=prefix)
12
+
13
+ @router.get("/__auth__/csrf-cookie", status_code=204)
14
+ def csrf_cookie(request: Request, manager: AuthManager = Depends(get_auth_manager)) -> Response:
15
+ """Prime a SPA for cookie auth (``axios.get("/__auth__/csrf-cookie")``).
16
+
17
+ Ensures a session exists — starting a guest one (no user) when the
18
+ request carries none — so the middleware pair can deliver both the
19
+ HttpOnly session cookie and the JS-readable CSRF cookie on the way out.
20
+ """
21
+ record = getattr(request.state, SESSION_KEY, None)
22
+ if record is None:
23
+ record = manager.session_store.create(
24
+ user_id=None,
25
+ guard=manager.session_guard_name(),
26
+ ttl=manager.session_config.get("ttl"),
27
+ )
28
+ setattr(request.state, SESSION_KEY, record)
29
+ setattr(request.state, FORGET_KEY, False)
30
+ # no-store: the response's only payload is Set-Cookie material; caching
31
+ # it would hand a shared cache a session bootstrap.
32
+ return Response(status_code=204, headers={"Cache-Control": "no-store"})
33
+
34
+ return router
@@ -0,0 +1,4 @@
1
+ from .hashing import BcryptHasher, Hasher
2
+ from .jwt import JWTEncoder
3
+
4
+ __all__ = ["BcryptHasher", "Hasher", "JWTEncoder"]
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ import bcrypt
8
+
9
+
10
+ @runtime_checkable
11
+ class Hasher(Protocol):
12
+ """Password hashing contract. Swap implementations via the auth config."""
13
+
14
+ def make(self, plain: str) -> str: ...
15
+
16
+ def verify(self, plain: str, hashed: str) -> bool: ...
17
+
18
+
19
+ class BcryptHasher:
20
+ """bcrypt hasher used in place of passlib.
21
+
22
+ passlib is avoided because it imports the stdlib ``crypt`` module, removed in
23
+ Python 3.13. Inputs are SHA-256 pre-hashed and base64-encoded so passwords
24
+ longer than bcrypt's 72-byte limit remain fully significant rather than being
25
+ silently truncated.
26
+ """
27
+
28
+ def __init__(self, rounds: int = 12) -> None:
29
+ self._rounds = rounds
30
+
31
+ def _prepare(self, plain: str) -> bytes:
32
+ digest = hashlib.sha256(plain.encode("utf-8")).digest()
33
+ return base64.b64encode(digest)
34
+
35
+ def make(self, plain: str) -> str:
36
+ salt = bcrypt.gensalt(self._rounds)
37
+ return bcrypt.hashpw(self._prepare(plain), salt).decode("utf-8")
38
+
39
+ def verify(self, plain: str, hashed: str) -> bool:
40
+ try:
41
+ return bcrypt.checkpw(self._prepare(plain), hashed.encode("utf-8"))
42
+ except (ValueError, TypeError):
43
+ return False
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from typing import Any
6
+
7
+ import jwt
8
+
9
+ from ..exceptions import InvalidToken
10
+
11
+
12
+ class JWTEncoder:
13
+ """Encode/decode signed JWTs for access tokens.
14
+
15
+ Every token gets a unique ``jti`` so it can be individually revoked and
16
+ introspected even though JWT verification itself is stateless.
17
+ """
18
+
19
+ def __init__(self, secret: str, algorithm: str = "HS256", leeway: int = 0) -> None:
20
+ if not secret:
21
+ raise ValueError("JWTEncoder requires a non-empty secret")
22
+ self._secret = secret
23
+ self._algorithm = algorithm
24
+ self._leeway = leeway
25
+
26
+ def encode(self, claims: dict[str, Any], ttl_seconds: int) -> str:
27
+ now = int(time.time())
28
+ payload = {
29
+ "iat": now,
30
+ "nbf": now,
31
+ "exp": now + ttl_seconds,
32
+ "jti": uuid.uuid4().hex,
33
+ **claims,
34
+ }
35
+ return jwt.encode(payload, self._secret, algorithm=self._algorithm)
36
+
37
+ def decode(self, token: str, verify_exp: bool = True) -> dict[str, Any]:
38
+ try:
39
+ return jwt.decode(
40
+ token,
41
+ self._secret,
42
+ algorithms=[self._algorithm],
43
+ leeway=self._leeway,
44
+ # sub may be null (client-credentials tokens have no user); we
45
+ # never rely on PyJWT's built-in sub type check.
46
+ options={"verify_exp": verify_exp, "verify_sub": False},
47
+ )
48
+ except jwt.PyJWTError as exc:
49
+ raise InvalidToken(str(exc)) from exc
@@ -0,0 +1,5 @@
1
+ from .models import SessionRecord
2
+ from .sql import SqlSessionStore
3
+ from .store import InMemorySessionStore, SessionStore
4
+
5
+ __all__ = ["SessionRecord", "SessionStore", "InMemorySessionStore", "SqlSessionStore"]
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class SessionRecord:
10
+ """Server-side record backing one session cookie.
11
+
12
+ The cookie carries only the opaque ``id``; everything else lives in the
13
+ store, so invalidation is authoritative. ``csrf_token`` is issued here so
14
+ Phase 2's CSRF protection can bind to the session without a model change.
15
+ """
16
+
17
+ id: str
18
+ user_id: Any
19
+ guard: str
20
+ csrf_token: str
21
+ created_at: float = field(default_factory=time.time)
22
+ last_activity: float = field(default_factory=time.time)
23
+ expires_at: float | None = None
24
+
25
+ def expired(self, idle_ttl: float | None = None, now: float | None = None) -> bool:
26
+ now = time.time() if now is None else now
27
+ if self.expires_at is not None and self.expires_at < now:
28
+ return True
29
+ return idle_ttl is not None and self.last_activity + idle_ttl < now
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from typing import Any
7
+
8
+ from .models import SessionRecord
9
+ from .store import generate_csrf_token, generate_session_id
10
+
11
+ _SCHEMA = """
12
+ CREATE TABLE IF NOT EXISTS {table} (
13
+ id TEXT PRIMARY KEY,
14
+ user_id TEXT,
15
+ guard TEXT NOT NULL,
16
+ csrf_token TEXT NOT NULL,
17
+ created_at REAL NOT NULL,
18
+ last_activity REAL NOT NULL,
19
+ expires_at REAL
20
+ )
21
+ """
22
+
23
+
24
+ class SqlSessionStore:
25
+ """Persistent :class:`~.store.SessionStore` backed by a ``sessions`` table.
26
+
27
+ Works with any DB-API 2.0 connection using ``qmark`` placeholders (sqlite3
28
+ out of the box). ``user_id`` is JSON-encoded so int and str identifiers
29
+ round-trip with their original type, matching the in-memory store. A lock
30
+ serialises access because FastAPI runs sync endpoints in a thread pool.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ connection: Any,
36
+ table: str = "sessions",
37
+ idle_ttl: float | None = None,
38
+ create_table: bool = True,
39
+ purge_interval: float = 300,
40
+ ) -> None:
41
+ self._conn = connection
42
+ self._table = table
43
+ self._idle_ttl = idle_ttl
44
+ self._lock = threading.Lock()
45
+ self._purge_interval = purge_interval
46
+ self._last_purge = time.time()
47
+ if create_table:
48
+ with self._lock:
49
+ self._conn.execute(_SCHEMA.format(table=table))
50
+ self._conn.commit()
51
+
52
+ def _maybe_purge(self) -> None:
53
+ """Drop expired rows periodically so the table stays bounded without a
54
+ scheduler (mirrors ``TokenService``'s purge-on-issue)."""
55
+ now = time.time()
56
+ # Check-and-update under the lock so concurrent creates cannot both
57
+ # claim the same purge window and sweep twice.
58
+ with self._lock:
59
+ if now - self._last_purge < self._purge_interval:
60
+ return
61
+ self._last_purge = now
62
+ self.purge_expired()
63
+
64
+ def create(self, *, user_id: Any, guard: str, ttl: float | None) -> SessionRecord:
65
+ self._maybe_purge()
66
+ now = time.time()
67
+ record = SessionRecord(
68
+ id=generate_session_id(),
69
+ user_id=user_id,
70
+ guard=guard,
71
+ csrf_token=generate_csrf_token(),
72
+ created_at=now,
73
+ last_activity=now,
74
+ expires_at=(now + ttl) if ttl is not None else None,
75
+ )
76
+ with self._lock:
77
+ self._conn.execute(
78
+ f"INSERT INTO {self._table} (id, user_id, guard, csrf_token, created_at, last_activity, expires_at) "
79
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
80
+ (
81
+ record.id,
82
+ json.dumps(record.user_id),
83
+ record.guard,
84
+ record.csrf_token,
85
+ record.created_at,
86
+ record.last_activity,
87
+ record.expires_at,
88
+ ),
89
+ )
90
+ self._conn.commit()
91
+ return record
92
+
93
+ def find(self, session_id: str) -> SessionRecord | None:
94
+ with self._lock:
95
+ row = self._conn.execute(
96
+ f"SELECT id, user_id, guard, csrf_token, created_at, last_activity, expires_at "
97
+ f"FROM {self._table} WHERE id = ?",
98
+ (session_id,),
99
+ ).fetchone()
100
+ if row is None:
101
+ return None
102
+ record = SessionRecord(
103
+ id=row[0],
104
+ user_id=json.loads(row[1]),
105
+ guard=row[2],
106
+ csrf_token=row[3],
107
+ created_at=row[4],
108
+ last_activity=row[5],
109
+ expires_at=row[6],
110
+ )
111
+ if record.expired(self._idle_ttl):
112
+ self.invalidate(session_id)
113
+ return None
114
+ return record
115
+
116
+ def touch(self, session_id: str) -> None:
117
+ with self._lock:
118
+ self._conn.execute(
119
+ f"UPDATE {self._table} SET last_activity = ? WHERE id = ?",
120
+ (time.time(), session_id),
121
+ )
122
+ self._conn.commit()
123
+
124
+ def regenerate_id(self, session_id: str) -> SessionRecord | None:
125
+ record = self.find(session_id)
126
+ if record is None:
127
+ return None
128
+ new_id = generate_session_id()
129
+ with self._lock:
130
+ self._conn.execute(
131
+ f"UPDATE {self._table} SET id = ? WHERE id = ?",
132
+ (new_id, session_id),
133
+ )
134
+ self._conn.commit()
135
+ record.id = new_id
136
+ return record
137
+
138
+ def invalidate(self, session_id: str) -> bool:
139
+ with self._lock:
140
+ cursor = self._conn.execute(
141
+ f"DELETE FROM {self._table} WHERE id = ?", (session_id,)
142
+ )
143
+ self._conn.commit()
144
+ return cursor.rowcount > 0
145
+
146
+ def purge_expired(self) -> None:
147
+ now = time.time()
148
+ with self._lock:
149
+ self._conn.execute(
150
+ f"DELETE FROM {self._table} WHERE expires_at IS NOT NULL AND expires_at < ?",
151
+ (now,),
152
+ )
153
+ if self._idle_ttl is not None:
154
+ self._conn.execute(
155
+ f"DELETE FROM {self._table} WHERE last_activity + ? < ?",
156
+ (self._idle_ttl, now),
157
+ )
158
+ self._conn.commit()