AtAuth
at main 215 lines 6.0 kB view raw
1/** 2 * Input validation utilities 3 */ 4 5import type { OAuthState } from './types'; 6 7/** 8 * Check if the current environment is production. 9 * Uses common environment indicators. 10 */ 11function isProduction(): boolean { 12 if (typeof process !== 'undefined' && process.env) { 13 return process.env.NODE_ENV === 'production'; 14 } 15 // Browser fallback: check if running on non-localhost 16 if (typeof window !== 'undefined') { 17 const hostname = window.location?.hostname; 18 return hostname !== 'localhost' && hostname !== '127.0.0.1' && hostname !== '::1'; 19 } 20 return false; 21} 22 23/** 24 * Require HTTPS for gateway URLs in production. 25 * 26 * In production environments, all gateway URLs must use HTTPS to protect 27 * tokens in transit. This prevents man-in-the-middle attacks. 28 * 29 * @param url - The URL to validate 30 * @param context - Description for error message (e.g., "gatewayUrl") 31 * @throws Error if HTTPS is required but not used 32 */ 33export function requireHttpsInProduction(url: string, context = 'URL'): void { 34 if (!isProduction()) { 35 return; // Allow HTTP in development 36 } 37 38 try { 39 const parsed = new URL(url); 40 if (parsed.protocol !== 'https:') { 41 throw new Error( 42 `Security error: ${context} must use HTTPS in production. ` + 43 `Got "${parsed.protocol}" in "${url}"` 44 ); 45 } 46 } catch (e) { 47 if (e instanceof Error && e.message.startsWith('Security error:')) { 48 throw e; 49 } 50 throw new Error(`Invalid ${context}: ${url}`); 51 } 52} 53 54/** 55 * Validate gateway URL for security requirements. 56 * 57 * @param gatewayUrl - The gateway URL to validate 58 * @throws Error if URL is invalid or insecure 59 */ 60export function validateGatewayUrl(gatewayUrl: string): void { 61 requireHttpsInProduction(gatewayUrl, 'gatewayUrl'); 62} 63 64/** 65 * Validate callback URL for security requirements. 66 * 67 * @param callbackUrl - The callback URL to validate 68 * @throws Error if URL is invalid or insecure 69 */ 70export function validateCallbackUrl(callbackUrl: string): void { 71 requireHttpsInProduction(callbackUrl, 'callbackUrl'); 72} 73 74/** 75 * Maximum allowed size for OAuth state string to prevent DoS. 76 */ 77const MAX_STATE_SIZE = 4096; 78 79/** 80 * Maximum nesting depth for state object. 81 */ 82const MAX_NESTING_DEPTH = 3; 83 84/** 85 * Validate a DID (Decentralized Identifier) format. 86 * 87 * @param did - The DID string to validate 88 * @returns True if valid DID format 89 */ 90export function isValidDid(did: unknown): did is string { 91 if (typeof did !== 'string') return false; 92 93 // AT Protocol DIDs start with "did:plc:" or "did:web:" 94 return /^did:(plc|web):[a-zA-Z0-9._%-]+$/.test(did); 95} 96 97/** 98 * Validate an AT Protocol handle format. 99 * 100 * @param handle - The handle to validate 101 * @returns True if valid handle format 102 */ 103export function isValidHandle(handle: unknown): handle is string { 104 if (typeof handle !== 'string') return false; 105 106 // Handles are domain-like strings (e.g., "user.bsky.social") 107 // Must be lowercase alphanumeric with dots, 3-253 chars 108 if (handle.length < 3 || handle.length > 253) return false; 109 110 return /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(handle) && 111 !handle.includes('..'); 112} 113 114/** 115 * Check object nesting depth. 116 */ 117function getDepth(obj: unknown, current = 0): number { 118 if (current > MAX_NESTING_DEPTH) return current; 119 if (typeof obj !== 'object' || obj === null) return current; 120 121 let maxDepth = current; 122 for (const value of Object.values(obj)) { 123 const depth = getDepth(value, current + 1); 124 if (depth > maxDepth) maxDepth = depth; 125 } 126 return maxDepth; 127} 128 129/** 130 * Validate and parse OAuth state from a string. 131 * Guards against malformed JSON, oversized payloads, and deeply nested objects. 132 * 133 * @param stateString - The state string from URL parameter 134 * @returns Validated OAuthState or null if invalid 135 */ 136export function parseOAuthState(stateString: unknown): OAuthState | null { 137 // Must be a string 138 if (typeof stateString !== 'string') { 139 return null; 140 } 141 142 // Check size limit to prevent DoS 143 if (stateString.length > MAX_STATE_SIZE) { 144 console.warn('OAuth state exceeds maximum size'); 145 return null; 146 } 147 148 // Parse JSON 149 let parsed: unknown; 150 try { 151 parsed = JSON.parse(stateString); 152 } catch { 153 console.warn('OAuth state is not valid JSON'); 154 return null; 155 } 156 157 // Must be an object 158 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { 159 console.warn('OAuth state must be an object'); 160 return null; 161 } 162 163 // Check nesting depth 164 if (getDepth(parsed) > MAX_NESTING_DEPTH) { 165 console.warn('OAuth state exceeds maximum nesting depth'); 166 return null; 167 } 168 169 // Validate known fields 170 const state = parsed as Record<string, unknown>; 171 172 // returnTo must be a string if present 173 if ('returnTo' in state && typeof state.returnTo !== 'string') { 174 console.warn('OAuth state returnTo must be a string'); 175 return null; 176 } 177 178 // nonce must be a string if present 179 if ('nonce' in state && typeof state.nonce !== 'string') { 180 console.warn('OAuth state nonce must be a string'); 181 return null; 182 } 183 184 // Validate returnTo is a safe URL (no javascript: etc.) 185 if (typeof state.returnTo === 'string') { 186 try { 187 const url = new URL(state.returnTo, window?.location?.origin || 'https://example.com'); 188 if (url.protocol !== 'http:' && url.protocol !== 'https:') { 189 console.warn('OAuth state returnTo has invalid protocol'); 190 return null; 191 } 192 } catch { 193 // Relative URLs are OK, but must not contain dangerous schemes 194 if (/^(javascript|data|vbscript):/i.test(state.returnTo)) { 195 console.warn('OAuth state returnTo has dangerous scheme'); 196 return null; 197 } 198 } 199 } 200 201 return state as OAuthState; 202} 203 204/** 205 * Validate an app ID. 206 * 207 * @param appId - The app ID to validate 208 * @returns True if valid 209 */ 210export function isValidAppId(appId: unknown): appId is string { 211 if (typeof appId !== 'string') return false; 212 213 // App IDs should be alphanumeric with hyphens/underscores, 1-64 chars 214 return /^[a-zA-Z0-9_-]{1,64}$/.test(appId); 215}