strat-gameplay-webapp/frontend-sba/pages/auth/callback.vue
Cal Corum e90a907e9e CLAUDE: Implement server-side OAuth flow with HttpOnly cookies
Fixes iPad Safari authentication issue where async JavaScript is blocked
on OAuth callback pages after cross-origin redirects (Cloudflare + Safari ITP).

**Problem**: iPad Safari blocks all async operations (Promises, setTimeout,
onMounted) on the OAuth callback page, preventing frontend token exchange.

**Solution**: Move entire OAuth flow to backend with HttpOnly cookies,
eliminating JavaScript dependency on callback page.

## Backend Changes (7 files)

### New Files
- app/services/oauth_state.py - Redis-based OAuth state management
  * CSRF protection with one-time use tokens (10min TTL)
  * Replaces frontend sessionStorage state validation

- app/utils/cookies.py - HttpOnly cookie utilities
  * Access token: 1 hour, Path=/api
  * Refresh token: 7 days, Path=/api/auth
  * Security: HttpOnly, Secure (prod), SameSite=Lax

### Modified Files
- app/api/routes/auth.py
  * NEW: GET /discord/login - Initiate OAuth with state creation
  * NEW: GET /discord/callback/server - Server-side callback handler
  * NEW: POST /logout - Clear auth cookies
  * UPDATED: GET /me - Cookie + header support (backwards compatible)
  * UPDATED: POST /refresh - Cookie + body support (backwards compatible)
  * FIXED: exchange_code_for_token() accepts redirect_uri parameter

- app/config.py
  * Added discord_server_redirect_uri config
  * Added frontend_url config for post-auth redirects

- app/websocket/handlers.py
  * Updated connect handler to parse cookies from environ
  * Falls back to auth object for backwards compatibility

- .env.example
  * Added DISCORD_SERVER_REDIRECT_URI example
  * Added FRONTEND_URL example

## Frontend Changes (10 files)

### Core Auth Changes
- store/auth.ts - Complete rewrite for cookie-based auth
  * Removed: token, refreshToken, tokenExpiresAt state (HttpOnly)
  * Added: checkAuth() - calls /api/auth/me with credentials
  * Updated: loginWithDiscord() - redirects to backend endpoint
  * Updated: logout() - calls backend logout endpoint
  * All $fetch calls use credentials: 'include'

- pages/auth/callback.vue - Simplified to error handler
  * No JavaScript token exchange needed
  * Displays errors from query params
  * Verifies auth with checkAuth() on success

- plugins/auth.client.ts
  * Changed from localStorage init to checkAuth() call
  * Async plugin to ensure auth state before navigation

- middleware/auth.ts - Simplified
  * Removed token validity checks (HttpOnly cookies)
  * Simple isAuthenticated check

### Cleanup Changes
- composables/useWebSocket.ts
  * Added withCredentials: true
  * Removed auth object with token
  * Updated canConnect to use isAuthenticated only

- layouts/default.vue, layouts/game.vue, pages/index.vue, pages/games/[id].vue
  * Removed initializeAuth() calls (handled by plugin)

## Documentation
- OAUTH_IPAD_ISSUE.md - Problem analysis and investigation notes
- OAUTH_SERVER_SIDE_IMPLEMENTATION.md - Complete implementation guide
  * Security improvements summary
  * Discord Developer Portal setup instructions
  * Testing checklist
  * OAuth flow diagram

## Security Improvements
- Tokens stored in HttpOnly cookies (XSS-safe)
- OAuth state in Redis with one-time use (CSRF-safe)
- Follows OAuth 2.0 Security Best Current Practice
- Backwards compatible with Authorization header auth

## Testing
-  Backend OAuth endpoints functional
-  Token exchange with correct redirect_uri
-  Cookie-based auth working
-  WebSocket connection with cookies
-  Desktop browser flow verified
-  iPad Safari testing pending Discord redirect URI config

## Next Steps
1. Add Discord redirect URI in Developer Portal:
   https://gameplay-demo.manticorum.com/api/auth/discord/callback/server
2. Test complete flow on iPad Safari
3. Verify WebSocket auto-reconnection with cookies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:16:30 -06:00

141 lines
4.2 KiB
Vue
Executable File

<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary to-blue-700 px-4">
<div class="max-w-md w-full">
<div class="bg-white rounded-2xl shadow-2xl p-8">
<!-- Loading State -->
<div v-if="isProcessing" class="text-center">
<div class="mb-6">
<div class="w-16 h-16 mx-auto border-4 border-primary/20 border-t-primary rounded-full animate-spin"/>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-2">
Authenticating...
</h2>
<p class="text-gray-600">
Please wait while we complete your sign in
</p>
</div>
<!-- Success State -->
<div v-else-if="success" class="text-center">
<div class="mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-2">
Sign In Successful!
</h2>
<p class="text-gray-600 mb-6">
Redirecting you now...
</p>
</div>
<!-- Error State -->
<div v-else-if="errorMessage" class="text-center">
<div class="mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-2">
Authentication Failed
</h2>
<p class="text-gray-600 mb-6">
{{ errorMessage }}
</p>
<!-- Retry Button -->
<NuxtLink
to="/auth/login"
class="inline-block px-6 py-3 bg-primary hover:bg-blue-700 text-white font-semibold rounded-lg transition"
>
Try Again
</NuxtLink>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '~/store/auth'
definePageMeta({
layout: false, // Don't use default layout
})
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const isProcessing = ref(true)
const success = ref(false)
const errorMessage = ref<string | null>(null)
// Error messages mapping
const errorMessages: Record<string, string> = {
invalid_state: 'Security validation failed. Please try again.',
unauthorized: 'Your Discord account is not authorized to access this system.',
auth_failed: 'Discord authentication failed. Please try again.',
server_error: 'An unexpected server error occurred. Please try again later.',
}
onMounted(async () => {
try {
// Check for error query parameter
const errorParam = route.query.error as string
if (errorParam) {
errorMessage.value = errorMessages[errorParam] || errorParam
isProcessing.value = false
return
}
// Success case - cookies are already set by backend
// Just verify auth and redirect
const isAuth = await authStore.checkAuth()
if (isAuth) {
success.value = true
// Redirect after a short delay
setTimeout(() => {
router.push('/')
}, 1500)
} else {
errorMessage.value = 'Authentication verification failed. Please try again.'
}
} catch (err: any) {
console.error('OAuth callback error:', err)
errorMessage.value = err.message || 'An unexpected error occurred'
} finally {
isProcessing.value = false
}
})
</script>
<style scoped>
/* Additional component styles if needed */
</style>