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 <noreply@anthropic.com>
9.2 KiB
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
- User clicks "Continue with Discord" (anchor tag, NOT JavaScript)
- Browser navigates to
/api/auth/discord/login - Backend creates state in Redis, redirects to Discord OAuth
- User authorizes on Discord
- Discord redirects to
/api/auth/discord/callback/server - Backend validates, creates JWT, sets HttpOnly cookies with
path="/" - Backend redirects to frontend
/games - Nuxt SSR middleware forwards cookies to
/api/auth/me - Backend validates cookie, returns user info
- Page renders with authenticated state
Critical Implementation Details
1. Cookie Configuration (Backend)
File: backend/app/utils/cookies.py
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=truefor cookies over HTTPS connections - Even in development, if accessed via HTTPS (through proxy), cookies need
Secure=true is_secure_context()returnsTrueifAPP_ENV=productionORFRONTEND_URLstarts withhttps://
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:
<!-- CORRECT: Works on iPad Safari and through proxy -->
<a :href="discordLoginUrl" class="...">
Continue with Discord
</a>
<!-- WRONG: JavaScript may be blocked -->
<button @click="handleLogin">
Continue with Discord
</button>
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
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
async function checkAuth(): Promise<boolean> {
const headers: Record<string, string> = {}
// 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':
// 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 <button @click> instead of <a href>
Solution: Use anchor tag with computed URL:
<a :href="discordLoginUrl">Continue with Discord</a>
const discordLoginUrl = computed(() => {
return `${config.public.apiUrl}/api/auth/discord/login?return_url=${encodeURIComponent(returnUrl)}`
})
Issue 2: Redirect Loop After Login
Symptom: OAuth succeeds but keeps redirecting to login
Cause: Cookie path is /api, so cookies aren't sent with /games requests
Solution: Set cookie path to / in backend/app/utils/cookies.py
Issue 3: SSR Returns 401 Unauthorized
Symptom: /api/auth/me returns 401 during SSR
Cause: SSR doesn't automatically forward browser cookies
Solution: Manually forward cookies in checkAuth():
if (import.meta.server) {
const event = useRequestEvent()
headers['Cookie'] = event?.node.req.headers.cookie
}
Issue 4: "Loading games..." Spins Forever
Symptom: Games page never loads data
Cause: API calls using authStore.token which no longer exists
Solution: Replace with credentials: 'include'
Issue 5: Zombie Backend Processes
Symptom: "Address already in use" error
Cause: Old Python processes not killed by stop script
Solution: Updated stop-services.sh to:
- Kill
python.*app.mainprocesses - Kill any process on port 8000
Issue 6: WebSocket "Disconnected" on iPad/Safari
Symptom: WebSocket shows "Disconnected from server. Attempting to reconnect..."
Cause: Cookies have Secure=false but site is accessed via HTTPS. Safari rejects non-Secure cookies over HTTPS.
Solution: Changed is_production() to is_secure_context() in backend/app/utils/cookies.py:
def is_secure_context() -> bool:
if getattr(settings, "app_env", "development") == "production":
return True
frontend_url = getattr(settings, "frontend_url", "")
return frontend_url.startswith("https://")
Issue 7: Games List "Loading games..." Forever (Client Hydration)
Symptom: Games list page shows loading spinner forever
Cause: onMounted hook only runs on client-side after hydration. If client-side JavaScript has issues, the hook never runs.
Solution: Use useAsyncData instead of onMounted for data fetching:
const { pending, error: fetchError, refresh } = await useAsyncData(
'games-list',
async () => {
const headers: Record<string, string> = {}
if (import.meta.server) {
const event = useRequestEvent()
const cookieHeader = event?.node.req.headers.cookie
if (cookieHeader) {
headers['Cookie'] = cookieHeader
}
}
return await $fetch('/api/games/', {
credentials: 'include',
headers,
})
}
)
Files Modified
| File | Change |
|---|---|
backend/app/utils/cookies.py |
Cookie path /api → /, is_secure_context() for HTTPS detection |
frontend-sba/pages/auth/login.vue |
Button → Anchor tag |
frontend-sba/middleware/auth.ts |
Calls checkAuth() directly |
frontend-sba/store/auth.ts |
SSR cookie forwarding |
frontend-sba/pages/games/index.vue |
useAsyncData with SSR cookie forwarding |
frontend-sba/pages/games/[id].vue |
Uses checkAuth() flow |
frontend-sba/composables/useWebSocket.ts |
Auto-connect via onMounted (not immediate watcher) |
stop-services.sh |
Better process cleanup |
Testing Checklist
After any auth-related changes:
- Clear browser cookies completely
- Navigate to
/games- should redirect to login - Click "Continue with Discord" - should redirect to Discord
- Authorize - should redirect back to
/games - Games should load (not spin forever)
- Click on a game - should load game page
- WebSocket should connect (no "Attempting to reconnect")
- Refresh page - should stay authenticated
- Test on iPad Safari if possible
Environment Requirements
Backend .env
DISCORD_SERVER_REDIRECT_URI=https://gameplay-demo.manticorum.com/api/auth/discord/callback/server
FRONTEND_URL=https://gameplay-demo.manticorum.com
Frontend .env
NUXT_PUBLIC_API_URL=https://gameplay-demo.manticorum.com
NUXT_PUBLIC_WS_URL=https://gameplay-demo.manticorum.com
Discord Developer Portal
Add redirect URI:
https://gameplay-demo.manticorum.com/api/auth/discord/callback/server
Security Notes
- HttpOnly cookies - Cannot be accessed by JavaScript (XSS protection)
- Secure flag - Only sent over HTTPS in production
- SameSite=Lax - CSRF protection while allowing OAuth redirects
- Path="/" - Sent with all requests (required for SSR)
- Short-lived access token - 1 hour expiration
- Refresh token - 7 days, restricted to
/api/authpath
Debugging
Check if cookies are set:
# Backend logs after OAuth callback
tail -f logs/backend.log | grep -i cookie
Check if cookies are forwarded:
# Frontend logs during SSR
tail -f logs/frontend.log | grep -i "SSR\|cookie\|checkAuth"
Check cookie in browser:
- Safari: Develop → Show Web Inspector → Storage → Cookies
- Chrome: DevTools → Application → Cookies
Verify backend receives cookie:
curl -v https://gameplay-demo.manticorum.com/api/auth/me \
-H "Cookie: pd_access_token=<token>"