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,11 @@
1
+ """Request-state keys shared by the session middleware, guard, and facade.
2
+
3
+ The middleware loads the session record under ``SESSION_KEY`` before the app
4
+ runs and inspects the same keys afterwards to decide whether to set or delete
5
+ the cookie. ``LOADED_ID_KEY`` holds the session id that arrived on the request
6
+ (if any) so a changed id is detected; ``FORGET_KEY`` marks an explicit logout.
7
+ """
8
+
9
+ SESSION_KEY = "auth_session"
10
+ LOADED_ID_KEY = "auth_session_loaded_id"
11
+ FORGET_KEY = "auth_session_forget"
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import time
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+ from .models import SessionRecord
8
+
9
+
10
+ def generate_session_id() -> str:
11
+ return secrets.token_urlsafe(32)
12
+
13
+
14
+ def generate_csrf_token() -> str:
15
+ return secrets.token_urlsafe(32)
16
+
17
+
18
+ @runtime_checkable
19
+ class SessionStore(Protocol):
20
+ """Contract every session backend satisfies.
21
+
22
+ Mirrors the repository pattern of ``InMemoryTokenRepository``: the package
23
+ ships an in-memory default and a SQL implementation, and apps may implement
24
+ the same surface against any other store.
25
+ """
26
+
27
+ def create(self, *, user_id: Any, guard: str, ttl: float | None) -> SessionRecord:
28
+ """Persist and return a new session with a fresh id and CSRF token."""
29
+
30
+ def find(self, session_id: str) -> SessionRecord | None:
31
+ """Return the live session for this id, or ``None`` if unknown/expired."""
32
+
33
+ def touch(self, session_id: str) -> None:
34
+ """Record activity on the session (slides the idle-expiry window)."""
35
+
36
+ def regenerate_id(self, session_id: str) -> SessionRecord | None:
37
+ """Re-key the session under a fresh id, invalidating the old id."""
38
+
39
+ def invalidate(self, session_id: str) -> bool:
40
+ """Delete the session server-side; returns whether it existed."""
41
+
42
+ def purge_expired(self) -> None:
43
+ """Drop expired sessions (maintenance)."""
44
+
45
+
46
+ class InMemorySessionStore:
47
+ """Default dict-backed session store for tests, demos, and prototypes.
48
+
49
+ ``idle_ttl`` expires sessions that have been inactive longer than the
50
+ window, independently of the absolute ``expires_at`` set at creation.
51
+ """
52
+
53
+ def __init__(self, idle_ttl: float | None = None) -> None:
54
+ self._sessions: dict[str, SessionRecord] = {}
55
+ self._idle_ttl = idle_ttl
56
+
57
+ def create(self, *, user_id: Any, guard: str, ttl: float | None) -> SessionRecord:
58
+ now = time.time()
59
+ record = SessionRecord(
60
+ id=generate_session_id(),
61
+ user_id=user_id,
62
+ guard=guard,
63
+ csrf_token=generate_csrf_token(),
64
+ created_at=now,
65
+ last_activity=now,
66
+ expires_at=(now + ttl) if ttl is not None else None,
67
+ )
68
+ self._sessions[record.id] = record
69
+ return record
70
+
71
+ def find(self, session_id: str) -> SessionRecord | None:
72
+ record = self._sessions.get(session_id)
73
+ if record is None:
74
+ return None
75
+ if record.expired(self._idle_ttl):
76
+ del self._sessions[session_id]
77
+ return None
78
+ return record
79
+
80
+ def touch(self, session_id: str) -> None:
81
+ record = self._sessions.get(session_id)
82
+ if record is not None:
83
+ record.last_activity = time.time()
84
+
85
+ def regenerate_id(self, session_id: str) -> SessionRecord | None:
86
+ record = self.find(session_id)
87
+ if record is None:
88
+ return None
89
+ del self._sessions[session_id]
90
+ record.id = generate_session_id()
91
+ self._sessions[record.id] = record
92
+ return record
93
+
94
+ def invalidate(self, session_id: str) -> bool:
95
+ return self._sessions.pop(session_id, None) is not None
96
+
97
+ def purge_expired(self) -> None:
98
+ self._sessions = {
99
+ sid: rec for sid, rec in self._sessions.items() if not rec.expired(self._idle_ttl)
100
+ }
@@ -0,0 +1,38 @@
1
+ """Framework-native provider for `fastapi-startkit` applications.
2
+
3
+ This module is the only place the package touches the optional framework
4
+ dependency, and it is imported lazily (via the package ``__getattr__``), so
5
+ plain-FastAPI installs never load it. Install the extra to use it::
6
+
7
+ pip install fastapi-startkit-auth[startkit]
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ try:
14
+ from fastapi_startkit.support import Provider
15
+ except ImportError as exc: # pragma: no cover - exercised via the package import test
16
+ raise ImportError(
17
+ "AuthServiceProvider requires the optional 'fastapi-startkit' framework. "
18
+ "Install it with: pip install fastapi-startkit-auth[startkit]"
19
+ ) from exc
20
+
21
+ _PUBLISHABLE = Path(__file__).resolve().parent / "publishable"
22
+
23
+
24
+ class AuthServiceProvider(Provider):
25
+ """Registers the auth package's publishable assets with the framework.
26
+
27
+ Add it to the application's ``providers=[...]`` list, then publish the
28
+ CORS stub with ``provider:publish -p auth`` (copied to ``config/cors.py``).
29
+ The stub's defaults are also merged under the ``cors`` config key, so the
30
+ published file only overrides them.
31
+ """
32
+
33
+ provider_key = "auth"
34
+
35
+ def register(self) -> None:
36
+ cors_stub = str(_PUBLISHABLE / "cors.py")
37
+ self.merge_config_from(cors_stub, "cors")
38
+ self.publishes({cors_stub: "config/cors.py"})
@@ -0,0 +1,9 @@
1
+ from .models import AccessTokenRecord, AuthorizationCode, RefreshTokenRecord
2
+ from .repository import InMemoryTokenRepository
3
+
4
+ __all__ = [
5
+ "AccessTokenRecord",
6
+ "RefreshTokenRecord",
7
+ "AuthorizationCode",
8
+ "InMemoryTokenRepository",
9
+ ]
@@ -0,0 +1,68 @@
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 AccessTokenRecord:
10
+ """Server-side record of an issued JWT access token.
11
+
12
+ The JWT itself is stateless; this record is what makes revocation and
13
+ introspection possible and also backs personal access tokens.
14
+ """
15
+
16
+ jti: str
17
+ user_id: Any | None
18
+ client_id: str | None
19
+ scopes: list[str] = field(default_factory=list)
20
+ expires_at: float | None = None
21
+ revoked: bool = False
22
+ name: str | None = None
23
+ personal_access: bool = False
24
+ created_at: float = field(default_factory=lambda: time.time())
25
+
26
+ @property
27
+ def expired(self) -> bool:
28
+ return self.expires_at is not None and self.expires_at < time.time()
29
+
30
+ @property
31
+ def active(self) -> bool:
32
+ return not self.revoked and not self.expired
33
+
34
+
35
+ @dataclass
36
+ class RefreshTokenRecord:
37
+ token_id: str
38
+ access_jti: str
39
+ user_id: Any | None
40
+ client_id: str | None
41
+ scopes: list[str] = field(default_factory=list)
42
+ expires_at: float | None = None
43
+ revoked: bool = False
44
+ created_at: float = field(default_factory=lambda: time.time())
45
+
46
+ @property
47
+ def expired(self) -> bool:
48
+ return self.expires_at is not None and self.expires_at < time.time()
49
+
50
+ @property
51
+ def active(self) -> bool:
52
+ return not self.revoked and not self.expired
53
+
54
+
55
+ @dataclass
56
+ class AuthorizationCode:
57
+ code: str
58
+ client_id: str
59
+ user_id: Any
60
+ scopes: list[str]
61
+ redirect_uri: str | None
62
+ code_challenge: str | None
63
+ code_challenge_method: str | None
64
+ expires_at: float
65
+
66
+ @property
67
+ def expired(self) -> bool:
68
+ return self.expires_at < time.time()
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from .models import AccessTokenRecord, AuthorizationCode, RefreshTokenRecord
7
+
8
+
9
+ class InMemoryTokenRepository:
10
+ """Default token store.
11
+
12
+ Holds access-token records (for revocation/introspection), refresh tokens,
13
+ and pending authorization codes. Implement the same method surface against a
14
+ real database to persist tokens across processes.
15
+ """
16
+
17
+ def __init__(self) -> None:
18
+ self._access: dict[str, AccessTokenRecord] = {}
19
+ self._refresh: dict[str, RefreshTokenRecord] = {}
20
+ self._codes: dict[str, AuthorizationCode] = {}
21
+
22
+ # --- access tokens -------------------------------------------------
23
+ def store_access_token(
24
+ self,
25
+ *,
26
+ jti: str,
27
+ user_id: Any | None,
28
+ client_id: str | None,
29
+ scopes: list[str],
30
+ expires_at: float | None,
31
+ name: str | None = None,
32
+ personal_access: bool = False,
33
+ ) -> AccessTokenRecord:
34
+ rec = AccessTokenRecord(
35
+ jti=jti,
36
+ user_id=user_id,
37
+ client_id=client_id,
38
+ scopes=list(scopes),
39
+ expires_at=expires_at,
40
+ name=name,
41
+ personal_access=personal_access,
42
+ )
43
+ self._access[jti] = rec
44
+ return rec
45
+
46
+ def find_access_token(self, jti: str) -> AccessTokenRecord | None:
47
+ return self._access.get(jti)
48
+
49
+ def revoke_access_token(self, jti: str) -> bool:
50
+ rec = self._access.get(jti)
51
+ if rec is None:
52
+ return False
53
+ rec.revoked = True
54
+ return True
55
+
56
+ def list_access_tokens(self, user_id: Any, personal_access: bool | None = None) -> list[AccessTokenRecord]:
57
+ out = [r for r in self._access.values() if r.user_id == user_id]
58
+ if personal_access is not None:
59
+ out = [r for r in out if r.personal_access == personal_access]
60
+ return out
61
+
62
+ # --- refresh tokens ------------------------------------------------
63
+ def store_refresh_token(
64
+ self,
65
+ *,
66
+ token_id: str,
67
+ access_jti: str,
68
+ user_id: Any | None,
69
+ client_id: str | None,
70
+ scopes: list[str],
71
+ expires_at: float | None,
72
+ ) -> RefreshTokenRecord:
73
+ rec = RefreshTokenRecord(
74
+ token_id=token_id,
75
+ access_jti=access_jti,
76
+ user_id=user_id,
77
+ client_id=client_id,
78
+ scopes=list(scopes),
79
+ expires_at=expires_at,
80
+ )
81
+ self._refresh[token_id] = rec
82
+ return rec
83
+
84
+ def find_refresh_token(self, token_id: str) -> RefreshTokenRecord | None:
85
+ return self._refresh.get(token_id)
86
+
87
+ def revoke_refresh_token(self, token_id: str) -> bool:
88
+ rec = self._refresh.get(token_id)
89
+ if rec is None:
90
+ return False
91
+ rec.revoked = True
92
+ return True
93
+
94
+ # --- authorization codes ------------------------------------------
95
+ def store_auth_code(
96
+ self,
97
+ *,
98
+ code: str,
99
+ client_id: str,
100
+ user_id: Any,
101
+ scopes: list[str],
102
+ redirect_uri: str | None,
103
+ code_challenge: str | None,
104
+ code_challenge_method: str | None,
105
+ expires_at: float,
106
+ ) -> AuthorizationCode:
107
+ rec = AuthorizationCode(
108
+ code=code,
109
+ client_id=client_id,
110
+ user_id=user_id,
111
+ scopes=list(scopes),
112
+ redirect_uri=redirect_uri,
113
+ code_challenge=code_challenge,
114
+ code_challenge_method=code_challenge_method,
115
+ expires_at=expires_at,
116
+ )
117
+ self._codes[code] = rec
118
+ return rec
119
+
120
+ def pull_auth_code(self, code: str) -> AuthorizationCode | None:
121
+ """Return and consume an authorization code (single use)."""
122
+ return self._codes.pop(code, None)
123
+
124
+ # --- maintenance ---------------------------------------------------
125
+ def purge_expired(self) -> None:
126
+ now = time.time()
127
+ self._access = {k: v for k, v in self._access.items() if not (v.expires_at and v.expires_at < now)}
128
+ self._refresh = {k: v for k, v in self._refresh.items() if not (v.expires_at and v.expires_at < now)}
129
+ self._codes = {k: v for k, v in self._codes.items() if v.expires_at >= now}
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ..exceptions import InvalidGrant, InvalidToken
9
+ from ..security.jwt import JWTEncoder
10
+ from .repository import InMemoryTokenRepository
11
+
12
+
13
+ @dataclass
14
+ class IssuedToken:
15
+ """The result of minting a token, serialisable to an OAuth2 token response."""
16
+
17
+ access_token: str
18
+ token_type: str
19
+ expires_in: int
20
+ scopes: list[str]
21
+ jti: str
22
+ refresh_token: str | None = None
23
+
24
+ def to_response(self) -> dict[str, Any]:
25
+ body: dict[str, Any] = {
26
+ "access_token": self.access_token,
27
+ "token_type": self.token_type,
28
+ "expires_in": self.expires_in,
29
+ "scope": " ".join(self.scopes),
30
+ }
31
+ if self.refresh_token is not None:
32
+ body["refresh_token"] = self.refresh_token
33
+ return body
34
+
35
+
36
+ class TokenService:
37
+ """Mints, rotates, revokes, and introspects tokens.
38
+
39
+ Access tokens are signed JWTs; a server-side record per ``jti`` enables
40
+ revocation and introspection. Refresh tokens are opaque high-entropy strings
41
+ mapped to stored records, and are rotated (old access + refresh revoked) on
42
+ every use.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ encoder: JWTEncoder,
48
+ repository: InMemoryTokenRepository | None = None,
49
+ access_ttl: int = 3600,
50
+ refresh_ttl: int = 1209600,
51
+ personal_access_ttl: int = 31536000,
52
+ purge_interval: int = 300,
53
+ ) -> None:
54
+ self.encoder = encoder
55
+ self.repository = repository or InMemoryTokenRepository()
56
+ self.access_ttl = access_ttl
57
+ self.refresh_ttl = refresh_ttl
58
+ self.personal_access_ttl = personal_access_ttl
59
+ self._purge_interval = purge_interval
60
+ self._last_purge = time.time()
61
+
62
+ def _maybe_purge(self) -> None:
63
+ """Drop expired records periodically so in-memory stores stay bounded."""
64
+ now = time.time()
65
+ if now - self._last_purge >= self._purge_interval:
66
+ self._last_purge = now
67
+ self.repository.purge_expired()
68
+
69
+ def issue(
70
+ self,
71
+ *,
72
+ user_id: Any | None,
73
+ client_id: str | None,
74
+ scopes: list[str],
75
+ with_refresh: bool = False,
76
+ ttl: int | None = None,
77
+ name: str | None = None,
78
+ personal_access: bool = False,
79
+ ) -> IssuedToken:
80
+ self._maybe_purge()
81
+ ttl = self.access_ttl if ttl is None else ttl
82
+ sub = None if user_id is None else str(user_id)
83
+ access_token = self.encoder.encode(
84
+ {
85
+ "sub": sub,
86
+ "client_id": client_id,
87
+ "scopes": list(scopes),
88
+ "token_type": "access",
89
+ },
90
+ ttl_seconds=ttl,
91
+ )
92
+ jti = self.encoder.decode(access_token, verify_exp=False)["jti"]
93
+ self.repository.store_access_token(
94
+ jti=jti,
95
+ user_id=user_id,
96
+ client_id=client_id,
97
+ scopes=list(scopes),
98
+ expires_at=time.time() + ttl,
99
+ name=name,
100
+ personal_access=personal_access,
101
+ )
102
+ refresh_token = None
103
+ if with_refresh:
104
+ refresh_token = self._issue_refresh(jti, user_id, client_id, scopes)
105
+ return IssuedToken(
106
+ access_token=access_token,
107
+ token_type="Bearer",
108
+ expires_in=ttl,
109
+ scopes=list(scopes),
110
+ jti=jti,
111
+ refresh_token=refresh_token,
112
+ )
113
+
114
+ def _issue_refresh(self, access_jti: str, user_id, client_id, scopes) -> str:
115
+ token_id = secrets.token_urlsafe(48)
116
+ self.repository.store_refresh_token(
117
+ token_id=token_id,
118
+ access_jti=access_jti,
119
+ user_id=user_id,
120
+ client_id=client_id,
121
+ scopes=list(scopes),
122
+ expires_at=time.time() + self.refresh_ttl,
123
+ )
124
+ return token_id
125
+
126
+ def create_personal_access_token(
127
+ self, *, user_id: Any, name: str, scopes: list[str], ttl: int | None = None
128
+ ) -> IssuedToken:
129
+ return self.issue(
130
+ user_id=user_id,
131
+ client_id=None,
132
+ scopes=scopes,
133
+ ttl=self.personal_access_ttl if ttl is None else ttl,
134
+ name=name,
135
+ personal_access=True,
136
+ )
137
+
138
+ def refresh(self, refresh_token: str, scopes: list[str] | None = None) -> IssuedToken:
139
+ record = self.repository.find_refresh_token(refresh_token)
140
+ if record is None or not record.active:
141
+ raise InvalidGrant("The refresh token is invalid, expired, or revoked.")
142
+
143
+ new_scopes = list(record.scopes)
144
+ if scopes is not None:
145
+ widened = set(scopes) - set(record.scopes)
146
+ if widened:
147
+ raise InvalidGrant("Requested scopes exceed those of the original grant.")
148
+ new_scopes = list(scopes)
149
+
150
+ # Rotate: revoke the previous access + refresh pair before minting a new one.
151
+ self.repository.revoke_access_token(record.access_jti)
152
+ self.repository.revoke_refresh_token(record.token_id)
153
+
154
+ return self.issue(
155
+ user_id=record.user_id,
156
+ client_id=record.client_id,
157
+ scopes=new_scopes,
158
+ with_refresh=True,
159
+ )
160
+
161
+ def authenticate(self, access_token: str) -> dict[str, Any]:
162
+ claims = self.encoder.decode(access_token)
163
+ record = self.repository.find_access_token(claims.get("jti", ""))
164
+ if record is None or not record.active:
165
+ raise InvalidToken("The access token has been revoked or is unknown.")
166
+ return claims
167
+
168
+ def revoke_access(self, jti: str) -> bool:
169
+ return self.repository.revoke_access_token(jti)
170
+
171
+ def revoke_refresh(self, token_id: str) -> bool:
172
+ return self.repository.revoke_refresh_token(token_id)
173
+
174
+ def introspect(self, access_token: str) -> dict[str, Any]:
175
+ try:
176
+ claims = self.authenticate(access_token)
177
+ except InvalidToken:
178
+ return {"active": False}
179
+ record = self.repository.find_access_token(claims["jti"])
180
+ return {
181
+ "active": True,
182
+ "scope": " ".join(record.scopes),
183
+ "client_id": record.client_id,
184
+ "sub": claims.get("sub"),
185
+ "token_type": "Bearer",
186
+ "exp": claims.get("exp"),
187
+ "iat": claims.get("iat"),
188
+ "jti": claims["jti"],
189
+ }