Files
memory-infrastructure-palace/code/websites/pokedex.online/src/composables/useOAuth.js

435 lines
12 KiB
JavaScript

/**
* 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<Object>} 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<Object>} 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<string>} 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
};
}