package atproto import ( "encoding/json" "fmt" "io" "net" "net/http" "strings" "time" ) var httpClient = &http.Client{Timeout: 10 * time.Second} // ResolveHandle resolves an AT Protocol handle (e.g. "alice.bsky.social" or "example.net") to a DID. // It tries DNS TXT first, then HTTP well-known fallback. func ResolveHandle(handle string) (string, error) { handle = strings.TrimPrefix(handle, "@") // Try DNS TXT: _atproto. → "did=" txts, err := net.LookupTXT("_atproto." + handle) if err == nil { for _, txt := range txts { if strings.HasPrefix(txt, "did=") { return strings.TrimPrefix(txt, "did="), nil } } } // Fallback: HTTPS GET https:///.well-known/atproto-did resp, err := httpClient.Get("https://" + handle + "/.well-known/atproto-did") if err != nil { return "", fmt.Errorf("resolve handle %s: %w", handle, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("resolve handle %s: HTTP %d", handle, resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", err } did := strings.TrimSpace(string(body)) if !strings.HasPrefix(did, "did:") { return "", fmt.Errorf("resolve handle %s: invalid DID response: %q", handle, did) } return did, nil } // DIDDocument is the minimal structure we need from a DID document. type DIDDocument struct { AlsoKnownAs []string `json:"alsoKnownAs"` Service []struct { ID string `json:"id"` Type string `json:"type"` ServiceEndpoint string `json:"serviceEndpoint"` } `json:"service"` } // HandleFromDIDDoc extracts the AT Protocol handle from a DID document's alsoKnownAs field. // Returns empty string if no handle is found. func HandleFromDIDDoc(doc *DIDDocument) string { for _, aka := range doc.AlsoKnownAs { if strings.HasPrefix(aka, "at://") { return strings.TrimPrefix(aka, "at://") } } return "" } // ResolveDIDDoc fetches and parses the DID document for a given DID. func ResolveDIDDoc(did string) (*DIDDocument, error) { var docURL string switch { case strings.HasPrefix(did, "did:plc:"): docURL = "https://plc.directory/" + did case strings.HasPrefix(did, "did:web:"): host := strings.TrimPrefix(did, "did:web:") docURL = "https://" + host + "/.well-known/did.json" default: return nil, fmt.Errorf("unsupported DID method: %s", did) } resp, err := httpClient.Get(docURL) if err != nil { return nil, fmt.Errorf("fetch DID doc for %s: %w", did, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch DID doc for %s: HTTP %d", did, resp.StatusCode) } var doc DIDDocument if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { return nil, fmt.Errorf("decode DID doc: %w", err) } return &doc, nil } // ResolvePDS resolves a DID to the PDS (Personal Data Server) URL. func ResolvePDS(did string) (string, error) { doc, err := ResolveDIDDoc(did) if err != nil { return "", err } for _, svc := range doc.Service { if svc.ID == "#atproto_pds" { return svc.ServiceEndpoint, nil } } return "", fmt.Errorf("no #atproto_pds service in DID doc for %s", did) } // ResolveHandle resolves a DID to the AT Protocol handle via the DID document. func ResolveHandleFromDID(did string) (string, error) { doc, err := ResolveDIDDoc(did) if err != nil { return "", err } handle := HandleFromDIDDoc(doc) if handle == "" { return "", fmt.Errorf("no handle found in DID doc for %s", did) } return handle, nil } // AuthServerMeta holds the OAuth authorization server metadata fields we need. type AuthServerMeta struct { Issuer string `json:"issuer"` AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` } // FetchAuthServerMeta fetches OAuth authorization server metadata from a PDS. // Per the ATProto OAuth spec, the PDS may delegate to a separate authorization server. // We discover it via the protected resource metadata if the PDS doesn't host its own. func FetchAuthServerMeta(pdsURL string) (*AuthServerMeta, error) { base := strings.TrimRight(pdsURL, "/") // First try the PDS directly (works for self-hosted PDSes). directURL := base + "/.well-known/oauth-authorization-server" resp, err := httpClient.Get(directURL) if err != nil { return nil, fmt.Errorf("fetch auth server meta from %s: %w", pdsURL, err) } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { var meta AuthServerMeta if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { return nil, fmt.Errorf("decode auth server meta: %w", err) } if meta.PushedAuthorizationRequestEndpoint == "" { return nil, fmt.Errorf("auth server at %s does not support PAR", pdsURL) } return &meta, nil } resp.Body.Close() // PDS doesn't host its own auth server — discover it via protected resource metadata. prURL := base + "/.well-known/oauth-protected-resource" prResp, err := httpClient.Get(prURL) if err != nil { return nil, fmt.Errorf("fetch protected resource meta from %s: %w", pdsURL, err) } defer prResp.Body.Close() if prResp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch protected resource meta from %s: HTTP %d", pdsURL, prResp.StatusCode) } var prMeta struct { AuthorizationServers []string `json:"authorization_servers"` } if err := json.NewDecoder(prResp.Body).Decode(&prMeta); err != nil { return nil, fmt.Errorf("decode protected resource meta: %w", err) } if len(prMeta.AuthorizationServers) == 0 { return nil, fmt.Errorf("no authorization_servers in protected resource meta for %s", pdsURL) } authServerURL := strings.TrimRight(prMeta.AuthorizationServers[0], "/") metaURL := authServerURL + "/.well-known/oauth-authorization-server" metaResp, err := httpClient.Get(metaURL) if err != nil { return nil, fmt.Errorf("fetch auth server meta from %s: %w", authServerURL, err) } defer metaResp.Body.Close() if metaResp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch auth server meta from %s: HTTP %d", authServerURL, metaResp.StatusCode) } var meta AuthServerMeta if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil { return nil, fmt.Errorf("decode auth server meta: %w", err) } if meta.PushedAuthorizationRequestEndpoint == "" { return nil, fmt.Errorf("auth server at %s does not support PAR", authServerURL) } return &meta, nil }