Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 203 lines 6.6 kB view raw
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}