Files
at-container-registry/pkg/atproto/resolver.go
T
Evan JarrettandClaude Fable 5.1 a01b08b924 atproto: gate local indigo behavior behind a testmode build tag
indigo's identity directory refuses HTTP and IP-hosted did:web, and its
OAuth client is growing an SSRF-guarded transport that refuses loopback
and private addresses. Local development and the test suites need both,
and the workarounds were scattered: two did:web fallbacks in the
resolver, a hand-rolled appview key fetch on the hold, and the OAuth
client left on indigo's defaults so any test driving it against an
httptest server depended on the transport staying permissive.

Move every departure from indigo's defaults into one file pair in
pkg/atproto: indigo_prod.go (!testmode) returns indigo's directory and
OAuth client unchanged; indigo_local.go (testmode) wraps the directory
so a did:web naming an IP, localhost, or a host with a port resolves
over plain HTTP, and gives the OAuth client plain HTTP clients. All six
identity and OAuth constructor call sites go through NewDirectory and
NewOAuthClientApp. The resolver fallbacks, DIDWebToURL, and the hold's
scheme-guessing key fetch are gone; the hold resolves the appview key
through the directory, preferring #appview, and purges and retries once
on a signature failure so a re-keyed appview is not masked by the
24-hour cache.

There is no runtime switch for this: a production binary cannot be
configured to resolve local DIDs. The runtime test_mode flag still
gates the remaining behavioral branches only.

Tests, the harness, make dev, Air, Dockerfile.dev, and docker-compose
build with the tag; fixtures that need loopback did:web fail fast
naming it. Test hold servers now serve a did.json via pkg/testpds so
they resolve as real holds under the tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 10:53:27 -05:00

293 lines
11 KiB
Go

