A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix auth issues in appview xrpc calls

+215 -16
+7 -1
cmd/appview/serve.go
··· 114 114 metricsDB := db.NewMetricsDB(uiDatabase) 115 115 middleware.SetGlobalDatabase(metricsDB) 116 116 117 + // Extract test mode from config 118 + testMode := appview.ExtractTestMode(config) 119 + if testMode { 120 + fmt.Println("TEST_MODE enabled - will use HTTP for local DID resolution") 121 + } 122 + 117 123 // Create RemoteHoldAuthorizer for hold authorization with caching 118 - holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase) 124 + holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase, testMode) 119 125 middleware.SetGlobalAuthorizer(holdAuthorizer) 120 126 fmt.Println("Hold authorizer initialized with database caching") 121 127
+27
pkg/appview/config.go
··· 284 284 285 285 return "" 286 286 } 287 + 288 + // ExtractTestMode extracts the test_mode flag from middleware config 289 + // Returns true if TEST_MODE=true, false otherwise 290 + func ExtractTestMode(config *configuration.Configuration) bool { 291 + // Navigate through: middleware.registry[].options.test_mode 292 + registryMiddleware, ok := config.Middleware["registry"] 293 + if !ok { 294 + return false 295 + } 296 + 297 + // Find atproto-resolver middleware 298 + for _, mw := range registryMiddleware { 299 + // Check if this is the atproto-resolver 300 + if mw.Name != "atproto-resolver" { 301 + continue 302 + } 303 + 304 + // Extract options - options is configuration.Parameters which is map[string]any 305 + if mw.Options != nil { 306 + if testMode, ok := mw.Options["test_mode"].(bool); ok { 307 + return testMode 308 + } 309 + } 310 + } 311 + 312 + return false 313 + }
+119
pkg/appview/config_test.go
··· 769 769 } 770 770 } 771 771 772 + func TestExtractTestMode(t *testing.T) { 773 + tests := []struct { 774 + name string 775 + config *configuration.Configuration 776 + want bool 777 + }{ 778 + { 779 + name: "test mode enabled", 780 + config: &configuration.Configuration{ 781 + Middleware: map[string][]configuration.Middleware{ 782 + "registry": { 783 + { 784 + Name: "atproto-resolver", 785 + Options: configuration.Parameters{ 786 + "test_mode": true, 787 + }, 788 + }, 789 + }, 790 + }, 791 + }, 792 + want: true, 793 + }, 794 + { 795 + name: "test mode disabled", 796 + config: &configuration.Configuration{ 797 + Middleware: map[string][]configuration.Middleware{ 798 + "registry": { 799 + { 800 + Name: "atproto-resolver", 801 + Options: configuration.Parameters{ 802 + "test_mode": false, 803 + }, 804 + }, 805 + }, 806 + }, 807 + }, 808 + want: false, 809 + }, 810 + { 811 + name: "no registry middleware", 812 + config: &configuration.Configuration{ 813 + Middleware: map[string][]configuration.Middleware{}, 814 + }, 815 + want: false, 816 + }, 817 + { 818 + name: "no atproto-resolver middleware", 819 + config: &configuration.Configuration{ 820 + Middleware: map[string][]configuration.Middleware{ 821 + "registry": { 822 + { 823 + Name: "other-middleware", 824 + Options: configuration.Parameters{ 825 + "foo": "bar", 826 + }, 827 + }, 828 + }, 829 + }, 830 + }, 831 + want: false, 832 + }, 833 + { 834 + name: "atproto-resolver without test_mode", 835 + config: &configuration.Configuration{ 836 + Middleware: map[string][]configuration.Middleware{ 837 + "registry": { 838 + { 839 + Name: "atproto-resolver", 840 + Options: configuration.Parameters{ 841 + "other_option": "value", 842 + }, 843 + }, 844 + }, 845 + }, 846 + }, 847 + want: false, 848 + }, 849 + { 850 + name: "test_mode is not a bool", 851 + config: &configuration.Configuration{ 852 + Middleware: map[string][]configuration.Middleware{ 853 + "registry": { 854 + { 855 + Name: "atproto-resolver", 856 + Options: configuration.Parameters{ 857 + "test_mode": "true", 858 + }, 859 + }, 860 + }, 861 + }, 862 + }, 863 + want: false, 864 + }, 865 + { 866 + name: "nil options", 867 + config: &configuration.Configuration{ 868 + Middleware: map[string][]configuration.Middleware{ 869 + "registry": { 870 + { 871 + Name: "atproto-resolver", 872 + Options: nil, 873 + }, 874 + }, 875 + }, 876 + }, 877 + want: false, 878 + }, 879 + } 880 + 881 + for _, tt := range tests { 882 + t.Run(tt.name, func(t *testing.T) { 883 + got := ExtractTestMode(tt.config) 884 + if got != tt.want { 885 + t.Errorf("ExtractTestMode() = %v, want %v", got, tt.want) 886 + } 887 + }) 888 + } 889 + } 890 + 772 891 func TestLoadConfigFromEnv(t *testing.T) { 773 892 tests := []struct { 774 893 name string
+2 -2
pkg/appview/middleware/registry.go
··· 194 194 195 195 // Create routing repository - routes manifests to ATProto, blobs to hold service 196 196 // The registry is stateless - no local storage is used 197 - // Pass hold DID, user DID, and authorizer as parameters (can't use context as it gets lost) 198 - routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer) 197 + // Pass hold DID, user DID, authorizer, and refresher as parameters (can't use context as it gets lost) 198 + routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer, globalRefresher) 199 199 200 200 // Cache the repository 201 201 nr.repositories.Store(cacheKey, routingRepo)
+34 -5
pkg/appview/storage/proxy_blob_store.go
··· 12 12 "time" 13 13 14 14 "atcr.io/pkg/auth" 15 + "atcr.io/pkg/auth/oauth" 15 16 "github.com/distribution/distribution/v3" 16 17 "github.com/opencontainers/go-digest" 17 18 ) ··· 38 39 database DatabaseMetrics 39 40 repository string 40 41 authorizer auth.HoldAuthorizer 42 + refresher *oauth.Refresher // OAuth refresher for authenticating to hold service 41 43 } 42 44 43 45 // NewProxyBlobStore creates a new proxy blob store 44 - func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore { 46 + func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer, refresher *oauth.Refresher) *ProxyBlobStore { 45 47 // Resolve DID to URL once at construction time 46 48 holdURL := resolveHoldURL(holdDID) 47 49 ··· 65 67 database: database, 66 68 repository: repository, 67 69 authorizer: authorizer, 70 + refresher: refresher, 68 71 } 72 + } 73 + 74 + // doAuthenticatedRequest performs an HTTP request with OAuth authentication (DPoP) 75 + // If OAuth session is available, uses session.DoWithAuth for DPoP headers 76 + // Otherwise, uses the default httpClient without authentication 77 + func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) { 78 + // Try to get OAuth session for DPoP authentication 79 + if p.refresher != nil { 80 + session, err := p.refresher.GetSession(ctx, p.did) 81 + if err != nil { 82 + fmt.Printf("DEBUG [proxy_blob_store]: Failed to get OAuth session for DID=%s: %v, will attempt without auth\n", p.did, err) 83 + } else { 84 + // Use session's DoWithAuth method (adds Authorization + DPoP headers) 85 + fmt.Printf("DEBUG [proxy_blob_store]: Using OAuth session for hold service request, DID=%s\n", p.did) 86 + // The endpoint parameter is not used for DPoP signing, just token refresh validation 87 + // For hold service XRPC requests, we can pass "com.atproto.repo.uploadBlob" 88 + return session.DoWithAuth(session.Client, req, "com.atproto.repo.uploadBlob") 89 + } 90 + } 91 + 92 + // Fall back to non-authenticated client 93 + return p.httpClient.Do(req) 69 94 } 70 95 71 96 // resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests ··· 403 428 } 404 429 req.Header.Set("Content-Type", "application/json") 405 430 406 - resp, err := p.httpClient.Do(req) 431 + // Use authenticated request (OAuth with DPoP) 432 + resp, err := p.doAuthenticatedRequest(ctx, req) 407 433 if err != nil { 408 434 return "", err 409 435 } ··· 453 479 } 454 480 req.Header.Set("Content-Type", "application/json") 455 481 456 - resp, err := p.httpClient.Do(req) 482 + // Use authenticated request (OAuth with DPoP) 483 + resp, err := p.doAuthenticatedRequest(ctx, req) 457 484 if err != nil { 458 485 return nil, err 459 486 } ··· 503 530 } 504 531 req.Header.Set("Content-Type", "application/json") 505 532 506 - resp, err := p.httpClient.Do(req) 533 + // Use authenticated request (OAuth with DPoP) 534 + resp, err := p.doAuthenticatedRequest(ctx, req) 507 535 if err != nil { 508 536 return err 509 537 } ··· 537 565 } 538 566 req.Header.Set("Content-Type", "application/json") 539 567 540 - resp, err := p.httpClient.Do(req) 568 + // Use authenticated request (OAuth with DPoP) 569 + resp, err := p.doAuthenticatedRequest(ctx, req) 541 570 if err != nil { 542 571 return err 543 572 }
+6 -2
pkg/appview/storage/routing_repository.go
··· 7 7 8 8 "atcr.io/pkg/atproto" 9 9 "atcr.io/pkg/auth" 10 + "atcr.io/pkg/auth/oauth" 10 11 "github.com/distribution/distribution/v3" 11 12 ) 12 13 ··· 28 29 blobStore *ProxyBlobStore // Cached blob store instance 29 30 database DatabaseMetrics // Database for metrics tracking 30 31 authorizer auth.HoldAuthorizer // Authorization for hold access 32 + refresher *oauth.Refresher // OAuth refresher for authenticating to hold service 31 33 } 32 34 33 35 // NewRoutingRepository creates a new routing repository ··· 39 41 did string, 40 42 database DatabaseMetrics, 41 43 authorizer auth.HoldAuthorizer, 44 + refresher *oauth.Refresher, 42 45 ) *RoutingRepository { 43 46 return &RoutingRepository{ 44 47 Repository: baseRepo, ··· 48 51 did: did, 49 52 database: database, 50 53 authorizer: authorizer, 54 + refresher: refresher, 51 55 } 52 56 } 53 57 ··· 108 112 panic("hold DID not set in RoutingRepository - ensure default_hold_did is configured in middleware") 109 113 } 110 114 111 - // Create and cache proxy blob store with authorization 112 - r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer) 115 + // Create and cache proxy blob store with authorization and OAuth refresher 116 + r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer, r.refresher) 113 117 return r.blobStore 114 118 } 115 119
+20 -6
pkg/auth/hold_remote.go
··· 24 24 cacheTTL time.Duration // TTL for captain record cache 25 25 recentDenials sync.Map // In-memory cache for first denials (10s backoff) 26 26 stopCleanup chan struct{} // Signal to stop cleanup goroutine 27 + testMode bool // If true, use HTTP for local DIDs 27 28 } 28 29 29 30 // denialEntry stores timestamp for in-memory first denials ··· 32 33 } 33 34 34 35 // NewRemoteHoldAuthorizer creates a new remote authorizer for AppView 35 - func NewRemoteHoldAuthorizer(db *sql.DB) HoldAuthorizer { 36 + func NewRemoteHoldAuthorizer(db *sql.DB, testMode bool) HoldAuthorizer { 36 37 a := &RemoteHoldAuthorizer{ 37 38 db: db, 38 39 httpClient: &http.Client{ ··· 40 41 }, 41 42 cacheTTL: 1 * time.Hour, // 1 hour cache TTL 42 43 stopCleanup: make(chan struct{}), 44 + testMode: testMode, 43 45 } 44 46 45 47 // Start cleanup goroutine for in-memory denials ··· 192 194 // fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record 193 195 func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) { 194 196 // Resolve DID to URL 195 - holdURL, err := resolveDIDToURL(holdDID) 197 + holdURL, err := a.resolveDIDToURL(holdDID) 196 198 if err != nil { 197 199 return nil, fmt.Errorf("failed to resolve hold DID: %w", err) 198 200 } ··· 293 295 // isCrewMemberNoCache queries XRPC without caching (internal helper) 294 296 func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) { 295 297 // Resolve DID to URL 296 - holdURL, err := resolveDIDToURL(holdDID) 298 + holdURL, err := a.resolveDIDToURL(holdDID) 297 299 if err != nil { 298 300 return false, fmt.Errorf("failed to resolve hold DID: %w", err) 299 301 } ··· 374 376 return CheckWriteAccessWithCaptain(captain, userDID, isCrew), nil 375 377 } 376 378 377 - // resolveDIDToURL converts a did:web DID to an HTTPS URL 379 + // resolveDIDToURL converts a did:web DID to an HTTP/HTTPS URL 378 380 // Example: did:web:hold01.atcr.io → https://hold01.atcr.io 379 - func resolveDIDToURL(did string) (string, error) { 381 + // Example (test mode): did:web:172.28.0.3:8080 → http://172.28.0.3:8080 382 + func (a *RemoteHoldAuthorizer) resolveDIDToURL(did string) (string, error) { 380 383 // Handle did:web format 381 384 if !strings.HasPrefix(did, "did:web:") { 382 385 return "", fmt.Errorf("only did:web is supported, got: %s", did) ··· 385 388 // Extract hostname from did:web:hostname 386 389 hostname := strings.TrimPrefix(did, "did:web:") 387 390 388 - // Convert to HTTPS URL 391 + // In test mode OR for local addresses, use HTTP instead of HTTPS 392 + // This matches the logic in pkg/appview/storage/proxy_blob_store.go:resolveHoldURL 393 + if a.testMode || 394 + strings.Contains(hostname, ":") || 395 + strings.Contains(hostname, "127.0.0.1") || 396 + strings.Contains(hostname, "localhost") || 397 + // Check if it's an IP address (contains only digits and dots) 398 + (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) { 399 + return "http://" + hostname, nil 400 + } 401 + 402 + // Convert to HTTPS URL for production domains 389 403 return "https://" + hostname, nil 390 404 } 391 405