This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 14 kB View raw
1// Default Bluesky OAuth client configuration 2const DEFAULT_AUTH_SERVER = 'https://public.api.bsky.app'; 3const REDIRECT_URI = 'https://flushes.app/auth/callback'; 4const CLIENT_ID = 'https://flushes.app/oauth-client-metadata.json'; 5// Need to include transition:generic to be able to create records 6const SCOPES = 'atproto transition:generic'; 7 8// Type definitions for handle resolution 9interface DidDocument { 10 id: string; 11 // Support both formats of service definitions 12 service?: Array<{ 13 id: string; 14 type: string; 15 serviceEndpoint: string; 16 }>; 17 services?: { 18 atproto_pds?: { 19 type: string; 20 endpoint: string; 21 }; 22 [key: string]: { 23 type: string; 24 endpoint: string; 25 } | undefined; 26 }; 27 alsoKnownAs?: string[]; 28} 29 30// Function to resolve a handle to a DID document 31export async function resolveHandleToDid(handle: string): Promise<{ 32 did: string; 33 pdsEndpoint: string | null; 34 hostname: string | null; 35}> { 36 try { 37 // First, check if handle already is a DID 38 if (handle.startsWith('did:')) { 39 return await fetchDidDocument(handle); 40 } 41 42 // If not a DID, resolve the handle to a DID 43 const resolveResponse = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); 44 45 if (!resolveResponse.ok) { 46 throw new Error(`Failed to resolve handle: ${resolveResponse.status}`); 47 } 48 49 const resolveData = await resolveResponse.json(); 50 const did = resolveData.did; 51 52 // Now get the DID document to find the PDS endpoint 53 return await fetchDidDocument(did); 54 } catch (error) { 55 console.error('Error resolving handle:', error); 56 throw new Error(`Failed to resolve handle. Please ensure it's valid.`); 57 } 58} 59 60// Function to fetch a DID document and extract PDS endpoint 61async function fetchDidDocument(did: string): Promise<{ 62 did: string; 63 pdsEndpoint: string | null; 64 hostname: string | null; 65}> { 66 try { 67 const response = await fetch(`https://plc.directory/${did}/data`); 68 69 if (!response.ok) { 70 throw new Error(`Failed to fetch DID document: ${response.status}`); 71 } 72 73 const didDoc: DidDocument = await response.json(); 74 75 // Find the PDS service endpoint - handle both formats 76 let pdsEndpoint = null; 77 78 // First try the services format (newer format) 79 if (didDoc.services?.atproto_pds) { 80 pdsEndpoint = didDoc.services.atproto_pds.endpoint; 81 } 82 // Or try any service with AtprotoPersonalDataServer type 83 else if (didDoc.services) { 84 const serviceKey = Object.keys(didDoc.services).find(key => 85 didDoc.services?.[key]?.type === 'AtprotoPersonalDataServer' 86 ); 87 if (serviceKey && didDoc.services[serviceKey]) { 88 pdsEndpoint = didDoc.services[serviceKey]?.endpoint || null; 89 } 90 } 91 // Fall back to the older service array format 92 else if (didDoc.service) { 93 const pdsService = didDoc.service.find(s => 94 s.type === 'AtprotoPersonalDataServer' || 95 s.id === '#atproto_pds' 96 ); 97 pdsEndpoint = pdsService?.serviceEndpoint || null; 98 } 99 let hostname = null; 100 101 // Extract hostname from the PDS endpoint 102 if (pdsEndpoint) { 103 try { 104 const url = new URL(pdsEndpoint); 105 hostname = url.hostname; 106 } catch (e) { 107 console.error('Error parsing PDS endpoint URL:', e); 108 } 109 } 110 111 return { 112 did, 113 pdsEndpoint, 114 hostname 115 }; 116 } catch (error) { 117 console.error('Error fetching DID document:', error); 118 return { 119 did, 120 pdsEndpoint: null, 121 hostname: null 122 }; 123 } 124} 125 126// Generate a random string for PKCE and state 127export function generateRandomString(length: number): string { 128 const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; 129 let result = ''; 130 const charactersLength = characters.length; 131 for (let i = 0; i < length; i++) { 132 result += characters.charAt(Math.floor(Math.random() * charactersLength)); 133 } 134 return result; 135} 136 137// Generate the code challenge for PKCE 138export async function generateCodeChallenge(codeVerifier: string): Promise<string> { 139 // Convert string to Uint8Array 140 const encoder = new TextEncoder(); 141 const data = encoder.encode(codeVerifier); 142 143 // Hash the data using SHA-256 144 const hashBuffer = await crypto.subtle.digest('SHA-256', data); 145 146 // Convert hash buffer to base64url format 147 const hashArray = Array.from(new Uint8Array(hashBuffer)); 148 const hashBase64 = btoa(String.fromCharCode.apply(null, hashArray)); 149 150 // Convert base64 to base64url by replacing characters 151 return hashBase64 152 .replace(/\+/g, '-') 153 .replace(/\//g, '_') 154 .replace(/=+$/, ''); 155} 156 157// Generate a DPoP JWK key pair 158export async function generateDPoPKeyPair(): Promise<CryptoKeyPair> { 159 return await window.crypto.subtle.generateKey( 160 { 161 name: 'ECDSA', 162 namedCurve: 'P-256' 163 }, 164 true, // extractable 165 ['sign', 'verify'] 166 ); 167} 168 169// Export the key to JWK format 170export async function exportJWK(key: CryptoKey): Promise<JsonWebKey> { 171 return await window.crypto.subtle.exportKey('jwk', key); 172} 173 174// Calculate the SHA-256 hash of a string 175async function sha256(str: string): Promise<ArrayBuffer> { 176 const encoder = new TextEncoder(); 177 const data = encoder.encode(str); 178 return await window.crypto.subtle.digest('SHA-256', data); 179} 180 181// Convert ArrayBuffer to base64url string 182function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 183 const bytes = new Uint8Array(buffer); 184 let binary = ''; 185 for (let i = 0; i < bytes.byteLength; i++) { 186 binary += String.fromCharCode(bytes[i]); 187 } 188 return btoa(binary) 189 .replace(/\+/g, '-') 190 .replace(/\//g, '_') 191 .replace(/=+$/, ''); 192} 193 194// Generate a DPoP token 195export async function generateDPoPToken( 196 privateKey: CryptoKey, 197 publicKey: JsonWebKey, 198 method: string, 199 url: string, 200 nonce?: string, 201 accessToken?: string // Add optional access token for ath claim 202): Promise<string> { 203 const now = Math.floor(Date.now() / 1000); 204 const jti = generateRandomString(16); 205 206 const header = { 207 alg: 'ES256', 208 typ: 'dpop+jwt', 209 jwk: publicKey 210 }; 211 212 const payload: any = { 213 jti, 214 htm: method, 215 htu: url, 216 iat: now 217 }; 218 219 // Add nonce if provided 220 if (nonce) { 221 payload.nonce = nonce; 222 } 223 224 // Add access token hash (ath) if access token is provided 225 if (accessToken) { 226 // Adding ath claim is required when using access tokens with DPoP 227 const tokenHash = await sha256(accessToken); 228 payload.ath = arrayBufferToBase64Url(tokenHash); 229 } 230 231 const encodedHeader = btoa(JSON.stringify(header)) 232 .replace(/\+/g, '-') 233 .replace(/\//g, '_') 234 .replace(/=+$/, ''); 235 236 const encodedPayload = btoa(JSON.stringify(payload)) 237 .replace(/\+/g, '-') 238 .replace(/\//g, '_') 239 .replace(/=+$/, ''); 240 241 const toSign = `${encodedHeader}.${encodedPayload}`; 242 const encoder = new TextEncoder(); 243 const data = encoder.encode(toSign); 244 245 const signature = await window.crypto.subtle.sign( 246 { 247 name: 'ECDSA', 248 hash: { name: 'SHA-256' }, 249 }, 250 privateKey, 251 data 252 ); 253 254 const signatureArray = Array.from(new Uint8Array(signature)); 255 const encodedSignature = btoa(String.fromCharCode.apply(null, signatureArray)) 256 .replace(/\+/g, '-') 257 .replace(/\//g, '_') 258 .replace(/=+$/, ''); 259 260 return `${encodedHeader}.${encodedPayload}.${encodedSignature}`; 261} 262 263// Get the authorization URL for Bluesky OAuth 264export async function getAuthorizationUrl( 265 pdsEndpoint?: string 266): Promise<{ url: string, state: string, codeVerifier: string, keyPair: CryptoKeyPair, pdsEndpoint: string }> { 267 const state = generateRandomString(32); 268 const codeVerifier = generateRandomString(64); 269 const codeChallenge = await generateCodeChallenge(codeVerifier); 270 const keyPair = await generateDPoPKeyPair(); 271 const publicKey = await exportJWK(keyPair.publicKey); 272 273 // Use the provided PDS endpoint or default to Bluesky's 274 const authServer = pdsEndpoint || DEFAULT_AUTH_SERVER; 275 276 // Get the service URL for OAuth (well-known endpoint) 277 let authEndpoint: string; 278 let metadataEndpoint: string; 279 280 try { 281 // Try to fetch the OAuth metadata from the PDS 282 metadataEndpoint = `${authServer}/.well-known/oauth-authorization-server`; 283 const parResponse = await fetch(metadataEndpoint, { 284 method: 'GET', 285 headers: { 286 'Content-Type': 'application/json', 287 }, 288 }); 289 290 if (!parResponse.ok) { 291 // If failed, use the default path structure for OAuth 292 console.warn(`Failed to fetch OAuth metadata from ${authServer}, using default paths`); 293 authEndpoint = `${authServer}/oauth/authorize`; 294 } else { 295 // If successful, get the authorization endpoint from the metadata 296 const metadata = await parResponse.json(); 297 authEndpoint = metadata.authorization_endpoint || `${authServer}/oauth/authorize`; 298 } 299 } catch (error) { 300 console.error('Error fetching OAuth metadata:', error); 301 // Fallback to default path structure 302 authEndpoint = `${authServer}/oauth/authorize`; 303 } 304 305 // Build the authorization URL 306 const authUrl = `${authEndpoint}` + 307 `?client_id=${encodeURIComponent(CLIENT_ID)}` + 308 `&response_type=code` + 309 `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + 310 `&scope=${encodeURIComponent(SCOPES)}` + 311 `&state=${encodeURIComponent(state)}` + 312 `&code_challenge=${encodeURIComponent(codeChallenge)}` + 313 `&code_challenge_method=S256`; 314 315 return { 316 url: authUrl, 317 state, 318 codeVerifier, 319 keyPair, 320 pdsEndpoint: authServer 321 }; 322} 323 324// Helper function to get a DPoP nonce 325async function getNonce(endpoint: string): Promise<string | null> { 326 try { 327 // Try OPTIONS first as it's less likely to have side effects 328 const optionsResponse = await fetch(endpoint, { 329 method: 'OPTIONS', 330 headers: { 331 'Accept': '*/*' 332 } 333 }); 334 335 const nonceFromOptions = optionsResponse.headers.get('DPoP-Nonce'); 336 if (nonceFromOptions) return nonceFromOptions; 337 338 // If OPTIONS doesn't work, try a HEAD request 339 const headResponse = await fetch(endpoint, { 340 method: 'HEAD' 341 }); 342 343 const nonceFromHead = headResponse.headers.get('DPoP-Nonce'); 344 if (nonceFromHead) return nonceFromHead; 345 346 // As a last resort, make a "probe" POST request that will fail but might give us a nonce 347 const probeResponse = await fetch(endpoint, { 348 method: 'POST', 349 headers: { 350 'Content-Type': 'application/x-www-form-urlencoded' 351 }, 352 // Empty body - this will fail but might return a nonce 353 body: new URLSearchParams({}) 354 }); 355 356 return probeResponse.headers.get('DPoP-Nonce'); 357 } catch (error) { 358 console.error('Error getting nonce:', error); 359 return null; 360 } 361} 362 363// Get a nonce using our server-side API endpoint 364async function fetchNonce(pdsEndpoint: string = DEFAULT_AUTH_SERVER): Promise<string | null> { 365 try { 366 const response = await fetch('/api/auth/nonce', { 367 method: 'POST', 368 headers: { 369 'Content-Type': 'application/json' 370 }, 371 body: JSON.stringify({ pdsEndpoint }) 372 }); 373 374 if (!response.ok) { 375 console.warn('Failed to get nonce from API'); 376 return null; 377 } 378 379 const data = await response.json(); 380 return data.nonce || null; 381 } catch (error) { 382 console.error('Error getting nonce from API:', error); 383 return null; 384 } 385} 386 387// Get access token from authorization code 388export async function getAccessToken( 389 code: string, 390 codeVerifier: string, 391 keyPair: CryptoKeyPair, 392 pdsEndpoint: string = DEFAULT_AUTH_SERVER, 393 dpopNonce?: string, 394 originalPdsEndpoint?: string // Added for third-party PDS support 395): Promise<any> { 396 const tokenEndpoint = `${pdsEndpoint}/oauth/token`; 397 398 // ALWAYS get a fresh nonce first if we don't have one 399 if (!dpopNonce) { 400 console.log('No nonce provided, getting one from API...'); 401 const nonce = await fetchNonce(pdsEndpoint); 402 if (nonce) { 403 console.log('Obtained nonce from API:', nonce); 404 dpopNonce = nonce; 405 } else { 406 console.warn('Could not obtain a nonce, proceeding without one'); 407 } 408 } 409 410 console.log('Creating DPoP token with nonce:', dpopNonce); 411 412 // For token requests, we don't include the ath claim as we don't have the token yet 413 const publicKey = await exportJWK(keyPair.publicKey); 414 const dpopToken = await generateDPoPToken( 415 keyPair.privateKey, 416 publicKey, 417 'POST', 418 tokenEndpoint, 419 dpopNonce 420 // No access token for token requests as we don't have it yet 421 ); 422 423 console.log('Making token request via proxy API'); 424 425 // Use our server-side proxy to make the token request 426 const proxyResponse = await fetch('/api/auth/token', { 427 method: 'POST', 428 headers: { 429 'Content-Type': 'application/json' 430 }, 431 body: JSON.stringify({ 432 code, 433 codeVerifier, 434 dpopToken, 435 pdsEndpoint, // Auth server endpoint (usually public.api.bsky.app) 436 originalPdsEndpoint // The original PDS endpoint (for third-party PDS) 437 }) 438 }); 439 440 const responseData = await proxyResponse.json(); 441 442 // Check if we got a nonce error 443 if ( 444 !proxyResponse.ok && 445 responseData.error === 'use_dpop_nonce' && 446 responseData.nonce 447 ) { 448 console.log('Received nonce from error response:', responseData.nonce); 449 // Retry with the new nonce 450 return getAccessToken(code, codeVerifier, keyPair, pdsEndpoint, responseData.nonce); 451 } 452 453 // Handle other errors 454 if (!proxyResponse.ok) { 455 console.error('Token request failed:', responseData); 456 throw new Error(`Token request failed: ${proxyResponse.status}, ${JSON.stringify(responseData)}`); 457 } 458 459 console.log('Token request successful'); 460 461 // Return the token response 462 return responseData; 463}