strat-gameplay-webapp/COOKIE_AUTH_IMPLEMENTATION.md
Cal Corum 9f88317b79 CLAUDE: Fix cookie security and SSR data fetching for iPad/Safari
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>
2025-11-27 21:06:42 -06:00

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

  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

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=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:

<!-- 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')
  }
})

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.main processes
  • 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:

  1. Clear browser cookies completely
  2. Navigate to /games - should redirect to login
  3. Click "Continue with Discord" - should redirect to Discord
  4. Authorize - should redirect back to /games
  5. Games should load (not spin forever)
  6. Click on a game - should load game page
  7. WebSocket should connect (no "Attempting to reconnect")
  8. Refresh page - should stay authenticated
  9. 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

  1. HttpOnly cookies - Cannot be accessed by JavaScript (XSS protection)
  2. Secure flag - Only sent over HTTPS in production
  3. SameSite=Lax - CSRF protection while allowing OAuth redirects
  4. Path="/" - Sent with all requests (required for SSR)
  5. Short-lived access token - 1 hour expiration
  6. Refresh token - 7 days, restricted to /api/auth path

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"
  • Safari: Develop → Show Web Inspector → Storage → Cookies
  • Chrome: DevTools → Application → Cookies
curl -v https://gameplay-demo.manticorum.com/api/auth/me \
  -H "Cookie: pd_access_token=<token>"