mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-28 21:15:33 +00:00
cleanup more auth
This commit is contained in:
+81
-60
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user