mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 23:36:57 +00:00
225 lines
6.0 KiB
Go
225 lines
6.0 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
)
|
|
|
|
// SessionCache represents a cached OAuth session
|
|
type SessionCache struct {
|
|
Session *oauth.ClientSession
|
|
SessionID string
|
|
}
|
|
|
|
// Refresher manages OAuth sessions and token refresh for AppView
|
|
type Refresher struct {
|
|
app *App
|
|
sessions map[string]*SessionCache // Key: DID string
|
|
mu sync.RWMutex
|
|
refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations
|
|
refreshLockMu sync.Mutex // Protects refreshLocks map
|
|
}
|
|
|
|
// NewRefresher creates a new session refresher
|
|
func NewRefresher(app *App) *Refresher {
|
|
return &Refresher{
|
|
app: app,
|
|
sessions: make(map[string]*SessionCache),
|
|
refreshLocks: make(map[string]*sync.Mutex),
|
|
}
|
|
}
|
|
|
|
// GetSession gets a fresh OAuth session for a DID
|
|
// Returns cached session if still valid, otherwise resumes from store
|
|
func (r *Refresher) GetSession(ctx context.Context, did string) (*oauth.ClientSession, error) {
|
|
// Check cache first (fast path)
|
|
r.mu.RLock()
|
|
cached, ok := r.sessions[did]
|
|
r.mu.RUnlock()
|
|
|
|
if ok && cached.Session != nil {
|
|
// Session cached, tokens will auto-refresh if needed
|
|
return cached.Session, nil
|
|
}
|
|
|
|
// Session not cached, need to resume from store
|
|
// Get or create per-DID lock to prevent concurrent resume operations
|
|
r.refreshLockMu.Lock()
|
|
didLock, ok := r.refreshLocks[did]
|
|
if !ok {
|
|
didLock = &sync.Mutex{}
|
|
r.refreshLocks[did] = didLock
|
|
}
|
|
r.refreshLockMu.Unlock()
|
|
|
|
// Acquire DID-specific lock
|
|
didLock.Lock()
|
|
defer didLock.Unlock()
|
|
|
|
// Double-check cache after acquiring lock (another goroutine might have loaded it)
|
|
r.mu.RLock()
|
|
cached, ok = r.sessions[did]
|
|
r.mu.RUnlock()
|
|
|
|
if ok && cached.Session != nil {
|
|
return cached.Session, nil
|
|
}
|
|
|
|
// Actually resume the session
|
|
return r.resumeSession(ctx, did)
|
|
}
|
|
|
|
// GetAccessToken gets a fresh access token for a DID
|
|
// This is a convenience method that extracts the access token from the session
|
|
func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, error) {
|
|
session, err := r.GetSession(ctx, did)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Get access token and DPoP nonce from session
|
|
accessToken, _ := session.GetHostAccessData()
|
|
return accessToken, nil
|
|
}
|
|
|
|
// GetHTTPClient returns an HTTP client with DPoP authentication for a DID
|
|
// The client automatically adds DPoP headers and refreshes tokens as needed
|
|
func (r *Refresher) GetHTTPClient(ctx context.Context, did string) (*http.Client, error) {
|
|
session, err := r.GetSession(ctx, did)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get API client from session
|
|
// This client automatically handles DPoP and token refresh
|
|
apiClient := session.APIClient()
|
|
return apiClient.Client, nil
|
|
}
|
|
|
|
// resumeSession loads a session from storage and caches it
|
|
func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) {
|
|
// Parse DID
|
|
accountDID, err := syntax.ParseDID(did)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse DID: %w", err)
|
|
}
|
|
|
|
// Get all sessions for this DID from store
|
|
fileStore, ok := r.app.clientApp.Store.(*FileStore)
|
|
if !ok {
|
|
return nil, fmt.Errorf("store is not a FileStore")
|
|
}
|
|
|
|
// Find a session for this DID
|
|
sessions := fileStore.ListSessions()
|
|
var sessionID string
|
|
for _, sessionData := range sessions {
|
|
if sessionData.AccountDID.String() == did {
|
|
sessionID = sessionData.SessionID
|
|
break
|
|
}
|
|
}
|
|
|
|
if sessionID == "" {
|
|
return nil, fmt.Errorf("no session found for DID: %s", did)
|
|
}
|
|
|
|
// Resume session
|
|
session, err := r.app.ResumeSession(ctx, accountDID, sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resume session: %w", err)
|
|
}
|
|
|
|
// Cache the session
|
|
r.mu.Lock()
|
|
r.sessions[did] = &SessionCache{
|
|
Session: session,
|
|
SessionID: sessionID,
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// InvalidateSession removes a cached session for a DID
|
|
// This is useful when a new OAuth flow creates a fresh session
|
|
func (r *Refresher) InvalidateSession(did string) {
|
|
r.mu.Lock()
|
|
delete(r.sessions, did)
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// RevokeSession removes a session from both cache and storage
|
|
func (r *Refresher) RevokeSession(ctx context.Context, did string) error {
|
|
// Remove from cache
|
|
r.mu.Lock()
|
|
cached, ok := r.sessions[did]
|
|
delete(r.sessions, did)
|
|
r.mu.Unlock()
|
|
|
|
if !ok {
|
|
// Not cached, still try to delete from storage
|
|
accountDID, err := syntax.ParseDID(did)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse DID: %w", err)
|
|
}
|
|
|
|
// Find session ID from store
|
|
fileStore, ok := r.app.clientApp.Store.(*FileStore)
|
|
if !ok {
|
|
return fmt.Errorf("store is not a FileStore")
|
|
}
|
|
|
|
sessions := fileStore.ListSessions()
|
|
for _, sessionData := range sessions {
|
|
if sessionData.AccountDID.String() == did {
|
|
return r.app.clientApp.Store.DeleteSession(ctx, accountDID, sessionData.SessionID)
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("no session found for DID: %s", did)
|
|
}
|
|
|
|
// Revoke the session via OAuth
|
|
if err := cached.Session.RevokeSession(ctx); err != nil {
|
|
fmt.Printf("WARNING: failed to revoke session for %s: %v\n", did, err)
|
|
// Continue anyway to delete from storage
|
|
}
|
|
|
|
// Delete from storage
|
|
accountDID, err := syntax.ParseDID(did)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse DID: %w", err)
|
|
}
|
|
|
|
return r.app.clientApp.Store.DeleteSession(ctx, accountDID, cached.SessionID)
|
|
}
|
|
|
|
// CleanupExpiredSessions removes expired sessions from cache
|
|
// Note: indigo handles token expiry automatically, but we clean up orphaned cache entries
|
|
func (r *Refresher) CleanupExpiredSessions(ctx context.Context) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// For each cached session, verify it still exists in storage
|
|
for did, cached := range r.sessions {
|
|
accountDID, err := syntax.ParseDID(did)
|
|
if err != nil {
|
|
delete(r.sessions, did)
|
|
continue
|
|
}
|
|
|
|
// Try to get session from store
|
|
_, err = r.app.clientApp.Store.GetSession(ctx, accountDID, cached.SessionID)
|
|
if err != nil {
|
|
// Session no longer exists, remove from cache
|
|
delete(r.sessions, did)
|
|
}
|
|
}
|
|
}
|