A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
73
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