diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 5c0bd12..a9c9a53 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -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") diff --git a/pkg/appview/config.go b/pkg/appview/config.go index c920414..61b78df 100644 --- a/pkg/appview/config.go +++ b/pkg/appview/config.go @@ -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 +} diff --git a/pkg/appview/config_test.go b/pkg/appview/config_test.go index 2eb498f..10ac466 100644 --- a/pkg/appview/config_test.go +++ b/pkg/appview/config_test.go @@ -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 diff --git a/pkg/appview/middleware/registry.go b/pkg/appview/middleware/registry.go index 4113298..3bbd193 100644 --- a/pkg/appview/middleware/registry.go +++ b/pkg/appview/middleware/registry.go @@ -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) diff --git a/pkg/appview/storage/proxy_blob_store.go b/pkg/appview/storage/proxy_blob_store.go index 241f297..19e7d29 100644 --- a/pkg/appview/storage/proxy_blob_store.go +++ b/pkg/appview/storage/proxy_blob_store.go @@ -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 } diff --git a/pkg/appview/storage/routing_repository.go b/pkg/appview/storage/routing_repository.go index a591709..e9aeb31 100644 --- a/pkg/appview/storage/routing_repository.go +++ b/pkg/appview/storage/routing_repository.go @@ -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 } diff --git a/pkg/auth/hold_remote.go b/pkg/auth/hold_remote.go index bdd2915..6f4f57e 100644 --- a/pkg/auth/hold_remote.go +++ b/pkg/auth/hold_remote.go @@ -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 }