Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

feat: add ATProto identity resolution (handle→DID→PDS→auth meta)

+123
+123
internal/atproto/identity.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "net" 8 + "net/http" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + var httpClient = &http.Client{Timeout: 10 * time.Second} 14 + 15 + // ResolveHandle resolves a Bluesky handle (e.g. "alice.bsky.social") to a DID. 16 + // It tries DNS TXT first, then HTTP well-known fallback. 17 + func 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. 52 + type DIDDocument struct { 53 + Service []struct { 54 + ID string `json:"id"` 55 + Type string `json:"type"` 56 + ServiceEndpoint string `json:"serviceEndpoint"` 57 + } `json:"service"` 58 + } 59 + 60 + // ResolvePDS resolves a DID to the PDS (Personal Data Server) URL. 61 + func ResolvePDS(did string) (string, error) { 62 + var docURL string 63 + switch { 64 + case strings.HasPrefix(did, "did:plc:"): 65 + docURL = "https://plc.directory/" + did 66 + case strings.HasPrefix(did, "did:web:"): 67 + host := strings.TrimPrefix(did, "did:web:") 68 + docURL = "https://" + host + "/.well-known/did.json" 69 + default: 70 + return "", fmt.Errorf("unsupported DID method: %s", did) 71 + } 72 + 73 + resp, err := httpClient.Get(docURL) 74 + if err != nil { 75 + return "", fmt.Errorf("fetch DID doc for %s: %w", did, err) 76 + } 77 + defer resp.Body.Close() 78 + if resp.StatusCode != http.StatusOK { 79 + return "", fmt.Errorf("fetch DID doc for %s: HTTP %d", did, resp.StatusCode) 80 + } 81 + 82 + var doc DIDDocument 83 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 84 + return "", fmt.Errorf("decode DID doc: %w", err) 85 + } 86 + 87 + for _, svc := range doc.Service { 88 + if svc.ID == "#atproto_pds" { 89 + return svc.ServiceEndpoint, nil 90 + } 91 + } 92 + return "", fmt.Errorf("no #atproto_pds service in DID doc for %s", did) 93 + } 94 + 95 + // AuthServerMeta holds the OAuth authorization server metadata fields we need. 96 + type AuthServerMeta struct { 97 + Issuer string `json:"issuer"` 98 + AuthorizationEndpoint string `json:"authorization_endpoint"` 99 + TokenEndpoint string `json:"token_endpoint"` 100 + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 101 + } 102 + 103 + // FetchAuthServerMeta fetches OAuth authorization server metadata from a PDS. 104 + func FetchAuthServerMeta(pdsURL string) (*AuthServerMeta, error) { 105 + url := strings.TrimRight(pdsURL, "/") + "/.well-known/oauth-authorization-server" 106 + resp, err := httpClient.Get(url) 107 + if err != nil { 108 + return nil, fmt.Errorf("fetch auth server meta from %s: %w", pdsURL, err) 109 + } 110 + defer resp.Body.Close() 111 + if resp.StatusCode != http.StatusOK { 112 + return nil, fmt.Errorf("fetch auth server meta from %s: HTTP %d", pdsURL, resp.StatusCode) 113 + } 114 + 115 + var meta AuthServerMeta 116 + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { 117 + return nil, fmt.Errorf("decode auth server meta: %w", err) 118 + } 119 + if meta.PushedAuthorizationRequestEndpoint == "" { 120 + return nil, fmt.Errorf("auth server at %s does not support PAR", pdsURL) 121 + } 122 + return &meta, nil 123 + }