···6677 "atcr.io/pkg/appview/db"
88 "atcr.io/pkg/auth/oauth"
99+ indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
910 "github.com/bluesky-social/indigo/atproto/syntax"
1011)
11121213// LogoutHandler handles user logout with proper OAuth token revocation
1314type LogoutHandler struct {
1414- OAuthApp *oauth.App
1515- Refresher *oauth.Refresher
1616- SessionStore *db.SessionStore
1717- OAuthStore *db.OAuthStore
1515+ OAuthClientApp *indigooauth.ClientApp
1616+ Refresher *oauth.Refresher
1717+ SessionStore *db.SessionStore
1818+ OAuthStore *db.OAuthStore
1819}
19202021func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···3738 // Attempt to revoke OAuth tokens on PDS side
3839 if uiSession.OAuthSessionID != "" {
3940 // Call indigo's Logout to revoke tokens on PDS
4040- if err := h.OAuthApp.GetClientApp().Logout(r.Context(), did, uiSession.OAuthSessionID); err != nil {
4141+ if err := h.OAuthClientApp.Logout(r.Context(), did, uiSession.OAuthSessionID); err != nil {
4142 // Log error but don't block logout - best effort revocation
4243 slog.Warn("Failed to revoke OAuth tokens on PDS", "component", "logout", "did", uiSession.DID, "error", err)
4344 } else {
4445 slog.Info("Successfully revoked OAuth tokens on PDS", "component", "logout", "did", uiSession.DID)
4546 }
4646-4747- // Invalidate refresher cache to clear local access tokens
4848- h.Refresher.InvalidateSession(uiSession.DID)
4949- slog.Info("Invalidated local OAuth cache", "component", "logout", "did", uiSession.DID)
50475148 // Delete OAuth session from database (cleanup, might already be done by Logout)
5249 if err := h.OAuthStore.DeleteSession(r.Context(), did, uiSession.OAuthSessionID); err != nil {
+7-18
pkg/appview/middleware/registry.go
···66 "fmt"
77 "log/slog"
88 "strings"
99- "sync"
1091110 "github.com/distribution/distribution/v3"
1211 "github.com/distribution/distribution/v3/registry/api/errcode"
···6968 defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
7069 baseURL string // Base URL for error messages (e.g., "https://atcr.io")
7170 testMode bool // If true, fallback to default hold when user's hold is unreachable
7272- repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
7371 refresher *oauth.Refresher // OAuth session manager (copied from global on init)
7472 database storage.DatabaseMetrics // Metrics database (copied from global on init)
7573 authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
···224222 // Example: "evan.jarrett.net/debian" -> store as "debian"
225223 repositoryName := imageName
226224227227- // Cache key is DID + repository name
228228- cacheKey := did + ":" + repositoryName
229229-230230- // Check cache first and update service token
231231- if cached, ok := nr.repositories.Load(cacheKey); ok {
232232- cachedRepo := cached.(*storage.RoutingRepository)
233233- // Always update the service token even for cached repos (token may have been renewed)
234234- cachedRepo.Ctx.ServiceToken = serviceToken
235235- return cachedRepo, nil
236236- }
237237-238225 // Create routing repository - routes manifests to ATProto, blobs to hold service
239226 // The registry is stateless - no local storage is used
240227 // Bundle all context into a single RegistryContext struct
228228+ //
229229+ // NOTE: We create a fresh RoutingRepository on every request (no caching) because:
230230+ // 1. Each layer upload is a separate HTTP request (possibly different process)
231231+ // 2. OAuth sessions can be refreshed/invalidated between requests
232232+ // 3. The refresher already caches sessions efficiently (in-memory + DB)
233233+ // 4. Caching the repository with a stale ATProtoClient causes refresh token errors
241234 registryCtx := &storage.RegistryContext{
242235 DID: did,
243236 Handle: handle,
···251244 Refresher: nr.refresher,
252245 ReadmeCache: nr.readmeCache,
253246 }
254254- routingRepo := storage.NewRoutingRepository(repo, registryCtx)
255247256256- // Cache the repository
257257- nr.repositories.Store(cacheKey, routingRepo)
258258-259259- return routingRepo, nil
248248+ return storage.NewRoutingRepository(repo, registryCtx), nil
260249}
261250262251// Repositories delegates to underlying namespace
···1313type InteractiveResult struct {
1414 SessionData *oauth.ClientSessionData
1515 Session *oauth.ClientSession
1616- App *App
1616+ ClientApp *oauth.ClientApp
1717}
18181919// InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling
···3232 return nil, fmt.Errorf("failed to create OAuth store: %w", err)
3333 }
34343535- // Create OAuth app with custom scopes (or defaults if nil)
3535+ // Create OAuth client app with custom scopes (or defaults if nil)
3636 // Interactive flows are typically for production use (credential helper, etc.)
3737- // so we default to testMode=false
3837 // For CLI tools, we use an empty keyPath since they're typically localhost (public client)
3938 // or ephemeral sessions
4040- var app *App
4141- if scopes != nil {
4242- app, err = NewAppWithScopes(baseURL, store, scopes, "", "AT Container Registry")
4343- } else {
4444- app, err = NewApp(baseURL, store, "*", "", "AT Container Registry")
3939+ if scopes == nil {
4040+ scopes = GetDefaultScopes("*")
4541 }
4242+ clientApp, err := NewClientApp(baseURL, store, scopes, "", "AT Container Registry")
4643 if err != nil {
4747- return nil, fmt.Errorf("failed to create OAuth app: %w", err)
4444+ return nil, fmt.Errorf("failed to create OAuth client app: %w", err)
4845 }
49465047 // Channel to receive callback result
···5451 // Create callback handler
5552 callbackHandler := func(w http.ResponseWriter, r *http.Request) {
5653 // Process callback
5757- sessionData, err := app.ProcessCallback(r.Context(), r.URL.Query())
5454+ sessionData, err := clientApp.ProcessCallback(r.Context(), r.URL.Query())
5855 if err != nil {
5956 errorChan <- fmt.Errorf("failed to process callback: %w", err)
6057 http.Error(w, "OAuth callback failed", http.StatusInternalServerError)
···6259 }
63606461 // Resume session
6565- session, err := app.ResumeSession(r.Context(), sessionData.AccountDID, sessionData.SessionID)
6262+ session, err := clientApp.ResumeSession(r.Context(), sessionData.AccountDID, sessionData.SessionID)
6663 if err != nil {
6764 errorChan <- fmt.Errorf("failed to resume session: %w", err)
6865 http.Error(w, "Failed to resume session", http.StatusInternalServerError)
···7370 resultChan <- &InteractiveResult{
7471 SessionData: sessionData,
7572 Session: session,
7676- App: app,
7373+ ClientApp: clientApp,
7774 }
78757976 // Return success to browser
···8784 }
88858986 // Start auth flow
9090- authURL, err := app.StartAuthFlow(ctx, handle)
8787+ authURL, err := clientApp.StartAuthFlow(ctx, handle)
9188 if err != nil {
9289 return nil, fmt.Errorf("failed to start auth flow: %w", err)
9390 }
-192
pkg/auth/oauth/refresher.go
···11-package oauth
22-33-import (
44- "context"
55- "fmt"
66- "log/slog"
77- "sync"
88- "time"
99-1010- "github.com/bluesky-social/indigo/atproto/auth/oauth"
1111- "github.com/bluesky-social/indigo/atproto/syntax"
1212-)
1313-1414-// SessionCache represents a cached OAuth session
1515-type SessionCache struct {
1616- Session *oauth.ClientSession
1717- SessionID string
1818-}
1919-2020-// UISessionStore interface for managing UI sessions
2121-// Shared between refresher and server
2222-type UISessionStore interface {
2323- Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
2424- DeleteByDID(did string)
2525-}
2626-2727-// Refresher manages OAuth sessions and token refresh for AppView
2828-type Refresher struct {
2929- app *App
3030- sessions map[string]*SessionCache // Key: DID string
3131- mu sync.RWMutex
3232- refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations
3333- refreshLockMu sync.Mutex // Protects refreshLocks map
3434- uiSessionStore UISessionStore // For invalidating UI sessions on OAuth failures
3535-}
3636-3737-// NewRefresher creates a new session refresher
3838-func NewRefresher(app *App) *Refresher {
3939- return &Refresher{
4040- app: app,
4141- sessions: make(map[string]*SessionCache),
4242- refreshLocks: make(map[string]*sync.Mutex),
4343- }
4444-}
4545-4646-// SetUISessionStore sets the UI session store for invalidating sessions on OAuth failures
4747-func (r *Refresher) SetUISessionStore(store UISessionStore) {
4848- r.uiSessionStore = store
4949-}
5050-5151-// GetSession gets a fresh OAuth session for a DID
5252-// Returns cached session if still valid, otherwise resumes from store
5353-func (r *Refresher) GetSession(ctx context.Context, did string) (*oauth.ClientSession, error) {
5454- // Check cache first (fast path)
5555- r.mu.RLock()
5656- cached, ok := r.sessions[did]
5757- r.mu.RUnlock()
5858-5959- if ok && cached.Session != nil {
6060- // Session cached, tokens will auto-refresh if needed
6161- return cached.Session, nil
6262- }
6363-6464- // Session not cached, need to resume from store
6565- // Get or create per-DID lock to prevent concurrent resume operations
6666- r.refreshLockMu.Lock()
6767- didLock, ok := r.refreshLocks[did]
6868- if !ok {
6969- didLock = &sync.Mutex{}
7070- r.refreshLocks[did] = didLock
7171- }
7272- r.refreshLockMu.Unlock()
7373-7474- // Acquire DID-specific lock
7575- didLock.Lock()
7676- defer didLock.Unlock()
7777-7878- // Double-check cache after acquiring lock (another goroutine might have loaded it)
7979- r.mu.RLock()
8080- cached, ok = r.sessions[did]
8181- r.mu.RUnlock()
8282-8383- if ok && cached.Session != nil {
8484- return cached.Session, nil
8585- }
8686-8787- // Actually resume the session
8888- return r.resumeSession(ctx, did)
8989-}
9090-9191-// resumeSession loads a session from storage and caches it
9292-func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) {
9393- // Parse DID
9494- accountDID, err := syntax.ParseDID(did)
9595- if err != nil {
9696- return nil, fmt.Errorf("failed to parse DID: %w", err)
9797- }
9898-9999- // Get the latest session for this DID from SQLite store
100100- // The store must implement GetLatestSessionForDID (returns newest by updated_at)
101101- type sessionGetter interface {
102102- GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error)
103103- }
104104-105105- getter, ok := r.app.clientApp.Store.(sessionGetter)
106106- if !ok {
107107- return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)")
108108- }
109109-110110- sessionData, sessionID, err := getter.GetLatestSessionForDID(ctx, did)
111111- if err != nil {
112112- return nil, fmt.Errorf("no session found for DID: %s", did)
113113- }
114114-115115- // Validate that session scopes match current desired scopes
116116- desiredScopes := r.app.GetConfig().Scopes
117117- if !ScopesMatch(sessionData.Scopes, desiredScopes) {
118118- slog.Debug("Scope mismatch, deleting session",
119119- "did", did,
120120- "storedScopes", sessionData.Scopes,
121121- "desiredScopes", desiredScopes)
122122-123123- // Delete the session from database since scopes have changed
124124- if err := r.app.clientApp.Store.DeleteSession(ctx, accountDID, sessionID); err != nil {
125125- slog.Warn("Failed to delete session with mismatched scopes", "error", err, "did", did)
126126- }
127127-128128- return nil, fmt.Errorf("OAuth scopes changed, re-authentication required")
129129- }
130130-131131- // Resume session
132132- session, err := r.app.ResumeSession(ctx, accountDID, sessionID)
133133- if err != nil {
134134- return nil, fmt.Errorf("failed to resume session: %w", err)
135135- }
136136-137137- // Set up callback to persist token updates to SQLite
138138- // This ensures that when indigo automatically refreshes tokens,
139139- // the new tokens are saved to the database immediately
140140- session.PersistSessionCallback = func(callbackCtx context.Context, updatedData *oauth.ClientSessionData) {
141141- if err := r.app.GetClientApp().Store.SaveSession(callbackCtx, *updatedData); err != nil {
142142- slog.Error("Failed to persist OAuth session update",
143143- "component", "oauth/refresher",
144144- "did", did,
145145- "sessionID", sessionID,
146146- "error", err)
147147- } else {
148148- slog.Debug("Persisted OAuth token refresh to database",
149149- "component", "oauth/refresher",
150150- "did", did,
151151- "sessionID", sessionID)
152152- }
153153- }
154154-155155- // Cache the session
156156- r.mu.Lock()
157157- r.sessions[did] = &SessionCache{
158158- Session: session,
159159- SessionID: sessionID,
160160- }
161161- r.mu.Unlock()
162162-163163- return session, nil
164164-}
165165-166166-// InvalidateSession removes a cached session for a DID
167167-// This is useful when a new OAuth flow creates a fresh session or when OAuth refresh fails
168168-// Also invalidates any UI sessions for this DID to force re-authentication
169169-func (r *Refresher) InvalidateSession(did string) {
170170- r.mu.Lock()
171171- delete(r.sessions, did)
172172- r.mu.Unlock()
173173-174174- // Also delete UI sessions to force user to re-authenticate
175175- if r.uiSessionStore != nil {
176176- r.uiSessionStore.DeleteByDID(did)
177177- }
178178-}
179179-180180-// GetSessionID returns the sessionID for a cached session
181181-// Returns empty string if session not cached
182182-func (r *Refresher) GetSessionID(did string) string {
183183- r.mu.RLock()
184184- defer r.mu.RUnlock()
185185-186186- cached, ok := r.sessions[did]
187187- if !ok || cached == nil {
188188- return ""
189189- }
190190-191191- return cached.SessionID
192192-}
-66
pkg/auth/oauth/refresher_test.go
···11-package oauth
22-33-import (
44- "testing"
55-)
66-77-func TestNewRefresher(t *testing.T) {
88- tmpDir := t.TempDir()
99- storePath := tmpDir + "/oauth-test.json"
1010-1111- store, err := NewFileStore(storePath)
1212- if err != nil {
1313- t.Fatalf("NewFileStore() error = %v", err)
1414- }
1515-1616- app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
1717- if err != nil {
1818- t.Fatalf("NewApp() error = %v", err)
1919- }
2020-2121- refresher := NewRefresher(app)
2222- if refresher == nil {
2323- t.Fatal("Expected non-nil refresher")
2424- }
2525-2626- if refresher.app == nil {
2727- t.Error("Expected app to be set")
2828- }
2929-3030- if refresher.sessions == nil {
3131- t.Error("Expected sessions map to be initialized")
3232- }
3333-3434- if refresher.refreshLocks == nil {
3535- t.Error("Expected refreshLocks map to be initialized")
3636- }
3737-}
3838-3939-func TestRefresher_SetUISessionStore(t *testing.T) {
4040- tmpDir := t.TempDir()
4141- storePath := tmpDir + "/oauth-test.json"
4242-4343- store, err := NewFileStore(storePath)
4444- if err != nil {
4545- t.Fatalf("NewFileStore() error = %v", err)
4646- }
4747-4848- app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
4949- if err != nil {
5050- t.Fatalf("NewApp() error = %v", err)
5151- }
5252-5353- refresher := NewRefresher(app)
5454-5555- // Test that SetUISessionStore doesn't panic with nil
5656- // Full mock implementation requires implementing the interface
5757- refresher.SetUISessionStore(nil)
5858-5959- // Verify nil is accepted
6060- if refresher.uiSessionStore != nil {
6161- t.Error("Expected UI session store to be nil after setting nil")
6262- }
6363-}
6464-6565-// Note: Full session management tests will be added in comprehensive implementation
6666-// Those tests will require mocking OAuth sessions and testing cache behavior
+8-15
pkg/auth/oauth/server.go
···1010 "time"
11111212 "atcr.io/pkg/atproto"
1313+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1314)
14151516// UISessionStore is the interface for UI session management
1616-// UISessionStore is defined in refresher.go to avoid duplication
1717+// UISessionStore is defined in client.go (session management section)
17181819// UserStore is the interface for user management
1920type UserStore interface {
···28292930// Server handles OAuth authorization for the AppView
3031type Server struct {
3131- app *App
3232+ clientApp *oauth.ClientApp
3233 refresher *Refresher
3334 uiSessionStore UISessionStore
3435 postAuthCallback PostAuthCallback
3536}
36373738// NewServer creates a new OAuth server
3838-func NewServer(app *App) *Server {
3939+func NewServer(clientApp *oauth.ClientApp) *Server {
3940 return &Server{
4040- app: app,
4141+ clientApp: clientApp,
4142 }
4243}
4344···7475 slog.Debug("Starting OAuth flow", "handle", handle)
75767677 // Start auth flow via indigo
7777- authURL, err := s.app.StartAuthFlow(r.Context(), handle)
7878+ authURL, err := s.clientApp.StartAuthFlow(r.Context(), handle)
7879 if err != nil {
7980 slog.Error("Failed to start auth flow", "error", err, "handle", handle)
8081···111112 }
112113113114 // Process OAuth callback via indigo (handles state validation internally)
114114- sessionData, err := s.app.ProcessCallback(r.Context(), r.URL.Query())
115115+ sessionData, err := s.clientApp.ProcessCallback(r.Context(), r.URL.Query())
115116 if err != nil {
116117 s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err))
117118 return
···129130 type sessionCleaner interface {
130131 DeleteOldSessionsForDID(ctx context.Context, did string, keepSessionID string) error
131132 }
132132- if cleaner, ok := s.app.clientApp.Store.(sessionCleaner); ok {
133133+ if cleaner, ok := s.clientApp.Store.(sessionCleaner); ok {
133134 if err := cleaner.DeleteOldSessionsForDID(r.Context(), did, sessionID); err != nil {
134135 slog.Warn("Failed to clean up old OAuth sessions", "did", did, "error", err)
135136 // Non-fatal - log and continue
136137 } else {
137138 slog.Debug("Cleaned up old OAuth sessions", "did", did, "kept", sessionID)
138139 }
139139- }
140140-141141- // Invalidate cached session (if any) since we have a new session with new tokens
142142- // This happens AFTER deleting old sessions from database, ensuring the cache
143143- // will load the correct session when it's next accessed
144144- if s.refresher != nil {
145145- s.refresher.InvalidateSession(did)
146146- slog.Debug("Invalidated cached session after creating new session", "did", did)
147140 }
148141149142 // Look up identity (resolve DID to handle)