# 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
<!-- 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`

```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<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'`:**

```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 `<button @click>` instead of `<a href>`

**Solution**: Use anchor tag with computed URL:
```vue
<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()`:
```typescript
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`:
```python
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:
```typescript
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`
```bash
DISCORD_SERVER_REDIRECT_URI=https://gameplay-demo.manticorum.com/api/auth/discord/callback/server
FRONTEND_URL=https://gameplay-demo.manticorum.com
```

### Frontend `.env`
```bash
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:
```bash
# Backend logs after OAuth callback
tail -f logs/backend.log | grep -i cookie
```

### Check if cookies are forwarded:
```bash
# 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:
```bash
curl -v https://gameplay-demo.manticorum.com/api/auth/me \
  -H "Cookie: pd_access_token=<token>"
```
