mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 12:17:00 +00:00
fix auth issues in appview xrpc calls
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user