memos/web/src/utils/oauth.ts
Johnny 1a9bd32cf1 feat(auth): add PKCE support and enhance OAuth security
Implements critical OAuth 2.0 security improvements to protect against authorization code interception attacks and improve provider compatibility:

- Add PKCE (RFC 7636) support with SHA-256 code challenge/verifier
- Fix access token extraction to use standard field instead of Extra()
- Add OAuth error parameter handling (access_denied, invalid_scope, etc.)
- Maintain backward compatibility for non-PKCE flows

This brings the OAuth implementation up to modern security standards as recommended by Auth0, Okta, and the OAuth 2.0 Security Best Current Practice (RFC 8252).

Backend changes:
- Add code_verifier parameter to ExchangeToken with PKCE support
- Use token.AccessToken for better provider compatibility
- Update proto definition with optional code_verifier field

Frontend changes:
- Generate cryptographically secure PKCE parameters
- Include code_challenge in authorization requests
- Handle and display OAuth provider errors gracefully
- Pass code_verifier during token exchange

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 00:04:26 +08:00

123 lines
4.1 KiB
TypeScript

const STATE_STORAGE_KEY = "oauth_state";
const STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
interface OAuthState {
state: string;
identityProviderId: number;
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<string> {
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 both state and codeChallenge for use in authorization URL
export async function storeOAuthState(identityProviderId: number, returnUrl?: string): Promise<{ state: string; codeChallenge: string }> {
const state = generateSecureState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const stateData: OAuthState = {
state,
identityProviderId,
timestamp: Date.now(),
returnUrl,
codeVerifier, // Store for later retrieval in callback
};
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 identityProviderId, returnUrl, and codeVerifier for PKCE
export function validateOAuthState(stateParam: string): { identityProviderId: number; 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 {
identityProviderId: stateData.identityProviderId,
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);
}
}