Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
1package atproto
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net"
8 "net/http"
9 "strings"
10 "time"
11)
12
13var httpClient = &http.Client{Timeout: 10 * time.Second}
14
15// ResolveHandle resolves an AT Protocol handle (e.g. "alice.bsky.social" or "example.net") to a DID.
16// It tries DNS TXT first, then HTTP well-known fallback.
17func ResolveHandle(handle string) (string, error) {
18 handle = strings.TrimPrefix(handle, "@")
19
20 // Try DNS TXT: _atproto.<handle> → "did=<did>"
21 txts, err := net.LookupTXT("_atproto." + handle)
22 if err == nil {
23 for _, txt := range txts {
24 if strings.HasPrefix(txt, "did=") {
25 return strings.TrimPrefix(txt, "did="), nil
26 }
27 }
28 }
29
30 // Fallback: HTTPS GET https://<handle>/.well-known/atproto-did
31 resp, err := httpClient.Get("https://" + handle + "/.well-known/atproto-did")
32 if err != nil {
33 return "", fmt.Errorf("resolve handle %s: %w", handle, err)
34 }
35 defer resp.Body.Close()
36 if resp.StatusCode != http.StatusOK {
37 return "", fmt.Errorf("resolve handle %s: HTTP %d", handle, resp.StatusCode)
38 }
39
40 body, err := io.ReadAll(resp.Body)
41 if err != nil {
42 return "", err
43 }
44 did := strings.TrimSpace(string(body))
45 if !strings.HasPrefix(did, "did:") {
46 return "", fmt.Errorf("resolve handle %s: invalid DID response: %q", handle, did)
47 }
48 return did, nil
49}
50
51// DIDDocument is the minimal structure we need from a DID document.
52type DIDDocument struct {
53 AlsoKnownAs []string `json:"alsoKnownAs"`
54 Service []struct {
55 ID string `json:"id"`
56 Type string `json:"type"`
57 ServiceEndpoint string `json:"serviceEndpoint"`
58 } `json:"service"`
59}
60
61// HandleFromDIDDoc extracts the AT Protocol handle from a DID document's alsoKnownAs field.
62// Returns empty string if no handle is found.
63func HandleFromDIDDoc(doc *DIDDocument) string {
64 for _, aka := range doc.AlsoKnownAs {
65 if strings.HasPrefix(aka, "at://") {
66 return strings.TrimPrefix(aka, "at://")
67 }
68 }
69 return ""
70}
71
72// ResolveDIDDoc fetches and parses the DID document for a given DID.
73func ResolveDIDDoc(did string) (*DIDDocument, error) {
74 var docURL string
75 switch {
76 case strings.HasPrefix(did, "did:plc:"):
77 docURL = "https://plc.directory/" + did
78 case strings.HasPrefix(did, "did:web:"):
79 host := strings.TrimPrefix(did, "did:web:")
80 docURL = "https://" + host + "/.well-known/did.json"
81 default:
82 return nil, fmt.Errorf("unsupported DID method: %s", did)
83 }
84
85 resp, err := httpClient.Get(docURL)
86 if err != nil {
87 return nil, fmt.Errorf("fetch DID doc for %s: %w", did, err)
88 }
89 defer resp.Body.Close()
90 if resp.StatusCode != http.StatusOK {
91 return nil, fmt.Errorf("fetch DID doc for %s: HTTP %d", did, resp.StatusCode)
92 }
93
94 var doc DIDDocument
95 if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
96 return nil, fmt.Errorf("decode DID doc: %w", err)
97 }
98 return &doc, nil
99}
100
101// ResolvePDS resolves a DID to the PDS (Personal Data Server) URL.
102func ResolvePDS(did string) (string, error) {
103 doc, err := ResolveDIDDoc(did)
104 if err != nil {
105 return "", err
106 }
107
108 for _, svc := range doc.Service {
109 if svc.ID == "#atproto_pds" {
110 return svc.ServiceEndpoint, nil
111 }
112 }
113 return "", fmt.Errorf("no #atproto_pds service in DID doc for %s", did)
114}
115
116// ResolveHandle resolves a DID to the AT Protocol handle via the DID document.
117func ResolveHandleFromDID(did string) (string, error) {
118 doc, err := ResolveDIDDoc(did)
119 if err != nil {
120 return "", err
121 }
122 handle := HandleFromDIDDoc(doc)
123 if handle == "" {
124 return "", fmt.Errorf("no handle found in DID doc for %s", did)
125 }
126 return handle, nil
127}
128
129// AuthServerMeta holds the OAuth authorization server metadata fields we need.
130type AuthServerMeta struct {
131 Issuer string `json:"issuer"`
132 AuthorizationEndpoint string `json:"authorization_endpoint"`
133 TokenEndpoint string `json:"token_endpoint"`
134 PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
135}
136
137// FetchAuthServerMeta fetches OAuth authorization server metadata from a PDS.
138// Per the ATProto OAuth spec, the PDS may delegate to a separate authorization server.
139// We discover it via the protected resource metadata if the PDS doesn't host its own.
140func FetchAuthServerMeta(pdsURL string) (*AuthServerMeta, error) {
141 base := strings.TrimRight(pdsURL, "/")
142
143 // First try the PDS directly (works for self-hosted PDSes).
144 directURL := base + "/.well-known/oauth-authorization-server"
145 resp, err := httpClient.Get(directURL)
146 if err != nil {
147 return nil, fmt.Errorf("fetch auth server meta from %s: %w", pdsURL, err)
148 }
149 defer resp.Body.Close()
150
151 if resp.StatusCode == http.StatusOK {
152 var meta AuthServerMeta
153 if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
154 return nil, fmt.Errorf("decode auth server meta: %w", err)
155 }
156 if meta.PushedAuthorizationRequestEndpoint == "" {
157 return nil, fmt.Errorf("auth server at %s does not support PAR", pdsURL)
158 }
159 return &meta, nil
160 }
161 resp.Body.Close()
162
163 // PDS doesn't host its own auth server — discover it via protected resource metadata.
164 prURL := base + "/.well-known/oauth-protected-resource"
165 prResp, err := httpClient.Get(prURL)
166 if err != nil {
167 return nil, fmt.Errorf("fetch protected resource meta from %s: %w", pdsURL, err)
168 }
169 defer prResp.Body.Close()
170 if prResp.StatusCode != http.StatusOK {
171 return nil, fmt.Errorf("fetch protected resource meta from %s: HTTP %d", pdsURL, prResp.StatusCode)
172 }
173
174 var prMeta struct {
175 AuthorizationServers []string `json:"authorization_servers"`
176 }
177 if err := json.NewDecoder(prResp.Body).Decode(&prMeta); err != nil {
178 return nil, fmt.Errorf("decode protected resource meta: %w", err)
179 }
180 if len(prMeta.AuthorizationServers) == 0 {
181 return nil, fmt.Errorf("no authorization_servers in protected resource meta for %s", pdsURL)
182 }
183
184 authServerURL := strings.TrimRight(prMeta.AuthorizationServers[0], "/")
185 metaURL := authServerURL + "/.well-known/oauth-authorization-server"
186 metaResp, err := httpClient.Get(metaURL)
187 if err != nil {
188 return nil, fmt.Errorf("fetch auth server meta from %s: %w", authServerURL, err)
189 }
190 defer metaResp.Body.Close()
191 if metaResp.StatusCode != http.StatusOK {
192 return nil, fmt.Errorf("fetch auth server meta from %s: HTTP %d", authServerURL, metaResp.StatusCode)
193 }
194
195 var meta AuthServerMeta
196 if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil {
197 return nil, fmt.Errorf("decode auth server meta: %w", err)
198 }
199 if meta.PushedAuthorizationRequestEndpoint == "" {
200 return nil, fmt.Errorf("auth server at %s does not support PAR", authServerURL)
201 }
202 return &meta, nil
203}