Spaces:
Sleeping
Sleeping
| import { authStore } from '$lib/stores/auth'; | |
| import { apiClient } from '$lib/services/api'; | |
| import { | |
| OAUTH_CLIENT_ID, | |
| OAUTH_REDIRECT_URI, | |
| OAUTH_PROVIDER_URL, | |
| ENABLE_MOCK_AUTH, | |
| MOCK_USER_ID, | |
| MOCK_USERNAME, | |
| MOCK_EMAIL, | |
| API_TOKEN | |
| } from '$lib/utils/constants'; | |
| import type { UserProfile } from '$lib/types/api'; | |
| /** | |
| * Authentication service for OAuth2 PKCE and mock auth | |
| */ | |
| // PKCE helper functions | |
| function generateRandomString(length: number): string { | |
| const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; | |
| const values = crypto.getRandomValues(new Uint8Array(length)); | |
| return Array.from(values) | |
| .map((x) => possible[x % possible.length]) | |
| .join(''); | |
| } | |
| async function sha256(plain: string): Promise<ArrayBuffer> { | |
| const encoder = new TextEncoder(); | |
| const data = encoder.encode(plain); | |
| return crypto.subtle.digest('SHA-256', data); | |
| } | |
| function base64urlencode(buffer: ArrayBuffer): string { | |
| const bytes = new Uint8Array(buffer); | |
| let str = ''; | |
| for (const byte of bytes) { | |
| str += String.fromCharCode(byte); | |
| } | |
| return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); | |
| } | |
| /** | |
| * Generate PKCE code verifier and challenge | |
| */ | |
| export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { | |
| const verifier = generateRandomString(128); | |
| const hashed = await sha256(verifier); | |
| const challenge = base64urlencode(hashed); | |
| return { verifier, challenge }; | |
| } | |
| /** | |
| * Initiate OAuth2 login flow | |
| */ | |
| export async function initiateOAuthLogin(): Promise<void | UserProfile> { | |
| if (ENABLE_MOCK_AUTH) { | |
| // Use mock auth in development | |
| return mockLogin(); | |
| } | |
| // Generate PKCE parameters | |
| const { verifier, challenge } = await generatePKCE(); | |
| // Store verifier in sessionStorage for callback | |
| sessionStorage.setItem('pkce_verifier', verifier); | |
| // Build authorization URL | |
| const params = new URLSearchParams({ | |
| client_id: OAUTH_CLIENT_ID, | |
| redirect_uri: OAUTH_REDIRECT_URI, | |
| response_type: 'code', | |
| scope: 'openid profile email', | |
| code_challenge: challenge, | |
| code_challenge_method: 'S256', | |
| state: generateRandomString(32) // CSRF protection | |
| }); | |
| // Store state for validation | |
| sessionStorage.setItem('oauth_state', params.get('state')!); | |
| // Redirect to OAuth provider | |
| window.location.href = `${OAUTH_PROVIDER_URL}?${params.toString()}`; | |
| } | |
| /** | |
| * Handle OAuth callback after authorization | |
| */ | |
| export async function handleOAuthCallback( | |
| code: string, | |
| state: string | |
| ): Promise<UserProfile | null> { | |
| // Validate state | |
| const storedState = sessionStorage.getItem('oauth_state'); | |
| if (!storedState || storedState !== state) { | |
| throw new Error('Invalid OAuth state - possible CSRF attack'); | |
| } | |
| // Get verifier | |
| const verifier = sessionStorage.getItem('pkce_verifier'); | |
| if (!verifier) { | |
| throw new Error('PKCE verifier not found'); | |
| } | |
| // Clean up stored values | |
| sessionStorage.removeItem('oauth_state'); | |
| sessionStorage.removeItem('pkce_verifier'); | |
| // Exchange authorization code for token | |
| const tokenResponse = await fetch(`${OAUTH_PROVIDER_URL}/token`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/x-www-form-urlencoded' | |
| }, | |
| body: new URLSearchParams({ | |
| client_id: OAUTH_CLIENT_ID, | |
| code, | |
| code_verifier: verifier, | |
| grant_type: 'authorization_code', | |
| redirect_uri: OAUTH_REDIRECT_URI | |
| }) | |
| }); | |
| if (!tokenResponse.ok) { | |
| throw new Error('Failed to exchange authorization code for token'); | |
| } | |
| const tokenData = await tokenResponse.json(); | |
| const accessToken = tokenData.access_token; | |
| const expiresIn = tokenData.expires_in || 3600; // Default 1 hour | |
| // Set auth headers for API client | |
| apiClient.setAuth(accessToken, ''); // user_id will be set after profile fetch | |
| // Fetch user profile from backend | |
| const userProfile = await apiClient.getUserProfile(); | |
| // Calculate token expiry | |
| const tokenExpiry = Date.now() + expiresIn * 1000; | |
| // Update API client with user_id | |
| apiClient.setAuth(accessToken, userProfile.user_id); | |
| // Update auth store | |
| authStore.login( | |
| { | |
| user_id: userProfile.user_id, | |
| username: userProfile.username, | |
| email: userProfile.email, | |
| avatar_url: userProfile.avatar_url, | |
| tokenExpiry | |
| }, | |
| accessToken, | |
| tokenExpiry | |
| ); | |
| return userProfile; | |
| } | |
| /** | |
| * Mock authentication for development | |
| */ | |
| export async function mockLogin(): Promise<UserProfile> { | |
| // Use configured API token for mock auth | |
| const mockToken = API_TOKEN; | |
| const tokenExpiry = Date.now() + 24 * 60 * 60 * 1000; // 24 hours | |
| // Set auth headers | |
| apiClient.setAuth(mockToken, MOCK_USER_ID); | |
| // Create mock user profile | |
| const mockProfile: UserProfile = { | |
| user_id: MOCK_USER_ID, | |
| username: MOCK_USERNAME, | |
| email: MOCK_EMAIL, | |
| avatar_url: undefined | |
| }; | |
| // Update auth store | |
| authStore.login( | |
| { | |
| ...mockProfile, | |
| tokenExpiry | |
| }, | |
| mockToken, | |
| tokenExpiry | |
| ); | |
| return mockProfile; | |
| } | |
| /** | |
| * Logout user | |
| */ | |
| export function logout(): void { | |
| // Clear API client auth | |
| apiClient.clearAuth(); | |
| // Clear auth store | |
| authStore.logout(); | |
| // Redirect to home | |
| if (typeof window !== 'undefined') { | |
| window.location.href = '/'; | |
| } | |
| } | |