cleanup more auth

This commit is contained in:
Evan Jarrett
2025-10-07 10:58:11 -05:00
parent 5b18538a8b
commit 2d16bbfee3
31 changed files with 2524 additions and 918 deletions
+81 -60
View File
@@ -3,69 +3,47 @@ package atproto
import (
"bytes"
"context"
"crypto/ecdsa"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/bluesky-social/indigo/atproto/client"
)
// Client wraps ATProto operations for the registry
type Client struct {
pdsEndpoint string
did string
accessToken string
httpClient *http.Client
useDPoP bool // true if using DPoP-bound tokens (OAuth)
pdsEndpoint string
did string
accessToken string // For Basic Auth only
httpClient *http.Client
useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically)
indigoClient *client.APIClient // indigo's API client for OAuth requests
}
// NewClient creates a new ATProto client for Basic Auth tokens
// NewClient creates a new ATProto client for Basic Auth tokens (app passwords)
func NewClient(pdsEndpoint, did, accessToken string) *Client {
return &Client{
pdsEndpoint: pdsEndpoint,
did: did,
accessToken: accessToken,
httpClient: &http.Client{},
useDPoP: false, // Basic Auth uses Bearer tokens
}
}
// NewClientWithDPoP creates a new ATProto client with DPoP support
// This is required for OAuth tokens
func NewClientWithDPoP(pdsEndpoint, did, accessToken string, dpopKey *ecdsa.PrivateKey, transport http.RoundTripper) *Client {
// NewClientWithIndigoClient creates an ATProto client using indigo's API client
// This uses indigo's native XRPC methods with automatic DPoP handling
func NewClientWithIndigoClient(pdsEndpoint, did string, indigoClient *client.APIClient) *Client {
return &Client{
pdsEndpoint: pdsEndpoint,
did: did,
accessToken: accessToken,
httpClient: &http.Client{
Transport: transport,
},
useDPoP: true, // OAuth uses DPoP tokens
pdsEndpoint: pdsEndpoint,
did: did,
useIndigoClient: true,
indigoClient: indigoClient,
httpClient: indigoClient.Client, // Keep for any fallback cases
}
}
// NewClientWithHTTPClient creates a new ATProto client with a pre-configured HTTP client
// This is useful when using indigo's OAuth session which provides a DPoP-configured client
// The access token will be used for Authorization headers, while the HTTP client
// handles transport-level concerns (like DPoP proofs)
func NewClientWithHTTPClient(pdsEndpoint, did, accessToken string, httpClient *http.Client) *Client {
return &Client{
pdsEndpoint: pdsEndpoint,
did: did,
accessToken: accessToken,
httpClient: httpClient,
useDPoP: true, // Assume DPoP when using custom client
}
}
// authHeader returns the appropriate Authorization header value
func (c *Client) authHeader() string {
if c.useDPoP {
return "DPoP " + c.accessToken
}
return "Bearer " + c.accessToken
}
// Record represents a generic ATProto record
type Record struct {
URI string `json:"uri"`
@@ -75,9 +53,6 @@ type Record struct {
// PutRecord stores a record in the ATProto repository
func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (*Record, error) {
// Construct the record URI
// Format: at://<did>/<collection>/<rkey>
payload := map[string]any{
"repo": c.did,
"collection": collection,
@@ -85,6 +60,17 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record
"record": record,
}
// Use indigo API client (OAuth with DPoP)
if c.useIndigoClient && c.indigoClient != nil {
var result Record
err := c.indigoClient.Post(ctx, "com.atproto.repo.putRecord", payload, &result)
if err != nil {
return nil, fmt.Errorf("putRecord failed: %w", err)
}
return &result, nil
}
// Basic Auth (app passwords)
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal record: %w", err)
@@ -96,7 +82,7 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record
return nil, err
}
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
@@ -120,6 +106,26 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record
// GetRecord retrieves a record from the ATProto repository
func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) {
// Use indigo API client (OAuth with DPoP)
if c.useIndigoClient && c.indigoClient != nil {
params := map[string]any{
"repo": c.did,
"collection": collection,
"rkey": rkey,
}
var result Record
err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
if err != nil {
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
return nil, fmt.Errorf("record not found")
}
return nil, fmt.Errorf("getRecord failed: %w", err)
}
return &result, nil
}
// Basic Auth (app passwords)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
c.pdsEndpoint, c.did, collection, rkey)
@@ -128,7 +134,7 @@ func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Recor
return nil, err
}
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -172,7 +178,7 @@ func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) erro
return err
}
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
@@ -199,7 +205,7 @@ func (c *Client) ListRecords(ctx context.Context, collection string, limit int)
return nil, err
}
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -238,22 +244,35 @@ type Link struct {
// UploadBlob uploads binary data to the PDS and returns a blob reference
func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) {
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.pdsEndpoint)
// Use indigo API client (OAuth with DPoP)
if c.useIndigoClient && c.indigoClient != nil {
var result struct {
Blob ATProtoBlobRef `json:"blob"`
}
err := c.indigoClient.LexDo(ctx,
"POST",
mimeType,
"com.atproto.repo.uploadBlob",
nil,
data,
&result,
)
if err != nil {
return nil, fmt.Errorf("uploadBlob failed: %w", err)
}
return &result.Blob, nil
}
// Basic Auth (app passwords)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.pdsEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
if err != nil {
return nil, err
}
// Only set Authorization header if we have an access token
if c.accessToken != "" {
authHeader := c.authHeader()
fmt.Printf("DEBUG [atproto/client]: UploadBlob Authorization header: %q (useDPoP=%v, token_length=%d)\n", authHeader, c.useDPoP, len(c.accessToken))
req.Header.Set("Authorization", authHeader)
} else {
fmt.Printf("DEBUG [atproto/client]: UploadBlob: No access token available, sending unauthenticated request\n")
return nil, fmt.Errorf("no access token available for authenticated PDS operation - please complete OAuth flow at: http://127.0.0.1:5000/auth/oauth/authorize?handle=<your-handle>")
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
req.Header.Set("Content-Type", mimeType)
resp, err := c.httpClient.Do(req)
@@ -288,7 +307,9 @@ func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) {
}
// Note: getBlob may not require auth for public repos, but we include it anyway
req.Header.Set("Authorization", c.authHeader())
if c.accessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -346,7 +367,7 @@ func (c *Client) ListReposByCollection(ctx context.Context, collection string, l
// This endpoint typically doesn't require auth for public data
// but we include it if available
if c.accessToken != "" {
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.httpClient.Do(req)
@@ -388,7 +409,7 @@ func (c *Client) ListRecordsForRepo(ctx context.Context, repoDID, collection str
// This endpoint typically doesn't require auth for public records
if c.accessToken != "" {
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.httpClient.Do(req)
-243
View File
@@ -1,243 +0,0 @@
package atproto
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
)
// Resolver handles DID/handle resolution for ATProto
type Resolver struct {
httpClient *http.Client
}
// NewResolver creates a new DID/handle resolver
func NewResolver() *Resolver {
return &Resolver{
httpClient: &http.Client{},
}
}
// ResolveIdentity resolves a handle or DID to a DID and PDS endpoint
// Input can be:
// - Handle: "alice.bsky.social" or "alice"
// - DID: "did:plc:xyz123abc"
func (r *Resolver) ResolveIdentity(ctx context.Context, identity string) (did string, pdsEndpoint string, err error) {
// Check if it's already a DID
if strings.HasPrefix(identity, "did:") {
did = identity
pdsEndpoint, err = r.ResolvePDS(ctx, did)
return did, pdsEndpoint, err
}
// Otherwise, resolve handle to DID
did, err = r.ResolveHandle(ctx, identity)
if err != nil {
return "", "", fmt.Errorf("failed to resolve handle %s: %w", identity, err)
}
// Then resolve DID to PDS
pdsEndpoint, err = r.ResolvePDS(ctx, did)
if err != nil {
return "", "", fmt.Errorf("failed to resolve PDS for DID %s: %w", did, err)
}
return did, pdsEndpoint, nil
}
// ResolveHandle resolves a handle to a DID using DNS TXT records or .well-known
func (r *Resolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
// Normalize handle
if !strings.Contains(handle, ".") {
// Default to .bsky.social if no domain provided
handle = handle + ".bsky.social"
}
// Try DNS TXT record first (faster)
if did, err := r.resolveHandleViaDNS(handle); err == nil && did != "" {
return did, nil
}
// Fall back to HTTPS .well-known method
url := fmt.Sprintf("https://%s/.well-known/atproto-did", handle)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch .well-known: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
did := strings.TrimSpace(string(body))
if strings.HasPrefix(did, "did:") {
return did, nil
}
}
return "", fmt.Errorf("could not resolve handle %s to DID", handle)
}
// resolveHandleViaDNS attempts to resolve handle via DNS TXT record at _atproto.<handle>
func (r *Resolver) resolveHandleViaDNS(handle string) (string, error) {
txtRecords, err := net.LookupTXT("_atproto." + handle)
if err != nil {
return "", err
}
// Look for a TXT record that starts with "did="
for _, record := range txtRecords {
if strings.HasPrefix(record, "did=") {
did := strings.TrimPrefix(record, "did=")
if strings.HasPrefix(did, "did:") {
return did, nil
}
}
}
return "", fmt.Errorf("no valid DID found in DNS TXT records")
}
// DIDDocument represents a simplified ATProto DID document
type DIDDocument struct {
ID string `json:"id"`
AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
Service []struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
} `json:"service"`
}
// ResolvePDS resolves a DID to its PDS endpoint
func (r *Resolver) ResolvePDS(ctx context.Context, did string) (string, error) {
if !strings.HasPrefix(did, "did:") {
return "", fmt.Errorf("invalid DID format: %s", did)
}
// Parse DID method
parts := strings.Split(did, ":")
if len(parts) < 3 {
return "", fmt.Errorf("invalid DID format: %s", did)
}
method := parts[1]
var resolverURL string
switch method {
case "plc":
// Use PLC directory
resolverURL = fmt.Sprintf("https://plc.directory/%s", did)
case "web":
// For did:web, convert to HTTPS URL
domain := parts[2]
resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
default:
return "", fmt.Errorf("unsupported DID method: %s", method)
}
req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil)
if err != nil {
return "", err
}
resp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch DID document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("DID resolution failed with status %d", resp.StatusCode)
}
var didDoc DIDDocument
if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
return "", fmt.Errorf("failed to parse DID document: %w", err)
}
// Find PDS service endpoint
for _, service := range didDoc.Service {
if service.Type == "AtprotoPersonalDataServer" {
return service.ServiceEndpoint, nil
}
}
return "", fmt.Errorf("no PDS endpoint found in DID document")
}
// ResolveDIDDocument fetches the full DID document for a DID
func (r *Resolver) ResolveDIDDocument(ctx context.Context, did string) (*DIDDocument, error) {
if !strings.HasPrefix(did, "did:") {
return nil, fmt.Errorf("invalid DID format: %s", did)
}
parts := strings.Split(did, ":")
if len(parts) < 3 {
return nil, fmt.Errorf("invalid DID format: %s", did)
}
method := parts[1]
var resolverURL string
switch method {
case "plc":
resolverURL = fmt.Sprintf("https://plc.directory/%s", did)
case "web":
domain := parts[2]
resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
default:
return nil, fmt.Errorf("unsupported DID method: %s", method)
}
req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil)
if err != nil {
return nil, err
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch DID document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DID resolution failed with status %d", resp.StatusCode)
}
var didDoc DIDDocument
if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
return nil, fmt.Errorf("failed to parse DID document: %w", err)
}
return &didDoc, nil
}
// ResolveHandle extracts the handle from a DID's alsoKnownAs field
func (r *Resolver) ResolveHandleFromDID(ctx context.Context, did string) (string, error) {
didDoc, err := r.ResolveDIDDocument(ctx, did)
if err != nil {
return "", err
}
// Look for handle in alsoKnownAs (format: "at://handle.bsky.social")
for _, aka := range didDoc.AlsoKnownAs {
if strings.HasPrefix(aka, "at://") {
handle := strings.TrimPrefix(aka, "at://")
return handle, nil
}
}
return "", fmt.Errorf("no handle found in DID document")
}