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>
516 lines
16 KiB
TypeScript
516 lines
16 KiB
TypeScript
/**
|
|
* WebSocket Composable
|
|
*
|
|
* Manages Socket.io connection with type safety, authentication, and auto-reconnection.
|
|
* This composable is league-agnostic and will be shared between SBA and PD frontends.
|
|
*
|
|
* Features:
|
|
* - Type-safe Socket.io client (TypedSocket)
|
|
* - JWT authentication integration
|
|
* - Auto-reconnection with exponential backoff
|
|
* - Connection status tracking
|
|
* - Event listener lifecycle management
|
|
* - Integration with game store
|
|
*/
|
|
|
|
import { ref, computed, watch, onUnmounted, readonly } from 'vue'
|
|
import type { Socket } from 'socket.io-client';
|
|
import { io } from 'socket.io-client'
|
|
import type {
|
|
TypedSocket,
|
|
ClientToServerEvents,
|
|
ServerToClientEvents,
|
|
PlayResult,
|
|
} from '~/types'
|
|
import { useAuthStore } from '~/store/auth'
|
|
import { useGameStore } from '~/store/game'
|
|
import { useUiStore } from '~/store/ui'
|
|
|
|
// Reconnection configuration
|
|
const RECONNECTION_DELAY_BASE = 1000 // Start with 1 second
|
|
const RECONNECTION_DELAY_MAX = 30000 // Max 30 seconds
|
|
const MAX_RECONNECTION_ATTEMPTS = 10
|
|
|
|
// Singleton socket instance
|
|
let socketInstance: Socket<ServerToClientEvents, ClientToServerEvents> | null = null
|
|
let reconnectionAttempts = 0
|
|
let reconnectionTimeout: NodeJS.Timeout | null = null
|
|
|
|
// Singleton reactive state (shared across all useWebSocket calls)
|
|
const isConnected = ref(false)
|
|
const isConnecting = ref(false)
|
|
const connectionError = ref<string | null>(null)
|
|
const lastConnectionAttempt = ref<number | null>(null)
|
|
|
|
export function useWebSocket() {
|
|
// State is now module-level singleton (above)
|
|
|
|
// ============================================================================
|
|
// Stores
|
|
// ============================================================================
|
|
|
|
const authStore = useAuthStore()
|
|
const gameStore = useGameStore()
|
|
const uiStore = useUiStore()
|
|
|
|
// ============================================================================
|
|
// Computed
|
|
// ============================================================================
|
|
|
|
const socket = computed((): TypedSocket | null => {
|
|
return socketInstance as TypedSocket | null
|
|
})
|
|
|
|
const canConnect = computed(() => {
|
|
return authStore.isAuthenticated
|
|
})
|
|
|
|
const shouldReconnect = computed(() => {
|
|
return (
|
|
!isConnected.value &&
|
|
canConnect.value &&
|
|
reconnectionAttempts < MAX_RECONNECTION_ATTEMPTS
|
|
)
|
|
})
|
|
|
|
// ============================================================================
|
|
// Connection Management
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Connect to WebSocket server with JWT authentication
|
|
*/
|
|
function connect() {
|
|
if (!canConnect.value) {
|
|
console.warn('[WebSocket] Cannot connect: not authenticated or token invalid')
|
|
return
|
|
}
|
|
|
|
if (isConnected.value || isConnecting.value) {
|
|
console.warn('[WebSocket] Already connected or connecting')
|
|
return
|
|
}
|
|
|
|
isConnecting.value = true
|
|
connectionError.value = null
|
|
lastConnectionAttempt.value = Date.now()
|
|
|
|
const config = useRuntimeConfig()
|
|
const wsUrl = config.public.wsUrl
|
|
|
|
console.log('[WebSocket] Connecting to', wsUrl)
|
|
|
|
// Create or reuse socket instance
|
|
if (!socketInstance) {
|
|
socketInstance = io(wsUrl, {
|
|
withCredentials: true, // Send cookies automatically
|
|
autoConnect: false,
|
|
reconnection: false, // We handle reconnection manually for better control
|
|
transports: ['websocket', 'polling'],
|
|
})
|
|
|
|
setupEventListeners()
|
|
}
|
|
|
|
// Connect
|
|
socketInstance.connect()
|
|
}
|
|
|
|
/**
|
|
* Disconnect from WebSocket server
|
|
*/
|
|
function disconnect() {
|
|
console.log('[WebSocket] Disconnecting')
|
|
|
|
// Clear reconnection timer
|
|
if (reconnectionTimeout) {
|
|
clearTimeout(reconnectionTimeout)
|
|
reconnectionTimeout = null
|
|
}
|
|
|
|
// Disconnect socket
|
|
if (socketInstance) {
|
|
socketInstance.disconnect()
|
|
}
|
|
|
|
isConnected.value = false
|
|
isConnecting.value = false
|
|
connectionError.value = null
|
|
reconnectionAttempts = 0
|
|
}
|
|
|
|
/**
|
|
* Reconnect with exponential backoff
|
|
*/
|
|
function scheduleReconnection() {
|
|
if (!shouldReconnect.value) {
|
|
console.log('[WebSocket] Reconnection not needed or max attempts reached')
|
|
return
|
|
}
|
|
|
|
// Calculate delay with exponential backoff
|
|
const delay = Math.min(
|
|
RECONNECTION_DELAY_BASE * Math.pow(2, reconnectionAttempts),
|
|
RECONNECTION_DELAY_MAX
|
|
)
|
|
|
|
console.log(
|
|
`[WebSocket] Scheduling reconnection attempt ${reconnectionAttempts + 1}/${MAX_RECONNECTION_ATTEMPTS} in ${delay}ms`
|
|
)
|
|
|
|
reconnectionTimeout = setTimeout(() => {
|
|
reconnectionAttempts++
|
|
connect()
|
|
}, delay)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Event Listeners Setup
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Set up all WebSocket event listeners
|
|
*/
|
|
function setupEventListeners() {
|
|
if (!socketInstance) return
|
|
|
|
// ========================================
|
|
// Connection Events
|
|
// ========================================
|
|
|
|
socketInstance.on('connect', () => {
|
|
console.log('[WebSocket] Connected successfully')
|
|
isConnected.value = true
|
|
isConnecting.value = false
|
|
connectionError.value = null
|
|
reconnectionAttempts = 0
|
|
|
|
// Update game store
|
|
gameStore.setConnected(true)
|
|
|
|
// Show success toast
|
|
uiStore.showSuccess('Connected to game server')
|
|
})
|
|
|
|
socketInstance.on('disconnect', (reason) => {
|
|
console.log('[WebSocket] Disconnected:', reason)
|
|
isConnected.value = false
|
|
isConnecting.value = false
|
|
|
|
// Update game store
|
|
gameStore.setConnected(false)
|
|
|
|
// Show warning toast
|
|
uiStore.showWarning('Disconnected from game server')
|
|
|
|
// Attempt reconnection if not intentional
|
|
if (reason !== 'io client disconnect') {
|
|
scheduleReconnection()
|
|
}
|
|
})
|
|
|
|
socketInstance.on('connect_error', (error) => {
|
|
console.error('[WebSocket] Connection error:', error)
|
|
isConnected.value = false
|
|
isConnecting.value = false
|
|
connectionError.value = error.message
|
|
|
|
// Update game store
|
|
gameStore.setError(error.message)
|
|
|
|
// Show error toast
|
|
uiStore.showError(`Connection failed: ${error.message}`)
|
|
|
|
// Attempt reconnection
|
|
scheduleReconnection()
|
|
})
|
|
|
|
socketInstance.on('connected', (data) => {
|
|
console.log('[WebSocket] Server confirmed connection for user:', data.user_id)
|
|
})
|
|
|
|
socketInstance.on('game_joined', (data: { game_id: string; role: string }) => {
|
|
console.log('[WebSocket] Successfully joined game:', data.game_id, 'as', data.role)
|
|
uiStore.showSuccess(`Joined game as ${data.role}`)
|
|
})
|
|
|
|
socketInstance.on('heartbeat_ack', () => {
|
|
// Heartbeat acknowledged - connection is healthy
|
|
// No action needed, just prevents timeout
|
|
})
|
|
|
|
// ========================================
|
|
// Game State Events
|
|
// ========================================
|
|
|
|
socketInstance.on('game_state', (state) => {
|
|
console.log('[WebSocket] Full game state received (from request_game_state)')
|
|
gameStore.setGameState(state)
|
|
})
|
|
|
|
socketInstance.on('game_state_update', (state) => {
|
|
console.log('[WebSocket] Game state update received')
|
|
gameStore.setGameState(state)
|
|
})
|
|
|
|
socketInstance.on('game_state_sync', (data) => {
|
|
console.log('[WebSocket] Full game state sync received')
|
|
gameStore.setGameState(data.state)
|
|
// Add recent plays to history
|
|
data.recent_plays.forEach((play) => {
|
|
gameStore.addPlayToHistory(play)
|
|
})
|
|
})
|
|
|
|
socketInstance.on('inning_change', (data) => {
|
|
console.log(`[WebSocket] Inning change: ${data.half} ${data.inning}`)
|
|
uiStore.showInfo(`${data.half === 'top' ? 'Top' : 'Bottom'} ${data.inning}`, 3000)
|
|
})
|
|
|
|
socketInstance.on('game_ended', (data) => {
|
|
console.log('[WebSocket] Game ended:', data.winner_team_id)
|
|
uiStore.showSuccess(
|
|
`Game Over! Final: ${data.final_score.away} - ${data.final_score.home}`,
|
|
10000
|
|
)
|
|
})
|
|
|
|
// ========================================
|
|
// Decision Events
|
|
// ========================================
|
|
|
|
socketInstance.on('decision_required', (prompt) => {
|
|
console.log('[WebSocket] Decision required:', prompt.phase)
|
|
gameStore.setDecisionPrompt(prompt)
|
|
})
|
|
|
|
socketInstance.on('defensive_decision_submitted', (data) => {
|
|
console.log('[WebSocket] Defensive decision submitted')
|
|
gameStore.clearDecisionPrompt()
|
|
|
|
// Request updated game state to get new decision_phase
|
|
if (socketInstance && gameStore.gameId) {
|
|
socketInstance.emit('request_game_state', {
|
|
game_id: gameStore.gameId
|
|
})
|
|
}
|
|
|
|
if (data.pending_decision) {
|
|
uiStore.showInfo('Defense set. Waiting for offense...', 3000)
|
|
} else {
|
|
uiStore.showSuccess('Defense set. Ready to play!', 3000)
|
|
}
|
|
})
|
|
|
|
socketInstance.on('offensive_decision_submitted', (data) => {
|
|
console.log('[WebSocket] Offensive decision submitted')
|
|
gameStore.clearDecisionPrompt()
|
|
|
|
// Request updated game state to get new decision_phase
|
|
if (socketInstance && gameStore.gameId) {
|
|
socketInstance.emit('request_game_state', {
|
|
game_id: gameStore.gameId
|
|
})
|
|
}
|
|
|
|
uiStore.showSuccess('Offense set. Ready to play!', 3000)
|
|
})
|
|
|
|
// ========================================
|
|
// Manual Workflow Events
|
|
// ========================================
|
|
|
|
socketInstance.on('dice_rolled', (data) => {
|
|
console.log('[WebSocket] Dice rolled:', data.roll_id)
|
|
gameStore.setPendingRoll({
|
|
roll_id: data.roll_id,
|
|
d6_one: data.d6_one,
|
|
d6_two_a: data.d6_two_a,
|
|
d6_two_b: data.d6_two_b,
|
|
d6_two_total: data.d6_two_total,
|
|
chaos_d20: data.chaos_d20,
|
|
resolution_d20: data.resolution_d20,
|
|
check_wild_pitch: data.check_wild_pitch,
|
|
check_passed_ball: data.check_passed_ball,
|
|
timestamp: data.timestamp,
|
|
})
|
|
uiStore.showInfo(data.message, 5000)
|
|
})
|
|
|
|
socketInstance.on('outcome_accepted', (data) => {
|
|
console.log('[WebSocket] Outcome accepted:', data.outcome)
|
|
gameStore.clearPendingRoll()
|
|
uiStore.showSuccess('Outcome submitted. Resolving play...', 3000)
|
|
})
|
|
|
|
socketInstance.on('play_resolved', (data) => {
|
|
console.log('[WebSocket] Play resolved:', data.description)
|
|
|
|
// Convert to PlayResult and add to game store
|
|
const playResult: PlayResult = {
|
|
play_number: data.play_number,
|
|
outcome: data.outcome,
|
|
hit_location: data.hit_location,
|
|
description: data.description,
|
|
outs_recorded: data.outs_recorded,
|
|
runs_scored: data.runs_scored,
|
|
batter_result: data.batter_result,
|
|
runners_advanced: data.runners_advanced,
|
|
is_hit: data.is_hit,
|
|
is_out: data.is_out,
|
|
is_walk: data.is_walk,
|
|
roll_id: data.roll_id,
|
|
x_check_details: data.x_check_details,
|
|
}
|
|
|
|
gameStore.setLastPlayResult(playResult)
|
|
gameStore.addPlayToHistory(playResult)
|
|
|
|
// Clear pending decisions since the play is complete and we'll need new ones for next batter
|
|
gameStore.clearPendingDecisions()
|
|
|
|
uiStore.showSuccess(data.description, 5000)
|
|
})
|
|
|
|
// ========================================
|
|
// Substitution Events
|
|
// ========================================
|
|
|
|
socketInstance.on('player_substituted', (data) => {
|
|
console.log('[WebSocket] Player substituted:', data.type)
|
|
uiStore.showInfo(data.message, 5000)
|
|
// Request updated lineup
|
|
if (socketInstance) {
|
|
socketInstance.emit('get_lineup', {
|
|
game_id: gameStore.gameId!,
|
|
team_id: data.team_id,
|
|
})
|
|
}
|
|
})
|
|
|
|
socketInstance.on('substitution_confirmed', (data) => {
|
|
console.log('[WebSocket] Substitution confirmed')
|
|
uiStore.showSuccess(data.message, 5000)
|
|
})
|
|
|
|
// ========================================
|
|
// Data Response Events
|
|
// ========================================
|
|
|
|
socketInstance.on('lineup_data', (data) => {
|
|
console.log('[WebSocket] Lineup data received for team:', data.team_id)
|
|
gameStore.updateLineup(data.team_id, data.players)
|
|
})
|
|
|
|
socketInstance.on('box_score_data', (data) => {
|
|
console.log('[WebSocket] Box score data received')
|
|
// Box score will be handled by dedicated component
|
|
// Just log for now
|
|
})
|
|
|
|
// ========================================
|
|
// Error Events
|
|
// ========================================
|
|
|
|
socketInstance.on('error', (data) => {
|
|
console.error('[WebSocket] Server error:', data.message)
|
|
gameStore.setError(data.message)
|
|
uiStore.showError(data.message, 7000)
|
|
})
|
|
|
|
socketInstance.on('outcome_rejected', (data) => {
|
|
console.error('[WebSocket] Outcome rejected:', data.message)
|
|
uiStore.showError(`Invalid outcome: ${data.message}`, 7000)
|
|
})
|
|
|
|
socketInstance.on('substitution_error', (data) => {
|
|
console.error('[WebSocket] Substitution error:', data.message)
|
|
uiStore.showError(`Substitution failed: ${data.message}`, 7000)
|
|
})
|
|
|
|
socketInstance.on('invalid_action', (data) => {
|
|
console.error('[WebSocket] Invalid action:', data.reason)
|
|
uiStore.showError(`Invalid action: ${data.reason}`, 7000)
|
|
})
|
|
|
|
socketInstance.on('connection_error', (data) => {
|
|
console.error('[WebSocket] Connection error:', data.error)
|
|
connectionError.value = data.error
|
|
uiStore.showError(`Connection error: ${data.error}`, 7000)
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Heartbeat
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Send periodic heartbeat to keep connection alive
|
|
*/
|
|
let heartbeatInterval: NodeJS.Timeout | null = null
|
|
|
|
function startHeartbeat() {
|
|
if (heartbeatInterval) return
|
|
|
|
heartbeatInterval = setInterval(() => {
|
|
if (socketInstance?.connected) {
|
|
socketInstance.emit('heartbeat')
|
|
}
|
|
}, 30000) // Every 30 seconds
|
|
}
|
|
|
|
function stopHeartbeat() {
|
|
if (heartbeatInterval) {
|
|
clearInterval(heartbeatInterval)
|
|
heartbeatInterval = null
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Watchers
|
|
// ============================================================================
|
|
|
|
// Auto-connect when authenticated
|
|
watch(
|
|
() => authStore.isAuthenticated,
|
|
(authenticated) => {
|
|
if (authenticated && !isConnected.value) {
|
|
connect()
|
|
startHeartbeat()
|
|
} else if (!authenticated && isConnected.value) {
|
|
disconnect()
|
|
stopHeartbeat()
|
|
}
|
|
},
|
|
{ immediate: false }
|
|
)
|
|
|
|
// ============================================================================
|
|
// Lifecycle
|
|
// ============================================================================
|
|
|
|
onUnmounted(() => {
|
|
console.log('[WebSocket] Component unmounted, cleaning up')
|
|
stopHeartbeat()
|
|
// Don't disconnect on unmount - keep connection alive for app
|
|
// Only disconnect when explicitly requested or user logs out
|
|
})
|
|
|
|
// ============================================================================
|
|
// Public API
|
|
// ============================================================================
|
|
|
|
return {
|
|
// State
|
|
socket,
|
|
isConnected: readonly(isConnected),
|
|
isConnecting: readonly(isConnecting),
|
|
connectionError: readonly(connectionError),
|
|
canConnect,
|
|
|
|
// Actions
|
|
connect,
|
|
disconnect,
|
|
}
|
|
}
|