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>
153 lines
3.9 KiB
TypeScript
153 lines
3.9 KiB
TypeScript
/**
|
|
* Authentication Store - Cookie-Based
|
|
*
|
|
* Manages user authentication state via HttpOnly cookies.
|
|
* All tokens are stored server-side in cookies, not accessible to JavaScript.
|
|
*/
|
|
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import type { DiscordUser, Team } from '~/types'
|
|
|
|
export const useAuthStore = defineStore('auth', () => {
|
|
// ============================================================================
|
|
// State
|
|
// ============================================================================
|
|
|
|
const user = ref<DiscordUser | null>(null)
|
|
const teams = ref<Team[]>([])
|
|
const isLoading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
// ============================================================================
|
|
// Getters
|
|
// ============================================================================
|
|
|
|
const isAuthenticated = computed(() => {
|
|
return user.value !== null
|
|
})
|
|
|
|
const currentUser = computed(() => user.value)
|
|
const userTeams = computed(() => teams.value)
|
|
const userId = computed(() => user.value?.id ?? null)
|
|
|
|
// ============================================================================
|
|
// Actions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Check authentication status by calling /api/auth/me
|
|
* Cookies are sent automatically with credentials: 'include'
|
|
*/
|
|
async function checkAuth(): Promise<boolean> {
|
|
isLoading.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
const config = useRuntimeConfig()
|
|
const response = await $fetch<{
|
|
user: DiscordUser
|
|
teams: Team[]
|
|
}>(`${config.public.apiUrl}/api/auth/me`, {
|
|
credentials: 'include', // Send cookies
|
|
})
|
|
|
|
user.value = response.user
|
|
teams.value = response.teams
|
|
return true
|
|
} catch (err: any) {
|
|
// Not authenticated or token expired
|
|
user.value = null
|
|
teams.value = []
|
|
return false
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redirect to backend OAuth initiation endpoint
|
|
* Backend handles state generation and Discord redirect
|
|
*/
|
|
function loginWithDiscord(returnUrl: string = '/') {
|
|
const config = useRuntimeConfig()
|
|
|
|
if (import.meta.client) {
|
|
const loginUrl = `${config.public.apiUrl}/api/auth/discord/login?return_url=${encodeURIComponent(
|
|
returnUrl
|
|
)}`
|
|
window.location.href = loginUrl
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logout user by clearing server-side cookies
|
|
*/
|
|
async function logout() {
|
|
isLoading.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
const config = useRuntimeConfig()
|
|
await $fetch(`${config.public.apiUrl}/api/auth/logout`, {
|
|
method: 'POST',
|
|
credentials: 'include', // Send cookies
|
|
})
|
|
|
|
user.value = null
|
|
teams.value = []
|
|
|
|
// Redirect to home page
|
|
if (import.meta.client) {
|
|
navigateTo('/auth/login')
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Logout failed:', err)
|
|
error.value = err.message || 'Logout failed'
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set user teams (loaded separately from login)
|
|
*/
|
|
function setTeams(userTeams: Team[]) {
|
|
teams.value = userTeams
|
|
}
|
|
|
|
/**
|
|
* Clear local auth state (does NOT clear server-side cookies)
|
|
*/
|
|
function clearAuth() {
|
|
user.value = null
|
|
teams.value = []
|
|
error.value = null
|
|
}
|
|
|
|
// ============================================================================
|
|
// Return Store API
|
|
// ============================================================================
|
|
|
|
return {
|
|
// State
|
|
user: readonly(user),
|
|
teams: readonly(teams),
|
|
isLoading: readonly(isLoading),
|
|
error: readonly(error),
|
|
|
|
// Getters
|
|
isAuthenticated,
|
|
currentUser,
|
|
userTeams,
|
|
userId,
|
|
|
|
// Actions
|
|
checkAuth,
|
|
loginWithDiscord,
|
|
logout,
|
|
setTeams,
|
|
clearAuth,
|
|
}
|
|
})
|