Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Issue **import + traceability** (#565) is **complete**: `POST /api/v2/integratio

**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.

**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.
**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.

Next, in order:
- **4A**: PR status tracking + PROOF9 merge gate
Expand Down
229 changes: 131 additions & 98 deletions codeframe/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,23 @@
# Truthy/falsy values for CODEFRAME_AUTH_REQUIRED (case-insensitive).
_AUTH_FALSY = {"0", "false", "no", "off"}

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


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


def auth_required() -> bool:
Expand All @@ -73,11 +75,13 @@ async def get_current_user(
) -> User:
"""Get currently authenticated user.

Requires a valid JWT, supplied as an ``Authorization: Bearer`` header.
On the allowlisted SSE routes only (``_QUERY_TOKEN_PATHS``), a
``?token=<JWT>`` query parameter is accepted when no header is present
(browser EventSource cannot send headers; mirrors the WebSocket
auth pattern).
Requires a valid JWT, supplied as an ``Authorization: Bearer`` header. On
the allowlisted SSE routes only (``_QUERY_TICKET_PATHS``), a
``?ticket=<value>`` query parameter is accepted when no header is present
(browser EventSource cannot send headers; mirrors the WebSocket auth
pattern). The ticket is a short-lived, single-use value minted by
``POST /auth/stream-ticket`` — not a JWT (issue #745) — so it is redeemed
rather than decoded.

Args:
request: FastAPI request object
Expand All @@ -89,33 +93,27 @@ async def get_current_user(
Raises:
HTTPException: 401 if authentication not provided or invalid
"""
# Resolve the bearer token from the Authorization header. Only the
# allowlisted SSE routes may fall back to a ?token= query parameter
# (EventSource cannot send headers); everywhere else query-string
# credentials are rejected to keep them out of logs/history.
token: Optional[str] = None
if credentials and getattr(credentials, "credentials", None):
token = credentials.credentials
elif request is not None and _query_token_allowed(request.url.path):
token = request.query_params.get("token")
return await _authenticate_bearer_token(credentials.credentials)

if request is not None and _query_ticket_allowed(request.url.path):
ticket = request.query_params.get("ticket")
if ticket:
return await _authenticate_stream_ticket(ticket)

raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)

if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)

async def _authenticate_bearer_token(token: str) -> User:
"""Decode a JWT bearer token and load the active user it names."""
# Validate JWT token
try:
import jwt as pyjwt
from codeframe.auth.manager import (
SECRET,
JWT_ALGORITHM,
JWT_AUDIENCE,
get_async_session_maker,
)
from sqlalchemy import select
from codeframe.auth.manager import SECRET, JWT_ALGORITHM, JWT_AUDIENCE

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

# Get user from database
async_session_maker = get_async_session_maker()
async with async_session_maker() as session:
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()

if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)

if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is inactive",
headers={"WWW-Authenticate": "Bearer"},
)

return user
return await _load_active_user(user_id)

except HTTPException:
raise
Expand All @@ -186,6 +164,79 @@ async def get_current_user(
)


async def _load_active_user(user_id: int) -> User:
"""Load a user by id, raising 401 if not found or inactive.

Shared by the JWT bearer path and the stream-ticket path so both apply
the same active-user check.
"""
from codeframe.auth.manager import get_async_session_maker
from sqlalchemy import select

async_session_maker = get_async_session_maker()
async with async_session_maker() as session:
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()

if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)

if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is inactive",
headers={"WWW-Authenticate": "Bearer"},
)

return user


async def _authenticate_stream_ticket(ticket: str) -> User:
"""Redeem a stream ticket (issue #745) and load the active user it names.

Raises 401 for an unknown/expired/already-used ticket, and for a ticket
that redeems to ``user_id=None`` (only mintable while auth is disabled —
there is no real user to load; ``require_auth``'s auth-disabled fallback
is what admits that case).
"""
from codeframe.auth.stream_tickets import TicketRedemptionError, redeem_ticket

try:
user_id = redeem_ticket(ticket)
except TicketRedemptionError as e:
logger.debug(f"Stream ticket redemption failed: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired ticket",
headers={"WWW-Authenticate": "Bearer"},
)

if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)

try:
return await _load_active_user(user_id)
except HTTPException:
raise
except Exception as e:
# Unexpected DB/session failures must degrade to 401, not 500 —
# matching the bearer path and authenticate_websocket.
logger.error(f"Stream ticket user lookup error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication failed",
headers={"WWW-Authenticate": "Bearer"},
)


