package main import ( "context" "encoding/json" "fmt" "net" "net/url" "regexp" "strings" ) var ( handlePattern = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`) didPattern = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) ) type didDocument struct { ID string `json:"id"` AlsoKnownAs []string `json:"alsoKnownAs"` Service []struct { ID string `json:"id"` Type string `json:"type"` ServiceEndpoint json.RawMessage `json:"serviceEndpoint"` } `json:"service"` } type identity struct { DID string Handle string PDS string } type authServer struct { Issuer string AuthorizationEndpoint string TokenEndpoint string PAREndpoint string RevocationEndpoint string IssParameterSupported bool } func normalizeHandle(h string) (string, error) { h = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(h), "@")) if len(h) > 253 || !handlePattern.MatchString(h) { return "", fmt.Errorf("%q is not a valid handle", h) } return h, nil } func (f *fetcher) resolveHandle(ctx context.Context, handle string) (string, error) { var viaDNS, viaHTTP string cctx, cancel := context.WithTimeout(ctx, f.cfg.CallTimeout) txts, _ := net.DefaultResolver.LookupTXT(cctx, "_atproto."+handle) cancel() for _, t := range txts { if did, ok := strings.CutPrefix(strings.TrimSpace(t), "did="); ok && didPattern.MatchString(did) { viaDNS = did break } } if body, err := f.get(ctx, "https://"+handle+"/.well-known/atproto-did", "text/plain"); err == nil { if did := strings.TrimSpace(string(body)); didPattern.MatchString(did) { viaHTTP = did } } switch { case viaDNS != "" && viaHTTP != "" && viaDNS != viaHTTP: return "", fmt.Errorf("handle %q: DNS says %s and the well-known file says %s", handle, viaDNS, viaHTTP) case viaDNS != "": return viaDNS, nil case viaHTTP != "": return viaHTTP, nil } return "", fmt.Errorf("handle %q resolves to no DID (DNS TXT _atproto and https well-known tried)", handle) } func (f *fetcher) resolveDIDDocument(ctx context.Context, did string) (*didDocument, error) { if !didPattern.MatchString(did) { return nil, fmt.Errorf("%q is not a DID", did) } var target string switch { case strings.HasPrefix(did, "did:plc:"): target = f.cfg.PLCDirectoryURL + "/" + did case strings.HasPrefix(did, "did:web:"): parts := strings.Split(strings.TrimPrefix(did, "did:web:"), ":") host, err := url.PathUnescape(parts[0]) if err != nil || host == "" { return nil, fmt.Errorf("%q is not a did:web", did) } path := "/.well-known/did.json" if len(parts) > 1 { segs := make([]string, len(parts)-1) for i, p := range parts[1:] { segs[i], _ = url.PathUnescape(p) } path = "/" + strings.Join(segs, "/") + "/did.json" } target = "https://" + host + path default: return nil, fmt.Errorf("DID method of %q is not supported (did:plc and did:web)", did) } body, err := f.get(ctx, target, "application/did+json, application/json") if err != nil { return nil, fmt.Errorf("resolve %s: %w", did, err) } var doc didDocument if err := json.Unmarshal(body, &doc); err != nil { return nil, fmt.Errorf("resolve %s: %w", did, err) } if doc.ID != did { return nil, fmt.Errorf("resolve %s: the document is for %q", did, doc.ID) } return &doc, nil } func (d *didDocument) pds(f *fetcher) (string, error) { for _, s := range d.Service { if !strings.HasSuffix(s.ID, "#atproto_pds") || s.Type != "AtprotoPersonalDataServer" { continue } var endpoint string if err := json.Unmarshal(s.ServiceEndpoint, &endpoint); err != nil { continue } u, err := f.checkURL(endpoint) if err != nil { return "", fmt.Errorf("PDS endpoint of %s: %w", d.ID, err) } if (u.Path != "" && u.Path != "/") || u.RawQuery != "" { return "", fmt.Errorf("PDS endpoint %q of %s is not an origin", endpoint, d.ID) } return u.Scheme + "://" + u.Host, nil } return "", fmt.Errorf("the DID document of %s has no #atproto_pds service", d.ID) } func (d *didDocument) hasHandle(handle string) bool { for _, a := range d.AlsoKnownAs { if h, ok := strings.CutPrefix(strings.ToLower(a), "at://"); ok && h == handle { return true } } return false } func (f *fetcher) identityFromHandle(ctx context.Context, handle string) (*identity, error) { handle, err := normalizeHandle(handle) if err != nil { return nil, err } did, err := f.resolveHandle(ctx, handle) if err != nil { return nil, err } doc, err := f.resolveDIDDocument(ctx, did) if err != nil { return nil, err } if !doc.hasHandle(handle) { return nil, fmt.Errorf("handle %q resolves to %s, whose DID document does not claim that handle", handle, did) } pds, err := doc.pds(f) if err != nil { return nil, err } return &identity{DID: did, Handle: handle, PDS: pds}, nil } func (f *fetcher) identityFromDID(ctx context.Context, did string) (*identity, error) { doc, err := f.resolveDIDDocument(ctx, did) if err != nil { return nil, err } pds, err := doc.pds(f) if err != nil { return nil, err } return &identity{DID: did, PDS: pds}, nil } func (f *fetcher) discoverAuthServer(ctx context.Context, pds string) (*authServer, error) { body, err := f.get(ctx, pds+"/.well-known/oauth-protected-resource", "application/json") if err != nil { return nil, fmt.Errorf("protected resource metadata of %s: %w", pds, err) } var resource struct { AuthorizationServers []string `json:"authorization_servers"` } if err := json.Unmarshal(body, &resource); err != nil { return nil, err } if len(resource.AuthorizationServers) != 1 { return nil, fmt.Errorf("protected resource metadata of %s names %d authorization servers, want 1", pds, len(resource.AuthorizationServers)) } issuer := strings.TrimRight(resource.AuthorizationServers[0], "/") u, err := f.checkURL(issuer) if err != nil { return nil, fmt.Errorf("authorization server of %s: %w", pds, err) } if (u.Path != "" && u.Path != "/") || u.RawQuery != "" { return nil, fmt.Errorf("authorization server %q is not an origin", issuer) } body, err = f.get(ctx, issuer+"/.well-known/oauth-authorization-server", "application/json") if err != nil { return nil, fmt.Errorf("authorization server metadata of %s: %w", issuer, err) } var meta struct { Issuer string `json:"issuer"` AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` PAREndpoint string `json:"pushed_authorization_request_endpoint"` RevocationEndpoint string `json:"revocation_endpoint"` RequirePAR bool `json:"require_pushed_authorization_requests"` ResponseTypes []string `json:"response_types_supported"` GrantTypes []string `json:"grant_types_supported"` CodeChallengeMethods []string `json:"code_challenge_methods_supported"` TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"` TokenEndpointAuthSigningAlgs []string `json:"token_endpoint_auth_signing_alg_values_supported"` DPoPSigningAlgs []string `json:"dpop_signing_alg_values_supported"` Scopes []string `json:"scopes_supported"` IssParameterSupported bool `json:"authorization_response_iss_parameter_supported"` ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` } if err := json.Unmarshal(body, &meta); err != nil { return nil, err } if strings.TrimRight(meta.Issuer, "/") != issuer { return nil, fmt.Errorf("authorization server metadata of %s says issuer %q", issuer, meta.Issuer) } needs := []struct { ok bool what string }{ {contains(meta.ResponseTypes, "code"), "response type code"}, {contains(meta.GrantTypes, "authorization_code") && contains(meta.GrantTypes, "refresh_token"), "grant types authorization_code and refresh_token"}, {contains(meta.CodeChallengeMethods, "S256"), "PKCE S256"}, {contains(meta.TokenEndpointAuthMethods, "private_key_jwt"), "token endpoint auth private_key_jwt"}, {contains(meta.TokenEndpointAuthSigningAlgs, "ES256"), "client assertion alg ES256"}, {contains(meta.DPoPSigningAlgs, "ES256"), "DPoP alg ES256"}, {contains(meta.Scopes, "atproto"), "scope atproto"}, {meta.RequirePAR && meta.PAREndpoint != "", "required pushed authorization requests"}, {meta.ClientIDMetadataDocumentSupported, "client metadata documents as client ids"}, } for _, n := range needs { if !n.ok { return nil, fmt.Errorf("authorization server %s does not support %s", issuer, n.what) } } for label, endpoint := range map[string]string{"authorization_endpoint": meta.AuthorizationEndpoint, "token_endpoint": meta.TokenEndpoint, "pushed_authorization_request_endpoint": meta.PAREndpoint} { if endpoint == "" { return nil, fmt.Errorf("authorization server %s has no %s", issuer, label) } if _, err := f.checkURL(endpoint); err != nil { return nil, fmt.Errorf("authorization server %s, %s: %w", issuer, label, err) } } if meta.RevocationEndpoint != "" { if _, err := f.checkURL(meta.RevocationEndpoint); err != nil { return nil, fmt.Errorf("authorization server %s, revocation_endpoint: %w", issuer, err) } } return &authServer{ Issuer: issuer, AuthorizationEndpoint: meta.AuthorizationEndpoint, TokenEndpoint: meta.TokenEndpoint, PAREndpoint: meta.PAREndpoint, RevocationEndpoint: meta.RevocationEndpoint, IssParameterSupported: meta.IssParameterSupported, }, nil } func contains(list []string, want string) bool { for _, s := range list { if s == want { return true } } return false }