Skip to content

Commit 63f0a43

Browse files
committed
fix: 15 review findings -- cookie validators, persist-before-cookie, lockout TTL, audit logs
Config validators: - Reject SameSite=None without Secure (browser requirement) - Reject colliding cookie names (session/csrf/refresh must be distinct) Controller: - Persist refresh token before appending cookie (skip cookie on failure) - Fix CSRF cookie lifetime note in docs/security.md (cascade revocation) Security hardening: - Wrap SimpleCookie parsing in try/except (malformed cookies = absent) - Lockout load_locked() restores remaining TTL, not fresh full duration - Log warning on missing authentication before raising 401 - Fix retry_after=0 truthiness (use 'is not None' check) - Wrap lockout store init in try/except (non-fatal on failure) - CHECK(used IN (0,1)) constraint on refresh_tokens - Gate SSE 401 handler with dev bypass check Audit logging: - Add INFO logs to RefreshStore create/revoke_by_session/revoke_by_user - New event constants: API_AUTH_REFRESH_CREATED, API_AUTH_REFRESH_REVOKED Tests: - Assert both session + CSRF cookies in integration test - Validate _extract_auth_cookies returns both cookies - Fix fetchUser test for new short-circuit logic - Wrap getCsrfToken decodeURIComponent in try/catch
1 parent d7e23db commit 63f0a43

17 files changed

Lines changed: 132 additions & 27 deletions

File tree

docs/security.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ on resolution.
7070
- **HttpOnly cookie sessions** -- JWTs are delivered via HttpOnly, Secure, SameSite=Strict cookies (never exposed to JavaScript). Password changes rotate the session cookie so the embedded `pwd_sig` stays current.
7171
- **CSRF protection** -- double-submit cookie pattern: a non-HttpOnly CSRF cookie is set alongside the session cookie; JavaScript reads it and sends it as the `X-CSRF-Token` header on mutating requests. The middleware validates header-vs-cookie match using constant-time comparison.
7272
- **Account lockout** -- after exceeding a configurable threshold of failed login attempts within a sliding window, the account is temporarily locked (HTTP 429 with `Retry-After` header). Lockout state is restored from SQLite on restart.
73-
- **Refresh token rotation** -- optional single-use refresh tokens with replay detection (reuse of a consumed token triggers a warning).
73+
- **Refresh token rotation** -- optional single-use refresh tokens with replay detection; reuse of a consumed token logs the event and the affected session's refresh tokens are cascade-revoked.
7474
- **Concurrent session limits** -- configurable maximum active sessions per user; oldest sessions are revoked when the limit is exceeded.
7575
- **JWT bearer tokens** with password-change detection (`pwd_sig` claim, skipped for system user)
7676
- **System user (CLI)** -- internal identity bootstrapped at startup with a random Argon2id password hash. CLI tokens use `sub: "system"` with `iss: "synthorg-cli"` and skip `pwd_sig` validation (JWT HMAC signature is the sole authentication gate). The system user cannot log in, change its password, or be modified through the API.

src/synthorg/api/auth/config.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,33 @@ def _validate_refresh_expiry(self) -> Self:
186186
raise ValueError(msg)
187187
return self
188188

