fix(sse): Fix critical SSE auth and URL issues

1. Fix SSE URL mismatch (CRITICAL):
   - Frontend was connecting to /events instead of /events/stream
   - Updated useProjectEvents.ts to use correct endpoint path

2. Fix SSE token authentication (CRITICAL):
   - EventSource API doesn't support custom headers
   - Added get_current_user_sse dependency that accepts tokens from:
     - Authorization header (preferred, for non-EventSource clients)
     - Query parameter 'token' (fallback for browser EventSource)
   - Updated SSE endpoint to use new auth dependency
   - Both auth methods now work correctly

Files changed:
- backend/app/api/dependencies/auth.py: +80 lines (new SSE auth)
- backend/app/api/routes/events.py: +23 lines (query param support)
- frontend/src/lib/hooks/useProjectEvents.ts: +5 lines (URL fix)

All 20 backend SSE tests pass.
All 17 frontend useProjectEvents tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 11:59:33 +01:00
parent 3d6fa6b791
commit 15d747eb28
3 changed files with 101 additions and 7 deletions

View File

@@ -151,3 +151,83 @@ async def get_optional_current_user(
return user return user
except (TokenExpiredError, TokenInvalidError): except (TokenExpiredError, TokenInvalidError):
return None return None
async def get_current_user_sse(
db: AsyncSession = Depends(get_db),
authorization: str | None = Header(None),
token: str | None = None, # Query parameter - passed directly from route
) -> User:
"""
Get the current authenticated user for SSE endpoints.
SSE (Server-Sent Events) via EventSource API doesn't support custom headers,
so this dependency accepts tokens from either:
1. Authorization header (preferred, for non-EventSource clients)
2. Query parameter 'token' (fallback for EventSource compatibility)
Security note: Query parameter tokens appear in server logs and browser history.
Consider implementing short-lived SSE-specific tokens for production if this
is a concern. The current approach is acceptable for internal/trusted networks.
Args:
db: Database session
authorization: Authorization header (Bearer token)
token: Query parameter token (fallback for EventSource)
Returns:
User: The authenticated user
Raises:
HTTPException: If authentication fails
"""
# Try Authorization header first (preferred)
auth_token = None
if authorization:
scheme, param = get_authorization_scheme_param(authorization)
if scheme.lower() == "bearer" and param:
auth_token = param
# Fall back to query parameter if no header token
if not auth_token and token:
auth_token = token
if not auth_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# Decode token and get user ID
token_data = get_token_data(auth_token)
# Get user from database
result = await db.execute(select(User).where(User.id == token_data.user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return user
except TokenExpiredError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
except TokenInvalidError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)

View File

@@ -20,12 +20,12 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Header, Request from fastapi import APIRouter, Depends, Header, Query, Request
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse
from app.api.dependencies.auth import get_current_user from app.api.dependencies.auth import get_current_user, get_current_user_sse
from app.api.dependencies.event_bus import get_event_bus from app.api.dependencies.event_bus import get_event_bus
from app.core.database import get_db from app.core.database import get_db
from app.core.exceptions import AuthorizationError from app.core.exceptions import AuthorizationError
@@ -150,9 +150,16 @@ async def event_generator(
description=""" description="""
Stream real-time events for a project via Server-Sent Events (SSE). Stream real-time events for a project via Server-Sent Events (SSE).
**Authentication**: Required (Bearer token) **Authentication**: Required (Bearer token OR query parameter)
**Authorization**: Must have access to the project **Authorization**: Must have access to the project
**Authentication Methods**:
- Bearer token in Authorization header (preferred)
- Query parameter `token` (for EventSource compatibility)
Note: EventSource API doesn't support custom headers, so the query parameter
option is provided for browser-based SSE clients.
**SSE Event Format**: **SSE Event Format**:
``` ```
event: agent.status_changed event: agent.status_changed
@@ -190,9 +197,10 @@ async def event_generator(
async def stream_project_events( async def stream_project_events(
request: Request, request: Request,
project_id: UUID, project_id: UUID,
current_user: User = Depends(get_current_user),
event_bus: EventBus = Depends(get_event_bus),
db: "AsyncSession" = Depends(get_db), db: "AsyncSession" = Depends(get_db),
event_bus: EventBus = Depends(get_event_bus),
token: str | None = Query(None, description="Auth token (for EventSource compatibility)"),
authorization: str | None = Header(None, alias="Authorization"),
last_event_id: str | None = Header(None, alias="Last-Event-ID"), last_event_id: str | None = Header(None, alias="Last-Event-ID"),
): ):
""" """
@@ -207,6 +215,11 @@ async def stream_project_events(
The connection is automatically cleaned up when the client disconnects. The connection is automatically cleaned up when the client disconnects.
""" """
# Authenticate user (supports both header and query param tokens)
current_user = await get_current_user_sse(
db=db, authorization=authorization, token=token
)
logger.info( logger.info(
f"SSE connection request for project {project_id} " f"SSE connection request for project {project_id} "
f"by user {current_user.id} " f"by user {current_user.id} "

View File

@@ -258,10 +258,11 @@ export function useProjectEvents(
// Build SSE URL with auth token // Build SSE URL with auth token
const baseUrl = config.api.url; const baseUrl = config.api.url;
const sseUrl = `${baseUrl}/api/v1/projects/${projectId}/events`; // Backend SSE endpoint is at /events/stream (not /events)
const sseUrl = `${baseUrl}/api/v1/projects/${projectId}/events/stream`;
// Note: EventSource doesn't support custom headers natively // Note: EventSource doesn't support custom headers natively
// We pass the token as a query parameter (backend should validate this) // Backend SSE endpoint accepts token as query parameter for EventSource compatibility
const urlWithAuth = `${sseUrl}?token=${encodeURIComponent(accessToken)}`; const urlWithAuth = `${sseUrl}?token=${encodeURIComponent(accessToken)}`;
try { try {