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.
- fastapi_startkit_auth/__init__.py +67 -0
- fastapi_startkit_auth/apitokens/__init__.py +13 -0
- fastapi_startkit_auth/apitokens/manager.py +122 -0
- fastapi_startkit_auth/apitokens/models.py +29 -0
- fastapi_startkit_auth/apitokens/repository.py +120 -0
- fastapi_startkit_auth/apitokens/sql.py +152 -0
- fastapi_startkit_auth/application.py +38 -0
- fastapi_startkit_auth/clients/__init__.py +4 -0
- fastapi_startkit_auth/clients/models.py +29 -0
- fastapi_startkit_auth/clients/repository.py +69 -0
- fastapi_startkit_auth/config.py +103 -0
- fastapi_startkit_auth/dependencies.py +93 -0
- fastapi_startkit_auth/exceptions.py +90 -0
- fastapi_startkit_auth/facade.py +107 -0
- fastapi_startkit_auth/grants/__init__.py +13 -0
- fastapi_startkit_auth/grants/authorization_code.py +83 -0
- fastapi_startkit_auth/grants/client_credentials.py +19 -0
- fastapi_startkit_auth/grants/password.py +32 -0
- fastapi_startkit_auth/grants/pkce.py +27 -0
- fastapi_startkit_auth/grants/refresh.py +13 -0
- fastapi_startkit_auth/guards/__init__.py +5 -0
- fastapi_startkit_auth/guards/guard.py +85 -0
- fastapi_startkit_auth/guards/session.py +81 -0
- fastapi_startkit_auth/guards/token.py +58 -0
- fastapi_startkit_auth/manager.py +320 -0
- fastapi_startkit_auth/middleware/__init__.py +4 -0
- fastapi_startkit_auth/middleware/csrf.py +160 -0
- fastapi_startkit_auth/middleware/session.py +117 -0
- fastapi_startkit_auth/passwords/__init__.py +4 -0
- fastapi_startkit_auth/passwords/broker.py +69 -0
- fastapi_startkit_auth/passwords/repository.py +44 -0
- fastapi_startkit_auth/provider.py +69 -0
- fastapi_startkit_auth/providers/__init__.py +4 -0
- fastapi_startkit_auth/providers/base.py +38 -0
- fastapi_startkit_auth/providers/memory.py +69 -0
- fastapi_startkit_auth/providers/model.py +70 -0
- fastapi_startkit_auth/publishable/cors.py +44 -0
- fastapi_startkit_auth/py.typed +0 -0
- fastapi_startkit_auth/routes.py +362 -0
- fastapi_startkit_auth/routes_spa.py +34 -0
- fastapi_startkit_auth/security/__init__.py +4 -0
- fastapi_startkit_auth/security/hashing.py +43 -0
- fastapi_startkit_auth/security/jwt.py +49 -0
- fastapi_startkit_auth/sessions/__init__.py +5 -0
- fastapi_startkit_auth/sessions/models.py +29 -0
- fastapi_startkit_auth/sessions/sql.py +158 -0
- fastapi_startkit_auth/sessions/state.py +11 -0
- fastapi_startkit_auth/sessions/store.py +100 -0
- fastapi_startkit_auth/startkit.py +38 -0
- fastapi_startkit_auth/tokens/__init__.py +9 -0
- fastapi_startkit_auth/tokens/models.py +68 -0
- fastapi_startkit_auth/tokens/repository.py +129 -0
- fastapi_startkit_auth/tokens/service.py +189 -0
- fastapi_startkit_auth-0.3.0.dist-info/METADATA +243 -0
- fastapi_startkit_auth-0.3.0.dist-info/RECORD +57 -0
- fastapi_startkit_auth-0.3.0.dist-info/WHEEL +4 -0
- 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,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,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()
|