feature-standards-python-auth 0.2.0__tar.gz

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.
@@ -0,0 +1,22 @@
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ .venv/
5
+ __pycache__/
6
+ *.py[cod]
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ node_modules/
10
+ dist/
11
+ test-results/
12
+ playwright-report/
13
+ .DS_Store
14
+ *.log
15
+ coverage.out
16
+ eval-results/
17
+ /implementations/go-net-http/go-net-http
18
+ /examples/consumer-go/consumer-go
19
+ /clients/react/lib/
20
+ /clients/react/*.tgz
21
+ bin/
22
+ target/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jongko54
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,23 @@
1
+ # Feature Standards Python authentication
2
+
3
+ Session authentication for FastAPI with a reusable application factory and authentication dependency. The implementation follows the same behavioral contracts as the project's Go, Java, Rust, and browser clients.
4
+
5
+ ```python
6
+ from typing import Annotated
7
+ from fastapi import Depends
8
+ from feature_auth import create_app, current_user
9
+
10
+ app = create_app()
11
+
12
+ @app.get("/private")
13
+ def private(user: Annotated[dict, Depends(current_user)]):
14
+ return {"user": user}
15
+ ```
16
+
17
+ Python 3.12 or later is required. Configuration is read when the application lifespan starts. In-memory mode supports one configured user and process-local sessions. PostgreSQL mode adds shared persistent sessions; the optional account lifecycle profile adds registration, email verification, and password reset.
18
+
19
+ For a loopback-only demo, explicitly set `AUTH_DEMO=true`, `AUTH_COOKIE_SECURE=false`, and `AUTH_ALLOWED_ORIGIN=http://127.0.0.1:5173`. Run Uvicorn with `--no-proxy-headers`. Demo credentials are `demo@example.com` / `correct-horse-battery-staple`; they are not production credentials.
20
+
21
+ Database migrations and seeding are explicit repository tools. Installing or starting this package does not create tables or users. Production HTTPS, proxy policy, secrets, mail delivery, and operational controls require deployment-specific configuration.
22
+
23
+ See the [source and integration guide](https://github.com/jongko54/feature-standards/tree/main/implementations/python-fastapi), [contracts](https://github.com/jongko54/feature-standards/tree/main/features/auth), and [security policy](https://github.com/jongko54/feature-standards/security/policy). Contract tests are not an independent security audit. MIT licensed.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: feature-standards-python-auth
3
+ Version: 0.2.0
4
+ Summary: Contract-tested session authentication for FastAPI
5
+ Project-URL: Repository, https://github.com/jongko54/feature-standards
6
+ Project-URL: Documentation, https://github.com/jongko54/feature-standards/tree/main/implementations/python-fastapi
7
+ Project-URL: Issues, https://github.com/jongko54/feature-standards/issues
8
+ Author: jongko54
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: argon2-cffi<26,>=25.1.0
13
+ Requires-Dist: fastapi<1,>=0.115
14
+ Requires-Dist: psycopg[binary]<4,>=3.2
15
+ Requires-Dist: uvicorn<1,>=0.34
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Feature Standards Python authentication
19
+
20
+ Session authentication for FastAPI with a reusable application factory and authentication dependency. The implementation follows the same behavioral contracts as the project's Go, Java, Rust, and browser clients.
21
+
22
+ ```python
23
+ from typing import Annotated
24
+ from fastapi import Depends
25
+ from feature_auth import create_app, current_user
26
+
27
+ app = create_app()
28
+
29
+ @app.get("/private")
30
+ def private(user: Annotated[dict, Depends(current_user)]):
31
+ return {"user": user}
32
+ ```
33
+
34
+ Python 3.12 or later is required. Configuration is read when the application lifespan starts. In-memory mode supports one configured user and process-local sessions. PostgreSQL mode adds shared persistent sessions; the optional account lifecycle profile adds registration, email verification, and password reset.
35
+
36
+ For a loopback-only demo, explicitly set `AUTH_DEMO=true`, `AUTH_COOKIE_SECURE=false`, and `AUTH_ALLOWED_ORIGIN=http://127.0.0.1:5173`. Run Uvicorn with `--no-proxy-headers`. Demo credentials are `demo@example.com` / `correct-horse-battery-staple`; they are not production credentials.
37
+
38
+ Database migrations and seeding are explicit repository tools. Installing or starting this package does not create tables or users. Production HTTPS, proxy policy, secrets, mail delivery, and operational controls require deployment-specific configuration.
39
+
40
+ See the [source and integration guide](https://github.com/jongko54/feature-standards/tree/main/implementations/python-fastapi), [contracts](https://github.com/jongko54/feature-standards/tree/main/features/auth), and [security policy](https://github.com/jongko54/feature-standards/security/policy). Contract tests are not an independent security audit. MIT licensed.
@@ -0,0 +1,15 @@
1
+ """Reusable browser-session authentication for FastAPI."""
2
+
3
+ from .core import AuthError, AuthService, AuthStore, Settings
4
+ from .http import create_app, current_user
5
+ from .memory import AuthState
6
+
7
+ __all__ = [
8
+ "AuthError",
9
+ "AuthService",
10
+ "AuthState",
11
+ "AuthStore",
12
+ "Settings",
13
+ "create_app",
14
+ "current_user",
15
+ ]
@@ -0,0 +1,36 @@
1
+ """v3 account input contract, independent of HTTP and PostgreSQL."""
2
+
3
+ import re
4
+
5
+ from .core import AuthError, normalize_email, valid_email
6
+
7
+ ACCOUNT_ROUTES = {
8
+ "/auth/register": "register",
9
+ "/auth/email-verification/request": "verify-request",
10
+ "/auth/email-verification/confirm": "verify-confirm",
11
+ "/auth/password-reset/request": "reset-request",
12
+ "/auth/password-reset/confirm": "reset-confirm",
13
+ }
14
+ ACCOUNT_FIELDS = {
15
+ "register": {"email", "password"},
16
+ "verify-request": {"email"},
17
+ "verify-confirm": {"token"},
18
+ "reset-request": {"email"},
19
+ "reset-confirm": {"token", "password"},
20
+ }
21
+
22
+
23
+ def account_input(action: str, data: dict[str, str]) -> tuple[str, str, str]:
24
+ email = normalize_email(data.get("email", ""))
25
+ token = data.get("token", "")
26
+ password = data.get("password", "")
27
+ if (
28
+ (
29
+ "email" in data
30
+ and (not valid_email(email) or any(ord(c) <= 32 or ord(c) == 127 for c in email))
31
+ )
32
+ or ("token" in data and re.fullmatch(r"[A-Za-z0-9_-]{43}", token) is None)
33
+ or ("password" in data and not (len(password) >= 15 and len(password.encode()) <= 1024))
34
+ ):
35
+ raise AuthError(400, "INVALID_REQUEST")
36
+ return email, token, password
@@ -0,0 +1,191 @@
1
+ """Shared configuration, storage protocol and authentication service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import os
7
+ from dataclasses import dataclass, field, replace
8
+ from typing import Protocol
9
+ from urllib.parse import urlsplit
10
+
11
+ COOKIE_NAME = "fs_session"
12
+ MAX_BODY_BYTES = 4096
13
+ DEMO_EMAIL = "demo@example.com"
14
+ DEMO_PASSWORD = "correct-horse-battery-staple"
15
+ ASCII_EMAIL_WHITESPACE = "\t\n\v\f\r "
16
+ ASCII_EMAIL_LOWERCASE = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
17
+
18
+
19
+ def normalize_email(email: str) -> str:
20
+ """Unicode 테이블과 무관하게 계약의 ASCII 정규화만 수행한다."""
21
+ return email.strip(ASCII_EMAIL_WHITESPACE).translate(ASCII_EMAIL_LOWERCASE)
22
+
23
+
24
+ def env_bool(name: str, default: bool) -> bool:
25
+ value = os.environ.get(name, str(default).lower())
26
+ if value not in ("true", "false"):
27
+ raise ValueError(f"{name} must be true or false")
28
+ return value == "true"
29
+
30
+
31
+ def env_positive_int(name: str, default: int) -> int:
32
+ raw = os.environ.get(name, str(default))
33
+ if not raw.isascii() or not raw.isdecimal() or int(raw) <= 0:
34
+ raise ValueError(f"{name} must be a positive integer")
35
+ return int(raw)
36
+
37
+
38
+ def valid_email(email: str) -> bool:
39
+ return "@" in email and 0 < len(email.encode("utf-8")) <= 254
40
+
41
+
42
+ def is_loopback(host: str) -> bool:
43
+ if host == "localhost":
44
+ return True
45
+ try:
46
+ return ipaddress.ip_address(host).is_loopback
47
+ except ValueError:
48
+ return False
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Settings:
53
+ demo: bool
54
+ email: str
55
+ password: str = field(repr=False)
56
+ origin: str = "http://127.0.0.1:5173"
57
+ secure_cookie: bool = True
58
+ session_ttl: int = 1800
59
+ rate_limit_max: int = 5
60
+ rate_limit_window: int = 60
61
+ store: str = "memory"
62
+ database_url: str = field(default="", repr=False)
63
+ account_lifecycle: bool = False
64
+
65
+ @classmethod
66
+ def from_env(cls) -> Settings:
67
+ store = os.environ.get("AUTH_STORE", "memory")
68
+ if store not in ("memory", "postgres"):
69
+ raise ValueError("AUTH_STORE must be memory or postgres")
70
+ account_lifecycle = env_bool("AUTH_ACCOUNT_LIFECYCLE", False)
71
+ if account_lifecycle and store != "postgres":
72
+ raise ValueError("AUTH_ACCOUNT_LIFECYCLE requires postgres storage")
73
+ database_url = os.environ.get("AUTH_DATABASE_URL", "") if store == "postgres" else ""
74
+ if store == "postgres" and not database_url.startswith(("postgresql://", "postgres://")):
75
+ raise ValueError("AUTH_DATABASE_URL must be a PostgreSQL URI")
76
+ demo = env_bool("AUTH_DEMO", False)
77
+ origin = os.environ.get("AUTH_ALLOWED_ORIGIN", "http://127.0.0.1:5173")
78
+ secure = env_bool("AUTH_COOKIE_SECURE", True)
79
+ parsed = urlsplit(origin)
80
+ try:
81
+ port = parsed.port
82
+ except ValueError as exc:
83
+ raise ValueError("AUTH_ALLOWED_ORIGIN has an invalid port") from exc
84
+ if (
85
+ parsed.scheme not in ("http", "https")
86
+ or not parsed.hostname
87
+ or parsed.username is not None
88
+ or parsed.password is not None
89
+ or parsed.path
90
+ or parsed.query
91
+ or parsed.fragment
92
+ or origin != f"{parsed.scheme}://{parsed.netloc}"
93
+ or "*" in origin
94
+ or any(char.isspace() for char in origin)
95
+ or (port is not None and not 1 <= port <= 65535)
96
+ ):
97
+ raise ValueError("AUTH_ALLOWED_ORIGIN must be an exact HTTP(S) origin")
98
+ if not demo and parsed.scheme != "https":
99
+ raise ValueError("Non-demo mode requires an HTTPS origin")
100
+ if not secure and not (demo and is_loopback(parsed.hostname)):
101
+ raise ValueError("Insecure cookies require demo mode with a loopback origin")
102
+ email, password = "", ""
103
+ if store == "memory":
104
+ email = DEMO_EMAIL if demo else normalize_email(os.environ.get("AUTH_USER_EMAIL", ""))
105
+ password = DEMO_PASSWORD if demo else os.environ.get("AUTH_USER_PASSWORD", "")
106
+ try:
107
+ valid_credentials = valid_email(email) and 1 <= len(password.encode("utf-8")) <= 1024
108
+ except UnicodeError:
109
+ valid_credentials = False
110
+ if store == "memory" and not valid_credentials:
111
+ raise ValueError("A valid AUTH_USER_EMAIL and AUTH_USER_PASSWORD are required")
112
+ env_positive_int("AUTH_PORT", 8000)
113
+ if int(os.environ.get("AUTH_PORT", "8000")) > 65535:
114
+ raise ValueError("AUTH_PORT must be at most 65535")
115
+ return cls(
116
+ store=store,
117
+ database_url=database_url,
118
+ account_lifecycle=account_lifecycle,
119
+ demo=demo,
120
+ email=email,
121
+ password=password,
122
+ origin=origin,
123
+ secure_cookie=secure,
124
+ session_ttl=env_positive_int("AUTH_SESSION_TTL_SECONDS", 1800),
125
+ rate_limit_max=env_positive_int("AUTH_RATE_LIMIT_MAX", 5),
126
+ rate_limit_window=env_positive_int("AUTH_RATE_LIMIT_WINDOW_SECONDS", 60),
127
+ )
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class Session:
132
+ csrf: str
133
+ expires_at: float
134
+ user: dict[str, str] | None = None
135
+
136
+
137
+ @dataclass
138
+ class FailureWindow:
139
+ count: int
140
+ expires_at: float
141
+
142
+
143
+ class AuthError(Exception):
144
+ def __init__(self, status: int, code: str, headers: dict[str, str] | None = None):
145
+ self.status = status
146
+ self.code = code
147
+ self.headers = headers
148
+
149
+
150
+ class AuthStore(Protocol):
151
+ """Atomic authentication operations; HTTP adapters never handle SQL."""
152
+
153
+ def authenticate(
154
+ self, peer: str, email: str, password: str, old_token: str
155
+ ) -> tuple[str, Session]: ...
156
+ def get_session(self, token: str) -> Session | None: ...
157
+ def logout(self, token: str, csrf: str) -> None: ...
158
+ def health(self) -> None: ...
159
+ def close(self) -> None: ...
160
+
161
+
162
+ class AuthService:
163
+ def __init__(self, settings: Settings, store: AuthStore):
164
+ self.settings = replace(settings, password="", database_url="")
165
+ self.store = store
166
+ self.profile = (
167
+ "browser-session-v3"
168
+ if settings.account_lifecycle
169
+ else ("browser-session-v2" if settings.store == "postgres" else "browser-session-v1")
170
+ )
171
+
172
+ def account(self, action: str, peer: str, email: str, token: str, password: str):
173
+ handler = getattr(self.store, "account", None)
174
+ if not self.settings.account_lifecycle or handler is None:
175
+ raise AuthError(404, "NOT_FOUND")
176
+ return handler(action, peer, email, token, password)
177
+
178
+ def authenticate(self, peer: str, email: str, password: str, old_token: str):
179
+ return self.store.authenticate(peer, email, password, old_token)
180
+
181
+ def get_session(self, token: str):
182
+ return self.store.get_session(token)
183
+
184
+ def logout(self, token: str, csrf: str):
185
+ return self.store.logout(token, csrf)
186
+
187
+ def payload(self, session: Session) -> dict:
188
+ return {
189
+ "user": session.user or {"id": "demo-user", "email": self.settings.email},
190
+ "csrfToken": session.csrf,
191
+ }
@@ -0,0 +1,231 @@
1
+ """FastAPI routes; authentication storage is injected through the service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from collections.abc import Callable
8
+ from contextlib import asynccontextmanager
9
+
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.responses import JSONResponse, Response
12
+ from starlette.concurrency import run_in_threadpool
13
+ from starlette.exceptions import HTTPException
14
+
15
+ from .accounts import ACCOUNT_FIELDS, ACCOUNT_ROUTES, account_input
16
+ from .core import (
17
+ COOKIE_NAME,
18
+ MAX_BODY_BYTES,
19
+ AuthError,
20
+ AuthService,
21
+ AuthStore,
22
+ Settings,
23
+ normalize_email,
24
+ valid_email,
25
+ )
26
+ from .service import build_service
27
+
28
+
29
+ def error_response(status: int, code: str, headers: dict | None = None) -> JSONResponse:
30
+ response_headers = {"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}
31
+ response_headers.update(headers or {})
32
+ return JSONResponse(
33
+ status_code=status,
34
+ content={"error": {"code": code, "message": code.replace("_", " ").lower()}},
35
+ headers=response_headers,
36
+ )
37
+
38
+
39
+ def require_origin(request: Request, settings: Settings) -> None:
40
+ if request.headers.getlist("origin") != [settings.origin]:
41
+ raise AuthError(403, "ORIGIN_DENIED")
42
+
43
+
44
+ def unique_object(pairs: list[tuple[str, object]]) -> dict:
45
+ result = {}
46
+ for key, value in pairs:
47
+ if key in result:
48
+ raise ValueError("Duplicate JSON key")
49
+ result[key] = value
50
+ return result
51
+
52
+
53
+ def invalid_constant(value: str) -> None:
54
+ raise ValueError("Invalid JSON constant")
55
+
56
+
57
+ async def read_object(request: Request, fields: set[str]) -> dict[str, str]:
58
+ media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
59
+ if media_type != "application/json":
60
+ raise AuthError(415, "UNSUPPORTED_MEDIA_TYPE")
61
+ body = bytearray()
62
+ async for chunk in request.stream():
63
+ body.extend(chunk)
64
+ if len(body) > MAX_BODY_BYTES:
65
+ raise AuthError(413, "REQUEST_TOO_LARGE")
66
+ try:
67
+ data = json.loads(
68
+ body.decode("utf-8"), object_pairs_hook=unique_object, parse_constant=invalid_constant
69
+ )
70
+ if not isinstance(data, dict) or set(data) != fields:
71
+ raise ValueError("Invalid object")
72
+ if any(not isinstance(value, str) for value in data.values()):
73
+ raise ValueError("Invalid field type")
74
+ for value in data.values():
75
+ value.encode("utf-8")
76
+ return data
77
+ except (ValueError, UnicodeError, RecursionError) as exc:
78
+ raise AuthError(400, "INVALID_REQUEST") from exc
79
+
80
+
81
+ async def read_credentials(request: Request) -> tuple[str, str]:
82
+ data = await read_object(request, {"email", "password"})
83
+ try:
84
+ email = normalize_email(data["email"])
85
+ password = data["password"]
86
+ if not valid_email(email) or not 1 <= len(password.encode("utf-8")) <= 1024:
87
+ raise ValueError("Invalid field length")
88
+ return email, password
89
+ except (ValueError, UnicodeError, RecursionError) as exc:
90
+ raise AuthError(400, "INVALID_REQUEST") from exc
91
+
92
+
93
+ def current_user(request: Request) -> dict[str, str]:
94
+ """Return the current user, also usable as Depends(current_user)."""
95
+ state: AuthService = request.app.state.auth
96
+ session = state.get_session(request.cookies.get(COOKIE_NAME, ""))
97
+ if session is None:
98
+ raise AuthError(401, "UNAUTHENTICATED")
99
+ return state.payload(session)["user"]
100
+
101
+
102
+ def create_app(
103
+ *,
104
+ settings: Settings | None = None,
105
+ clock: Callable[[], float] = time.monotonic,
106
+ store: AuthStore | None = None,
107
+ ) -> FastAPI:
108
+ @asynccontextmanager
109
+ async def lifespan(application: FastAPI):
110
+ application.state.auth = await run_in_threadpool(
111
+ build_service, settings or Settings.from_env(), store, clock
112
+ )
113
+ try:
114
+ yield
115
+ finally:
116
+ await run_in_threadpool(application.state.auth.store.close)
117
+
118
+ application = FastAPI(
119
+ title="Feature Standards Auth",
120
+ lifespan=lifespan,
121
+ docs_url=None,
122
+ redoc_url=None,
123
+ openapi_url=None,
124
+ redirect_slashes=False,
125
+ )
126
+
127
+ @application.middleware("http")
128
+ async def auth_headers(request: Request, call_next):
129
+ if (
130
+ request.url.path in ACCOUNT_ROUTES
131
+ and not request.app.state.auth.settings.account_lifecycle
132
+ ):
133
+ return error_response(404, "NOT_FOUND")
134
+ response = await call_next(request)
135
+ if request.url.path.startswith("/auth/"):
136
+ response.headers["Cache-Control"] = "no-store"
137
+ response.headers["X-Content-Type-Options"] = "nosniff"
138
+ return response
139
+
140
+ @application.exception_handler(AuthError)
141
+ async def handle_auth_error(request: Request, error: AuthError):
142
+ return error_response(error.status, error.code, error.headers)
143
+
144
+ @application.exception_handler(HTTPException)
145
+ async def handle_http_error(request: Request, error: HTTPException):
146
+ code = {404: "NOT_FOUND", 405: "METHOD_NOT_ALLOWED"}.get(error.status_code, "HTTP_ERROR")
147
+ return error_response(error.status_code, code, error.headers)
148
+
149
+ @application.exception_handler(Exception)
150
+ async def handle_internal_error(request: Request, error: Exception):
151
+ return error_response(500, "INTERNAL_ERROR")
152
+
153
+ @application.get("/healthz")
154
+ def healthz(request: Request):
155
+ state = request.app.state.auth
156
+ state.store.health()
157
+ return {"status": "ok", "profile": state.profile, "implementation": "python-fastapi"}
158
+
159
+ @application.post("/auth/login")
160
+ async def login(request: Request):
161
+ state: AuthService = request.app.state.auth
162
+ require_origin(request, state.settings)
163
+ email, password = await read_credentials(request)
164
+ token, session = await run_in_threadpool(
165
+ state.authenticate,
166
+ request.client.host if request.client else "unknown",
167
+ email,
168
+ password,
169
+ request.cookies.get(COOKIE_NAME, ""),
170
+ )
171
+ response = JSONResponse(state.payload(session))
172
+ response.set_cookie(
173
+ COOKIE_NAME,
174
+ token,
175
+ max_age=state.settings.session_ttl,
176
+ path="/",
177
+ secure=state.settings.secure_cookie,
178
+ httponly=True,
179
+ samesite="lax",
180
+ )
181
+ return response
182
+
183
+ @application.get("/auth/me")
184
+ def me(request: Request):
185
+ state: AuthService = request.app.state.auth
186
+ session = state.get_session(request.cookies.get(COOKIE_NAME, ""))
187
+ if session is None:
188
+ raise AuthError(401, "UNAUTHENTICATED")
189
+ return state.payload(session)
190
+
191
+ @application.post("/auth/logout")
192
+ def logout(request: Request):
193
+ state: AuthService = request.app.state.auth
194
+ require_origin(request, state.settings)
195
+ csrf_headers = request.headers.getlist("x-csrf-token")
196
+ csrf = csrf_headers[0] if len(csrf_headers) == 1 else ""
197
+ state.logout(request.cookies.get(COOKIE_NAME, ""), csrf)
198
+ response = Response(status_code=204)
199
+ response.delete_cookie(
200
+ COOKIE_NAME,
201
+ path="/",
202
+ secure=state.settings.secure_cookie,
203
+ httponly=True,
204
+ samesite="lax",
205
+ )
206
+ return response
207
+
208
+ def account_endpoint(action: str):
209
+ async def endpoint(request: Request):
210
+ state: AuthService = request.app.state.auth
211
+ require_origin(request, state.settings)
212
+ data = await read_object(request, ACCOUNT_FIELDS[action])
213
+ email, token, password = account_input(action, data)
214
+ await run_in_threadpool(
215
+ state.account,
216
+ action,
217
+ request.client.host if request.client else "unknown",
218
+ email,
219
+ token,
220
+ password,
221
+ )
222
+ if action.endswith("confirm"):
223
+ return Response(status_code=204)
224
+ return JSONResponse({"status": "accepted"}, status_code=202)
225
+
226
+ return endpoint
227
+
228
+ for path, action in ACCOUNT_ROUTES.items():
229
+ application.add_api_route(path, account_endpoint(action), methods=["POST"])
230
+
231
+ return application
@@ -0,0 +1,90 @@
1
+ """The original process-local v1 adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import math
7
+ import secrets
8
+ import threading
9
+ from collections.abc import Callable
10
+ from dataclasses import replace
11
+
12
+ from argon2 import PasswordHasher
13
+ from argon2.exceptions import VerifyMismatchError
14
+
15
+ from .core import AuthError, FailureWindow, Session, Settings
16
+
17
+
18
+ class AuthState:
19
+ def __init__(self, settings: Settings, clock: Callable[[], float]):
20
+ self.clock = clock
21
+ self.hasher = PasswordHasher()
22
+ self.password_hash = self.hasher.hash(settings.password)
23
+ self.settings = replace(settings, password="")
24
+ self.sessions: dict[str, Session] = {}
25
+ self.failures: dict[str, FailureWindow] = {}
26
+ self.lock = threading.RLock()
27
+
28
+ def _prune(self, now: float) -> None:
29
+ self.sessions = {
30
+ token: session for token, session in self.sessions.items() if session.expires_at > now
31
+ }
32
+ self.failures = {
33
+ peer: window for peer, window in self.failures.items() if window.expires_at > now
34
+ }
35
+
36
+ def authenticate(
37
+ self, peer: str, email: str, password: str, old_token: str
38
+ ) -> tuple[str, Session]:
39
+ # 해싱을 직렬화해 병렬 로그인 메모리 폭증과 실패 횟수 경합 우회를 막는다.
40
+ with self.lock:
41
+ now = self.clock()
42
+ self._prune(now)
43
+ failure = self.failures.get(peer)
44
+ if failure is not None and failure.count >= self.settings.rate_limit_max:
45
+ retry_after = max(1, math.ceil(failure.expires_at - now))
46
+ raise AuthError(429, "RATE_LIMITED", {"Retry-After": str(retry_after)})
47
+ try:
48
+ password_matches = self.hasher.verify(self.password_hash, password)
49
+ except VerifyMismatchError:
50
+ password_matches = False
51
+ email_matches = hmac.compare_digest(email.encode(), self.settings.email.encode())
52
+ if not password_matches or not email_matches:
53
+ if failure is None:
54
+ self.failures[peer] = FailureWindow(1, now + self.settings.rate_limit_window)
55
+ else:
56
+ failure.count += 1
57
+ raise AuthError(401, "INVALID_CREDENTIALS")
58
+ self.failures.pop(peer, None)
59
+ self.sessions.pop(old_token, None)
60
+ token = secrets.token_urlsafe(32)
61
+ session = Session(secrets.token_urlsafe(32), self.clock() + self.settings.session_ttl)
62
+ self.sessions[token] = session
63
+ return token, session
64
+
65
+ def get_session(self, token: str) -> Session | None:
66
+ with self.lock:
67
+ self._prune(self.clock())
68
+ return self.sessions.get(token)
69
+
70
+ def logout(self, token: str, csrf: str) -> None:
71
+ with self.lock:
72
+ self._prune(self.clock())
73
+ session = self.sessions.get(token)
74
+ if session is not None:
75
+ if not hmac.compare_digest(csrf.encode("utf-8"), session.csrf.encode("utf-8")):
76
+ raise AuthError(403, "CSRF_INVALID")
77
+ del self.sessions[token]
78
+
79
+ def health(self) -> None:
80
+ pass
81
+
82
+ def close(self) -> None:
83
+ self.sessions.clear()
84
+ self.failures.clear()
85
+
86
+ def payload(self, session: Session) -> dict:
87
+ return {
88
+ "user": session.user or {"id": "demo-user", "email": self.settings.email},
89
+ "csrfToken": session.csrf,
90
+ }
@@ -0,0 +1,215 @@
1
+ """PostgreSQL adapter. Transactions complete before values reach the HTTP layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import math
9
+ import re
10
+ import secrets
11
+ from contextlib import contextmanager
12
+
13
+ import psycopg
14
+ from argon2 import PasswordHasher
15
+ from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
16
+
17
+ from .core import AuthError, Session, Settings
18
+
19
+ PHC = re.compile(r"^\$argon2id\$v=19\$m=65536,t=3,p=4\$([A-Za-z0-9+/]{22})\$([A-Za-z0-9+/]{43})$")
20
+
21
+
22
+ def valid_phc(value: str) -> bool:
23
+ match = PHC.fullmatch(value)
24
+ if not match:
25
+ return False
26
+ try:
27
+ salt, digest = (
28
+ base64.b64decode(part + "=" * (-len(part) % 4), validate=True)
29
+ for part in match.groups()
30
+ )
31
+ return (
32
+ len(salt) == 16
33
+ and len(digest) == 32
34
+ and base64.b64encode(salt).decode().rstrip("=") == match[1]
35
+ and base64.b64encode(digest).decode().rstrip("=") == match[2]
36
+ )
37
+ except ValueError:
38
+ return False
39
+
40
+
41
+ def token_hash(token: str) -> bytes:
42
+ return hashlib.sha256(token.encode("utf-8")).digest()
43
+
44
+
45
+ class PostgresStore:
46
+ def __init__(self, settings: Settings):
47
+ if not settings.database_url.startswith(("postgresql://", "postgres://")):
48
+ raise ValueError("AUTH_DATABASE_URL must be a PostgreSQL URI")
49
+ self._url = settings.database_url
50
+ self.ttl = settings.session_ttl
51
+ self.limit = settings.rate_limit_max
52
+ self.window = settings.rate_limit_window
53
+ self.account_lifecycle = settings.account_lifecycle
54
+ self.hasher = PasswordHasher(
55
+ time_cost=3, memory_cost=65536, parallelism=4, salt_len=16, hash_len=32
56
+ )
57
+ self._dummy = self.hasher.hash(secrets.token_urlsafe(32))
58
+ try:
59
+ self.health()
60
+ except AuthError:
61
+ raise ValueError(
62
+ "PostgreSQL authentication storage is unavailable or incompatible"
63
+ ) from None
64
+
65
+ @contextmanager
66
+ def _connection(self):
67
+ try:
68
+ # Each operation owns its connection and transaction; no thread shares one.
69
+ with psycopg.connect(
70
+ self._url,
71
+ connect_timeout=3,
72
+ options="-c statement_timeout=10000 -c lock_timeout=8000",
73
+ ) as conn:
74
+ yield conn
75
+ except psycopg.Error:
76
+ raise AuthError(503, "STORAGE_UNAVAILABLE") from None
77
+
78
+ def health(self) -> None:
79
+ with self._connection() as conn:
80
+ rows = conn.execute("SELECT version FROM feature_auth.schema_version").fetchall()
81
+ if rows != [(2 if self.account_lifecycle else 1,)]:
82
+ raise AuthError(503, "STORAGE_UNAVAILABLE")
83
+
84
+ def close(self) -> None:
85
+ pass
86
+
87
+ def account(self, action: str, peer: str, email: str, token: str, password: str) -> None:
88
+ if not self.account_lifecycle:
89
+ raise AuthError(404, "NOT_FOUND")
90
+ with self._connection() as conn:
91
+ outcome = conn.execute(
92
+ "SELECT feature_auth.account_reserve(%s,%s)", (action, peer)
93
+ ).fetchone()[0]
94
+ if not isinstance(outcome, int) or outcome < 0:
95
+ raise AuthError(503, "STORAGE_UNAVAILABLE")
96
+ if outcome == 0:
97
+ # Reserve the peer budget in this transaction before allocating Argon2 memory.
98
+ encoded = self.hasher.hash(password) if password else ""
99
+ if action in ("register", "verify-request", "reset-request"):
100
+ token = secrets.token_urlsafe(32)
101
+ outcome = conn.execute(
102
+ "SELECT feature_auth.account_apply(%s,%s,%s,%s,%s)",
103
+ (action, peer, email, token, encoded),
104
+ ).fetchone()[0]
105
+ # The connection context commits before any semantic outcome reaches HTTP.
106
+ if not isinstance(outcome, int):
107
+ raise AuthError(503, "STORAGE_UNAVAILABLE")
108
+ if outcome == -1:
109
+ raise AuthError(400, "INVALID_TOKEN")
110
+ if outcome > 0:
111
+ raise AuthError(429, "RATE_LIMITED", {"Retry-After": str(outcome)})
112
+ if outcome != 0:
113
+ raise AuthError(503, "STORAGE_UNAVAILABLE")
114
+
115
+ def authenticate(self, peer: str, email: str, password: str, old_token: str):
116
+ outcome = None
117
+ result = None
118
+ with self._connection() as conn:
119
+ conn.execute(
120
+ "SELECT pg_advisory_xact_lock(20261102, hashtext(host(CAST(%s AS inet))))", (peer,)
121
+ )
122
+ now = conn.execute("SELECT clock_timestamp()").fetchone()[0]
123
+ conn.execute(
124
+ "DELETE FROM feature_auth.login_attempts "
125
+ "WHERE peer = CAST(%s AS inet) AND expires_at <= %s",
126
+ (peer, now),
127
+ )
128
+ attempt = conn.execute(
129
+ "SELECT failures, expires_at FROM feature_auth.login_attempts "
130
+ "WHERE peer = CAST(%s AS inet)",
131
+ (peer,),
132
+ ).fetchone()
133
+ if attempt and attempt[0] >= self.limit:
134
+ retry = max(1, math.ceil((attempt[1] - now).total_seconds()))
135
+ outcome = AuthError(429, "RATE_LIMITED", {"Retry-After": str(retry)})
136
+ else:
137
+ user = conn.execute(
138
+ "SELECT id, email, password_hash, enabled "
139
+ "FROM feature_auth.users WHERE email = %s FOR SHARE",
140
+ (email,),
141
+ ).fetchone()
142
+ encoded = user[2] if user else self._dummy
143
+ if not valid_phc(encoded):
144
+ raise AuthError(503, "STORAGE_UNAVAILABLE")
145
+ try:
146
+ matches = self.hasher.verify(encoded, password)
147
+ except VerifyMismatchError:
148
+ matches = False
149
+ except (InvalidHashError, VerificationError):
150
+ raise AuthError(503, "STORAGE_UNAVAILABLE") from None
151
+ if not matches or not user or not user[3]:
152
+ conn.execute(
153
+ """INSERT INTO feature_auth.login_attempts(peer, failures, expires_at)
154
+ VALUES (CAST(%s AS inet), 1, %s + %s * interval '1 second')
155
+ ON CONFLICT (peer) DO UPDATE
156
+ SET failures = feature_auth.login_attempts.failures + 1""",
157
+ (peer, now, self.window),
158
+ )
159
+ outcome = AuthError(401, "INVALID_CREDENTIALS")
160
+ else:
161
+ conn.execute(
162
+ "DELETE FROM feature_auth.login_attempts WHERE peer = CAST(%s AS inet)",
163
+ (peer,),
164
+ )
165
+ conn.execute(
166
+ "DELETE FROM feature_auth.sessions WHERE token_hash = %s",
167
+ (token_hash(old_token),),
168
+ )
169
+ token, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
170
+ # Read DB time after hashing so TTL begins at session creation.
171
+ expires = conn.execute(
172
+ """INSERT INTO feature_auth.sessions
173
+ (token_hash,user_id,csrf_token,created_at,expires_at)
174
+ SELECT %s,%s,%s,t,t + %s * interval '1 second'
175
+ FROM (SELECT clock_timestamp() AS t) AS clock
176
+ RETURNING expires_at""",
177
+ (token_hash(token), user[0], csrf, self.ttl),
178
+ ).fetchone()[0]
179
+ result = (
180
+ token,
181
+ Session(csrf, expires.timestamp(), {"id": user[0], "email": user[1]}),
182
+ )
183
+ # Raising inside the transaction would roll back the failed-attempt counter.
184
+ if outcome:
185
+ raise outcome
186
+ return result
187
+
188
+ def get_session(self, token: str) -> Session | None:
189
+ with self._connection() as conn:
190
+ row = conn.execute(
191
+ """SELECT s.csrf_token,s.expires_at,u.id,u.email
192
+ FROM feature_auth.sessions s JOIN feature_auth.users u ON u.id=s.user_id
193
+ WHERE s.token_hash=%s AND s.expires_at > clock_timestamp() AND u.enabled""",
194
+ (token_hash(token),),
195
+ ).fetchone()
196
+ if row:
197
+ return Session(row[0], row[1].timestamp(), {"id": row[2], "email": row[3]})
198
+ return None
199
+
200
+ def logout(self, token: str, csrf: str) -> None:
201
+ with self._connection() as conn:
202
+ digest = token_hash(token)
203
+ row = conn.execute(
204
+ "SELECT csrf_token,expires_at FROM feature_auth.sessions "
205
+ "WHERE token_hash=%s FOR UPDATE",
206
+ (digest,),
207
+ ).fetchone()
208
+ now = conn.execute("SELECT clock_timestamp()").fetchone()[0]
209
+ if (
210
+ row
211
+ and row[1] > now
212
+ and not hmac.compare_digest(csrf.encode("utf-8"), row[0].encode("utf-8"))
213
+ ):
214
+ raise AuthError(403, "CSRF_INVALID")
215
+ conn.execute("DELETE FROM feature_auth.sessions WHERE token_hash=%s", (digest,))
@@ -0,0 +1,22 @@
1
+ """Composition root for the interchangeable store adapters."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ from .core import AuthService, AuthStore, Settings
6
+
7
+
8
+ def build_service(settings: Settings, store: AuthStore | None, clock: Callable[[], float]):
9
+ if settings.store not in ("memory", "postgres"):
10
+ raise ValueError("AUTH_STORE must be memory or postgres")
11
+ if settings.account_lifecycle and settings.store != "postgres":
12
+ raise ValueError("AUTH_ACCOUNT_LIFECYCLE requires postgres storage")
13
+ if store is None:
14
+ if settings.store == "postgres":
15
+ from .postgres import PostgresStore
16
+
17
+ store = PostgresStore(settings)
18
+ else:
19
+ from .memory import AuthState
20
+
21
+ store = AuthState(settings, clock)
22
+ return AuthService(settings, store)
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "feature-standards-python-auth"
3
+ version = "0.2.0"
4
+ description = "Contract-tested session authentication for FastAPI"
5
+ readme = "PACKAGE.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{name = "jongko54"}]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "argon2-cffi>=25.1.0,<26",
12
+ "fastapi>=0.115,<1",
13
+ "uvicorn>=0.34,<1",
14
+ "psycopg[binary]>=3.2,<4",
15
+ ]
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/jongko54/feature-standards"
19
+ Documentation = "https://github.com/jongko54/feature-standards/tree/main/implementations/python-fastapi"
20
+ Issues = "https://github.com/jongko54/feature-standards/issues"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "httpx2>=2.16.0,<3",
25
+ "pytest>=8,<10",
26
+ "ruff>=0.12,<1",
27
+ ]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
31
+ pythonpath = ["."]
32
+
33
+ [tool.ruff]
34
+ target-version = "py312"
35
+ line-length = 100
36
+
37
+ [tool.ruff.lint]
38
+ select = ["E", "F", "I", "UP", "B"]
39
+
40
+ [build-system]
41
+ requires = ["hatchling>=1.27,<2"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["feature_auth"]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = ["feature_auth", "pyproject.toml", "PACKAGE.md", "LICENSE"]