AtAuth
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}