fix auth issues in appview xrpc calls

This commit is contained in:
Evan Jarrett
2025-10-18 12:48:28 -05:00
parent f4b84ca75f
commit 6f3c1fc0ba
7 changed files with 215 additions and 16 deletions
+7 -1
View File
@@ -114,8 +114,14 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
metricsDB := db.NewMetricsDB(uiDatabase)
middleware.SetGlobalDatabase(metricsDB)
// Extract test mode from config
testMode := appview.ExtractTestMode(config)
if testMode {
fmt.Println("TEST_MODE enabled - will use HTTP for local DID resolution")
}
// Create RemoteHoldAuthorizer for hold authorization with caching
holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase)
holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase, testMode)
middleware.SetGlobalAuthorizer(holdAuthorizer)
fmt.Println("Hold authorizer initialized with database caching")
+27
View File
@@ -284,3 +284,30 @@ func ExtractDefaultHoldDID(config *configuration.Configuration) string {
return ""
}
// ExtractTestMode extracts the test_mode flag from middleware config
// Returns true if TEST_MODE=true, false otherwise
func ExtractTestMode(config *configuration.Configuration) bool {
// Navigate through: middleware.registry[].options.test_mode
registryMiddleware, ok := config.Middleware["registry"]
if !ok {
return false
}
// Find atproto-resolver middleware
for _, mw := range registryMiddleware {
// Check if this is the atproto-resolver
if mw.Name != "atproto-resolver" {
continue
}
// Extract options - options is configuration.Parameters which is map[string]any
if mw.Options != nil {
if testMode, ok := mw.Options["test_mode"].(bool); ok {
return testMode
}
}
}
return false
}
+119
View File
@@ -769,6 +769,125 @@ func TestExtractDefaultHoldDID(t *testing.T) {
}
}
func TestExtractTestMode(t *testing.T) {
tests := []struct {
name string
config *configuration.Configuration
want bool
}{
{
name: "test mode enabled",
config: &configuration.Configuration{
Middleware: map[string][]configuration.Middleware{
"registry": {
{
Name: "atproto-resolver",
Options: configuration.Parameters{
"test_mode": true,
},
},
},
},
},
want: true,
},
{
name: "test mode disabled",
config: &configuration.Configuration{
Middleware: map[string][]configuration.Middleware{
"registry": {
{
Name: "atproto-resolver",
Options: configuration.Parameters{
"test_mode": false,
},
},
},
},
},
want: false,
},
{
name: "no registry middleware",
config: &configuration.Configuration{
Middleware: map[string][]configuration.Middleware{},
},
want: false,
},
{
name: "no atproto-resolver middleware",
config: &configuration.Configuration{
Middleware: map[string][]configuration.Middleware{
"registry": {
{
Name: "other-middleware",
Options: configuration.Parameters{
"foo": "bar",
},
},
},
},
},
want: false,
},
{
name: "atproto-resolver without test_mode",
config: &configuration.Configuration{
Middleware: map[string][]configuration.Middleware{
"registry": {
{
Name: "atproto-resolver",
Options: configuration.Parameters{
"other_option": "value",
},
},
},
},
},
want: false,
},
{
name: "test_mode is not a bool",
config: &configuration.Configuration{
Middleware: map[string][]configuration.Middleware{
"registry": {
{
Name: "atproto-resolver",
Options: configuration.Parameters{
"test_mode": "true",
},
},
},
},
},
want: false,
},
{
name: "nil options",
config: &configuration.Configuration{
Middleware: map[string][]configuration.Middleware{
"registry": {
{
Name: "atproto-resolver",
Options: nil,
},
},
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractTestMode(tt.config)
if got != tt.want {
t.Errorf("ExtractTestMode() = %v, want %v", got, tt.want)
}
})
}
}
func TestLoadConfigFromEnv(t *testing.T) {
tests := []struct {
name string
+2 -2
View File
@@ -194,8 +194,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
// Create routing repository - routes manifests to ATProto, blobs to hold service
// The registry is stateless - no local storage is used
// Pass hold DID, user DID, and authorizer as parameters (can't use context as it gets lost)
routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer)
// Pass hold DID, user DID, authorizer, and refresher as parameters (can't use context as it gets lost)
routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer, globalRefresher)
// Cache the repository
nr.repositories.Store(cacheKey, routingRepo)
+34 -5
View File
@@ -12,6 +12,7 @@ import (
"time"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"github.com/distribution/distribution/v3"
"github.com/opencontainers/go-digest"
)
@@ -38,10 +39,11 @@ type ProxyBlobStore struct {
database DatabaseMetrics
repository string
authorizer auth.HoldAuthorizer
refresher *oauth.Refresher // OAuth refresher for authenticating to hold service
}
// NewProxyBlobStore creates a new proxy blob store
func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore {
func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer, refresher *oauth.Refresher) *ProxyBlobStore {
// Resolve DID to URL once at construction time
holdURL := resolveHoldURL(holdDID)
@@ -65,9 +67,32 @@ func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository
database: database,
repository: repository,
authorizer: authorizer,
refresher: refresher,
}
}
// doAuthenticatedRequest performs an HTTP request with OAuth authentication (DPoP)
// If OAuth session is available, uses session.DoWithAuth for DPoP headers
// Otherwise, uses the default httpClient without authentication
func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
// Try to get OAuth session for DPoP authentication
if p.refresher != nil {
session, err := p.refresher.GetSession(ctx, p.did)
if err != nil {
fmt.Printf("DEBUG [proxy_blob_store]: Failed to get OAuth session for DID=%s: %v, will attempt without auth\n", p.did, err)
} else {
// Use session's DoWithAuth method (adds Authorization + DPoP headers)
fmt.Printf("DEBUG [proxy_blob_store]: Using OAuth session for hold service request, DID=%s\n", p.did)
// The endpoint parameter is not used for DPoP signing, just token refresh validation
// For hold service XRPC requests, we can pass "com.atproto.repo.uploadBlob"
return session.DoWithAuth(session.Client, req, "com.atproto.repo.uploadBlob")
}
}
// Fall back to non-authenticated client
return p.httpClient.Do(req)
}
// resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests
// did:web:hold01.atcr.io → https://hold01.atcr.io
// did:web:172.28.0.3:8080 → http://172.28.0.3:8080
@@ -403,7 +428,8 @@ func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
// Use authenticated request (OAuth with DPoP)
resp, err := p.doAuthenticatedRequest(ctx, req)
if err != nil {
return "", err
}
@@ -453,7 +479,8 @@ func (p *ProxyBlobStore) getPartUploadInfo(ctx context.Context, digest, uploadID
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
// Use authenticated request (OAuth with DPoP)
resp, err := p.doAuthenticatedRequest(ctx, req)
if err != nil {
return nil, err
}
@@ -503,7 +530,8 @@ func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, up
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
// Use authenticated request (OAuth with DPoP)
resp, err := p.doAuthenticatedRequest(ctx, req)
if err != nil {
return err
}
@@ -537,7 +565,8 @@ func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, digest, uploa
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
// Use authenticated request (OAuth with DPoP)
resp, err := p.doAuthenticatedRequest(ctx, req)
if err != nil {
return err
}
+6 -2
View File
@@ -7,6 +7,7 @@ import (
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"github.com/distribution/distribution/v3"
)
@@ -28,6 +29,7 @@ type RoutingRepository struct {
blobStore *ProxyBlobStore // Cached blob store instance
database DatabaseMetrics // Database for metrics tracking
authorizer auth.HoldAuthorizer // Authorization for hold access
refresher *oauth.Refresher // OAuth refresher for authenticating to hold service
}
// NewRoutingRepository creates a new routing repository
@@ -39,6 +41,7 @@ func NewRoutingRepository(
did string,
database DatabaseMetrics,
authorizer auth.HoldAuthorizer,
refresher *oauth.Refresher,
) *RoutingRepository {
return &RoutingRepository{
Repository: baseRepo,
@@ -48,6 +51,7 @@ func NewRoutingRepository(
did: did,
database: database,
authorizer: authorizer,
refresher: refresher,
}
}
@@ -108,8 +112,8 @@ func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
panic("hold DID not set in RoutingRepository - ensure default_hold_did is configured in middleware")
}
// Create and cache proxy blob store with authorization
r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer)
// Create and cache proxy blob store with authorization and OAuth refresher
r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer, r.refresher)
return r.blobStore
}
+20 -6
View File
@@ -24,6 +24,7 @@ type RemoteHoldAuthorizer struct {
cacheTTL time.Duration // TTL for captain record cache
recentDenials sync.Map // In-memory cache for first denials (10s backoff)
stopCleanup chan struct{} // Signal to stop cleanup goroutine
testMode bool // If true, use HTTP for local DIDs
}
// denialEntry stores timestamp for in-memory first denials
@@ -32,7 +33,7 @@ type denialEntry struct {
}
// NewRemoteHoldAuthorizer creates a new remote authorizer for AppView
func NewRemoteHoldAuthorizer(db *sql.DB) HoldAuthorizer {
func NewRemoteHoldAuthorizer(db *sql.DB, testMode bool) HoldAuthorizer {
a := &RemoteHoldAuthorizer{
db: db,
httpClient: &http.Client{
@@ -40,6 +41,7 @@ func NewRemoteHoldAuthorizer(db *sql.DB) HoldAuthorizer {
},
cacheTTL: 1 * time.Hour, // 1 hour cache TTL
stopCleanup: make(chan struct{}),
testMode: testMode,
}
// Start cleanup goroutine for in-memory denials
@@ -192,7 +194,7 @@ func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *at
// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
// Resolve DID to URL
holdURL, err := resolveDIDToURL(holdDID)
holdURL, err := a.resolveDIDToURL(holdDID)
if err != nil {
return nil, fmt.Errorf("failed to resolve hold DID: %w", err)
}
@@ -293,7 +295,7 @@ func (a *RemoteHoldAuthorizer) IsCrewMember(ctx context.Context, holdDID, userDI
// isCrewMemberNoCache queries XRPC without caching (internal helper)
func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) {
// Resolve DID to URL
holdURL, err := resolveDIDToURL(holdDID)
holdURL, err := a.resolveDIDToURL(holdDID)
if err != nil {
return false, fmt.Errorf("failed to resolve hold DID: %w", err)
}
@@ -374,9 +376,10 @@ func (a *RemoteHoldAuthorizer) CheckWriteAccess(ctx context.Context, holdDID, us
return CheckWriteAccessWithCaptain(captain, userDID, isCrew), nil
}
// resolveDIDToURL converts a did:web DID to an HTTPS URL
// resolveDIDToURL converts a did:web DID to an HTTP/HTTPS URL
// Example: did:web:hold01.atcr.io → https://hold01.atcr.io
func resolveDIDToURL(did string) (string, error) {
// Example (test mode): did:web:172.28.0.3:8080 → http://172.28.0.3:8080
func (a *RemoteHoldAuthorizer) resolveDIDToURL(did string) (string, error) {
// Handle did:web format
if !strings.HasPrefix(did, "did:web:") {
return "", fmt.Errorf("only did:web is supported, got: %s", did)
@@ -385,7 +388,18 @@ func resolveDIDToURL(did string) (string, error) {
// Extract hostname from did:web:hostname
hostname := strings.TrimPrefix(did, "did:web:")
// Convert to HTTPS URL
// In test mode OR for local addresses, use HTTP instead of HTTPS
// This matches the logic in pkg/appview/storage/proxy_blob_store.go:resolveHoldURL
if a.testMode ||
strings.Contains(hostname, ":") ||
strings.Contains(hostname, "127.0.0.1") ||
strings.Contains(hostname, "localhost") ||
// Check if it's an IP address (contains only digits and dots)
(len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
return "http://" + hostname, nil
}
// Convert to HTTPS URL for production domains
return "https://" + hostname, nil
}