A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
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}