this repo has no description
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}