189+
@model_validator(mode="after")
190+
def _validate_cookie_settings(self) -> Self:
191+
"""Reject invalid cookie configuration combinations.
192+
193+
- ``SameSite=None`` requires ``Secure=True`` (browser
194+
requirement).
195+
- Cookie names must be distinct to avoid collisions.
196+
"""
197+
if self.cookie_samesite == "none" and not self.cookie_secure:
198+
msg = (
199+
"cookie_secure must be True when "
200+
"cookie_samesite is 'none' (browser requirement)"
201+
)
202+
raise ValueError(msg)
203+
names = [
204+
self.cookie_name,
205+
self.csrf_cookie_name,
206+
self.refresh_cookie_name,
207+
]
208+
if len(set(names)) != len(names):
209+
msg = (
210+
"cookie_name, csrf_cookie_name, and "
211+
"refresh_cookie_name must all be distinct"
212+
)
213+
raise ValueError(msg)
214+
return self
215+
189216
def with_secret(self, secret: str) -> AuthConfig:
190217
"""Return a copy with the JWT secret set.
191218

src/synthorg/api/auth/controller.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,9 @@ async def _make_session_cookies( # noqa: PLR0913
262262
if config.jwt_refresh_enabled:
263263
refresh_token = secrets.token_urlsafe(32)
264264
refresh_max_age = config.jwt_refresh_expiry_minutes * 60
265-
cookies.append(
266-
make_refresh_cookie(refresh_token, refresh_max_age, config),
267-
)
268-
# Persist hashed refresh token for single-use validation.
265+
# Persist hashed refresh token BEFORE setting the cookie.
266+
# Only append the cookie if persistence succeeds.
267+
refresh_persisted = False
269268
if app_state is not None and session_id and user_id:
270269
try:
271270
from synthorg.api.auth.refresh_store import ( # noqa: PLC0415, TC001
@@ -286,6 +285,12 @@ async def _make_session_cookies( # noqa: PLR0913
286285
user_id=user_id,
287286
expires_at=refresh_expiry,
288287
)
288+
refresh_persisted = True
289+
else:
290+
logger.warning(
291+
API_AUTH_FAILED,
292+
reason="refresh_store_not_available",
293+
)
289294
except MemoryError, RecursionError:
290295
raise
291296
except Exception:
@@ -294,6 +299,18 @@ async def _make_session_cookies( # noqa: PLR0913
294299
reason="refresh_token_persist_failed",
295300
exc_info=True,
296301
)
302+
else:
303+
logger.warning(
304+
API_AUTH_FAILED,
305+
reason="refresh_token_persist_skipped",
306+
has_app_state=app_state is not None,
307+
has_session_id=bool(session_id),
308+
has_user_id=bool(user_id),
309+
)
310+
if refresh_persisted:
311+
cookies.append(
312+
make_refresh_cookie(refresh_token, refresh_max_age, config),
313+
)
297314
return cookies
298315

299316

src/synthorg/api/auth/csrf.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,11 @@ def _parse_cookies(
174174
result: dict[str, str] = {}
175175
for name, value in headers:
176176
if name.lower() == b"cookie":
177-
morsel = SimpleCookie(value.decode("latin-1"))
177+
try:
178+
morsel = SimpleCookie(value.decode("latin-1"))
179+
except Exception: # noqa: S112
180+
# Malformed cookie header -- treat as absent.
181+
continue
178182
for key, m in morsel.items():
179183
result[key] = m.value
180184
return result

src/synthorg/api/auth/lockout_store.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,21 +87,29 @@ async def load_locked(self) -> int:
8787
now = datetime.now(UTC)
8888
window_start = (now - self._window).isoformat()
8989
cursor = await self._db.execute(
90-
"SELECT username, COUNT(*) AS cnt "
90+
"SELECT username, COUNT(*) AS cnt, "
91+
"MAX(attempted_at) AS max_attempted_at "
9192
"FROM login_attempts "
9293
"WHERE attempted_at >= ? "
9394
"GROUP BY username "
9495
"HAVING cnt >= ?",
9596
(window_start, self._threshold),
9697
)
9798
rows = await cursor.fetchall()
99+
mono_now = time.monotonic()
98100
restored = 0
99101
for row in rows:
100102
uname = row["username"]
101103
uname = uname.lower()
102104
if uname not in self._locked:
103-
self._locked[uname] = time.monotonic() + self._duration_seconds
104-
restored += 1
105+
max_at = datetime.fromisoformat(
106+
row["max_attempted_at"],
107+
)
108+
locked_until = max_at + self._duration
109+
remaining = (locked_until - now).total_seconds()
110+
if remaining > 0:
111+
self._locked[uname] = mono_now + remaining
112+
restored += 1
105113
if restored:
106114
logger.info(
107115
API_AUTH_ACCOUNT_LOCKED,

src/synthorg/api/auth/middleware.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ async def authenticate_request(
127127
raise NotAuthorizedException(
128128
detail="Invalid session cookie",
129129
)
130+
logger.warning(
131+
API_AUTH_FAILED,
132+
reason="missing_authentication",
133+
path=path,
134+
)
130135
raise NotAuthorizedException(
131136
detail="Missing authentication",
132137
)

src/synthorg/api/auth/refresh_store.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
from synthorg.observability.events.api import (
1919
API_AUTH_REFRESH_CLEANUP,
2020
API_AUTH_REFRESH_CONSUMED,
21+
API_AUTH_REFRESH_CREATED,
2122
API_AUTH_REFRESH_REJECTED,
23+
API_AUTH_REFRESH_REVOKED,
2224
)
2325

2426
logger = get_logger(__name__)
@@ -96,6 +98,11 @@ async def create(
9698
),
9799
)
98100
await self._db.commit()
101+
logger.info(
102+
API_AUTH_REFRESH_CREATED,
103+
session_id=session_id,
104+
user_id=user_id,
105+
)
99106

100107
async def consume(
101108
self,
@@ -193,7 +200,14 @@ async def revoke_by_session(self, session_id: str) -> int:
193200
(session_id,),
194201
)
195202
await self._db.commit()
196-
return cursor.rowcount
203+
count = cursor.rowcount
204+
if count:
205+
logger.info(
206+
API_AUTH_REFRESH_REVOKED,
207+
session_id=session_id,
208+
revoked=count,
209+
)
210+
return count
197211

198212
async def revoke_by_user(self, user_id: str) -> int:
199213
"""Mark all refresh tokens for a user as used.
@@ -209,7 +223,14 @@ async def revoke_by_user(self, user_id: str) -> int:
209223
(user_id,),
210224
)
211225
await self._db.commit()
212-
return cursor.rowcount
226+
count = cursor.rowcount
227+
if count:
228+
logger.info(
229+
API_AUTH_REFRESH_REVOKED,
230+
user_id=user_id,
231+
revoked=count,
232+
)
233+
return count
213234

