This repository has no description
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}