🔒 Add OAuth support for authentication
This commit is contained in:
417
code/websites/pokedex.online/src/composables/useOAuth.js
Normal file
417
code/websites/pokedex.online/src/composables/useOAuth.js
Normal file
@@ -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<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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user