diff --git a/.claude/SAFARI_WEBSOCKET_ISSUE.md b/.claude/SAFARI_WEBSOCKET_ISSUE.md index 1ab2947..9608265 100644 --- a/.claude/SAFARI_WEBSOCKET_ISSUE.md +++ b/.claude/SAFARI_WEBSOCKET_ISSUE.md @@ -26,7 +26,15 @@ In Nuxt 4, composables MUST be called during component setup, not inside callbac **Fix:** Moved `useRuntimeConfig()` to composable setup phase in `useWebSocket.ts` -### 3. Potential Safari-Specific Issues (Not Yet Fixed) +### 3. Auth Middleware Disabled on Game Page (Fixed 2025-11-28) +The game detail page (`pages/games/[id].vue`) had `middleware: ['auth']` commented out for WebSocket testing. This caused: +- SSR rendered page without checking auth +- Page showed "Auth: Failed" immediately from SSR +- Safari's client-side auth check wasn't reaching the backend (likely cached) + +**Fix:** Re-enabled `middleware: ['auth']` in `definePageMeta` on the game page. + +### 4. Potential Safari-Specific Issues (Not Yet Fixed) - **Safari ITP**: Can expire cookies unpredictably - **iOS Memory Management**: Safari may clear JS state when backgrounded @@ -36,7 +44,9 @@ In Nuxt 4, composables MUST be called during component setup, not inside callbac 1. `backend/app/utils/cookies.py` - Cookie SameSite policy 2. `frontend-sba/composables/useWebSocket.ts` - Composable context fix -3. `backend/app/api/routes/auth.py` - Added GET /logout endpoint +3. `backend/app/api/routes/auth.py` - Added GET /logout endpoint, added /ws-token endpoint +4. `frontend-sba/pages/games/[id].vue` - Re-enabled auth middleware +5. `backend/app/websocket/handlers.py` - Added token query param support for Safari fallback ## Prevention Strategies @@ -63,3 +73,4 @@ In Nuxt 4, composables MUST be called during component setup, not inside callbac |------|-------|-----|--------| | 2025-11-28 | SameSite=Lax not working on Safari | Changed to SameSite=None | Worked initially | | 2025-11-28 | useRuntimeConfig in callback | Moved to setup phase | Connected | +| 2025-11-28 | Auth middleware disabled on game page | Re-enabled middleware: ['auth'] | Auth: OK, WebSocket connected | diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 2265d26..3fe05ad 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -499,6 +499,56 @@ async def get_current_user_info( raise HTTPException(status_code=500, detail="Failed to get user information") +@router.get("/ws-token") +async def get_websocket_token(request: Request): + """ + Get a short-lived token for WebSocket authentication. + + Safari/iOS sometimes fails to send cookies with WebSocket connections. + This endpoint provides a token that can be passed as a query parameter. + + The token is valid for 5 minutes and can only be used for WebSocket auth. + + Returns: + {"token": "..."} - Short-lived JWT token + """ + # Get token from cookie + token = request.cookies.get(ACCESS_TOKEN_COOKIE) + + # Debug: log all cookies received + logger.info(f"ws-token request cookies: {list(request.cookies.keys())}") + if token: + logger.info(f"Token length: {len(token)}, starts with: {token[:20] if len(token) > 20 else token}...") + + if not token: + raise HTTPException(status_code=401, detail="Missing authentication") + + try: + # Verify the existing token + payload = verify_token(token) + + # Create a short-lived token (5 minutes) for WebSocket auth + ws_token = create_token( + { + "user_id": payload["user_id"], + "discord_id": payload["discord_id"], + "username": payload["username"], + "ws_only": True, # Mark as WebSocket-only token + }, + expires_minutes=5, + ) + + logger.debug(f"Generated WS token for user {payload['username']}") + return {"token": ws_token} + + except JWTError: + logger.warning("Invalid token in /ws-token request") + raise HTTPException(status_code=401, detail="Invalid or expired token") + except Exception as e: + logger.error(f"WS token generation error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to generate token") + + @router.get("/verify") async def verify_auth(authorization: str = Header(None)): """ diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index 32605a3..2472fc8 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -11,17 +11,23 @@ logger = logging.getLogger(f"{__name__}.auth") settings = get_settings() -def create_token(user_data: dict[str, Any]) -> str: +def create_token(user_data: dict[str, Any], expires_minutes: int | None = None) -> str: """ Create JWT token for user Args: user_data: User information to encode in token + expires_minutes: Optional expiration in minutes. If None, defaults to 7 days. Returns: JWT token string """ - payload = {**user_data, "exp": pendulum.now("UTC").add(days=7).int_timestamp} + if expires_minutes is not None: + expiration = pendulum.now("UTC").add(minutes=expires_minutes).int_timestamp + else: + expiration = pendulum.now("UTC").add(days=7).int_timestamp + + payload = {**user_data, "exp": expiration} token = jwt.encode(payload, settings.secret_key, algorithm="HS256") return token diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index e5b92ac..3a69587 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -34,10 +34,12 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: @sio.event async def connect(sid, environ, auth): """ - Handle new connection with cookie or auth object support. + Handle new connection with cookie, query param, or auth object support. - Tries cookie-based auth first (from HttpOnly cookies), - falls back to auth object (for direct JS clients). + Tries in order: + 1. Cookie-based auth (from HttpOnly cookies) + 2. Query parameter auth (for Safari/iOS fallback) + 3. Auth object (for direct JS clients) """ try: token = None @@ -53,6 +55,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: token = cookie["pd_access_token"].value logger.debug(f"Connection {sid} using cookie auth") + # Try query parameter (Safari/iOS fallback) + if not token: + query_string = environ.get("QUERY_STRING", "") + if "token=" in query_string: + from urllib.parse import parse_qs + params = parse_qs(query_string) + if "token" in params: + token = params["token"][0] + logger.debug(f"Connection {sid} using query param auth (Safari fallback)") + # Fall back to auth object (for direct JS clients) if not token and auth: token = auth.get("token") @@ -639,12 +651,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Broadcast updated game state so frontend sees new batter, outs, etc. updated_state = state_manager.get_state(game_id) if updated_state: + batter_info = f"lineup_id={updated_state.current_batter.lineup_id}, batting_order={updated_state.current_batter.batting_order}" if updated_state.current_batter else "None" + logger.info(f"Broadcasting game_state_update with current_batter: {batter_info}") await manager.broadcast_to_game( str(game_id), "game_state_update", updated_state.model_dump(mode="json"), ) - logger.debug(f"Broadcast updated game state after play resolution") except asyncio.TimeoutError: logger.error(f"Lock timeout while submitting manual outcome for game {game_id}") diff --git a/frontend-sba/pages/games/[id].vue b/frontend-sba/pages/games/[id].vue index 1d67fe2..2e02871 100755 --- a/frontend-sba/pages/games/[id].vue +++ b/frontend-sba/pages/games/[id].vue @@ -192,8 +192,8 @@
- - Auth: {{ authStore.isAuthenticated ? 'OK' : 'Failed' }} + + Auth: {{ authStore.isAuthenticated ? 'OK' : authStore.isLoading ? 'Checking...' : 'Failed' }}
@@ -339,7 +339,7 @@ import type { Lineup } from '~/types/player' definePageMeta({ layout: 'game', - // middleware: ['auth'], // Temporarily disabled for WebSocket testing + middleware: ['auth'], }) const route = useRoute()