Coves frontend - a photon fork
1import { randomBytes, timingSafeEqual } from 'crypto'
2
3// ============================================================================
4// Branded Types for Type-Safe CSRF State
5// ============================================================================
6
7/**
8 * Branded type for OAuth CSRF state tokens.
9 * These are 64-character hex strings (32 bytes of entropy).
10 */
11export type OAuthState = string & { readonly __brand: 'OAuthState' }
12
13/**
14 * Type guard to validate OAuthState format.
15 * State tokens must be 64-character hexadecimal strings.
16 *
17 * @param value - The string to validate
18 * @returns True if the value matches the OAuthState format
19 */
20export function isValidOAuthState(value: string): value is OAuthState {
21 return /^[a-f0-9]{64}$/.test(value)
22}
23
24/**
25 * Creates a branded OAuthState from a string.
26 * @throws Error if the value is not a valid OAuthState format
27 */
28export function asOAuthState(value: string): OAuthState {
29 if (!isValidOAuthState(value)) {
30 throw new Error(`Invalid OAuthState format: expected 64 hex characters, got ${value.length} characters`)
31 }
32 return value
33}
34
35/**
36 * Safely attempts to create a branded OAuthState from a string.
37 * @returns The branded OAuthState or null if invalid
38 */
39export function tryAsOAuthState(value: string): OAuthState | null {
40 return isValidOAuthState(value) ? value : null
41}
42
43// ============================================================================
44// State Generation
45// ============================================================================
46
47/**
48 * Generates a cryptographically secure random state for OAuth CSRF protection.
49 * Uses crypto.randomBytes(32) for 256 bits of entropy.
50 *
51 * @returns A 64-character hex string as an OAuthState branded type
52 */
53export function generateOAuthState(): OAuthState {
54 return randomBytes(32).toString('hex') as OAuthState
55}
56
57// ============================================================================
58// State Validation
59// ============================================================================
60
61/**
62 * Validates that two OAuth state strings match using a timing-safe comparison.
63 * This prevents timing attacks that could be used to infer state values.
64 *
65 * @param expected - The expected state value (stored in session)
66 * @param actual - The actual state value (received from callback)
67 * @returns True if the states match, false otherwise
68 */
69export function validateOAuthState(expected: string, actual: string): boolean {
70 // Early return on length mismatch is acceptable here because:
71 // 1. The state parameter is visible in the URL, so attackers already know its length
72 // 2. Valid OAuth states are always 64 hex characters, so length leakage reveals nothing
73 // 3. The actual value comparison below uses timing-safe comparison
74 if (expected.length !== actual.length) {
75 return false
76 }
77
78 // Use timing-safe comparison to prevent timing attacks
79 const expectedBuffer = Buffer.from(expected, 'utf8')
80 const actualBuffer = Buffer.from(actual, 'utf8')
81
82 return timingSafeEqual(expectedBuffer, actualBuffer)
83}
84
85// ============================================================================
86// Origin Validation
87// ============================================================================
88
89/**
90 * Result of origin validation check.
91 */
92export interface OriginValidationResult {
93 /** Whether the origin is valid (same-origin or no origin header) */
94 valid: boolean
95 /** Reason for the validation result (useful for logging) */
96 reason?: string
97}
98
99/**
100 * Validates that a request originates from the expected origin.
101 * Checks the Origin header first, falling back to the Referer header.
102 *
103 * This helps prevent CSRF attacks by ensuring requests come from the same origin.
104 * If neither Origin nor Referer headers are present, the request is considered valid
105 * because some browsers strip these headers for privacy reasons.
106 *
107 * @param request - The incoming request object
108 * @param expectedOrigin - The expected origin URL (e.g., "https://example.com")
109 * @returns An object with valid status and optional reason
110 */
111export function validateRequestOrigin(
112 request: Request,
113 expectedOrigin: string
114): OriginValidationResult {
115 const origin = request.headers.get('Origin')
116 const referer = request.headers.get('Referer')
117
118 // Check Origin header first (preferred)
119 if (origin) {
120 if (origin === expectedOrigin) {
121 return { valid: true, reason: 'Origin header matches expected origin' }
122 }
123 return {
124 valid: false,
125 reason: `Origin mismatch: expected "${expectedOrigin}", got "${origin}"`,
126 }
127 }
128
129 // Fall back to Referer header
130 if (referer) {
131 try {
132 const refererUrl = new URL(referer)
133 const refererOrigin = refererUrl.origin
134
135 if (refererOrigin === expectedOrigin) {
136 return { valid: true, reason: 'Referer origin matches expected origin' }
137 }
138 return {
139 valid: false,
140 reason: `Referer origin mismatch: expected "${expectedOrigin}", got "${refererOrigin}"`,
141 }
142 } catch {
143 return {
144 valid: false,
145 reason: `Invalid Referer URL: "${referer}"`,
146 }
147 }
148 }
149
150 // No Origin or Referer header - accept the request
151 // Some browsers strip these headers for privacy, so we can't reject
152 return {
153 valid: true,
154 reason: 'No Origin or Referer header present (accepted for browser compatibility)',
155 }
156}