Files
fast-next-template/frontend/src/lib/auth/crypto.ts
Felipe Cardoso ee938ce6a6 Add extensive form tests and enhanced error handling for auth components.
- Introduced comprehensive tests for `RegisterForm`, `PasswordResetRequestForm`, and `PasswordResetConfirmForm` covering successful submissions, validation errors, and API error handling.
- Refactored forms to handle unexpected errors gracefully and improve test coverage for edge cases.
- Updated `crypto` and `storage` modules with robust error handling for storage issues and encryption key management.
- Removed unused `axios-mock-adapter` dependency for cleaner dependency management.
2025-11-01 05:24:26 +01:00

160 lines
4.6 KiB
TypeScript

/**
* Cryptographic utilities for secure token storage
* Implements AES-GCM encryption for localStorage fallback
* SSR-safe: All browser APIs guarded
*/
const ENCRYPTION_KEY_NAME = 'auth_encryption_key';
/**
* Check if crypto APIs are available (browser only)
*/
function isCryptoAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined' &&
typeof sessionStorage !== 'undefined'
);
}
/**
* Generate or retrieve encryption key
* Key is stored in sessionStorage (cleared on browser close)
*/
async function getEncryptionKey(): Promise<CryptoKey> {
/* istanbul ignore next - SSR guard, should never be hit due to guards in encrypt/decrypt */
if (!isCryptoAvailable()) {
throw new Error('Crypto API not available - must be called in browser context');
}
// Check if key exists in session
const storedKey = sessionStorage.getItem(ENCRYPTION_KEY_NAME);
if (storedKey) {
try {
const keyData = JSON.parse(storedKey);
return await crypto.subtle.importKey(
'jwk',
keyData,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
} catch (error) {
// Corrupted key, regenerate
console.warn('Failed to import stored key, generating new key:', error);
sessionStorage.removeItem(ENCRYPTION_KEY_NAME);
}
}
// Generate new key
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
// Store key in sessionStorage
try {
const exportedKey = await crypto.subtle.exportKey('jwk', key);
sessionStorage.setItem(ENCRYPTION_KEY_NAME, JSON.stringify(exportedKey));
} catch (error) {
/* istanbul ignore next - Error logging only, key continues in memory */
console.warn('Failed to store encryption key:', error);
// Continue anyway - key is in memory
}
return key;
}
/**
* Encrypt data using AES-GCM
* @param data - Data to encrypt
* @returns Base64 encoded encrypted data with IV
* @throws Error if crypto is not available or encryption fails
*/
export async function encryptData(data: string): Promise<string> {
/* istanbul ignore next - SSR guard tested in E2E */
if (!isCryptoAvailable()) {
throw new Error('Encryption not available in SSR context');
}
try {
const key = await getEncryptionKey();
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV for GCM
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
const encryptedData = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encodedData
);
// Combine IV and encrypted data
const combined = new Uint8Array(iv.length + encryptedData.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encryptedData), iv.length);
// Convert to base64
return btoa(String.fromCharCode(...combined));
} catch (error) {
/* istanbul ignore next - Error logging before throw */
console.error('Encryption failed:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Decrypt data encrypted with encryptData
* @param encryptedData - Base64 encoded encrypted data with IV
* @returns Decrypted string
* @throws Error if crypto is not available or decryption fails
*/
export async function decryptData(encryptedData: string): Promise<string> {
/* istanbul ignore next - SSR guard tested in E2E */
if (!isCryptoAvailable()) {
throw new Error('Decryption not available in SSR context');
}
try {
const key = await getEncryptionKey();
// Decode from base64
const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0));
// Extract IV and encrypted data
const iv = combined.slice(0, 12);
const data = combined.slice(12);
const decryptedData = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
data
);
const decoder = new TextDecoder();
return decoder.decode(decryptedData);
} catch (error) {
console.error('Decryption failed:', error);
throw new Error('Failed to decrypt data');
}
}
/**
* Clear encryption key from session
* Call this on logout to invalidate encrypted data
* SSR-safe: No-op if sessionStorage not available
*/
export function clearEncryptionKey(): void {
if (typeof window !== 'undefined' && typeof sessionStorage !== 'undefined') {
try {
sessionStorage.removeItem(ENCRYPTION_KEY_NAME);
} catch (error) {
/* istanbul ignore next - Error logging only */
console.warn('Failed to clear encryption key:', error);
}
}
}