Skip to content

Commit 62586b4

Browse files
authored
fix(security): single-use stream tickets replace JWTs in SSE/WS URLs (#745) (#800)
## Summary - New POST /auth/stream-ticket mints a 60s single-use opaque ticket (write scope via has_scope; auth rate-limited; in-process store mirroring the rate-limiter pattern) - SSE allowlist paths and both WS routes redeem ?ticket=; ?token=<JWT> is no longer accepted anywhere - Frontend mints a fresh ticket per (re)connect: useEventSource/useTerminalSocket take an async buildUrl re-resolved on every connect AND retry; bare-URL fallback for auth-disabled mode - Unexpected user-load failures on the ticket path degrade to 401, not 500 ## Validation - Tests: 3940 backend + 1043 frontend passing (locally + CI) - Lint: Clean (ruff + next lint); build green - Diff coverage: backend 92%, frontend 100% (changed lines) - Test mutation check: Passed (single-use pop + ticket param name) - Internal review: Completed (advisory; fixed useAgentChat stale-connect race) - Cross-family review: codex, 3 rounds (terminal-retry replay, stale-workspace reconnect, read-only-key WS escalation, admin-scope hierarchy — all fixed) - Early-PR feedback: CodeRabbit Major (500 on DB failure) fixed in fbfdb8b; 5 nitpicks skipped (trivial) - Final feedback triage: 0 new Critical/Major since 2026-07-03T18:55Z cutoff - Demo: all acceptance criteria verified with outcome evidence against a live server (mint 401/200, JWT-in-URL 401, ticket passes auth, reuse 401, WS consumption proof, 61s expiry 401) Closes #745
1 parent 48f26b8 commit 62586b4

27 files changed

Lines changed: 1748 additions & 457 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Issue **import + traceability** (#565) is **complete**: `POST /api/v2/integratio
5454

5555
**Phase 3.5C is complete**`CaptureGlitchModal` form (description/markdown, source, scope, gate obligations, severity, expiry) reachable from the PROOF9 page and the persistent sidebar "Capture Glitch" button. REQ detail view (`/proof/[req_id]`) ships markdown description rendering, `ProofScope` metadata display, obligations table with `Latest Run` column, sortable/filterable evidence history, and empty-state CTA. Backend: `ScopeOut` model on `RequirementResponse`. Issues #568, #569.
5656

57-
**v2 API auth enforcement (#336) is complete** — all 22 v2 REST routers require auth (`require_auth`: JWT Bearer or `X-API-Key`) via router-level dependencies in `server.py`; env-gated `CODEFRAME_AUTH_REQUIRED` (default **ON**; set `false` for local dev — read at request time). `?token=<JWT>` query auth works **only** on the two SSE routes (allowlist `_QUERY_TOKEN_PATHS` in `codeframe/auth/dependencies.py`); WS routers keep their own `?token=` auth. `/auth/register` admits only the bootstrap first user (403 after; seeded `!DISABLED!` admin excluded; in-process lock closes the TOCTOU window). Web UI: `/login` page (sign-in + create-first-account), proactive client-side auth guard in `AppLayout` (token-present → allow; no token → `checkAuthAccess` probe → redirect or fail-open; #651), axios Bearer interceptor for reactive 401→`/login` redirect, SSE/WS hooks probe the `require_auth`-gated `/api/v2/settings/keys` (which respects `CODEFRAME_AUTH_REQUIRED`) on stream failure to catch token expiry (#651), SSE hooks append the token, sidebar logout; `/auth/*` proxied in `next.config.js`. Backend tests run auth-off via root `tests/conftest.py` `setdefault`; `tests/ui/test_v2_auth_enforcement.py` opts back in.
57+
**v2 API auth enforcement (#336) is complete** — all 22 v2 REST routers require auth (`require_auth`: JWT Bearer or `X-API-Key`) via router-level dependencies in `server.py`; env-gated `CODEFRAME_AUTH_REQUIRED` (default **ON**; set `false` for local dev — read at request time). Streams never carry the JWT in the URL (#745): an authenticated `POST /auth/stream-ticket` (write scope; `has_scope`, so admin implies write) mints a 60s **single-use** ticket (`codeframe/auth/stream_tickets.py`, in-process store — multi-worker caveat documented in the module), redeemed as `?ticket=` **only** on the two SSE routes (allowlist `_QUERY_TICKET_PATHS` in `codeframe/auth/dependencies.py`) and by `authenticate_websocket` for the two WS routes; `?token=<JWT>` is no longer accepted anywhere. Frontend fetches a fresh ticket per (re)connect via `fetchStreamTicket()`/`withStreamTicket()`; `useEventSource`/`useTerminalSocket` take an async `buildUrl` re-resolved on every retry. `/auth/register` admits only the bootstrap first user (403 after; seeded `!DISABLED!` admin excluded; in-process lock closes the TOCTOU window). Web UI: `/login` page (sign-in + create-first-account), proactive client-side auth guard in `AppLayout` (token-present → allow; no token → `checkAuthAccess` probe → redirect or fail-open; #651), axios Bearer interceptor for reactive 401→`/login` redirect, SSE/WS hooks probe the `require_auth`-gated `/api/v2/settings/keys` (which respects `CODEFRAME_AUTH_REQUIRED`) on stream failure to catch token expiry (#651), SSE hooks append a fresh stream ticket, sidebar logout; `/auth/*` proxied in `next.config.js`. Backend tests run auth-off via root `tests/conftest.py` `setdefault`; `tests/ui/test_v2_auth_enforcement.py` opts back in.
5858

5959
Next, in order:
6060
- **4A**: PR status tracking + PROOF9 merge gate

codeframe/auth/dependencies.py

Lines changed: 131 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,23 @@
3535
# Truthy/falsy values for CODEFRAME_AUTH_REQUIRED (case-insensitive).
3636
_AUTH_FALSY = {"0", "false", "no", "off"}
3737

38-
# Routes allowed to authenticate via a ?token=<JWT> query parameter. Browser
39-
# EventSource (SSE) cannot send an Authorization header, so these streaming
40-
# routes accept the token in the URL — the same trade-off the WebSocket
41-
# routes already make. Keep this list tight: query-string credentials can
42-
# leak via proxy/access logs and browser history, so the fallback must NOT
43-
# apply to the rest of the API (codex review P2, issue #336).
44-
_QUERY_TOKEN_PATHS = (
38+
# Routes allowed to authenticate via a ?ticket=<value> query parameter.
39+
# Browser EventSource (SSE) cannot send an Authorization header, so these
40+
# streaming routes accept a short-lived, single-use ticket in the URL instead
41+
# — the same trade-off the WebSocket routes already make. Keep this list
42+
# tight: query-string credentials can leak via proxy/access logs and browser
43+
# history, so the fallback must NOT apply to the rest of the API (codex
44+
# review P2, issue #336). Tickets (not long-lived JWTs) close that exposure
45+
# window to TICKET_TTL_SECONDS and single use (issue #745).
46+
_QUERY_TICKET_PATHS = (
4547
re.compile(r"^/api/v2/tasks/[^/]+/stream$"), # task event stream (SSE)
4648
re.compile(r"^/api/v2/prd/stress-test$"), # PRD stress-test stream (SSE)
4749
)
4850

4951

50-
def _query_token_allowed(path: str) -> bool:
51-
"""Whether this request path may authenticate via ?token= (SSE only)."""
52-
return any(pattern.match(path) for pattern in _QUERY_TOKEN_PATHS)
52+
def _query_ticket_allowed(path: str) -> bool:
53+
"""Whether this request path may authenticate via ?ticket= (SSE only)."""
54+
return any(pattern.match(path) for pattern in _QUERY_TICKET_PATHS)
5355

5456

5557
def auth_required() -> bool:
@@ -73,11 +75,13 @@ async def get_current_user(
7375
) -> User:
7476
"""Get currently authenticated user.
7577
76-
Requires a valid JWT, supplied as an ``Authorization: Bearer`` header.
77-
On the allowlisted SSE routes only (``_QUERY_TOKEN_PATHS``), a
78-
``?token=<JWT>`` query parameter is accepted when no header is present
79-
(browser EventSource cannot send headers; mirrors the WebSocket
80-
auth pattern).
78+
Requires a valid JWT, supplied as an ``Authorization: Bearer`` header. On
79+
the allowlisted SSE routes only (``_QUERY_TICKET_PATHS``), a
80+
``?ticket=<value>`` query parameter is accepted when no header is present
81+
(browser EventSource cannot send headers; mirrors the WebSocket auth
82+
pattern). The ticket is a short-lived, single-use value minted by
83+
``POST /auth/stream-ticket`` — not a JWT (issue #745) — so it is redeemed
84+
rather than decoded.
8185
8286
Args:
8387
request: FastAPI request object
@@ -89,33 +93,27 @@ async def get_current_user(
8993
Raises:
9094
HTTPException: 401 if authentication not provided or invalid
9195
"""
92-
# Resolve the bearer token from the Authorization header. Only the
93-
# allowlisted SSE routes may fall back to a ?token= query parameter
94-
# (EventSource cannot send headers); everywhere else query-string
95-
# credentials are rejected to keep them out of logs/history.
96-
token: Optional[str] = None
9796
if credentials and getattr(credentials, "credentials", None):
98-
token = credentials.credentials
99-
elif request is not None and _query_token_allowed(request.url.path):
100-
token = request.query_params.get("token")
97+
return await _authenticate_bearer_token(credentials.credentials)
98+
99+
if request is not None and _query_ticket_allowed(request.url.path):
100+
ticket = request.query_params.get("ticket")
101+
if ticket:
102+
return await _authenticate_stream_ticket(ticket)
103+
104+
raise HTTPException(
105+
status_code=status.HTTP_401_UNAUTHORIZED,
106+
detail="Not authenticated",
107+
headers={"WWW-Authenticate": "Bearer"},
108+
)
101109

102-
if not token:
103-
raise HTTPException(
104-
status_code=status.HTTP_401_UNAUTHORIZED,
105-
detail="Not authenticated",
106-
headers={"WWW-Authenticate": "Bearer"},
107-
)
108110

111+
async def _authenticate_bearer_token(token: str) -> User:
112+
"""Decode a JWT bearer token and load the active user it names."""
109113
# Validate JWT token
110114
try:
111115
import jwt as pyjwt
112-
from codeframe.auth.manager import (
113-
SECRET,
114-
JWT_ALGORITHM,
115-
JWT_AUDIENCE,
116-
get_async_session_maker,
117-
)
118-
from sqlalchemy import select
116+
from codeframe.auth.manager import SECRET, JWT_ALGORITHM, JWT_AUDIENCE
119117

120118
# Decode JWT token directly using PyJWT
121119
# Note: We use direct PyJWT decoding instead of JWTStrategy.read_token()
@@ -151,27 +149,7 @@ async def get_current_user(
151149
headers={"WWW-Authenticate": "Bearer"},
152150
)
153151

154-
# Get user from database
155-
async_session_maker = get_async_session_maker()
156-
async with async_session_maker() as session:
157-
result = await session.execute(select(User).where(User.id == user_id))
158-
user = result.scalar_one_or_none()
159-
160-
if user is None:
161-
raise HTTPException(
162-
status_code=status.HTTP_401_UNAUTHORIZED,
163-
detail="User not found",
164-
headers={"WWW-Authenticate": "Bearer"},
165-
)
166-
167-
if not user.is_active:
168-
raise HTTPException(
169-
status_code=status.HTTP_401_UNAUTHORIZED,
170-
detail="User is inactive",
171-
headers={"WWW-Authenticate": "Bearer"},
172-
)
173-
174-
return user
152+
return await _load_active_user(user_id)
175153

176154
except HTTPException:
177155
raise
@@ -186,6 +164,79 @@ async def get_current_user(
186164
)
187165

188166

167+
async def _load_active_user(user_id: int) -> User:
168+
"""Load a user by id, raising 401 if not found or inactive.
169+
170+
Shared by the JWT bearer path and the stream-ticket path so both apply
171+
the same active-user check.
172+
"""
173+
from codeframe.auth.manager import get_async_session_maker
174+
from sqlalchemy import select
175+
176+
async_session_maker = get_async_session_maker()
177+
async with async_session_maker() as session:
178+
result = await session.execute(select(User).where(User.id == user_id))
179+
user = result.scalar_one_or_none()
180+
181+
if user is None:
182+
raise HTTPException(
183+
status_code=status.HTTP_401_UNAUTHORIZED,
184+
detail="User not found",
185+
headers={"WWW-Authenticate": "Bearer"},
186+
)
187+
188+
if not user.is_active:
189+
raise HTTPException(
190+
status_code=status.HTTP_401_UNAUTHORIZED,
191+
detail="User is inactive",
192+
headers={"WWW-Authenticate": "Bearer"},
193+
)
194+
195+
return user
196+
197+
198+
async def _authenticate_stream_ticket(ticket: str) -> User:
199+
"""Redeem a stream ticket (issue #745) and load the active user it names.
200+
201+
Raises 401 for an unknown/expired/already-used ticket, and for a ticket
202+
that redeems to ``user_id=None`` (only mintable while auth is disabled —
203+
there is no real user to load; ``require_auth``'s auth-disabled fallback
204+
is what admits that case).
205+
"""
206+
from codeframe.auth.stream_tickets import TicketRedemptionError, redeem_ticket
207+
208+
try:
209+
user_id = redeem_ticket(ticket)
210+
except TicketRedemptionError as e:
211+
logger.debug(f"Stream ticket redemption failed: {e}")
212+
raise HTTPException(
213+
status_code=status.HTTP_401_UNAUTHORIZED,
214+
detail="Invalid or expired ticket",
215+
headers={"WWW-Authenticate": "Bearer"},
216+
)
217+
218+
if user_id is None:
219+
raise HTTPException(
220+
status_code=status.HTTP_401_UNAUTHORIZED,
221+
detail="Not authenticated",
222+
headers={"WWW-Authenticate": "Bearer"},
223+
)
224+
225+
try:
226+
return await _load_active_user(user_id)
227+
except HTTPException:
228+
raise
229+
except Exception as e:
230+
# Unexpected DB/session failures must degrade to 401, not 500 —
231+
# matching the bearer path and authenticate_websocket.
232+
logger.error(f"Stream ticket user lookup error: {e}")
233+
raise HTTPException(
234+
status_code=status.HTTP_401_UNAUTHORIZED,
235+
detail="Authentication failed",
236+
headers={"WWW-Authenticate": "Bearer"},
237+
)
238+
239+
189240
async def get_current_user_optional(
190241
request: Request,
191242
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
@@ -363,11 +414,13 @@ async def authenticate_websocket(
363414
session-chat sockets cannot drift from the REST behavior of ``require_auth()``:
364415
365416
- When auth is disabled (``CODEFRAME_AUTH_REQUIRED`` falsy), returns
366-
``(True, None)`` without requiring a token — the same synthetic local
417+
``(True, None)`` without requiring a ticket — the same synthetic local
367418
principal (``user_id=None``) REST admits in no-auth mode.
368-
- Otherwise validates the ``?token=<JWT>`` query parameter (decode → subject
369-
→ active DB user). On success returns ``(True, user_id)``. On any failure
370-
the socket is closed with ``close_code`` and ``(False, None)`` is returned.
419+
- Otherwise redeems the ``?ticket=<value>`` query parameter — a short-lived,
420+
single-use value minted by ``POST /auth/stream-ticket`` (issue #745), not
421+
a JWT — then loads the active DB user it names. On success returns
422+
``(True, user_id)``. On any failure the socket is closed with
423+
``close_code`` and ``(False, None)`` is returned.
371424
372425
Args:
373426
websocket: The incoming WebSocket connection (not yet accepted).
@@ -382,51 +435,31 @@ async def authenticate_websocket(
382435
if not auth_required():
383436
return True, None
384437

385-
token = websocket.query_params.get("token")
386-
if not token:
387-
await websocket.close(code=close_code, reason="Authentication required: missing token")
438+
ticket = websocket.query_params.get("ticket")
439+
if not ticket:
440+
await websocket.close(code=close_code, reason="Authentication required: missing ticket")
388441
return False, None
389442

390-
import jwt as pyjwt
391-
from sqlalchemy import select
392-
393-
from codeframe.auth import manager
394-
from codeframe.auth.manager import (
395-
JWT_ALGORITHM,
396-
JWT_AUDIENCE,
397-
get_async_session_maker,
398-
)
443+
from codeframe.auth.stream_tickets import TicketRedemptionError, redeem_ticket
399444

400445
try:
401-
# Read manager.SECRET live: it may be refreshed from .env at server
402-
# startup (after import), so binding the value at import would stale it.
403-
payload = pyjwt.decode(
404-
token, manager.SECRET, algorithms=[JWT_ALGORITHM], audience=JWT_AUDIENCE
405-
)
406-
user_id_str = payload.get("sub")
407-
if not user_id_str:
408-
await websocket.close(code=close_code, reason="Invalid token: missing subject")
409-
return False, None
410-
user_id = int(user_id_str)
411-
except pyjwt.ExpiredSignatureError:
412-
await websocket.close(code=close_code, reason="Token expired")
446+
user_id = redeem_ticket(ticket)
447+
except TicketRedemptionError as exc:
448+
logger.debug("WebSocket ticket redemption failed: %s", exc)
449+
await websocket.close(code=close_code, reason="Invalid or expired ticket")
413450
return False, None
414-
except (pyjwt.InvalidTokenError, ValueError) as exc:
415-
logger.debug("WebSocket JWT decode error: %s", exc)
416-
await websocket.close(code=close_code, reason="Invalid authentication token")
451+
452+
if user_id is None:
453+
# Only mintable while auth was disabled at mint time; auth is required
454+
# here, so there is no real user to admit.
455+
await websocket.close(code=close_code, reason="Authentication required")
417456
return False, None
418457

419458
try:
420-
async_session_maker = get_async_session_maker()
421-
async with async_session_maker() as session:
422-
result = await session.execute(select(User).where(User.id == user_id))
423-
user = result.scalar_one_or_none()
424-
if user is None:
425-
await websocket.close(code=close_code, reason="User not found")
426-
return False, None
427-
if not user.is_active:
428-
await websocket.close(code=close_code, reason="User is inactive")
429-
return False, None
459+
await _load_active_user(user_id)
460+
except HTTPException:
461+
await websocket.close(code=close_code, reason="Authentication failed")
462+
return False, None
430463
except Exception as exc:
431464
logger.error("WebSocket user lookup error: %s", exc)
432465
await websocket.close(code=close_code, reason="Authentication failed")

codeframe/auth/router.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
"""Auth router configuration."""
22
import asyncio
3+
from typing import Any, Dict
34

45
from fastapi import APIRouter, Depends, HTTPException, status
6+
from pydantic import BaseModel
57
from sqlalchemy import func, select
68

79
from codeframe.auth.schemas import UserCreate, UserRead, UserUpdate
810
from codeframe.auth.manager import auth_backend, fastapi_users, get_async_session_maker
911
from codeframe.auth.models import User
1012
from codeframe.auth.api_key_router import router as api_key_router
13+
from codeframe.auth.dependencies import require_auth
14+
from codeframe.auth.stream_tickets import TICKET_TTL_SECONDS, mint_ticket
1115
from codeframe.lib.rate_limiter import enforce_auth_rate_limit
1216

1317
router = APIRouter()
1418

1519

20+
class StreamTicketResponse(BaseModel):
21+
"""Response body for POST /auth/stream-ticket."""
22+
23+
ticket: str
24+
expires_in: int
25+
26+
1627
# Placeholder password for the seeded bootstrap admin (id=1). It cannot match
1728
# any bcrypt hash, so that account can never log in. It is therefore NOT a real
1829
# account and does not close the registration window. See SchemaManager
@@ -95,3 +106,38 @@ async def allow_registration():
95106

96107
# API key management routes at /api/auth/api-keys
97108
router.include_router(api_key_router)
109+
110+
111+
@router.post(
112+
"/auth/stream-ticket",
113+
response_model=StreamTicketResponse,
114+
dependencies=[Depends(enforce_auth_rate_limit)],
115+
)
116+
async def create_stream_ticket(
117+
auth: Dict[str, Any] = Depends(require_auth),
118+
) -> StreamTicketResponse:
119+
"""Mint a short-lived, single-use ticket for SSE/WS stream authentication (#745).
120+
121+
Browser ``EventSource`` (SSE) and WebSocket clients cannot send a custom
122+
``Authorization`` header, so streaming routes accept a ``?ticket=<value>``
123+
query parameter instead of a long-lived JWT. Call this endpoint first
124+
(authenticated the normal way, via JWT Bearer or ``X-API-Key``), then open
125+
the stream with the returned ticket. The ticket is single-use and expires
126+
after ``expires_in`` seconds.
127+
128+
Minting requires **write** scope: a redeemed ticket acts as a full user
129+
session on the WebSocket routes (terminal input / chat both mutate state),
130+
so a read-only API key must not be able to escalate through it (codex
131+
review P1). Read-only keys don't need tickets — header-capable clients
132+
authenticate the SSE routes with ``X-API-Key`` directly.
133+
"""
134+
from codeframe.auth.api_keys import SCOPE_WRITE
135+
from codeframe.auth.scopes import has_scope
136+
137+
if not has_scope(auth, SCOPE_WRITE):
138+
raise HTTPException(
139+
status_code=status.HTTP_403_FORBIDDEN,
140+
detail="Stream tickets require write scope",
141+
)
142+
ticket = mint_ticket(auth.get("user_id"))
143+
return StreamTicketResponse(ticket=ticket, expires_in=TICKET_TTL_SECONDS)

0 commit comments

Comments
 (0)