From 9f88317b790af63fc95e11bdffad4b576e755575 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 27 Nov 2025 21:06:42 -0600 Subject: [PATCH] CLAUDE: Fix cookie security and SSR data fetching for iPad/Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolved WebSocket connection issues and games list loading on iPad: - cookies.py: Added is_secure_context() to set Secure flag when accessed via HTTPS even in development mode (Safari requires this) - useWebSocket.ts: Changed auto-connect from immediate watcher to onMounted hook for safer SSR hydration - games/index.vue: Replaced onMounted + fetchGames() with useAsyncData for SSR data fetching with proper cookie forwarding - Updated COOKIE_AUTH_IMPLEMENTATION.md with new issues and solutions - Updated composables/CLAUDE.md with auto-connect pattern documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- COOKIE_AUTH_IMPLEMENTATION.md | 338 +++++++++++++++++++++++ backend/app/utils/cookies.py | 29 +- frontend-sba/composables/CLAUDE.md | 21 ++ frontend-sba/composables/useWebSocket.ts | 18 +- frontend-sba/pages/games/index.vue | 70 +++-- 5 files changed, 433 insertions(+), 43 deletions(-) create mode 100644 COOKIE_AUTH_IMPLEMENTATION.md diff --git a/COOKIE_AUTH_IMPLEMENTATION.md b/COOKIE_AUTH_IMPLEMENTATION.md new file mode 100644 index 0000000..e06f356 --- /dev/null +++ b/COOKIE_AUTH_IMPLEMENTATION.md @@ -0,0 +1,338 @@ +# Cookie-Based Authentication Implementation + +**Date**: 2025-11-27 +**Status**: Complete + +This document captures the full implementation of HttpOnly cookie-based authentication, including all issues encountered and their solutions. + +--- + +## Architecture Overview + +``` +Browser → Nginx Proxy Manager → Nuxt SSR → Backend API + (gameplay-demo.manticorum.com) +``` + +### Authentication Flow + +1. User clicks "Continue with Discord" (anchor tag, NOT JavaScript) +2. Browser navigates to `/api/auth/discord/login` +3. Backend creates state in Redis, redirects to Discord OAuth +4. User authorizes on Discord +5. Discord redirects to `/api/auth/discord/callback/server` +6. Backend validates, creates JWT, sets HttpOnly cookies with `path="/"` +7. Backend redirects to frontend `/games` +8. Nuxt SSR middleware forwards cookies to `/api/auth/me` +9. Backend validates cookie, returns user info +10. Page renders with authenticated state + +--- + +## Critical Implementation Details + +### 1. Cookie Configuration (Backend) + +**File**: `backend/app/utils/cookies.py` + +```python +response.set_cookie( + key="pd_access_token", + value=access_token, + max_age=3600, # 1 hour + httponly=True, + secure=is_secure_context(), # True for HTTPS even in dev + samesite="lax", + path="/", # CRITICAL: Must be "/" not "/api" +) +``` + +**Why `is_secure_context()` instead of `is_production()`?** +- Safari/iOS requires `Secure=true` for cookies over HTTPS connections +- Even in development, if accessed via HTTPS (through proxy), cookies need `Secure=true` +- `is_secure_context()` returns `True` if `APP_ENV=production` OR `FRONTEND_URL` starts with `https://` + +**Why `path="/"`?** +- Cookies with `path="/api"` are only sent to `/api/*` routes +- When browser requests `/games`, cookie isn't sent +- SSR server receives no cookie to forward +- Auth check fails → redirect loop + +### 2. Login Button (Frontend) + +**File**: `frontend-sba/pages/auth/login.vue` + +**MUST use anchor tag, NOT JavaScript button:** + +```vue + + + Continue with Discord + + + + +``` + +**Why?** +- iPad Safari blocks async JavaScript on certain pages +- Nginx Proxy Manager may interfere with JS execution +- Anchor tags work reliably in all scenarios + +### 3. Auth Middleware (Frontend) + +**File**: `frontend-sba/middleware/auth.ts` + +```typescript +export default defineNuxtRouteMiddleware(async (to, from) => { + const authStore = useAuthStore() + + if (authStore.isAuthenticated) { + return // Already authenticated + } + + // Call backend to verify cookies + const isAuthed = await authStore.checkAuth() + + if (!isAuthed) { + return navigateTo('/auth/login') + } +}) +``` + +### 4. Auth Store - Cookie Forwarding for SSR + +**File**: `frontend-sba/store/auth.ts` + +```typescript +async function checkAuth(): Promise { + const headers: Record = {} + + // SSR: Forward cookies from incoming request + if (import.meta.server) { + const event = useRequestEvent() + const cookieHeader = event?.node.req.headers.cookie + if (cookieHeader) { + headers['Cookie'] = cookieHeader + } + } + + const response = await $fetch('/api/auth/me', { + credentials: 'include', // Client-side + headers, // Server-side cookie forwarding + }) + + // ... handle response +} +``` + +### 5. API Calls - Use Cookies, Not Tokens + +**All API calls must use `credentials: 'include'`:** + +```typescript +// CORRECT +const response = await $fetch('/api/games/', { + credentials: 'include', +}) + +// WRONG - authStore.token no longer exists +const response = await $fetch('/api/games/', { + headers: { + Authorization: `Bearer ${authStore.token}` + } +}) +``` + +--- + +## Common Issues & Solutions + +### Issue 1: "Continue with Discord" Does Nothing + +**Symptom**: Clicking login button has no effect + +**Cause**: Using ` @@ -205,28 +205,6 @@ const loading = ref(true) const error = ref(null) const isCreatingQuickGame = ref(false) -// Fetch games from API (client-side only) -async function fetchGames() { - try { - loading.value = true - error.value = null - - const response = await $fetch(`${config.public.apiUrl}/api/games/`, { - headers: { - Authorization: `Bearer ${authStore.token}` - } - }) - - games.value = response - console.log('[Games Page] Fetched games:', games.value) - } catch (err: any) { - console.error('[Games Page] Failed to fetch games:', err) - error.value = err.message || 'Failed to load games' - } finally { - loading.value = false - } -} - // Quick-create a demo game with pre-configured lineups async function handleQuickCreate() { try { @@ -239,9 +217,7 @@ async function handleQuickCreate() { `${config.public.apiUrl}/api/games/quick-create`, { method: 'POST', - headers: { - Authorization: `Bearer ${authStore.token}` - } + credentials: 'include', // Send HttpOnly cookies } ) @@ -266,10 +242,44 @@ const completedGames = computed(() => { return games.value?.filter(g => g.status === 'completed' || g.status === 'final') || [] }) -// Fetch on mount (client-side) -onMounted(() => { - fetchGames() -}) +// Fetch games using useAsyncData (works on both SSR and client) +const { pending, error: fetchError, refresh } = await useAsyncData( + 'games-list', + async () => { + const headers: Record = {} + if (import.meta.server) { + const event = useRequestEvent() + const cookieHeader = event?.node.req.headers.cookie + if (cookieHeader) { + headers['Cookie'] = cookieHeader + } + } + + const response = await $fetch(`${config.public.apiUrl}/api/games/`, { + credentials: 'include', + headers, + }) + + games.value = response + return response + }, + { + server: true, // Fetch on server + lazy: false, // Block rendering until done + } +) + +// Sync loading state with pending +watch(pending, (isPending) => { + loading.value = isPending +}, { immediate: true }) + +// Sync error state +watch(fetchError, (err) => { + if (err) { + error.value = err.message || 'Failed to load games' + } +}, { immediate: true })