From 0a5e1b9251875fabb0695c1dea70ccaf65a1f9d9 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Thu, 29 Jan 2026 15:21:15 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20Add=20OAuth=20support=20for=20au?= =?UTF-8?q?thentication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/composables/useOAuth.js | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 code/websites/pokedex.online/src/composables/useOAuth.js diff --git a/code/websites/pokedex.online/src/composables/useOAuth.js b/code/websites/pokedex.online/src/composables/useOAuth.js new file mode 100644 index 0000000..dac9e80 --- /dev/null +++ b/code/websites/pokedex.online/src/composables/useOAuth.js @@ -0,0 +1,417 @@ +/** + * 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 + }; +}