const STATE_STORAGE_KEY = "oauth_state"; const STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes interface OAuthState { state: string; identityProviderName: string; timestamp: number; returnUrl?: string; codeVerifier?: string; // PKCE code_verifier } // Generate a cryptographically secure random state value function generateSecureState(): string { const array = new Uint8Array(32); crypto.getRandomValues(array); return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Generate a cryptographically secure random code_verifier for PKCE (RFC 7636) // Returns a URL-safe base64 string (43-128 characters) function generateCodeVerifier(): string { const array = new Uint8Array(32); // 256 bits = 32 bytes crypto.getRandomValues(array); // Convert to base64url (URL-safe base64 without padding) return base64UrlEncode(array); } // Generate code_challenge from code_verifier using SHA-256 async function generateCodeChallenge(codeVerifier: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(codeVerifier); const hash = await crypto.subtle.digest("SHA-256", data); return base64UrlEncode(new Uint8Array(hash)); } // Base64URL encoding (RFC 4648 base64url without padding) function base64UrlEncode(buffer: Uint8Array): string { const base64 = btoa(String.fromCharCode(...buffer)); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // Store OAuth state and PKCE parameters in sessionStorage // Returns state and optional codeChallenge for use in authorization URL // PKCE is optional - if crypto APIs are unavailable (HTTP context), falls back to standard OAuth export async function storeOAuthState( identityProviderName: string, returnUrl?: string, ): Promise<{ state: string; codeChallenge?: string }> { const state = generateSecureState(); // Try to generate PKCE parameters if crypto.subtle is available (HTTPS/localhost) // Falls back to standard OAuth flow if unavailable (HTTP context) let codeVerifier: string | undefined; let codeChallenge: string | undefined; try { // Check if crypto.subtle is available (requires secure context: HTTPS or localhost) if (typeof crypto !== "undefined" && crypto.subtle) { codeVerifier = generateCodeVerifier(); codeChallenge = await generateCodeChallenge(codeVerifier); } else { console.warn( "PKCE not available: crypto.subtle requires HTTPS. Falling back to standard OAuth flow without PKCE. " + "For enhanced security, please access Memos over HTTPS.", ); } } catch (error) { // If PKCE generation fails for any reason, fall back to standard OAuth console.warn("Failed to generate PKCE parameters, falling back to standard OAuth:", error); codeVerifier = undefined; codeChallenge = undefined; } const stateData: OAuthState = { state, identityProviderName, timestamp: Date.now(), returnUrl, codeVerifier, // Store for later retrieval in callback (undefined if PKCE not available) }; try { sessionStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(stateData)); } catch (error) { console.error("Failed to store OAuth state:", error); throw new Error("Failed to initialize OAuth flow"); } return { state, codeChallenge }; } // Validate and retrieve OAuth state from storage (CSRF protection) // Returns identityProviderName, returnUrl, and codeVerifier for PKCE export function validateOAuthState(stateParam: string): { identityProviderName: string; returnUrl?: string; codeVerifier?: string } | null { try { const storedData = sessionStorage.getItem(STATE_STORAGE_KEY); if (!storedData) { console.error("No OAuth state found in storage"); return null; } const stateData: OAuthState = JSON.parse(storedData); // Check if state has expired if (Date.now() - stateData.timestamp > STATE_EXPIRY_MS) { console.error("OAuth state has expired"); sessionStorage.removeItem(STATE_STORAGE_KEY); return null; } // Validate state matches (CSRF protection) if (stateData.state !== stateParam) { console.error("OAuth state mismatch - possible CSRF attack"); sessionStorage.removeItem(STATE_STORAGE_KEY); return null; } // State is valid, clean up and return data sessionStorage.removeItem(STATE_STORAGE_KEY); return { identityProviderName: stateData.identityProviderName, returnUrl: stateData.returnUrl, codeVerifier: stateData.codeVerifier, // Return PKCE code_verifier }; } catch (error) { console.error("Failed to validate OAuth state:", error); sessionStorage.removeItem(STATE_STORAGE_KEY); return null; } } // Clean up expired OAuth states (call on app init) export function cleanupExpiredOAuthState(): void { try { const storedData = sessionStorage.getItem(STATE_STORAGE_KEY); if (!storedData) { return; } const stateData: OAuthState = JSON.parse(storedData); if (Date.now() - stateData.timestamp > STATE_EXPIRY_MS) { sessionStorage.removeItem(STATE_STORAGE_KEY); } } catch { // If parsing fails, remove the corrupted data sessionStorage.removeItem(STATE_STORAGE_KEY); } }