/** * DID and Handle Resolution utilities for ATProtocol * Implements handle→DID and DID→Document resolution */ interface DidDocument { id: string; alsoKnownAs?: string[]; verificationMethod?: Array<{ id: string; type: string; controller?: string; publicKeyMultibase?: string; }>; service?: Array<{ id: string; type: string; serviceEndpoint: string; }>; } interface AuthorizationServerMetadata { issuer: string; authorization_endpoint: string; token_endpoint: string; pushed_authorization_request_endpoint: string; response_types_supported: string[]; grant_types_supported: string[]; code_challenge_methods_supported: string[]; scopes_supported: string[]; require_pushed_authorization_requests: boolean; dpop_signing_alg_values_supported: string[]; } interface ResourceServerMetadata { resource: string; authorization_servers: string[]; } /** * Resolve a handle to a DID * Tries DNS TXT record first, falls back to HTTPS well-known * @param handle - The handle to resolve (e.g., "alice.bsky.social") * @returns The DID or throws an error */ export async function resolveHandleToDid(handle: string): Promise { // Validate handle format if (!isValidHandle(handle)) { throw new Error(`Invalid handle format: ${handle}`); } // Try HTTPS well-known first (more reliable in browser) try { const httpsDid = await resolveViaHttps(handle); if (httpsDid) { return httpsDid; } } catch (error) { console.warn('HTTPS resolution failed, trying DNS:', error); } // Try DNS TXT record (may not work in all browsers due to CORS) try { const dnsDid = await resolveViaDns(handle); if (dnsDid) { return dnsDid; } } catch (error) { console.warn('DNS resolution failed:', error); } throw new Error(`Failed to resolve handle ${handle} to DID`); } /** * Resolve handle via HTTPS well-known * @param handle - The handle to resolve * @returns The DID or null */ async function resolveViaHttps(handle: string): Promise { try { const response = await fetch(`https://${handle}/.well-known/atproto-did`, { method: 'GET', headers: { 'Accept': 'text/plain', }, cache: 'no-cache', }); if (!response.ok) { return null; } const did = (await response.text()).trim(); if (!isValidDid(did)) { throw new Error(`Invalid DID format: ${did}`); } return did; } catch (error) { console.error('HTTPS resolution error:', error); return null; } } /** * Resolve handle via DNS TXT record * Note: This may not work in browsers due to CORS restrictions * @param handle - The handle to resolve * @returns The DID or null */ async function resolveViaDns(handle: string): Promise { try { // Use a public DNS-over-HTTPS service const response = await fetch( `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, { headers: { 'Accept': 'application/dns-json', }, } ); if (!response.ok) { return null; } const data = await response.json(); if (!data.Answer || data.Answer.length === 0) { return null; } for (const answer of data.Answer) { if (answer.type === 16 && answer.data) { // TXT records are quoted, remove quotes const txtData = answer.data.replace(/"/g, ''); if (txtData.startsWith('did=')) { const did = txtData.substring(4); if (isValidDid(did)) { return did; } } } } return null; } catch (error) { console.error('DNS resolution error:', error); return null; } } /** * Resolve a DID to a DID document * Supports did:plc and did:web * @param did - The DID to resolve * @returns The DID document */ export async function resolveDidDocument(did: string): Promise { if (!isValidDid(did)) { throw new Error(`Invalid DID format: ${did}`); } if (did.startsWith('did:plc:')) { return await resolvePlcDid(did); } else if (did.startsWith('did:web:')) { return await resolveWebDid(did); } else { throw new Error(`Unsupported DID method: ${did}`); } } /** * Resolve a did:plc DID * @param did - The did:plc DID * @returns The DID document */ async function resolvePlcDid(did: string): Promise { const plcId = did.substring('did:plc:'.length); // Try multiple PLC directories for resilience const plcDirectories = [ 'https://plc.directory', 'https://plc.bsky-sandbox.dev', ]; for (const directory of plcDirectories) { try { const response = await fetch(`${directory}/${did}`, { headers: { 'Accept': 'application/json', }, }); if (response.ok) { const document = await response.json(); return document; } } catch (error) { console.warn(`Failed to resolve from ${directory}:`, error); } } throw new Error(`Failed to resolve did:plc: ${did}`); } /** * Resolve a did:web DID * @param did - The did:web DID * @returns The DID document */ async function resolveWebDid(did: string): Promise { const domain = did.substring('did:web:'.length).replace(/:/g, '/'); const response = await fetch(`https://${domain}/.well-known/did.json`, { headers: { 'Accept': 'application/json', }, }); if (!response.ok) { throw new Error(`Failed to resolve did:web: ${did}`); } const document = await response.json(); return document; } /** * Extract PDS URL from DID document * @param document - The DID document * @returns The PDS URL or null */ export function extractPdsUrl(document: DidDocument): string | null { if (!document.service || !Array.isArray(document.service)) { return null; } for (const service of document.service) { if ( service.type === 'AtprotoPersonalDataServer' && service.id.endsWith('#atproto_pds') ) { return service.serviceEndpoint; } } return null; } /** * Verify bidirectional handle/DID binding * @param handle - The handle * @param did - The DID * @returns True if the binding is valid */ export async function verifyHandleDidBinding( handle: string, did: string ): Promise { try { // Verify handle resolves to DID const resolvedDid = await resolveHandleToDid(handle); if (resolvedDid !== did) { return false; } // Verify DID document includes handle in alsoKnownAs const didDocument = await resolveDidDocument(did); if (!didDocument.alsoKnownAs || !Array.isArray(didDocument.alsoKnownAs)) { return false; } const expectedAlias = `at://${handle}`; return didDocument.alsoKnownAs.includes(expectedAlias); } catch (error) { console.error('Failed to verify handle/DID binding:', error); return false; } } /** * Discover Authorization Server metadata for a PDS * @param pdsUrl - The PDS URL * @returns The authorization server metadata */ export async function discoverAuthorizationServer( pdsUrl: string ): Promise { // First, get the resource server metadata const resourceResponse = await fetch( `${pdsUrl}/.well-known/oauth-protected-resource`, { headers: { 'Accept': 'application/json', }, } ); if (!resourceResponse.ok) { throw new Error('Failed to fetch resource server metadata'); } const resourceMetadata: ResourceServerMetadata = await resourceResponse.json(); if (!resourceMetadata.authorization_servers || resourceMetadata.authorization_servers.length === 0) { throw new Error('No authorization servers found'); } // Use the first authorization server const authServerUrl = resourceMetadata.authorization_servers[0]; // Fetch the authorization server metadata const authResponse = await fetch( `${authServerUrl}/.well-known/oauth-authorization-server`, { headers: { 'Accept': 'application/json', }, } ); if (!authResponse.ok) { throw new Error('Failed to fetch authorization server metadata'); } const authMetadata: AuthorizationServerMetadata = await authResponse.json(); // Validate required fields if (!authMetadata.authorization_endpoint || !authMetadata.token_endpoint || !authMetadata.pushed_authorization_request_endpoint) { throw new Error('Invalid authorization server metadata'); } return authMetadata; } /** * Validate handle format * @param handle - The handle to validate * @returns True if valid */ export function isValidHandle(handle: string): boolean { // Handle must be a valid domain name 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; return handleRegex.test(handle); } /** * Validate DID format * @param did - The DID to validate * @returns True if valid */ export function isValidDid(did: string): boolean { // Basic DID validation const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/; return didRegex.test(did); } /** * Complete handle to PDS discovery flow * @param handle - The handle to resolve * @returns Object with DID, PDS URL, and auth server metadata */ export async function discoverFromHandle(handle: string): Promise<{ did: string; pdsUrl: string; authServer: AuthorizationServerMetadata; }> { // Resolve handle to DID const did = await resolveHandleToDid(handle); // Resolve DID to document const didDocument = await resolveDidDocument(did); // Extract PDS URL const pdsUrl = extractPdsUrl(didDocument); if (!pdsUrl) { throw new Error('No PDS URL found in DID document'); } // Discover authorization server const authServer = await discoverAuthorizationServer(pdsUrl); return { did, pdsUrl, authServer, }; }