diff --git a/backend/app/api/dependencies/auth.py b/backend/app/api/dependencies/auth.py index 5d6a7aa..5eb91f3 100755 --- a/backend/app/api/dependencies/auth.py +++ b/backend/app/api/dependencies/auth.py @@ -151,3 +151,83 @@ async def get_optional_current_user( return user except (TokenExpiredError, TokenInvalidError): 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"}, + ) diff --git a/backend/app/api/routes/events.py b/backend/app/api/routes/events.py index 5d987a8..4e4d463 100644 --- a/backend/app/api/routes/events.py +++ b/backend/app/api/routes/events.py @@ -20,12 +20,12 @@ import logging from typing import TYPE_CHECKING 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.util import get_remote_address 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.core.database import get_db from app.core.exceptions import AuthorizationError @@ -150,9 +150,16 @@ async def event_generator( description=""" 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 + **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**: ``` event: agent.status_changed @@ -190,9 +197,10 @@ async def event_generator( async def stream_project_events( request: Request, project_id: UUID, - current_user: User = Depends(get_current_user), - event_bus: EventBus = Depends(get_event_bus), 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"), ): """ @@ -207,6 +215,11 @@ async def stream_project_events( 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( f"SSE connection request for project {project_id} " f"by user {current_user.id} " diff --git a/frontend/src/lib/hooks/useProjectEvents.ts b/frontend/src/lib/hooks/useProjectEvents.ts index bf36003..cee36c7 100644 --- a/frontend/src/lib/hooks/useProjectEvents.ts +++ b/frontend/src/lib/hooks/useProjectEvents.ts @@ -258,10 +258,11 @@ export function useProjectEvents( // Build SSE URL with auth token 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 - // 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)}`; try {