/** * Unified OAuth Composable * * Handles OAuth flow for multiple providers (Challonge, Discord, etc.) * * Features: * - Multi-provider token storage with localStorage persistence * - Authorization URL generation with return_to support * - CSRF protection via state parameter * - Code exchange with provider routing * - Automatic token refresh with 5-minute expiry buffer * - Token validation and cleanup * - Comprehensive error handling * * Usage: * const oauth = useOAuth('challonge'); * oauth.login({ scope: 'tournaments:read tournaments:write', return_to: '/challonge-test' }); * // ... user redirected to OAuth provider ... * await oauth.exchangeCode(code, state); // called from callback */ import { ref, computed } from 'vue'; import { PLATFORMS } from '../config/platforms.js'; // Multi-provider token storage (shared across all instances) const tokenStores = new Map(); /** * Initialize OAuth state for a provider * @param {string} provider - Provider name (e.g., 'challonge', 'discord') * @returns {Object} OAuth state for this provider * @throws {Error} If platform not found */ function initializeProvider(provider) { // Return existing state if already initialized if (tokenStores.has(provider)) { return tokenStores.get(provider); } // Validate platform exists const platformConfig = PLATFORMS[provider]; if (!platformConfig) { throw new Error(`Platform not found: ${provider}`); } // Get storage key from OAuth config const oauthConfig = platformConfig.auth.oauth; if (!oauthConfig?.enabled) { throw new Error(`OAuth not enabled for ${provider}`); } const storageKey = oauthConfig.storageKey; // Create provider-specific state const state = { tokens: ref(null), loading: ref(false), error: ref(null), provider, storageKey }; // Load existing tokens from localStorage on initialization try { const stored = localStorage.getItem(storageKey); if (stored) { state.tokens.value = JSON.parse(stored); console.log(`✅ Loaded ${provider} OAuth tokens from storage`); } } catch (err) { console.error(`Failed to load ${provider} OAuth tokens:`, err); } tokenStores.set(provider, state); return state; } /** * Main composable for OAuth authentication * @param {string} provider - Provider name (default: 'challonge') * @returns {Object} OAuth composable API */ export function useOAuth(provider = 'challonge') { const state = initializeProvider(provider); const platformConfig = PLATFORMS[provider]; const oauthConfig = platformConfig.auth.oauth; // Computed properties for token state const isAuthenticated = computed(() => { return !!state.tokens.value?.access_token; }); const isExpired = computed(() => { if (!state.tokens.value?.expires_at) return false; return Date.now() >= state.tokens.value.expires_at; }); const expiresIn = computed(() => { if (!state.tokens.value?.expires_at) return null; const diff = state.tokens.value.expires_at - Date.now(); return diff > 0 ? Math.floor(diff / 1000) : 0; }); const accessToken = computed(() => { return state.tokens.value?.access_token || null; }); const refreshToken = computed(() => { return state.tokens.value?.refresh_token || null; }); /** * Generate authorization URL for OAuth flow * * @param {string|Object} scopeOrOptions - Scope string or options object * @param {Object} options - Additional options (scope, return_to) * @returns {Object} {authUrl, state, returnTo} * @throws {Error} If OAuth credentials not configured */ function getAuthorizationUrl(scopeOrOptions, options = {}) { const clientId = import.meta.env[ `VITE_${provider.toUpperCase()}_CLIENT_ID` ]; const redirectUri = import.meta.env[ `VITE_${provider.toUpperCase()}_REDIRECT_URI` ]; if (!clientId || !redirectUri) { throw new Error( `OAuth credentials not configured for ${provider}. ` + `Check VITE_${provider.toUpperCase()}_CLIENT_ID and VITE_${provider.toUpperCase()}_REDIRECT_URI in .env` ); } // Parse arguments (support both string scope and options object) let scope = oauthConfig.scopes.join(' '); let returnTo = null; if (typeof scopeOrOptions === 'string') { scope = scopeOrOptions; returnTo = options.return_to; } else if (typeof scopeOrOptions === 'object') { scope = scopeOrOptions.scope || scope; returnTo = scopeOrOptions.return_to; } // Generate CSRF state const oauthState = generateState(); // Build authorization URL const params = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: scope, state: oauthState }); // Add provider-specific parameters if needed if (provider === 'discord') { params.append('prompt', 'none'); // Don't show consent screen if already authorized } return { authUrl: `${oauthConfig.endpoint}?${params.toString()}`, state: oauthState, returnTo }; } /** * Start OAuth authorization flow * Redirects user to OAuth provider * * @param {Object} options - Options including scope and return_to * @throws {Error} If OAuth credentials missing */ function login(options = {}) { try { const { authUrl, state, returnTo } = getAuthorizationUrl(options); // Store state and provider for CSRF validation in callback sessionStorage.setItem('oauth_state', state); sessionStorage.setItem('oauth_provider', provider); if (returnTo) { sessionStorage.setItem('oauth_return_to', returnTo); } console.log( `🔐 Starting ${provider} OAuth flow with state:`, state.substring(0, 8) + '...' ); // Redirect to OAuth provider window.location.href = authUrl; } catch (err) { state.error.value = err.message; console.error(`${provider} OAuth login error:`, err); throw err; } } /** * Exchange authorization code for access token * Called from OAuth callback page * * @param {string} code - Authorization code from OAuth provider * @param {string} stateParam - State parameter for CSRF validation * @returns {Promise} Tokens object {access_token, refresh_token, expires_at, ...} * @throws {Error} If CSRF validation fails or token exchange fails */ async function exchangeCode(code, stateParam) { // Verify CSRF state parameter const storedState = sessionStorage.getItem('oauth_state'); const storedProvider = sessionStorage.getItem('oauth_provider'); if (stateParam !== storedState) { const err = new Error('Invalid state parameter - possible CSRF attack'); state.error.value = err.message; throw err; } if (storedProvider !== provider) { const err = new Error( `Provider mismatch: expected ${storedProvider}, got ${provider}` ); state.error.value = err.message; throw err; } state.loading.value = true; state.error.value = null; try { // Exchange code for tokens via backend endpoint const response = await fetch(oauthConfig.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, provider }) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( errorData.error_description || errorData.error || `Token exchange failed with status ${response.status}` ); } const data = await response.json(); // Calculate token expiration time (expires_in is in seconds) const expiresAt = Date.now() + (data.expires_in || 3600) * 1000; // Store tokens const tokens = { access_token: data.access_token, refresh_token: data.refresh_token || null, token_type: data.token_type || 'Bearer', expires_in: data.expires_in || 3600, expires_at: expiresAt, scope: data.scope, created_at: Date.now() }; state.tokens.value = tokens; localStorage.setItem(state.storageKey, JSON.stringify(tokens)); // Clean up session storage sessionStorage.removeItem('oauth_state'); sessionStorage.removeItem('oauth_provider'); sessionStorage.removeItem('oauth_return_to'); console.log( `✅ ${provider} OAuth authentication successful, expires in ${data.expires_in}s` ); return tokens; } catch (err) { state.error.value = err.message; console.error(`${provider} token exchange error:`, err); throw err; } finally { state.loading.value = false; } } /** * Refresh access token using refresh token * Called when token is expired or about to expire * * @returns {Promise} Updated tokens object * @throws {Error} If no refresh token available or refresh fails */ async function refreshTokenFn() { if (!state.tokens.value?.refresh_token) { throw new Error(`No refresh token available for ${provider}`); } state.loading.value = true; state.error.value = null; try { const response = await fetch(oauthConfig.refreshEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: state.tokens.value.refresh_token, provider }) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( errorData.error_description || errorData.error || `Token refresh failed with status ${response.status}` ); } const data = await response.json(); const expiresAt = Date.now() + (data.expires_in || 3600) * 1000; // Update tokens (keep old refresh token if new one not provided) const tokens = { ...state.tokens.value, access_token: data.access_token, refresh_token: data.refresh_token || state.tokens.value.refresh_token, expires_in: data.expires_in || 3600, expires_at: expiresAt, refreshed_at: Date.now() }; state.tokens.value = tokens; localStorage.setItem(state.storageKey, JSON.stringify(tokens)); console.log( `✅ ${provider} token refreshed, new expiry in ${data.expires_in}s` ); return tokens; } catch (err) { state.error.value = err.message; console.error(`${provider} token refresh error:`, err); // If refresh fails, clear authentication logout(); throw err; } finally { state.loading.value = false; } } /** * Get valid access token, refreshing if necessary * Automatically refreshes tokens expiring within 5 minutes * * @returns {Promise} Valid access token * @throws {Error} If not authenticated */ async function getValidToken() { if (!state.tokens.value) { throw new Error(`Not authenticated with ${provider}`); } // Calculate time until expiry const expiresIn = state.tokens.value.expires_at - Date.now(); const fiveMinutes = 5 * 60 * 1000; // Refresh if expired or expiring within 5 minutes if (expiresIn < fiveMinutes) { console.log( `🔄 ${provider} token expiring in ${Math.floor(expiresIn / 1000)}s, refreshing...` ); await refreshTokenFn(); } return state.tokens.value.access_token; } /** * Logout and clear all tokens * Removes tokens from storage and session */ function logout() { state.tokens.value = null; localStorage.removeItem(state.storageKey); sessionStorage.removeItem('oauth_state'); sessionStorage.removeItem('oauth_provider'); sessionStorage.removeItem('oauth_return_to'); console.log(`👋 ${provider} logged out`); } /** * Generate random state for CSRF protection * Uses crypto.getRandomValues for secure randomness * * @returns {string} 64-character hex string */ function generateState() { const array = new Uint8Array(32); crypto.getRandomValues(array); return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join( '' ); } return { // State tokens: computed(() => state.tokens.value), isAuthenticated, isExpired, expiresIn, accessToken, refreshToken, loading: computed(() => state.loading.value), error: computed(() => state.error.value), // Methods login, logout, exchangeCode, refreshToken: refreshTokenFn, getValidToken, getAuthorizationUrl }; }