this repo has no description
at main 394 lines 9.9 kB view raw
1/** 2 * DID and Handle Resolution utilities for ATProtocol 3 * Implements handle→DID and DID→Document resolution 4 */ 5 6interface DidDocument { 7 id: string; 8 alsoKnownAs?: string[]; 9 verificationMethod?: Array<{ 10 id: string; 11 type: string; 12 controller?: string; 13 publicKeyMultibase?: string; 14 }>; 15 service?: Array<{ 16 id: string; 17 type: string; 18 serviceEndpoint: string; 19 }>; 20} 21 22interface AuthorizationServerMetadata { 23 issuer: string; 24 authorization_endpoint: string; 25 token_endpoint: string; 26 pushed_authorization_request_endpoint: string; 27 response_types_supported: string[]; 28 grant_types_supported: string[]; 29 code_challenge_methods_supported: string[]; 30 scopes_supported: string[]; 31 require_pushed_authorization_requests: boolean; 32 dpop_signing_alg_values_supported: string[]; 33} 34 35interface ResourceServerMetadata { 36 resource: string; 37 authorization_servers: string[]; 38} 39 40/** 41 * Resolve a handle to a DID 42 * Tries DNS TXT record first, falls back to HTTPS well-known 43 * @param handle - The handle to resolve (e.g., "alice.bsky.social") 44 * @returns The DID or throws an error 45 */ 46export async function resolveHandleToDid(handle: string): Promise<string> { 47 // Validate handle format 48 if (!isValidHandle(handle)) { 49 throw new Error(`Invalid handle format: ${handle}`); 50 } 51 52 // Try HTTPS well-known first (more reliable in browser) 53 try { 54 const httpsDid = await resolveViaHttps(handle); 55 if (httpsDid) { 56 return httpsDid; 57 } 58 } catch (error) { 59 console.warn('HTTPS resolution failed, trying DNS:', error); 60 } 61 62 // Try DNS TXT record (may not work in all browsers due to CORS) 63 try { 64 const dnsDid = await resolveViaDns(handle); 65 if (dnsDid) { 66 return dnsDid; 67 } 68 } catch (error) { 69 console.warn('DNS resolution failed:', error); 70 } 71 72 throw new Error(`Failed to resolve handle ${handle} to DID`); 73} 74 75/** 76 * Resolve handle via HTTPS well-known 77 * @param handle - The handle to resolve 78 * @returns The DID or null 79 */ 80async function resolveViaHttps(handle: string): Promise<string | null> { 81 try { 82 const response = await fetch(`https://${handle}/.well-known/atproto-did`, { 83 method: 'GET', 84 headers: { 85 'Accept': 'text/plain', 86 }, 87 cache: 'no-cache', 88 }); 89 90 if (!response.ok) { 91 return null; 92 } 93 94 const did = (await response.text()).trim(); 95 96 if (!isValidDid(did)) { 97 throw new Error(`Invalid DID format: ${did}`); 98 } 99 100 return did; 101 } catch (error) { 102 console.error('HTTPS resolution error:', error); 103 return null; 104 } 105} 106 107/** 108 * Resolve handle via DNS TXT record 109 * Note: This may not work in browsers due to CORS restrictions 110 * @param handle - The handle to resolve 111 * @returns The DID or null 112 */ 113async function resolveViaDns(handle: string): Promise<string | null> { 114 try { 115 // Use a public DNS-over-HTTPS service 116 const response = await fetch( 117 `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, 118 { 119 headers: { 120 'Accept': 'application/dns-json', 121 }, 122 } 123 ); 124 125 if (!response.ok) { 126 return null; 127 } 128 129 const data = await response.json(); 130 131 if (!data.Answer || data.Answer.length === 0) { 132 return null; 133 } 134 135 for (const answer of data.Answer) { 136 if (answer.type === 16 && answer.data) { 137 // TXT records are quoted, remove quotes 138 const txtData = answer.data.replace(/"/g, ''); 139 if (txtData.startsWith('did=')) { 140 const did = txtData.substring(4); 141 if (isValidDid(did)) { 142 return did; 143 } 144 } 145 } 146 } 147 148 return null; 149 } catch (error) { 150 console.error('DNS resolution error:', error); 151 return null; 152 } 153} 154 155/** 156 * Resolve a DID to a DID document 157 * Supports did:plc and did:web 158 * @param did - The DID to resolve 159 * @returns The DID document 160 */ 161export async function resolveDidDocument(did: string): Promise<DidDocument> { 162 if (!isValidDid(did)) { 163 throw new Error(`Invalid DID format: ${did}`); 164 } 165 166 if (did.startsWith('did:plc:')) { 167 return await resolvePlcDid(did); 168 } else if (did.startsWith('did:web:')) { 169 return await resolveWebDid(did); 170 } else { 171 throw new Error(`Unsupported DID method: ${did}`); 172 } 173} 174 175/** 176 * Resolve a did:plc DID 177 * @param did - The did:plc DID 178 * @returns The DID document 179 */ 180async function resolvePlcDid(did: string): Promise<DidDocument> { 181 const plcId = did.substring('did:plc:'.length); 182 183 // Try multiple PLC directories for resilience 184 const plcDirectories = [ 185 'https://plc.directory', 186 'https://plc.bsky-sandbox.dev', 187 ]; 188 189 for (const directory of plcDirectories) { 190 try { 191 const response = await fetch(`${directory}/${did}`, { 192 headers: { 193 'Accept': 'application/json', 194 }, 195 }); 196 197 if (response.ok) { 198 const document = await response.json(); 199 return document; 200 } 201 } catch (error) { 202 console.warn(`Failed to resolve from ${directory}:`, error); 203 } 204 } 205 206 throw new Error(`Failed to resolve did:plc: ${did}`); 207} 208 209/** 210 * Resolve a did:web DID 211 * @param did - The did:web DID 212 * @returns The DID document 213 */ 214async function resolveWebDid(did: string): Promise<DidDocument> { 215 const domain = did.substring('did:web:'.length).replace(/:/g, '/'); 216 217 const response = await fetch(`https://${domain}/.well-known/did.json`, { 218 headers: { 219 'Accept': 'application/json', 220 }, 221 }); 222 223 if (!response.ok) { 224 throw new Error(`Failed to resolve did:web: ${did}`); 225 } 226 227 const document = await response.json(); 228 return document; 229} 230 231/** 232 * Extract PDS URL from DID document 233 * @param document - The DID document 234 * @returns The PDS URL or null 235 */ 236export function extractPdsUrl(document: DidDocument): string | null { 237 if (!document.service || !Array.isArray(document.service)) { 238 return null; 239 } 240 241 for (const service of document.service) { 242 if ( 243 service.type === 'AtprotoPersonalDataServer' && 244 service.id.endsWith('#atproto_pds') 245 ) { 246 return service.serviceEndpoint; 247 } 248 } 249 250 return null; 251} 252 253/** 254 * Verify bidirectional handle/DID binding 255 * @param handle - The handle 256 * @param did - The DID 257 * @returns True if the binding is valid 258 */ 259export async function verifyHandleDidBinding( 260 handle: string, 261 did: string 262): Promise<boolean> { 263 try { 264 // Verify handle resolves to DID 265 const resolvedDid = await resolveHandleToDid(handle); 266 if (resolvedDid !== did) { 267 return false; 268 } 269 270 // Verify DID document includes handle in alsoKnownAs 271 const didDocument = await resolveDidDocument(did); 272 if (!didDocument.alsoKnownAs || !Array.isArray(didDocument.alsoKnownAs)) { 273 return false; 274 } 275 276 const expectedAlias = `at://${handle}`; 277 return didDocument.alsoKnownAs.includes(expectedAlias); 278 } catch (error) { 279 console.error('Failed to verify handle/DID binding:', error); 280 return false; 281 } 282} 283 284/** 285 * Discover Authorization Server metadata for a PDS 286 * @param pdsUrl - The PDS URL 287 * @returns The authorization server metadata 288 */ 289export async function discoverAuthorizationServer( 290 pdsUrl: string 291): Promise<AuthorizationServerMetadata> { 292 // First, get the resource server metadata 293 const resourceResponse = await fetch( 294 `${pdsUrl}/.well-known/oauth-protected-resource`, 295 { 296 headers: { 297 'Accept': 'application/json', 298 }, 299 } 300 ); 301 302 if (!resourceResponse.ok) { 303 throw new Error('Failed to fetch resource server metadata'); 304 } 305 306 const resourceMetadata: ResourceServerMetadata = await resourceResponse.json(); 307 308 if (!resourceMetadata.authorization_servers || 309 resourceMetadata.authorization_servers.length === 0) { 310 throw new Error('No authorization servers found'); 311 } 312 313 // Use the first authorization server 314 const authServerUrl = resourceMetadata.authorization_servers[0]; 315 316 // Fetch the authorization server metadata 317 const authResponse = await fetch( 318 `${authServerUrl}/.well-known/oauth-authorization-server`, 319 { 320 headers: { 321 'Accept': 'application/json', 322 }, 323 } 324 ); 325 326 if (!authResponse.ok) { 327 throw new Error('Failed to fetch authorization server metadata'); 328 } 329 330 const authMetadata: AuthorizationServerMetadata = await authResponse.json(); 331 332 // Validate required fields 333 if (!authMetadata.authorization_endpoint || 334 !authMetadata.token_endpoint || 335 !authMetadata.pushed_authorization_request_endpoint) { 336 throw new Error('Invalid authorization server metadata'); 337 } 338 339 return authMetadata; 340} 341 342/** 343 * Validate handle format 344 * @param handle - The handle to validate 345 * @returns True if valid 346 */ 347export function isValidHandle(handle: string): boolean { 348 // Handle must be a valid domain name 349 const handleRegex = /^([a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9])(\.([a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9]))+$/i; 350 return handleRegex.test(handle); 351} 352 353/** 354 * Validate DID format 355 * @param did - The DID to validate 356 * @returns True if valid 357 */ 358export function isValidDid(did: string): boolean { 359 // Basic DID validation 360 const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/; 361 return didRegex.test(did); 362} 363 364/** 365 * Complete handle to PDS discovery flow 366 * @param handle - The handle to resolve 367 * @returns Object with DID, PDS URL, and auth server metadata 368 */ 369export async function discoverFromHandle(handle: string): Promise<{ 370 did: string; 371 pdsUrl: string; 372 authServer: AuthorizationServerMetadata; 373}> { 374 // Resolve handle to DID 375 const did = await resolveHandleToDid(handle); 376 377 // Resolve DID to document 378 const didDocument = await resolveDidDocument(did); 379 380 // Extract PDS URL 381 const pdsUrl = extractPdsUrl(didDocument); 382 if (!pdsUrl) { 383 throw new Error('No PDS URL found in DID document'); 384 } 385 386 // Discover authorization server 387 const authServer = await discoverAuthorizationServer(pdsUrl); 388 389 return { 390 did, 391 pdsUrl, 392 authServer, 393 }; 394}