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,117 @@
1
+ from __future__ import annotations
2
+
3
+ from http.cookies import SimpleCookie
4
+ from typing import Any
5
+
6
+ from starlette.datastructures import MutableHeaders
7
+ from starlette.responses import Response
8
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
9
+
10
+ from ..sessions.state import FORGET_KEY, LOADED_ID_KEY, SESSION_KEY
11
+ from ..sessions.store import SessionStore
12
+
13
+
14
+ class SessionMiddleware:
15
+ """Loads the server-side session from the cookie and issues the cookie back.
16
+
17
+ Before the app runs: parse the configured cookie, look the id up in the
18
+ store, and attach the live :class:`SessionRecord` (or ``None``) to
19
+ ``request.state``. After the app decides (via ``Auth.login`` / ``logout``):
20
+
21
+ - a session with an id different from the incoming cookie gets ``Set-Cookie``
22
+ with the configured flags (HttpOnly / SameSite / Secure);
23
+ - an explicit logout — or a stale cookie that resolved to no session —
24
+ gets a deletion ``Set-Cookie``.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ app: ASGIApp,
30
+ store: SessionStore,
31
+ cookie: str = "startkit_session",
32
+ ttl: float | None = 7200,
33
+ http_only: bool = True,
34
+ same_site: str = "lax",
35
+ secure: bool = True,
36
+ domain: str | None = None,
37
+ path: str = "/",
38
+ ) -> None:
39
+ self.app = app
40
+ self.store = store
41
+ self.cookie = cookie
42
+ self.ttl = ttl
43
+ self.http_only = http_only
44
+ self.same_site = same_site
45
+ self.secure = secure
46
+ self.domain = domain
47
+ self.path = path
48
+
49
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
50
+ if scope["type"] != "http":
51
+ await self.app(scope, receive, send)
52
+ return
53
+
54
+ state = scope.setdefault("state", {})
55
+ incoming_id = self._read_cookie(scope)
56
+ record = self.store.find(incoming_id) if incoming_id else None
57
+ if record is not None:
58
+ self.store.touch(record.id)
59
+ state[SESSION_KEY] = record
60
+ state[LOADED_ID_KEY] = incoming_id
61
+ state[FORGET_KEY] = False
62
+
63
+ async def send_wrapper(message: Message) -> None:
64
+ if message["type"] == "http.response.start":
65
+ header = self._cookie_header(state)
66
+ if header is not None:
67
+ MutableHeaders(scope=message).append("set-cookie", header)
68
+ await send(message)
69
+
70
+ await self.app(scope, receive, send_wrapper)
71
+
72
+ def _read_cookie(self, scope: Scope) -> str | None:
73
+ raw = next(
74
+ (value for name, value in scope.get("headers", []) if name == b"cookie"),
75
+ None,
76
+ )
77
+ if raw is None:
78
+ return None
79
+ jar: SimpleCookie = SimpleCookie()
80
+ jar.load(raw.decode("latin-1"))
81
+ morsel = jar.get(self.cookie)
82
+ return morsel.value if morsel is not None else None
83
+
84
+ def _cookie_header(self, state: dict[str, Any]) -> str | None:
85
+ record = state.get(SESSION_KEY)
86
+ incoming_id = state.get(LOADED_ID_KEY)
87
+ response = Response()
88
+ if record is not None:
89
+ if record.id == incoming_id:
90
+ return None
91
+ response.set_cookie(
92
+ self.cookie,
93
+ record.id,
94
+ max_age=int(self.ttl) if self.ttl is not None else None,
95
+ path=self.path,
96
+ domain=self.domain,
97
+ secure=self.secure,
98
+ httponly=self.http_only,
99
+ samesite=self.same_site,
100
+ )
101
+ elif state.get(FORGET_KEY) or incoming_id:
102
+ # Explicit logout, or a cookie that resolved to no live session.
103
+ response.delete_cookie(
104
+ self.cookie,
105
+ path=self.path,
106
+ domain=self.domain,
107
+ secure=self.secure,
108
+ httponly=self.http_only,
109
+ samesite=self.same_site,
110
+ )
111
+ else:
112
+ return None
113
+ return next(
114
+ value.decode("latin-1")
115
+ for name, value in response.raw_headers
116
+ if name == b"set-cookie"
117
+ )
@@ -0,0 +1,4 @@
1
+ from .broker import PasswordBroker
2
+ from .repository import InMemoryPasswordResetRepository, PasswordResetToken
3
+
4
+ __all__ = ["PasswordBroker", "InMemoryPasswordResetRepository", "PasswordResetToken"]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from typing import Callable
5
+
6
+ from ..exceptions import InvalidGrant, ThrottleException
7
+ from ..providers.base import UserProvider
8
+ from ..security.hashing import BcryptHasher, Hasher
9
+ from .repository import InMemoryPasswordResetRepository
10
+
11
+
12
+ class PasswordBroker:
13
+ """Password reset flow backed by the ``password_reset_tokens`` table.
14
+
15
+ Tokens are stored hashed, expire after ``expire_minutes``, and are
16
+ rate-limited per user by ``throttle_seconds`` — matching Laravel's broker.
17
+ The plaintext token is returned from ``send_reset_link`` so the caller can
18
+ deliver it (e.g. by email); only its hash is persisted.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ user_provider: UserProvider,
24
+ repository: InMemoryPasswordResetRepository | None = None,
25
+ hasher: Hasher | None = None,
26
+ expire_minutes: int = 60,
27
+ throttle_seconds: int = 60,
28
+ notifier: Callable[[str, str], None] | None = None,
29
+ ) -> None:
30
+ self._users = user_provider
31
+ self._repo = repository or InMemoryPasswordResetRepository()
32
+ self._hasher = hasher or BcryptHasher()
33
+ self._expire_minutes = expire_minutes
34
+ self._throttle_seconds = throttle_seconds
35
+ self._notifier = notifier
36
+
37
+ def _find_user(self, email: str):
38
+ return self._users.retrieve_by_credentials({"email": email, "username": email})
39
+
40
+ def send_reset_link(self, email: str) -> str:
41
+ user = self._find_user(email)
42
+ if user is None:
43
+ # Do equivalent hashing work before failing so response timing does
44
+ # not reveal whether the email is registered.
45
+ self._hasher.make(secrets.token_urlsafe(40))
46
+ raise InvalidGrant("We can't find a user with that email address.")
47
+ if self._repo.recently_created(email, self._throttle_seconds):
48
+ raise ThrottleException("Please wait before requesting another reset link.")
49
+ token = secrets.token_urlsafe(40)
50
+ self._repo.create(email, self._hasher.make(token))
51
+ if self._notifier is not None:
52
+ self._notifier(email, token)
53
+ return token
54
+
55
+ def reset(self, email: str, token: str, new_password: str) -> bool:
56
+ record = self._repo.find(email)
57
+ if record is None:
58
+ raise InvalidGrant("This password reset token is invalid.")
59
+ if record.age() > self._expire_minutes * 60:
60
+ self._repo.delete(email)
61
+ raise InvalidGrant("This password reset token has expired.")
62
+ if not self._hasher.verify(token, record.hashed_token):
63
+ raise InvalidGrant("This password reset token is invalid.")
64
+ user = self._find_user(email)
65
+ if user is None:
66
+ raise InvalidGrant("We can't find a user with that email address.")
67
+ self._users.update_password(user, new_password)
68
+ self._repo.delete(email)
69
+ return True
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class PasswordResetToken:
9
+ """A row in the ``password_reset_tokens`` table."""
10
+
11
+ email: str
12
+ hashed_token: str
13
+ created_at: float
14
+
15
+ def age(self) -> float:
16
+ return time.time() - self.created_at
17
+
18
+
19
+ class InMemoryPasswordResetRepository:
20
+ """Stores at most one active reset token per identifier (email).
21
+
22
+ Mirrors Laravel's ``password_reset_tokens`` table where issuing a new token
23
+ replaces any previous one for the same user.
24
+ """
25
+
26
+ def __init__(self) -> None:
27
+ self._tokens: dict[str, PasswordResetToken] = {}
28
+
29
+ def create(self, email: str, hashed_token: str) -> PasswordResetToken:
30
+ rec = PasswordResetToken(email=email, hashed_token=hashed_token, created_at=time.time())
31
+ self._tokens[email] = rec
32
+ return rec
33
+
34
+ def find(self, email: str) -> PasswordResetToken | None:
35
+ return self._tokens.get(email)
36
+
37
+ def delete(self, email: str) -> None:
38
+ self._tokens.pop(email, None)
39
+
40
+ def recently_created(self, email: str, throttle_seconds: int) -> bool:
41
+ rec = self._tokens.get(email)
42
+ if rec is None:
43
+ return False
44
+ return rec.age() < throttle_seconds
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.responses import JSONResponse
7
+
8
+ from .exceptions import AuthError
9
+ from .manager import AuthManager
10
+ from .middleware.csrf import CsrfMiddleware
11
+ from .middleware.session import SessionMiddleware
12
+ from .routes import build_router
13
+ from .routes_spa import build_spa_router
14
+
15
+
16
+ async def _auth_error_handler(request: Request, exc: AuthError) -> JSONResponse:
17
+ headers = {}
18
+ if exc.status_code == 401:
19
+ headers["WWW-Authenticate"] = "Bearer"
20
+ return JSONResponse(status_code=exc.status_code, content=exc.to_dict(), headers=headers)
21
+
22
+
23
+ class AuthProvider:
24
+ """Registers the auth stack onto a FastAPI application.
25
+
26
+ Instantiated with an :class:`AuthConfig`, it builds the :class:`AuthManager`,
27
+ stores it on ``app.state.auth_manager`` (where the dependencies read it),
28
+ mounts the OAuth2 router, and installs the OAuth2 error handler.
29
+ """
30
+
31
+ def __init__(self, config: Any, prefix: str = "") -> None:
32
+ self.config = config
33
+ self.prefix = prefix
34
+ self.manager = AuthManager(config)
35
+
36
+ def register(self, app: FastAPI) -> None:
37
+ app.state.auth_manager = self.manager
38
+ app.include_router(build_router(prefix=self.prefix))
39
+ app.add_exception_handler(AuthError, _auth_error_handler)
40
+ if self.manager.has_session_guard():
41
+ session = self.manager.session_config
42
+ if self.manager.spa_enabled():
43
+ spa = self.manager.spa_config
44
+ app.include_router(build_spa_router(prefix=self.prefix))
45
+ # Added before SessionMiddleware so it ends up inside it and
46
+ # sees the loaded session on the request state.
47
+ app.add_middleware(
48
+ CsrfMiddleware,
49
+ cookie=spa["csrf_cookie"],
50
+ header=spa["csrf_header"],
51
+ exempt_paths=spa["csrf_exempt_paths"],
52
+ stateful_origins=spa["stateful_origins"],
53
+ ttl=session["ttl"],
54
+ same_site=session["same_site"],
55
+ secure=session["secure"],
56
+ domain=session["domain"],
57
+ path=session["path"],
58
+ )
59
+ app.add_middleware(
60
+ SessionMiddleware,
61
+ store=self.manager.session_store,
62
+ cookie=session["cookie"],
63
+ ttl=session["ttl"],
64
+ http_only=session["http_only"],
65
+ same_site=session["same_site"],
66
+ secure=session["secure"],
67
+ domain=session["domain"],
68
+ path=session["path"],
69
+ )
@@ -0,0 +1,4 @@
1
+ from .base import UserProvider
2
+ from .memory import InMemoryUserProvider
3
+
4
+ __all__ = ["UserProvider", "InMemoryUserProvider"]
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, runtime_checkable
4
+
5
+ User = Any
6
+
7
+
8
+ @runtime_checkable
9
+ class UserProvider(Protocol):
10
+ """Retrieves and verifies users from a backing store.
11
+
12
+ Kept deliberately small so any store (ORM, in-memory, external service) can
13
+ implement it. Credential *lookup* is separate from credential *verification*
14
+ so timing and hashing stay under the provider's control.
15
+ """
16
+
17
+ def retrieve_by_id(self, identifier: Any) -> User | None:
18
+ """Return the user with this primary identifier, or ``None``."""
19
+
20
+ def retrieve_by_credentials(self, credentials: dict[str, Any]) -> User | None:
21
+ """Find a user by identifying fields (e.g. email); does NOT check the password."""
22
+
23
+ def validate_credentials(self, user: User, credentials: dict[str, Any]) -> bool:
24
+ """Return whether ``credentials`` authenticate ``user``."""
25
+
26
+ def dummy_verify(self) -> None:
27
+ """Burn a credential verification against a throwaway hash.
28
+
29
+ Called when ``retrieve_by_credentials`` finds no user, so the
30
+ absent-user path takes as long as a real password check and login
31
+ timing does not enumerate accounts.
32
+ """
33
+
34
+ def get_identifier(self, user: User) -> Any:
35
+ """Return the primary identifier stored in a token's ``sub`` claim."""
36
+
37
+ def update_password(self, user: User, plain: str) -> None:
38
+ """Persist a new hashed password for ``user`` (used by password resets)."""
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from typing import Any
5
+
6
+ from ..security.hashing import BcryptHasher, Hasher
7
+
8
+
9
+ class InMemoryUserProvider:
10
+ """Dict-backed user provider for tests, demos, and prototypes.
11
+
12
+ Users are plain dicts keyed by an ``id`` field. Real deployments swap this
13
+ for an ORM-backed provider via the ``providers`` config, but the contract is
14
+ identical.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ hasher: Hasher | None = None,
20
+ id_field: str = "id",
21
+ username_field: str = "email",
22
+ password_field: str = "password",
23
+ ) -> None:
24
+ self._hasher = hasher or BcryptHasher()
25
+ self._id_field = id_field
26
+ self._username_field = username_field
27
+ self._password_field = password_field
28
+ self._users: dict[Any, dict[str, Any]] = {}
29
+ # Precomputed hash for constant-time verification of absent users.
30
+ self._dummy_hash = self._hasher.make(secrets.token_urlsafe(16))
31
+
32
+ def add(self, user: dict[str, Any]) -> dict[str, Any]:
33
+ self._users[user[self._id_field]] = user
34
+ return user
35
+
36
+ def create(self, *, password: str, **fields: Any) -> dict[str, Any]:
37
+ fields[self._password_field] = self._hasher.make(password)
38
+ return self.add(fields)
39
+
40
+ def retrieve_by_id(self, identifier: Any) -> dict[str, Any] | None:
41
+ return self._users.get(identifier)
42
+
43
+ def retrieve_by_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
44
+ username = credentials.get(self._username_field)
45
+ if username is None:
46
+ return None
47
+ for user in self._users.values():
48
+ if user.get(self._username_field) == username:
49
+ return user
50
+ return None
51
+
52
+ def validate_credentials(self, user: dict[str, Any], credentials: dict[str, Any]) -> bool:
53
+ if user is None:
54
+ return False
55
+ supplied = credentials.get(self._password_field)
56
+ if supplied is None:
57
+ return False
58
+ return self._hasher.verify(supplied, user.get(self._password_field, ""))
59
+
60
+ def dummy_verify(self) -> None:
61
+ """Run a throwaway hash verification to equalise timing for absent users."""
62
+ self._hasher.verify("invalid", self._dummy_hash)
63
+
64
+ def get_identifier(self, user: dict[str, Any]) -> Any:
65
+ return user[self._id_field]
66
+
67
+ def update_password(self, user: dict[str, Any], plain: str) -> None:
68
+ stored = self._users[user[self._id_field]]
69
+ stored[self._password_field] = self._hasher.make(plain)
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from typing import Any
5
+
6
+ from ..security.hashing import BcryptHasher, Hasher
7
+
8
+
9
+ def _attr(obj: Any, name: str) -> Any:
10
+ if isinstance(obj, dict):
11
+ return obj.get(name)
12
+ return getattr(obj, name, None)
13
+
14
+
15
+ class ModelUserProvider:
16
+ """User provider backed by an active-record-style model class.
17
+
18
+ Works with masoniteorm (``config providers['users']['driver'] = 'masoniteorm'``)
19
+ and any ORM whose model exposes ``find(id)`` and ``where(field, value).first()``.
20
+ The model isn't imported here, keeping the ORM an optional dependency.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ model: Any,
26
+ hasher: Hasher | None = None,
27
+ id_field: str = "id",
28
+ username_field: str = "email",
29
+ password_field: str = "password",
30
+ ) -> None:
31
+ self._model = model
32
+ self._hasher = hasher or BcryptHasher()
33
+ self._id_field = id_field
34
+ self._username_field = username_field
35
+ self._password_field = password_field
36
+ self._dummy_hash = self._hasher.make(secrets.token_urlsafe(16))
37
+
38
+ def dummy_verify(self) -> None:
39
+ """Run a throwaway hash verification to equalise timing for absent users."""
40
+ self._hasher.verify("invalid", self._dummy_hash)
41
+
42
+ def retrieve_by_id(self, identifier: Any) -> Any | None:
43
+ return self._model.find(identifier)
44
+
45
+ def retrieve_by_credentials(self, credentials: dict[str, Any]) -> Any | None:
46
+ username = credentials.get(self._username_field)
47
+ if username is None:
48
+ return None
49
+ return self._model.where(self._username_field, username).first()
50
+
51
+ def validate_credentials(self, user: Any, credentials: dict[str, Any]) -> bool:
52
+ if user is None:
53
+ return False
54
+ supplied = credentials.get(self._password_field)
55
+ if supplied is None:
56
+ return False
57
+ return self._hasher.verify(supplied, _attr(user, self._password_field) or "")
58
+
59
+ def get_identifier(self, user: Any) -> Any:
60
+ return _attr(user, self._id_field)
61
+
62
+ def update_password(self, user: Any, plain: str) -> None:
63
+ hashed = self._hasher.make(plain)
64
+ if isinstance(user, dict):
65
+ user[self._password_field] = hashed
66
+ else:
67
+ setattr(user, self._password_field, hashed)
68
+ save = getattr(user, "save", None)
69
+ if callable(save):
70
+ save()
@@ -0,0 +1,44 @@
1
+ """CORS configuration for SPA cookie authentication.
2
+
3
+ Published into the application as ``config/cors.py`` by::
4
+
5
+ python artisan provider:publish -p auth
6
+
7
+ Cookie-authenticated cross-origin requests require ``allow_credentials=True``,
8
+ and browsers reject a wildcard ``*`` origin when credentials are enabled —
9
+ always list the exact first-party origins here, matching
10
+ ``AuthConfig.spa["stateful_origins"]``.
11
+
12
+ Wire it into Starlette's CORS middleware::
13
+
14
+ from starlette.middleware.cors import CORSMiddleware
15
+ from config import cors
16
+
17
+ app.add_middleware(
18
+ CORSMiddleware,
19
+ allow_origins=cors.ALLOW_ORIGINS,
20
+ allow_credentials=cors.ALLOW_CREDENTIALS,
21
+ allow_methods=cors.ALLOW_METHODS,
22
+ allow_headers=cors.ALLOW_HEADERS,
23
+ expose_headers=cors.EXPOSE_HEADERS,
24
+ max_age=cors.MAX_AGE,
25
+ )
26
+ """
27
+
28
+ # Exact first-party SPA origins (scheme + host + port). Never use "*" here:
29
+ # credentials would silently stop working in every browser.
30
+ ALLOW_ORIGINS = [
31
+ "http://localhost:5173",
32
+ ]
33
+
34
+ # Required so the browser sends the session cookie and accepts Set-Cookie.
35
+ ALLOW_CREDENTIALS = True
36
+
37
+ ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
38
+
39
+ # X-XSRF-TOKEN is the CSRF header the auth package's middleware verifies.
40
+ ALLOW_HEADERS = ["Authorization", "Content-Type", "X-XSRF-TOKEN", "X-Requested-With"]
41
+
42
+ EXPOSE_HEADERS: list[str] = []
43
+
44
+ MAX_AGE = 600
File without changes