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,81 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import Request
6
+
7
+ from ..exceptions import InvalidSession
8
+ from ..providers.base import UserProvider
9
+ from ..sessions.models import SessionRecord
10
+ from ..sessions.state import FORGET_KEY, SESSION_KEY
11
+ from ..sessions.store import SessionStore
12
+ from .guard import AuthContext
13
+
14
+
15
+ class SessionGuard:
16
+ """Cookie/session guard: resolves the server-side session to a user.
17
+
18
+ Corresponds to a ``{"driver": "session"}`` guard entry. The middleware puts
19
+ the loaded :class:`SessionRecord` on ``request.state``; this guard turns it
20
+ into an :class:`AuthContext` and owns the login/logout primitives used by
21
+ the ``Auth`` facade. Session-authenticated requests get the wildcard scope,
22
+ like a first-party browser login.
23
+ """
24
+
25
+ def __init__(self, name: str, store: SessionStore, provider: UserProvider, ttl: float | None) -> None:
26
+ self.name = name
27
+ self.store = store
28
+ self.provider = provider
29
+ self.ttl = ttl
30
+
31
+ def user_from_token(self, access_token: str) -> AuthContext:
32
+ """Resolve a raw session id (the cookie value) to an ``AuthContext``."""
33
+ record = self.store.find(access_token)
34
+ if record is None:
35
+ raise InvalidSession("Not authenticated.")
36
+ return self._context(record)
37
+
38
+ def authenticate(self, request: Request) -> AuthContext:
39
+ """Resolve the session attached to the request by the middleware."""
40
+ record = getattr(request.state, SESSION_KEY, None)
41
+ if record is None:
42
+ raise InvalidSession("Not authenticated.")
43
+ return self._context(record)
44
+
45
+ def login(self, request: Request, user: Any) -> SessionRecord:
46
+ """Start a fresh session for ``user``.
47
+
48
+ Always issues a new session id (fixation protection): any session that
49
+ arrived with the request is invalidated server-side and the middleware
50
+ sends the new id in the cookie.
51
+ """
52
+ previous = getattr(request.state, SESSION_KEY, None)
53
+ record = self.store.create(
54
+ user_id=self.provider.get_identifier(user),
55
+ guard=self.name,
56
+ ttl=self.ttl,
57
+ )
58
+ if previous is not None:
59
+ self.store.invalidate(previous.id)
60
+ setattr(request.state, SESSION_KEY, record)
61
+ setattr(request.state, FORGET_KEY, False)
62
+ return record
63
+
64
+ def logout(self, request: Request) -> None:
65
+ """Invalidate the server-side session and mark the cookie for deletion."""
66
+ record = getattr(request.state, SESSION_KEY, None)
67
+ if record is not None:
68
+ self.store.invalidate(record.id)
69
+ setattr(request.state, SESSION_KEY, None)
70
+ setattr(request.state, FORGET_KEY, True)
71
+
72
+ def _context(self, record: SessionRecord) -> AuthContext:
73
+ if record.user_id is None:
74
+ # Guest session (issued by /__auth__/csrf-cookie): it carries a CSRF
75
+ # token but no user, and must survive 401s so the SPA can log in.
76
+ raise InvalidSession("Not authenticated.")
77
+ user = self.provider.retrieve_by_id(record.user_id)
78
+ if user is None:
79
+ self.store.invalidate(record.id)
80
+ raise InvalidSession("The session user no longer exists.")
81
+ return AuthContext(user=user, scopes=["*"])
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Request
4
+
5
+ from ..apitokens.manager import ApiTokenManager
6
+ from ..exceptions import InvalidToken
7
+ from ..providers.base import UserProvider
8
+ from .guard import AuthContext
9
+
10
+
11
+ class TokenGuard:
12
+ """Opaque API-token guard (Sanctum-style).
13
+
14
+ Corresponds to a ``{"driver": "token"}`` guard entry. Resolves
15
+ ``Authorization: Bearer {id}|{secret}`` — or the raw token in a configured
16
+ custom header — through :class:`ApiTokenManager` and turns the record's
17
+ abilities into the ``AuthContext`` scopes, so ``require_scopes`` works
18
+ unchanged.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ name: str,
24
+ tokens: ApiTokenManager,
25
+ provider: UserProvider,
26
+ header: str = "Authorization",
27
+ ) -> None:
28
+ self.name = name
29
+ self._tokens = tokens
30
+ self.provider = provider
31
+ self.header = header
32
+
33
+ def authenticate(self, request: Request) -> AuthContext:
34
+ """Resolve the request's configured token header to an ``AuthContext``."""
35
+ token = self._extract(request)
36
+ if not token:
37
+ raise InvalidToken("Not authenticated.")
38
+ return self.user_from_token(token)
39
+
40
+ def user_from_token(self, access_token: str) -> AuthContext:
41
+ record = self._tokens.verify(access_token)
42
+ user = self.provider.retrieve_by_id(record.user_id)
43
+ if user is None:
44
+ # Orphaned token (user deleted): revoke it, fail generically.
45
+ self._tokens.revoke(record.id)
46
+ raise InvalidToken("The API token is invalid.")
47
+ return AuthContext(user=user, scopes=list(record.abilities))
48
+
49
+ def _extract(self, request: Request) -> str | None:
50
+ raw = request.headers.get(self.header)
51
+ if raw is None:
52
+ return None
53
+ if self.header.lower() == "authorization":
54
+ scheme, _, value = raw.partition(" ")
55
+ if scheme.lower() != "bearer":
56
+ return None
57
+ return value.strip()
58
+ return raw.strip()
@@ -0,0 +1,320 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import warnings
5
+ from typing import Any, Callable
6
+
7
+ from .apitokens.manager import ApiTokenManager
8
+ from .apitokens.repository import ApiTokenRepository, InMemoryApiTokenRepository
9
+ from .apitokens.sql import SqlApiTokenRepository
10
+ from .clients.repository import InMemoryClientRepository
11
+ from .config import AuthConfig, as_config_class
12
+ from .grants.authorization_code import AuthorizationCodeGrant
13
+ from .grants.client_credentials import ClientCredentialsGrant
14
+ from .grants.password import PasswordGrant
15
+ from .grants.refresh import RefreshTokenGrant
16
+ from .guards.guard import Guard, PassportGuard
17
+ from .guards.session import SessionGuard
18
+ from .guards.token import TokenGuard
19
+ from .passwords.broker import PasswordBroker
20
+ from .passwords.repository import InMemoryPasswordResetRepository
21
+ from .providers.base import UserProvider
22
+ from .providers.memory import InMemoryUserProvider
23
+ from .providers.model import ModelUserProvider
24
+ from .security.hashing import BcryptHasher
25
+ from .security.jwt import JWTEncoder
26
+ from .sessions.sql import SqlSessionStore
27
+ from .sessions.store import InMemorySessionStore, SessionStore
28
+ from .tokens.repository import InMemoryTokenRepository
29
+ from .tokens.service import TokenService
30
+
31
+ GuardFactory = Callable[[str, dict[str, Any]], Guard]
32
+
33
+
34
+ def _resolve_connection(connection: Any) -> Any:
35
+ """Return an open DB-API connection from either a connection or a factory.
36
+
37
+ A raw connection (e.g. ``sqlite3.Connection``) is itself callable, so we
38
+ duck-type on ``execute`` — which every SQL store relies on — instead of
39
+ ``callable()`` to tell an already-open connection apart from a zero-arg
40
+ factory that produces one.
41
+ """
42
+ if hasattr(connection, "execute"):
43
+ return connection
44
+ if callable(connection):
45
+ return connection()
46
+ return connection
47
+
48
+
49
+ class AuthManager:
50
+ """Central registry built from an :class:`AuthConfig`.
51
+
52
+ Wires the shared hasher, JWT encoder, token store/service, client registry,
53
+ guards, providers, grant handlers, and password brokers, and resolves them by
54
+ the names declared in config. This is what ``AuthProvider`` registers onto the
55
+ application and what the FastAPI dependencies read from.
56
+ """
57
+
58
+ def __init__(self, config: Any) -> None:
59
+ self._config = as_config_class(config)
60
+ cfg = self._config
61
+
62
+ rounds = cfg.get("bcrypt_rounds", 12)
63
+ self.hasher = BcryptHasher(rounds=rounds)
64
+
65
+ key = cfg.get("key")
66
+ if not key:
67
+ key = secrets.token_urlsafe(48)
68
+ warnings.warn(
69
+ "AuthConfig.key is not set; generated an ephemeral signing key. "
70
+ "Tokens will be invalidated on restart. Set a stable key in production.",
71
+ stacklevel=2,
72
+ )
73
+ self.encoder = JWTEncoder(secret=key, algorithm=cfg.get("algorithm", "HS256"))
74
+
75
+ self.token_repository = InMemoryTokenRepository()
76
+ self.token_service = TokenService(
77
+ encoder=self.encoder,
78
+ repository=self.token_repository,
79
+ access_ttl=cfg.get("access_token_ttl", 3600),
80
+ refresh_ttl=cfg.get("refresh_token_ttl", 1209600),
81
+ personal_access_ttl=cfg.get("personal_access_token_ttl", 31536000),
82
+ )
83
+ self.client_repository = InMemoryClientRepository(hasher=self.hasher)
84
+
85
+ self._notifier = cfg.get("password_reset_notifier")
86
+ self.debug_expose_reset_token = bool(cfg.get("debug_expose_reset_token", False))
87
+
88
+ self.session_config = {**AuthConfig.session, **cfg.get("session", {})}
89
+ self.spa_config = {**AuthConfig.spa, **cfg.get("spa", {})}
90
+ self.api_tokens_config = {**AuthConfig.api_tokens, **cfg.get("api_tokens", {})}
91
+ self._session_store: SessionStore | None = None
92
+ self._api_tokens: ApiTokenManager | None = None
93
+
94
+ self._providers: dict[str, UserProvider] = {
95
+ name: self._build_provider(spec) for name, spec in cfg.get("providers", {}).items()
96
+ }
97
+ self._guard_drivers: dict[str, GuardFactory] = {}
98
+ self.register_guard_driver("passport", self._build_passport_guard)
99
+ self.register_guard_driver("session", self._build_session_guard)
100
+ self.register_guard_driver("token", self._build_token_guard)
101
+ self._guards: dict[str, Guard] = {
102
+ name: self._build_guard(name, spec) for name, spec in cfg.get("guards", {}).items()
103
+ }
104
+ self._brokers: dict[str, PasswordBroker] = {
105
+ name: self._build_broker(spec) for name, spec in cfg.get("passwords", {}).items()
106
+ }
107
+
108
+ # --- construction helpers -----------------------------------------
109
+ def _build_provider(self, spec: dict[str, Any]) -> UserProvider:
110
+ driver = spec.get("driver", "instance")
111
+ if driver == "instance":
112
+ return spec["instance"]
113
+ if driver == "factory":
114
+ return spec["factory"]()
115
+ if driver == "memory":
116
+ provider = InMemoryUserProvider(
117
+ hasher=self.hasher,
118
+ username_field=spec.get("username_field", "email"),
119
+ )
120
+ for user in spec.get("users", []):
121
+ provider.add(user)
122
+ return provider
123
+ if driver in ("masoniteorm", "orm", "model"):
124
+ return ModelUserProvider(
125
+ model=spec["model"],
126
+ hasher=self.hasher,
127
+ username_field=spec.get("username_field", "email"),
128
+ password_field=spec.get("password_field", "password"),
129
+ id_field=spec.get("id_field", "id"),
130
+ )
131
+ raise ValueError(f"Unknown user provider driver: {driver!r}")
132
+
133
+ def register_guard_driver(self, driver: str, factory: GuardFactory) -> None:
134
+ """Register a factory that builds a guard for a config ``driver`` key.
135
+
136
+ Guards are constructed by their ``spec["driver"]`` rather than hardcoded,
137
+ so app-defined drivers register alongside the built-in ``passport``,
138
+ ``session``, and ``token`` drivers without touching the resolution
139
+ logic.
140
+
141
+ Ordering: config-declared guards are built during ``__init__`` right after
142
+ the ``passport`` driver registers, so calling this post-construction does
143
+ NOT retroactively build config-declared guards — register custom drivers
144
+ before or at construction (e.g. in a subclass ``__init__`` before
145
+ ``super().__init__``, or by extending this manager).
146
+ """
147
+ self._guard_drivers[driver] = factory
148
+
149
+ def _build_guard(self, name: str, spec: dict[str, Any]) -> Guard:
150
+ driver = spec.get("driver", "passport")
151
+ factory = self._guard_drivers.get(driver)
152
+ if factory is None:
153
+ raise ValueError(f"Unknown auth guard driver: {driver!r}")
154
+ return factory(name, spec)
155
+
156
+ def _build_passport_guard(self, name: str, spec: dict[str, Any]) -> Guard:
157
+ provider = self._require_provider(spec.get("provider"))
158
+ return PassportGuard(name=name, token_service=self.token_service, provider=provider)
159
+
160
+ def _build_session_guard(self, name: str, spec: dict[str, Any]) -> Guard:
161
+ provider = self._require_provider(spec.get("provider"))
162
+ if not self.session_config.get("secure", True):
163
+ warnings.warn(
164
+ "AuthConfig.session['secure'] is False; the session cookie will be sent "
165
+ "over plain HTTP. Enable it in production.",
166
+ stacklevel=2,
167
+ )
168
+ return SessionGuard(
169
+ name=name,
170
+ store=self.session_store,
171
+ provider=provider,
172
+ ttl=self.session_config.get("ttl"),
173
+ )
174
+
175
+ def _build_token_guard(self, name: str, spec: dict[str, Any]) -> Guard:
176
+ provider = self._require_provider(spec.get("provider"))
177
+ return TokenGuard(
178
+ name=name,
179
+ tokens=self.api_tokens,
180
+ provider=provider,
181
+ header=self.api_tokens_config.get("header", "Authorization"),
182
+ )
183
+
184
+ @property
185
+ def api_tokens(self) -> ApiTokenManager:
186
+ """The API-token manager, built on first use.
187
+
188
+ Lazy for the same reason as :attr:`session_store`: apps without a token
189
+ guard never pay for (or have to configure) the repository — notably the
190
+ SQL connection.
191
+ """
192
+ if self._api_tokens is None:
193
+ self._api_tokens = ApiTokenManager(
194
+ repository=self._build_api_token_repository(),
195
+ default_ttl=self.api_tokens_config.get("ttl"),
196
+ purge_interval=self.api_tokens_config.get("purge_interval", 300),
197
+ )
198
+ return self._api_tokens
199
+
200
+ def _build_api_token_repository(self) -> ApiTokenRepository:
201
+ spec = self.api_tokens_config
202
+ kind = spec.get("store", "memory")
203
+ if kind == "memory":
204
+ return InMemoryApiTokenRepository()
205
+ if kind == "sql":
206
+ connection = spec.get("connection")
207
+ if connection is None:
208
+ raise ValueError('AuthConfig.api_tokens with store "sql" requires a "connection".')
209
+ connection = _resolve_connection(connection)
210
+ return SqlApiTokenRepository(
211
+ connection,
212
+ table=spec.get("table", "personal_api_tokens"),
213
+ )
214
+ if kind == "instance":
215
+ return spec["instance"]
216
+ raise ValueError(f"Unknown api_tokens store: {kind!r}")
217
+
218
+ @property
219
+ def session_store(self) -> SessionStore:
220
+ """The configured session store, built on first use.
221
+
222
+ Lazy so apps without a session guard never pay for (or have to
223
+ configure) a store — notably the SQL connection.
224
+ """
225
+ if self._session_store is None:
226
+ self._session_store = self._build_session_store()
227
+ return self._session_store
228
+
229
+ def _build_session_store(self) -> SessionStore:
230
+ spec = self.session_config
231
+ kind = spec.get("store", "memory")
232
+ idle_ttl = spec.get("idle_ttl")
233
+ if kind == "memory":
234
+ return InMemorySessionStore(idle_ttl=idle_ttl)
235
+ if kind == "sql":
236
+ connection = spec.get("connection")
237
+ if connection is None:
238
+ raise ValueError('AuthConfig.session with store "sql" requires a "connection".')
239
+ connection = _resolve_connection(connection)
240
+ return SqlSessionStore(
241
+ connection,
242
+ table=spec.get("table", "sessions"),
243
+ idle_ttl=idle_ttl,
244
+ purge_interval=spec.get("purge_interval", 300),
245
+ )
246
+ if kind == "instance":
247
+ return spec["instance"]
248
+ raise ValueError(f"Unknown session store: {kind!r}")
249
+
250
+ def session_guard_name(self) -> str | None:
251
+ """Name of the first configured session guard, or ``None``."""
252
+ return next(
253
+ (name for name, guard in self._guards.items() if isinstance(guard, SessionGuard)),
254
+ None,
255
+ )
256
+
257
+ def has_session_guard(self) -> bool:
258
+ return self.session_guard_name() is not None
259
+
260
+ def spa_enabled(self) -> bool:
261
+ return bool(self.spa_config.get("enabled"))
262
+
263
+ def _build_broker(self, spec: dict[str, Any]) -> PasswordBroker:
264
+ provider = self._resolve_broker_provider(spec.get("provider"))
265
+ return PasswordBroker(
266
+ user_provider=provider,
267
+ repository=InMemoryPasswordResetRepository(),
268
+ hasher=self.hasher,
269
+ expire_minutes=spec.get("expire", 60),
270
+ throttle_seconds=spec.get("throttle", 60),
271
+ notifier=self._notifier,
272
+ )
273
+
274
+ def _resolve_broker_provider(self, name: str | None) -> UserProvider:
275
+ # The passwords `provider` key may reference a providers entry; fall back
276
+ # to the default guard's provider if the name doesn't resolve.
277
+ if name and name in self._providers:
278
+ return self._providers[name]
279
+ default_guard = self._config.get("default", {}).get("guard")
280
+ guard_spec = self._config.get("guards", {}).get(default_guard, {})
281
+ return self._require_provider(guard_spec.get("provider"))
282
+
283
+ def _require_provider(self, name: str | None) -> UserProvider:
284
+ if name not in self._providers:
285
+ raise ValueError(f"Auth provider {name!r} is not defined in AuthConfig.providers")
286
+ return self._providers[name]
287
+
288
+ # --- public accessors ---------------------------------------------
289
+ def guard(self, name: str | None = None) -> Guard:
290
+ name = name or self._config.get("default", {}).get("guard")
291
+ if name not in self._guards:
292
+ raise ValueError(f"Auth guard {name!r} is not defined in AuthConfig.guards")
293
+ return self._guards[name]
294
+
295
+ def provider(self, name: str) -> UserProvider:
296
+ return self._require_provider(name)
297
+
298
+ def broker(self, name: str | None = None) -> PasswordBroker:
299
+ name = name or self._config.get("default", {}).get("passwords")
300
+ if name not in self._brokers:
301
+ raise ValueError(f"Password broker {name!r} is not defined in AuthConfig.passwords")
302
+ return self._brokers[name]
303
+
304
+ def default_guard_name(self) -> str:
305
+ return self._config.get("default", {}).get("guard")
306
+
307
+ # --- grant factories ----------------------------------------------
308
+ def password_grant(self, guard: str | None = None) -> PasswordGrant:
309
+ return PasswordGrant(self.token_service, self.guard(guard).provider)
310
+
311
+ def client_credentials_grant(self) -> ClientCredentialsGrant:
312
+ return ClientCredentialsGrant(self.token_service)
313
+
314
+ def refresh_grant(self) -> RefreshTokenGrant:
315
+ return RefreshTokenGrant(self.token_service)
316
+
317
+ def authorization_code_grant(self) -> AuthorizationCodeGrant:
318
+ return AuthorizationCodeGrant(
319
+ self.token_service, code_ttl=self._config.get("authorization_code_ttl", 600)
320
+ )
@@ -0,0 +1,4 @@
1
+ from .csrf import CsrfMiddleware
2
+ from .session import SessionMiddleware
3
+
4
+ __all__ = ["CsrfMiddleware", "SessionMiddleware"]
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from http.cookies import SimpleCookie
5
+ from typing import Any, Iterable
6
+
7
+ from starlette.datastructures import MutableHeaders
8
+ from starlette.responses import JSONResponse, Response
9
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
10
+
11
+ from ..exceptions import CsrfTokenMismatch
12
+ from ..sessions.state import FORGET_KEY, SESSION_KEY
13
+
14
+ UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
15
+
16
+
17
+ class CsrfMiddleware:
18
+ """Session-bound double-submit CSRF protection for SPA cookie auth.
19
+
20
+ Runs *inside* :class:`~.session.SessionMiddleware` (added to the app before
21
+ it), so the session record is already on the request state.
22
+
23
+ Request phase — unsafe methods (POST/PUT/PATCH/DELETE) on requests that
24
+ carry a live session must present the configured header (default
25
+ ``X-XSRF-TOKEN``) matching the session's ``csrf_token``; the comparison is
26
+ constant-time and failure is a 403 ``csrf_token_mismatch``. Requests
27
+ without a session (bearer/token clients — no ambient credential) and safe
28
+ methods are exempt, as are configured exempt paths. When
29
+ ``stateful_origins`` is set, a foreign ``Origin`` header is rejected before
30
+ the token is even checked (defense in depth).
31
+
32
+ Response phase — whenever the session's CSRF token differs from the
33
+ incoming CSRF cookie (first issue, or rotation on login), the cookie
34
+ (default ``XSRF-TOKEN``) is (re)set. It is deliberately **not** HttpOnly:
35
+ the SPA must read it to echo it in the header, and it is useless to an
36
+ attacker without the HttpOnly session cookie. A stale cookie with no
37
+ backing session is deleted.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ app: ASGIApp,
43
+ cookie: str = "XSRF-TOKEN",
44
+ header: str = "X-XSRF-TOKEN",
45
+ exempt_paths: Iterable[str] = (),
46
+ stateful_origins: Iterable[str] = (),
47
+ ttl: float | None = 7200,
48
+ same_site: str = "lax",
49
+ secure: bool = True,
50
+ domain: str | None = None,
51
+ path: str = "/",
52
+ ) -> None:
53
+ self.app = app
54
+ self.cookie = cookie
55
+ self.header = header.lower().encode("latin-1")
56
+ self.exempt_paths = tuple(exempt_paths)
57
+ self.stateful_origins = frozenset(stateful_origins)
58
+ self.ttl = ttl
59
+ self.same_site = same_site
60
+ self.secure = secure
61
+ self.domain = domain
62
+ self.path = path
63
+
64
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
65
+ if scope["type"] != "http":
66
+ await self.app(scope, receive, send)
67
+ return
68
+
69
+ state = scope.setdefault("state", {})
70
+ record = state.get(SESSION_KEY)
71
+ if scope["method"] in UNSAFE_METHODS and record is not None:
72
+ rejection = self._reject(scope, record)
73
+ if rejection is not None:
74
+ await rejection(scope, receive, send)
75
+ return
76
+
77
+ incoming_token = self._read_cookie(scope)
78
+
79
+ async def send_wrapper(message: Message) -> None:
80
+ if message["type"] == "http.response.start":
81
+ header = self._cookie_header(state, incoming_token)
82
+ if header is not None:
83
+ MutableHeaders(scope=message).append("set-cookie", header)
84
+ await send(message)
85
+
86
+ await self.app(scope, receive, send_wrapper)
87
+
88
+ def _reject(self, scope: Scope, record: Any) -> Response | None:
89
+ origin = self._header_value(scope, b"origin")
90
+ if self.stateful_origins and origin is not None and origin not in self.stateful_origins:
91
+ return self._forbidden("Origin is not in the configured stateful origins.")
92
+ if self._exempt(scope["path"]):
93
+ return None
94
+ supplied = self._header_value(scope, self.header)
95
+ if supplied is None or not secrets.compare_digest(
96
+ supplied.encode("latin-1"), record.csrf_token.encode("latin-1")
97
+ ):
98
+ return self._forbidden("CSRF token missing or invalid.")
99
+ return None
100
+
101
+ @staticmethod
102
+ def _forbidden(description: str) -> Response:
103
+ error = CsrfTokenMismatch(description)
104
+ return JSONResponse(status_code=error.status_code, content=error.to_dict())
105
+
106
+ def _exempt(self, path: str) -> bool:
107
+ for pattern in self.exempt_paths:
108
+ if pattern.endswith("*"):
109
+ if path.startswith(pattern[:-1]):
110
+ return True
111
+ elif path == pattern:
112
+ return True
113
+ return False
114
+
115
+ @staticmethod
116
+ def _header_value(scope: Scope, name: bytes) -> str | None:
117
+ raw = next((value for key, value in scope.get("headers", []) if key == name), None)
118
+ return raw.decode("latin-1") if raw is not None else None
119
+
120
+ def _read_cookie(self, scope: Scope) -> str | None:
121
+ raw = self._header_value(scope, b"cookie")
122
+ if raw is None:
123
+ return None
124
+ jar: SimpleCookie = SimpleCookie()
125
+ jar.load(raw)
126
+ morsel = jar.get(self.cookie)
127
+ return morsel.value if morsel is not None else None
128
+
129
+ def _cookie_header(self, state: dict[str, Any], incoming_token: str | None) -> str | None:
130
+ record = state.get(SESSION_KEY)
131
+ response = Response()
132
+ if record is not None:
133
+ if incoming_token == record.csrf_token:
134
+ return None
135
+ response.set_cookie(
136
+ self.cookie,
137
+ record.csrf_token,
138
+ max_age=int(self.ttl) if self.ttl is not None else None,
139
+ path=self.path,
140
+ domain=self.domain,
141
+ secure=self.secure,
142
+ httponly=False,
143
+ samesite=self.same_site,
144
+ )
145
+ elif state.get(FORGET_KEY) or incoming_token is not None:
146
+ response.delete_cookie(
147
+ self.cookie,
148
+ path=self.path,
149
+ domain=self.domain,
150
+ secure=self.secure,
151
+ httponly=False,
152
+ samesite=self.same_site,
153
+ )
154
+ else:
155
+ return None
156
+ return next(
157
+ value.decode("latin-1")
158
+ for name, value in response.raw_headers
159
+ if name == b"set-cookie"
160
+ )