package atproto
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// ErrHoldDIDPermanent marks a hold-DID resolution failure that no retry can
// fix: a malformed identifier, or a DID whose document does not exist. It is a
// fact about stored data — typically a stale or hand-edited sailor profile
// defaultHold — rather than a fault on our side, so callers can log it quietly
// instead of at ERROR and avoid burying real faults. Transient failures (a PLC
// blip, a network error) deliberately do not carry it.
var ErrHoldDIDPermanent = errors.New("hold DID is not resolvable")
// didWebHostUnusable reports whether a did:web identifier names something the
// identity directory can never resolve. The directory requires a plain
// hostname and rejects ports (percent-encoded as %3A), bare IPs, and
// localhost. Detecting those here, rather than string-matching the directory's
// error prose, is what lets ResolveHoldDIDToURL classify the failure as
// permanent. Conservative by design: anything not clearly hopeless is left to
// the transient path, so an unfamiliar failure still surfaces loudly.
//
// A `-tags testmode` build resolves exactly these identifiers over plain HTTP
// (see indigo_local.go), so there a failure means the local service is down
// rather than unresolvable; the quieter classification is harmless in dev.
func didWebHostUnusable(did string) bool {
host, ok := strings.CutPrefix(did, "did:web:")
if !ok {
return false
}
if i := strings.IndexByte(host, ':'); i != -1 { // path-qualified did:web
host = host[:i]
}
if strings.Contains(host, "%3A") || strings.Contains(host, "%3a") {
return true // a port survived encoding, e.g. localhost%3A8080
}
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
return net.ParseIP(host) != nil
}
// holdDIDResolveClient bounds the /.well-known/atproto-did fetch so an
// unreachable or slow hold can't stall /auth/token (this resolution runs on the
// hot gate + fetch path) past Docker's token-fetch timeout. It's a plain
// idempotent GET, so a hard timeout is safe.
var holdDIDResolveClient = &http.Client{Timeout: 10 * time.Second}
// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL.
// For DIDs (both did:web and did:plc), resolves via the indigo identity directory
// which caches results (24h TTL). Prefers the #atcr_hold service endpoint,
// falls back to #atproto_pds.
//
// Supported formats:
// - URL: https://hold.example.com → passthrough
// - DID: did:web:hold01.atcr.io → resolved via /.well-known/did.json
// - DID: did:plc:abc123 → resolved via PLC directory
func ResolveHoldURL(ctx context.Context, holdIdentifier string) (string, error) {
// If it's already a URL (has scheme), return as-is
if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
return holdIdentifier, nil
}
// If it's a DID, resolve via identity directory
if strings.HasPrefix(holdIdentifier, "did:") {
return ResolveHoldDIDToURL(ctx, holdIdentifier)
}
// Fallback: assume it's a hostname and use HTTPS
return "https://" + holdIdentifier, nil
}
// ResolveHoldDID resolves a hold identifier (DID, URL, or hostname) to its actual DID.
// If the input is already a DID, it is returned as-is.
// If the input is a URL or hostname, the hold's /.well-known/atproto-did endpoint is
// fetched to discover the real DID (which may be did:web or did:plc).
func ResolveHoldDID(ctx context.Context, holdIdentifier string) (string, error) {
if holdIdentifier == "" {
return "", fmt.Errorf("empty hold identifier")
}
// If already a DID, return as-is
if _, err := syntax.ParseDID(holdIdentifier); err == nil {
return holdIdentifier, nil
}
// Normalize to a full URL
holdURL := holdIdentifier
if !strings.HasPrefix(holdURL, "http://") && !strings.HasPrefix(holdURL, "https://") {
holdURL = "https://" + holdURL
}
holdURL = strings.TrimSuffix(holdURL, "/")
// Fetch /.well-known/atproto-did to discover the hold's actual DID
req, err := http.NewRequestWithContext(ctx, "GET", holdURL+"/.well-known/atproto-did", nil)
if err != nil {
return "", fmt.Errorf("failed to create request for hold DID resolution: %w", err)
}
resp, err := holdDIDResolveClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch hold DID from %s: %w", holdURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("hold at %s returned status %d for DID resolution", holdURL, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
if err != nil {
return "", fmt.Errorf("failed to read hold DID response: %w", err)
}
did := strings.TrimSpace(string(body))
if _, err := syntax.ParseDID(did); err != nil {
return "", fmt.Errorf("hold at %s returned invalid DID: %q", holdURL, did)
}
return did, nil
}
// ResolveHoldDIDToURL resolves a hold DID to its HTTP service endpoint.
// Prefers the #atcr_hold service endpoint, falls back to #atproto_pds.
// Uses the shared identity directory with cache TTL and event-driven invalidation.
func ResolveHoldDIDToURL(ctx context.Context, did string) (string, error) {
directory := GetDirectory()
didParsed, err := syntax.ParseDID(did)
if err != nil {
return "", fmt.Errorf("invalid hold DID %q: %w: %w", did, ErrHoldDIDPermanent, err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
// A missing DID document or a structurally unusable did:web will fail
// identically on every retry, so mark it permanent and let callers pick
// a quieter log level.
if errors.Is(err, identity.ErrDIDNotFound) || didWebHostUnusable(did) {
return "", fmt.Errorf("failed to resolve hold DID %s: %w: %w", did, ErrHoldDIDPermanent, err)
}
return "", fmt.Errorf("failed to resolve hold DID %s: %w", did, err)
}
// Prefer #atcr_hold service (hold-specific endpoint)
if url := ident.GetServiceEndpoint("atcr_hold"); url != "" {
return url, nil
}
// Fall back to #atproto_pds (hold publishes both with same URL)
if url := ident.PDSEndpoint(); url != "" {
return url, nil
}
return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
}
// HasHoldService reports whether a DID's identity document advertises an
// #atcr_hold service endpoint, i.e. whether the DID actually runs a hold
// service. Any ATProto account can publish io.atcr.hold.captain records, but
// only real holds publish the atcr_hold service in their DID document — use
// this to verify captain records discovered on the network before caching.
// Resolution goes through the shared identity directory (cached, 24h TTL).
func HasHoldService(ctx context.Context, did string) (bool, error) {
didParsed, err := syntax.ParseDID(did)
if err != nil {
return false, fmt.Errorf("invalid hold DID %q: %w", did, err)
}
ident, err := GetDirectory().LookupDID(ctx, didParsed)
if err != nil {
return false, fmt.Errorf("failed to resolve DID %s: %w", did, err)
}
return ident.GetServiceEndpoint("atcr_hold") != "", nil
}
// NormalizeDID ensures did:web DIDs use %3A encoding for port separators
// per the did:web spec. Other DID methods are returned as-is.
// e.g., "did:web:172.28.0.3:8080" → "did:web:172.28.0.3%3A8080"
func NormalizeDID(did string) string {
if !strings.HasPrefix(did, "did:web:") {
return did
}
host := strings.TrimPrefix(did, "did:web:")
// Only fix bare colons — skip if already percent-encoded
if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
host = strings.Replace(host, ":", "%3A", 1)
}
return "did:web:" + host
}
// ResolveDIDToPDS resolves a DID to its PDS endpoint.
// Uses the shared identity directory with cache TTL and event-driven invalidation.
func ResolveDIDToPDS(ctx context.Context, did string) (string, error) {
directory := GetDirectory()
didParsed, err := syntax.ParseDID(did)
if err != nil {
return "", fmt.Errorf("invalid DID: %w", err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
return "", fmt.Errorf("failed to resolve DID: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return "", fmt.Errorf("no PDS endpoint found for DID")
}
return pdsEndpoint, nil
}
// ResolveIdentity resolves an ATProto identifier (handle or DID) to DID, handle, and PDS endpoint.
// Uses the shared identity directory with cache TTL and event-driven invalidation.
//
// If the handle is invalid (handle.invalid), it returns the DID as the handle for display purposes.
// Returns: did, handle, pdsEndpoint, error
func ResolveIdentity(ctx context.Context, identifier string) (string, string, string, error) {
directory := GetDirectory()
atID, err := syntax.ParseAtIdentifier(identifier)
if err != nil {
return "", "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err)
}
ident, err := directory.Lookup(ctx, atID)
if err != nil {
return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
}
did := ident.DID.String()
handle := ident.Handle.String()
pdsEndpoint := ident.PDSEndpoint()
// If handle is invalid, use DID as display name
if handle == "handle.invalid" || handle == "" {
handle = did
}
// PDS endpoint is required for XRPC calls
if pdsEndpoint == "" {
return "", "", "", fmt.Errorf("no PDS endpoint found for identifier %q", identifier)
}
return did, handle, pdsEndpoint, nil
}
// ResolveHandleToDID resolves a handle or DID to just the DID.
// Uses the shared identity directory with cache TTL and event-driven invalidation.
// This is useful when you only need the DID and don't care about handle/PDS.
func ResolveHandleToDID(ctx context.Context, identifier string) (string, error) {
directory := GetDirectory()
atID, err := syntax.ParseAtIdentifier(identifier)
if err != nil {
return "", fmt.Errorf("invalid identifier: %w", err)
}
ident, err := directory.Lookup(ctx, atID)
if err != nil {
return "", err
}
return ident.DID.String(), nil
}
// InvalidateIdentity purges cached identity data for a DID or handle.
// This should be called when identity changes are detected (e.g., via Jetstream events)
// to ensure the cache is refreshed on the next lookup.
//
// Use cases:
// - Handle changes (identity events from Jetstream)
// - Account deactivation/migration (account events from Jetstream)
// - PDS migrations (deactivation followed by reactivation at new PDS)
func InvalidateIdentity(ctx context.Context, identifier string) error {
directory := GetDirectory()
atID, err := syntax.ParseAtIdentifier(identifier)
if err != nil {
return fmt.Errorf("invalid identifier for cache invalidation: %w", err)
}
return directory.Purge(ctx, atID)
}