mirror of
https://github.com/usememos/memos.git
synced 2025-12-18 06:41:32 +08:00
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>
123 lines
4.1 KiB
TypeScript
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);
|
|
}
|
|
}
|