mirror of https://github.com/usememos/memos.git
149 lines
5.2 KiB
TypeScript
149 lines
5.2 KiB
TypeScript
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<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 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);
|
|
}
|
|
}
|