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.

at main 425 lines 16 kB view raw
1// Package oauth provides OAuth client configuration and helper functions for ATCR. 2// It provides helpers for setting up indigo's OAuth library with ATCR-specific 3// configuration, including default scopes, confidential client setup, and 4// interactive browser-based authentication flows. 5package oauth 6 7import ( 8 "context" 9 "fmt" 10 "log/slog" 11 "strings" 12 "sync" 13 "time" 14 15 "atcr.io/pkg/atproto" 16 "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18) 19 20// permissionSetExpansions maps lexicon IDs to their expanded scope format. 21// These must match the collections defined in lexicons/io/atcr/authFullApp.json 22// Collections are sorted alphabetically for consistent comparison with PDS-expanded scopes. 23var permissionSetExpansions = map[string]string{ 24 "io.atcr.authFullApp": "repo?" + 25 "collection=io.atcr.manifest&" + 26 "collection=io.atcr.repo.page&" + 27 "collection=io.atcr.sailor.profile&" + 28 "collection=io.atcr.sailor.star&" + 29 "collection=io.atcr.tag", 30} 31 32// ExpandIncludeScopes expands any "include:" prefixed scopes to their full form 33// by looking up the corresponding permission-set in the embedded lexicon files. 34// For example, "include:io.atcr.authFullApp" expands to "repo?collection=io.atcr.manifest&..." 35func ExpandIncludeScopes(scopes []string) []string { 36 var expanded []string 37 for _, scope := range scopes { 38 if strings.HasPrefix(scope, "include:") { 39 lexiconID := strings.TrimPrefix(scope, "include:") 40 if exp, ok := permissionSetExpansions[lexiconID]; ok { 41 expanded = append(expanded, exp) 42 } else { 43 expanded = append(expanded, scope) // Keep original if unknown 44 } 45 } else { 46 expanded = append(expanded, scope) 47 } 48 } 49 return expanded 50} 51 52// NewClientApp creates an indigo OAuth ClientApp with ATCR-specific configuration 53// Automatically configures confidential client for production deployments 54// keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost) 55// clientName is added to OAuth client metadata (currently unused, reserved for future) 56func NewClientApp(baseURL string, store oauth.ClientAuthStore, scopes []string, keyPath string, clientName string) (*oauth.ClientApp, error) { 57 var config oauth.ClientConfig 58 redirectURI := RedirectURI(baseURL) 59 60 // If production (not localhost), automatically set up confidential client 61 if !isLocalhost(baseURL) { 62 clientID := baseURL + "/oauth-client-metadata.json" 63 config = oauth.NewPublicConfig(clientID, redirectURI, scopes) 64 65 // Generate or load P-256 key 66 privateKey, err := GenerateOrLoadClientKey(keyPath) 67 if err != nil { 68 return nil, fmt.Errorf("failed to load OAuth client key: %w", err) 69 } 70 71 // Generate key ID from public key 72 keyID, err := GenerateKeyID(privateKey) 73 if err != nil { 74 return nil, fmt.Errorf("failed to generate key ID: %w", err) 75 } 76 77 // Upgrade to confidential client 78 if err := config.SetClientSecret(privateKey, keyID); err != nil { 79 return nil, fmt.Errorf("failed to configure confidential client: %w", err) 80 } 81 82 slog.Info("Configured confidential OAuth client", 83 "key_id", keyID, 84 "key_path", keyPath, 85 ) 86 } else { 87 config = oauth.NewLocalhostConfig(redirectURI, scopes) 88 89 slog.Info("Using public OAuth client (localhost development)") 90 } 91 92 clientApp := oauth.NewClientApp(&config, store) 93 clientApp.Dir = atproto.GetDirectory() 94 95 return clientApp, nil 96} 97 98// RedirectURI returns the OAuth redirect URI for ATCR 99func RedirectURI(baseURL string) string { 100 return baseURL + "/auth/oauth/callback" 101} 102 103// GetDefaultScopes returns the default OAuth scopes for ATCR registry operations. 104// Includes io.atcr.authFullApp permission-set plus individual scopes for PDS compatibility. 105// Blob scopes are listed explicitly (not supported in Lexicon permission-sets). 106func GetDefaultScopes(did string) []string { 107 return []string{ 108 "atproto", 109 // Permission-set 110 // See lexicons/io/atcr/authFullApp.json for definition 111 "include:io.atcr.authFullApp", 112 // com.atproto scopes must be separate (permission-sets are namespace-limited) 113 "rpc:com.atproto.repo.getRecord?aud=*", 114 // Blob scopes (not supported in Lexicon permission-sets) 115 // Image manifest types (single-arch) 116 "blob:application/vnd.oci.image.manifest.v1+json", 117 "blob:application/vnd.docker.distribution.manifest.v2+json", 118 // Manifest list/index types (multi-arch) 119 "blob:application/vnd.oci.image.index.v1+json", 120 "blob:application/vnd.docker.distribution.manifest.list.v2+json", 121 // OCI artifact manifests (for cosign signatures, SBOMs, attestations) 122 "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", 123 // Helm chart support 124 "blob:application/vnd.cncf.helm.config.v1+json", 125 "blob:application/vnd.cncf.helm.chart.content.v1.tar+gzip", 126 // Image avatars 127 "blob:image/*", 128 } 129} 130 131// ScopesMatch checks if two scope lists are equivalent (order-independent) 132// Returns true if both lists contain the same scopes, regardless of order. 133// Expands any "include:" prefixed scopes in the desired list before comparing, 134// since the PDS returns expanded scopes in the stored session. 135func ScopesMatch(stored, desired []string) bool { 136 // Expand any include: scopes in desired before comparing 137 expandedDesired := ExpandIncludeScopes(desired) 138 139 // Handle nil/empty cases 140 if len(stored) == 0 && len(expandedDesired) == 0 { 141 return true 142 } 143 if len(stored) != len(expandedDesired) { 144 return false 145 } 146 147 // Build map of desired scopes for O(1) lookup 148 desiredMap := make(map[string]bool, len(expandedDesired)) 149 for _, scope := range expandedDesired { 150 desiredMap[scope] = true 151 } 152 153 // Check if all stored scopes exist in desired 154 for _, scope := range stored { 155 if !desiredMap[scope] { 156 return false 157 } 158 } 159 160 return true 161} 162 163// isLocalhost checks if a base URL is a localhost address 164func isLocalhost(baseURL string) bool { 165 return strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") 166} 167 168// ---------------------------------------------------------------------------- 169// Session Management 170// ---------------------------------------------------------------------------- 171 172// SessionCache represents a cached OAuth session 173type SessionCache struct { 174 Session *oauth.ClientSession 175 SessionID string 176} 177 178// UISessionStore interface for managing UI sessions 179// Shared between refresher and server 180type UISessionStore interface { 181 Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) 182 DeleteByDID(did string) 183} 184 185// Refresher manages OAuth sessions and token refresh for AppView 186// Sessions are loaded fresh from database on every request (database is source of truth) 187type Refresher struct { 188 clientApp *oauth.ClientApp 189 uiSessionStore UISessionStore // For invalidating UI sessions on OAuth failures 190 didLocks sync.Map // Per-DID mutexes to prevent concurrent DPoP nonce races 191} 192 193// NewRefresher creates a new session refresher 194func NewRefresher(clientApp *oauth.ClientApp) *Refresher { 195 return &Refresher{ 196 clientApp: clientApp, 197 } 198} 199 200// SetUISessionStore sets the UI session store for invalidating sessions on OAuth failures 201func (r *Refresher) SetUISessionStore(store UISessionStore) { 202 r.uiSessionStore = store 203} 204 205// DoWithSession executes a function with a locked OAuth session. 206// The lock is held for the entire duration of the function, preventing DPoP nonce races. 207// 208// This is the preferred way to make PDS requests that require OAuth/DPoP authentication. 209// The lock is held through the entire PDS interaction, ensuring that: 210// 1. Only one goroutine at a time can negotiate DPoP nonces with the PDS for a given DID 211// 2. The session's PersistSessionCallback saves the updated nonce before other goroutines load 212// 3. Concurrent layer uploads don't race on stale nonces 213// 214// Why locking is critical: 215// During docker push, multiple layers upload concurrently. Each layer creates a new 216// ClientSession by loading from database. Without locking, this race condition occurs: 217// 1. Layer A loads session with stale DPoP nonce from DB 218// 2. Layer B loads session with same stale nonce (A hasn't updated DB yet) 219// 3. Layer A makes request → 401 "use_dpop_nonce" → gets fresh nonce → saves to DB 220// 4. Layer B makes request → 401 "use_dpop_nonce" (using stale nonce from step 2) 221// 5. DPoP nonce thrashing continues, eventually causing 500 errors 222// 223// With per-DID locking: 224// 1. Layer A acquires lock, loads session, handles nonce negotiation, saves, releases lock 225// 2. Layer B acquires lock AFTER A releases, loads fresh nonce from DB, succeeds 226// 227// Example usage: 228// 229// var result MyResult 230// err := refresher.DoWithSession(ctx, did, func(session *oauth.ClientSession) error { 231// resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth") 232// if err != nil { 233// return err 234// } 235// // Parse response into result... 236// return nil 237// }) 238func (r *Refresher) DoWithSession(ctx context.Context, did string, fn func(session *oauth.ClientSession) error) error { 239 // Get or create a mutex for this DID 240 mutexInterface, _ := r.didLocks.LoadOrStore(did, &sync.Mutex{}) 241 mutex := mutexInterface.(*sync.Mutex) 242 243 // Hold the lock for the ENTIRE operation (load + PDS request + nonce save) 244 mutex.Lock() 245 defer mutex.Unlock() 246 247 slog.Debug("Acquired session lock for DoWithSession", 248 "component", "oauth/refresher", 249 "did", did) 250 251 // Load session while holding lock 252 session, err := r.resumeSession(ctx, did) 253 if err != nil { 254 return err 255 } 256 257 // Execute the function (PDS request) while still holding lock 258 // The session's PersistSessionCallback will save nonce updates to DB 259 err = fn(session) 260 261 // If request failed with auth error, delete session to force re-auth 262 if err != nil && isAuthError(err) { 263 slog.Warn("Auth error detected, deleting session to force re-auth", 264 "component", "oauth/refresher", 265 "did", did, 266 "error", err) 267 // Don't hold the lock while deleting - release first 268 mutex.Unlock() 269 _ = r.DeleteSession(ctx, did) 270 mutex.Lock() // Re-acquire for the deferred unlock 271 } 272 273 slog.Debug("Released session lock for DoWithSession", 274 "component", "oauth/refresher", 275 "did", did, 276 "success", err == nil) 277 278 return err 279} 280 281// isAuthError checks if an error looks like an OAuth/auth failure 282func isAuthError(err error) bool { 283 if err == nil { 284 return false 285 } 286 errStr := strings.ToLower(err.Error()) 287 return strings.Contains(errStr, "unauthorized") || 288 strings.Contains(errStr, "invalid_token") || 289 strings.Contains(errStr, "insufficient_scope") || 290 strings.Contains(errStr, "token expired") || 291 strings.Contains(errStr, "401") 292} 293 294// resumeSession loads a session from storage 295func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) { 296 // Parse DID 297 accountDID, err := syntax.ParseDID(did) 298 if err != nil { 299 return nil, fmt.Errorf("failed to parse DID: %w", err) 300 } 301 302 // Get the latest session for this DID from SQLite store 303 // The store must implement GetLatestSessionForDID (returns newest by updated_at) 304 type sessionGetter interface { 305 GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) 306 } 307 308 getter, ok := r.clientApp.Store.(sessionGetter) 309 if !ok { 310 return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)") 311 } 312 313 sessionData, sessionID, err := getter.GetLatestSessionForDID(ctx, did) 314 if err != nil { 315 return nil, fmt.Errorf("no session found for DID: %s", did) 316 } 317 318 // Log scope differences for debugging, but don't delete session 319 // The PDS will reject requests if scopes are insufficient 320 // (Permission-sets get expanded by PDS, so exact matching doesn't work) 321 desiredScopes := r.clientApp.Config.Scopes 322 if !ScopesMatch(sessionData.Scopes, desiredScopes) { 323 slog.Debug("Session scopes differ from desired (may be permission-set expansion)", 324 "did", did, 325 "storedScopes", sessionData.Scopes, 326 "desiredScopes", desiredScopes) 327 } 328 329 // Resume session 330 session, err := r.clientApp.ResumeSession(ctx, accountDID, sessionID) 331 if err != nil { 332 return nil, fmt.Errorf("failed to resume session: %w", err) 333 } 334 335 // Set up callback to persist token updates to SQLite 336 // This ensures that when indigo automatically refreshes tokens or updates DPoP nonces, 337 // the new state is saved to the database immediately 338 session.PersistSessionCallback = func(callbackCtx context.Context, updatedData *oauth.ClientSessionData) { 339 if err := r.clientApp.Store.SaveSession(callbackCtx, *updatedData); err != nil { 340 slog.Error("Failed to persist OAuth session update", 341 "component", "oauth/refresher", 342 "did", did, 343 "sessionID", sessionID, 344 "error", err) 345 } else { 346 // Log session updates (token refresh, DPoP nonce updates, etc.) 347 // Note: updatedData contains the full session state including DPoP nonce, 348 // but we don't log sensitive data like tokens or nonces themselves 349 slog.Debug("Persisted OAuth session update to database", 350 "component", "oauth/refresher", 351 "did", did, 352 "sessionID", sessionID, 353 "hint", "This includes token refresh and DPoP nonce updates") 354 } 355 } 356 return session, nil 357} 358 359// DeleteSession removes an OAuth session from storage and optionally invalidates the UI session 360// This is called when OAuth authentication fails to force re-authentication 361func (r *Refresher) DeleteSession(ctx context.Context, did string) error { 362 // Parse DID 363 accountDID, err := syntax.ParseDID(did) 364 if err != nil { 365 return fmt.Errorf("failed to parse DID: %w", err) 366 } 367 368 // Get the session ID before deleting (for logging) 369 type sessionGetter interface { 370 GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) 371 } 372 373 getter, ok := r.clientApp.Store.(sessionGetter) 374 if !ok { 375 return fmt.Errorf("store must implement GetLatestSessionForDID") 376 } 377 378 _, sessionID, err := getter.GetLatestSessionForDID(ctx, did) 379 if err != nil { 380 // No session to delete - this is fine 381 slog.Debug("No OAuth session to delete", "did", did) 382 return nil 383 } 384 385 // Delete OAuth session from database 386 if err := r.clientApp.Store.DeleteSession(ctx, accountDID, sessionID); err != nil { 387 slog.Warn("Failed to delete OAuth session", "did", did, "sessionID", sessionID, "error", err) 388 return fmt.Errorf("failed to delete OAuth session: %w", err) 389 } 390 391 slog.Info("Deleted stale OAuth session", 392 "component", "oauth/refresher", 393 "did", did, 394 "sessionID", sessionID, 395 "reason", "OAuth authentication failed") 396 397 // Also invalidate the UI session if store is configured 398 if r.uiSessionStore != nil { 399 r.uiSessionStore.DeleteByDID(did) 400 slog.Info("Invalidated UI session for DID", 401 "component", "oauth/refresher", 402 "did", did, 403 "reason", "OAuth session deleted") 404 } 405 406 return nil 407} 408 409// ValidateSession checks if an OAuth session is usable by attempting to load it. 410// This triggers token refresh if needed (via indigo's auto-refresh in DoWithSession). 411// Returns nil if session is valid, error if session is invalid/expired/needs re-auth. 412// 413// This is used by the token handler to validate OAuth sessions before issuing JWTs, 414// preventing the flood of errors that occurs when a stale session is discovered 415// during parallel layer uploads. 416func (r *Refresher) ValidateSession(ctx context.Context, did string) error { 417 return r.DoWithSession(ctx, did, func(session *oauth.ClientSession) error { 418 // Session loaded and refreshed successfully 419 // DoWithSession already handles token refresh if needed 420 slog.Debug("OAuth session validated successfully", 421 "component", "oauth/refresher", 422 "did", did) 423 return nil 424 }) 425}