214235
async def cleanup_expired(self) -> int:
215236
"""Remove expired and used tokens.

src/synthorg/api/exception_handlers.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,9 +348,12 @@ def handle_api_error(
348348
msg = type(exc).default_message
349349
else:
350350
msg = str(exc)
351-
retry_after_val: int | None = getattr(exc, "retry_after", None) or None
351+
retry_after_raw = getattr(exc, "retry_after", None)
352+
retry_after_val: int | None = (
353+
retry_after_raw if retry_after_raw is not None else None
354+
)
352355
headers: dict[str, str] | None = None
353-
if retry_after_val:
356+
if retry_after_val is not None:
354357
headers = {"Retry-After": str(retry_after_val)}
355358
return _build_response(
356359
request,

src/synthorg/api/lifecycle.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,22 @@ async def _safe_startup( # noqa: PLR0913, PLR0912, PLR0915, C901
239239
app_state.config.api.auth if app_state.config is not None else None
240240
)
241241
if auth_cfg is not None:
242-
lockout_store = LockoutStore(db, auth_cfg)
243-
await lockout_store.load_locked()
244-
app_state.set_lockout_store(lockout_store)
245-
logger.info(
246-
API_APP_STARTUP,
247-
note="Lockout store initialized",
248-
)
242+
try:
243+
lockout_store = LockoutStore(db, auth_cfg)
244+
await lockout_store.load_locked()
245+
app_state.set_lockout_store(lockout_store)
246+
logger.info(
247+
API_APP_STARTUP,
248+
note="Lockout store initialized",
249+
)
250+
except MemoryError, RecursionError:
251+
raise
252+
except Exception:
253+
logger.error(
254+
API_APP_STARTUP,
255+
note="Lockout store initialization failed",
256+
exc_info=True,
257+
)
249258

250259
if message_bus is not None:
251260
try:

src/synthorg/observability/events/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,10 @@
107107
API_AUTH_LOCKOUT_CLEANUP: Final[str] = "api.auth.lockout_cleanup"
108108

109109
# Refresh tokens
110+
API_AUTH_REFRESH_CREATED: Final[str] = "api.auth.refresh_created"
110111
API_AUTH_REFRESH_CONSUMED: Final[str] = "api.auth.refresh_consumed"
111112
API_AUTH_REFRESH_REJECTED: Final[str] = "api.auth.refresh_rejected"
113+
API_AUTH_REFRESH_REVOKED: Final[str] = "api.auth.refresh_revoked"
112114
API_AUTH_REFRESH_CLEANUP: Final[str] = "api.auth.refresh_cleanup"
113115

114116
# Cookie auth

0 commit comments

Comments
 (0)