Comment thread
coderabbitai[bot] marked this conversation as resolved.
async def get_current_user_optional(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
Expand Down Expand Up @@ -363,11 +414,13 @@ async def authenticate_websocket(
session-chat sockets cannot drift from the REST behavior of ``require_auth()``:

- When auth is disabled (``CODEFRAME_AUTH_REQUIRED`` falsy), returns
``(True, None)`` without requiring a token — the same synthetic local
``(True, None)`` without requiring a ticket — the same synthetic local
principal (``user_id=None``) REST admits in no-auth mode.
- Otherwise validates the ``?token=<JWT>`` query parameter (decode → subject
→ active DB user). On success returns ``(True, user_id)``. On any failure
the socket is closed with ``close_code`` and ``(False, None)`` is returned.
- Otherwise redeems the ``?ticket=<value>`` query parameter — a short-lived,
single-use value minted by ``POST /auth/stream-ticket`` (issue #745), not
a JWT — then loads the active DB user it names. On success returns
``(True, user_id)``. On any failure the socket is closed with
``close_code`` and ``(False, None)`` is returned.

Args:
websocket: The incoming WebSocket connection (not yet accepted).
Expand All @@ -382,51 +435,31 @@ async def authenticate_websocket(
if not auth_required():
return True, None

token = websocket.query_params.get("token")
if not token:
await websocket.close(code=close_code, reason="Authentication required: missing token")
ticket = websocket.query_params.get("ticket")
if not ticket:
await websocket.close(code=close_code, reason="Authentication required: missing ticket")
return False, None

import jwt as pyjwt
from sqlalchemy import select

from codeframe.auth import manager
from codeframe.auth.manager import (
JWT_ALGORITHM,
JWT_AUDIENCE,
get_async_session_maker,
)
from codeframe.auth.stream_tickets import TicketRedemptionError, redeem_ticket

try:
# Read manager.SECRET live: it may be refreshed from .env at server
# startup (after import), so binding the value at import would stale it.
payload = pyjwt.decode(
token, manager.SECRET, algorithms=[JWT_ALGORITHM], audience=JWT_AUDIENCE
)
user_id_str = payload.get("sub")
if not user_id_str:
await websocket.close(code=close_code, reason="Invalid token: missing subject")
return False, None
user_id = int(user_id_str)
except pyjwt.ExpiredSignatureError:
await websocket.close(code=close_code, reason="Token expired")
user_id = redeem_ticket(ticket)
except TicketRedemptionError as exc:
logger.debug("WebSocket ticket redemption failed: %s", exc)
await websocket.close(code=close_code, reason="Invalid or expired ticket")
return False, None
except (pyjwt.InvalidTokenError, ValueError) as exc:
logger.debug("WebSocket JWT decode error: %s", exc)
await websocket.close(code=close_code, reason="Invalid authentication token")

if user_id is None:
# Only mintable while auth was disabled at mint time; auth is required
# here, so there is no real user to admit.
await websocket.close(code=close_code, reason="Authentication required")
return False, None

try:
async_session_maker = get_async_session_maker()
async with async_session_maker() as session:
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
await websocket.close(code=close_code, reason="User not found")
return False, None
if not user.is_active:
await websocket.close(code=close_code, reason="User is inactive")
return False, None
await _load_active_user(user_id)
except HTTPException:
await websocket.close(code=close_code, reason="Authentication failed")
return False, None
except Exception as exc:
logger.error("WebSocket user lookup error: %s", exc)
await websocket.close(code=close_code, reason="Authentication failed")
Expand Down
46 changes: 46 additions & 0 deletions codeframe/auth/router.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
"""Auth router configuration."""
import asyncio
from typing import Any, Dict

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import func, select

from codeframe.auth.schemas import UserCreate, UserRead, UserUpdate
from codeframe.auth.manager import auth_backend, fastapi_users, get_async_session_maker
from codeframe.auth.models import User
from codeframe.auth.api_key_router import router as api_key_router
from codeframe.auth.dependencies import require_auth
from codeframe.auth.stream_tickets import TICKET_TTL_SECONDS, mint_ticket
from codeframe.lib.rate_limiter import enforce_auth_rate_limit

router = APIRouter()


class StreamTicketResponse(BaseModel):
"""Response body for POST /auth/stream-ticket."""

ticket: str
expires_in: int


# Placeholder password for the seeded bootstrap admin (id=1). It cannot match
# any bcrypt hash, so that account can never log in. It is therefore NOT a real
# account and does not close the registration window. See SchemaManager
Expand Down Expand Up @@ -95,3 +106,38 @@ async def allow_registration():

# API key management routes at /api/auth/api-keys
router.include_router(api_key_router)


@router.post(
"/auth/stream-ticket",
response_model=StreamTicketResponse,
dependencies=[Depends(enforce_auth_rate_limit)],
)
async def create_stream_ticket(
auth: Dict[str, Any] = Depends(require_auth),
) -> StreamTicketResponse:
"""Mint a short-lived, single-use ticket for SSE/WS stream authentication (#745).

Browser ``EventSource`` (SSE) and WebSocket clients cannot send a custom
``Authorization`` header, so streaming routes accept a ``?ticket=<value>``
query parameter instead of a long-lived JWT. Call this endpoint first
(authenticated the normal way, via JWT Bearer or ``X-API-Key``), then open
the stream with the returned ticket. The ticket is single-use and expires
after ``expires_in`` seconds.

Minting requires **write** scope: a redeemed ticket acts as a full user
session on the WebSocket routes (terminal input / chat both mutate state),
so a read-only API key must not be able to escalate through it (codex
review P1). Read-only keys don't need tickets — header-capable clients
authenticate the SSE routes with ``X-API-Key`` directly.
"""
from codeframe.auth.api_keys import SCOPE_WRITE
from codeframe.auth.scopes import has_scope

if not has_scope(auth, SCOPE_WRITE):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Stream tickets require write scope",
)
ticket = mint_ticket(auth.get("user_id"))
return StreamTicketResponse(ticket=ticket, expires_in=TICKET_TTL_SECONDS)
Loading
Loading