Coves frontend - a photon fork
at main 256 lines 8.8 kB view raw
1import { describe, it, expect } from 'vitest' 2import { 3 generateOAuthState, 4 validateOAuthState, 5 validateRequestOrigin, 6 isValidOAuthState, 7 asOAuthState, 8 tryAsOAuthState, 9 type OAuthState, 10} from './csrf' 11 12// ============================================================================ 13// State Generation Tests 14// ============================================================================ 15 16describe('generateOAuthState', () => { 17 it('produces a 64-character hex string', () => { 18 const state = generateOAuthState() 19 20 expect(state).toHaveLength(64) 21 expect(/^[a-f0-9]{64}$/.test(state)).toBe(true) 22 }) 23 24 it('produces unique values on each call', () => { 25 const states = new Set<string>() 26 27 // Generate 100 states and verify uniqueness 28 for (let i = 0; i < 100; i++) { 29 states.add(generateOAuthState()) 30 } 31 32 expect(states.size).toBe(100) 33 }) 34 35 it('returns a branded OAuthState type', () => { 36 const state = generateOAuthState() 37 38 // Type check: state should be assignable to OAuthState 39 const typedState: OAuthState = state 40 expect(typedState).toBe(state) 41 }) 42}) 43 44// ============================================================================ 45// State Validation Tests 46// ============================================================================ 47 48describe('validateOAuthState', () => { 49 it('returns true for matching states', () => { 50 const state = generateOAuthState() 51 52 expect(validateOAuthState(state, state)).toBe(true) 53 }) 54 55 it('returns true for identical manually created states', () => { 56 const state = 'a'.repeat(64) 57 58 expect(validateOAuthState(state, state)).toBe(true) 59 }) 60 61 it('returns false for mismatched states', () => { 62 const state1 = generateOAuthState() 63 const state2 = generateOAuthState() 64 65 expect(validateOAuthState(state1, state2)).toBe(false) 66 }) 67 68 it('returns false for states with different lengths', () => { 69 const state1 = generateOAuthState() 70 const state2 = state1.slice(0, 32) // Half length 71 72 expect(validateOAuthState(state1, state2)).toBe(false) 73 }) 74 75 it('returns false for empty strings', () => { 76 const state = generateOAuthState() 77 78 expect(validateOAuthState(state, '')).toBe(false) 79 expect(validateOAuthState('', state)).toBe(false) 80 expect(validateOAuthState('', '')).toBe(true) // Both empty is technically equal 81 }) 82 83 it('handles states that differ only in one character', () => { 84 const state1 = 'a'.repeat(64) 85 const state2 = 'a'.repeat(63) + 'b' 86 87 expect(validateOAuthState(state1, state2)).toBe(false) 88 }) 89 90 it('uses timing-safe comparison (uses timingSafeEqual internally)', () => { 91 // We can verify this by checking the function imports crypto.timingSafeEqual 92 // The actual timing safety is guaranteed by Node's crypto module 93 const state1 = generateOAuthState() 94 const state2 = generateOAuthState() 95 96 // Multiple calls should have consistent performance regardless of where they differ 97 // This is a property test - the timing-safe comparison should work identically 98 const results: boolean[] = [] 99 for (let i = 0; i < 10; i++) { 100 results.push(validateOAuthState(state1, state2)) 101 } 102 103 // All results should be false (states are different) 104 expect(results.every((r) => r === false)).toBe(true) 105 }) 106}) 107 108// ============================================================================ 109// OAuthState Type Validation Tests 110// ============================================================================ 111 112describe('isValidOAuthState', () => { 113 it('validates correct 64-char hex strings', () => { 114 expect(isValidOAuthState('a'.repeat(64))).toBe(true) 115 expect(isValidOAuthState('0'.repeat(64))).toBe(true) 116 expect(isValidOAuthState('0123456789abcdef'.repeat(4))).toBe(true) 117 }) 118 119 it('rejects non-hex characters', () => { 120 expect(isValidOAuthState('g'.repeat(64))).toBe(false) 121 expect(isValidOAuthState('A'.repeat(64))).toBe(false) // Uppercase not allowed 122 expect(isValidOAuthState('!'.repeat(64))).toBe(false) 123 }) 124 125 it('rejects wrong length strings', () => { 126 expect(isValidOAuthState('a'.repeat(63))).toBe(false) 127 expect(isValidOAuthState('a'.repeat(65))).toBe(false) 128 expect(isValidOAuthState('')).toBe(false) 129 }) 130}) 131 132describe('asOAuthState', () => { 133 it('returns branded type for valid state', () => { 134 const validState = 'a'.repeat(64) 135 const branded = asOAuthState(validState) 136 137 expect(branded).toBe(validState) 138 }) 139 140 it('throws for invalid state', () => { 141 expect(() => asOAuthState('invalid')).toThrow('Invalid OAuthState format') 142 expect(() => asOAuthState('a'.repeat(63))).toThrow('Invalid OAuthState format') 143 }) 144}) 145 146describe('tryAsOAuthState', () => { 147 it('returns branded type for valid state', () => { 148 const validState = 'a'.repeat(64) 149 const result = tryAsOAuthState(validState) 150 151 expect(result).toBe(validState) 152 }) 153 154 it('returns null for invalid state', () => { 155 expect(tryAsOAuthState('invalid')).toBeNull() 156 expect(tryAsOAuthState('')).toBeNull() 157 }) 158}) 159 160// ============================================================================ 161// Origin Validation Tests 162// ============================================================================ 163 164describe('validateRequestOrigin', () => { 165 const expectedOrigin = 'https://example.com' 166 167 function createMockRequest(headers: Record<string, string>): Request { 168 return new Request('https://example.com/test', { headers }) 169 } 170 171 it('accepts same-origin requests (Origin header)', () => { 172 const request = createMockRequest({ Origin: 'https://example.com' }) 173 const result = validateRequestOrigin(request, expectedOrigin) 174 175 expect(result.valid).toBe(true) 176 expect(result.reason).toContain('Origin header matches') 177 }) 178 179 it('rejects cross-origin requests (Origin header)', () => { 180 const request = createMockRequest({ Origin: 'https://evil.com' }) 181 const result = validateRequestOrigin(request, expectedOrigin) 182 183 expect(result.valid).toBe(false) 184 expect(result.reason).toContain('Origin mismatch') 185 expect(result.reason).toContain('https://evil.com') 186 }) 187 188 it('accepts same-origin requests via Referer header when Origin is missing', () => { 189 const request = createMockRequest({ Referer: 'https://example.com/some/path' }) 190 const result = validateRequestOrigin(request, expectedOrigin) 191 192 expect(result.valid).toBe(true) 193 expect(result.reason).toContain('Referer origin matches') 194 }) 195 196 it('rejects cross-origin requests via Referer header', () => { 197 const request = createMockRequest({ Referer: 'https://evil.com/attack' }) 198 const result = validateRequestOrigin(request, expectedOrigin) 199 200 expect(result.valid).toBe(false) 201 expect(result.reason).toContain('Referer origin mismatch') 202 }) 203 204 it('prefers Origin header over Referer header', () => { 205 // Even if Referer is cross-origin, if Origin matches, it should pass 206 const request = createMockRequest({ 207 Origin: 'https://example.com', 208 Referer: 'https://evil.com/attack', 209 }) 210 const result = validateRequestOrigin(request, expectedOrigin) 211 212 expect(result.valid).toBe(true) 213 expect(result.reason).toContain('Origin header matches') 214 }) 215 216 it('accepts requests without Origin or Referer header', () => { 217 const request = createMockRequest({}) 218 const result = validateRequestOrigin(request, expectedOrigin) 219 220 expect(result.valid).toBe(true) 221 expect(result.reason).toContain('No Origin or Referer header') 222 }) 223 224 it('handles invalid Referer URL gracefully', () => { 225 const request = createMockRequest({ Referer: 'not-a-valid-url' }) 226 const result = validateRequestOrigin(request, expectedOrigin) 227 228 expect(result.valid).toBe(false) 229 expect(result.reason).toContain('Invalid Referer URL') 230 }) 231 232 it('handles port differences correctly', () => { 233 const request = createMockRequest({ Origin: 'https://example.com:443' }) 234 const result = validateRequestOrigin(request, expectedOrigin) 235 236 // https://example.com:443 is NOT the same string as https://example.com 237 // Even though they're semantically equivalent, the string comparison fails 238 expect(result.valid).toBe(false) 239 }) 240 241 it('handles protocol differences', () => { 242 const request = createMockRequest({ Origin: 'http://example.com' }) 243 const result = validateRequestOrigin(request, expectedOrigin) 244 245 expect(result.valid).toBe(false) 246 expect(result.reason).toContain('Origin mismatch') 247 }) 248 249 it('handles subdomain differences', () => { 250 const request = createMockRequest({ Origin: 'https://sub.example.com' }) 251 const result = validateRequestOrigin(request, expectedOrigin) 252 253 expect(result.valid).toBe(false) 254 expect(result.reason).toContain('Origin mismatch') 255 }) 256})