Files
at-container-registry/pkg/auth/oauth/store.go
T

237 lines
6.0 KiB
Go

package oauth
import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"sync"
"time"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// FileStore implements oauth.ClientAuthStore with file-based persistence
type FileStore struct {
path string
sessions map[string]*oauth.ClientSessionData // Key: "did:sessionID"
requests map[string]*oauth.AuthRequestData // Key: state
mu sync.RWMutex
}
// FileStoreData represents the JSON structure stored on disk
type FileStoreData struct {
Sessions map[string]*oauth.ClientSessionData `json:"sessions"`
Requests map[string]*oauth.AuthRequestData `json:"requests"`
}
// NewFileStore creates a new file-based OAuth store
func NewFileStore(path string) (*FileStore, error) {
store := &FileStore{
path: path,
sessions: make(map[string]*oauth.ClientSessionData),
requests: make(map[string]*oauth.AuthRequestData),
}
// Load existing data if file exists
if err := store.load(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load store: %w", err)
}
// File doesn't exist yet, that's ok
}
return store, nil
}
// GetDefaultStorePath returns the default storage path for OAuth data
func GetDefaultStorePath() (string, error) {
// For AppView: /var/lib/atcr/oauth-sessions.json
// For CLI tools: ~/.atcr/oauth-sessions.json
// Check if running as a service (has write access to /var/lib)
servicePath := "/var/lib/atcr/oauth-sessions.json"
if err := os.MkdirAll(filepath.Dir(servicePath), 0700); err == nil {
// Can write to /var/lib, use service path
return servicePath, nil
}
// Fall back to user home directory
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
atcrDir := filepath.Join(homeDir, ".atcr")
if err := os.MkdirAll(atcrDir, 0700); err != nil {
return "", fmt.Errorf("failed to create .atcr directory: %w", err)
}
return filepath.Join(atcrDir, "oauth-sessions.json"), nil
}
// GetSession retrieves a session by DID and session ID
func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
key := makeSessionKey(did.String(), sessionID)
session, ok := s.sessions[key]
if !ok {
return nil, fmt.Errorf("session not found: %s/%s", did, sessionID)
}
return session, nil
}
// SaveSession saves or updates a session (upsert)
func (s *FileStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
s.mu.Lock()
defer s.mu.Unlock()
key := makeSessionKey(sess.AccountDID.String(), sess.SessionID)
s.sessions[key] = &sess
return s.save()
}
// DeleteSession removes a session
func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
s.mu.Lock()
defer s.mu.Unlock()
key := makeSessionKey(did.String(), sessionID)
delete(s.sessions, key)
return s.save()
}
// GetAuthRequestInfo retrieves authentication request data by state
func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
request, ok := s.requests[state]
if !ok {
return nil, fmt.Errorf("auth request not found: %s", state)
}
return request, nil
}
// SaveAuthRequestInfo saves authentication request data
func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
s.mu.Lock()
defer s.mu.Unlock()
s.requests[info.State] = &info
return s.save()
}
// DeleteAuthRequestInfo removes authentication request data
func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.requests, state)
return s.save()
}
// CleanupExpired removes expired sessions and auth requests
// Should be called periodically (e.g., every hour)
func (s *FileStore) CleanupExpired() error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
modified := false
// Clean up auth requests older than 10 minutes
// (OAuth flows should complete quickly)
for state := range s.requests {
// Note: AuthRequestData doesn't have a timestamp in indigo's implementation
// For now, we'll rely on the OAuth server's cleanup routine
// or we could extend AuthRequestData with metadata
_ = state // Placeholder for future expiration logic
}
// Sessions don't have expiry in the data structure
// Cleanup would need to be token-based (check token expiry)
// For now, manual cleanup via DeleteSession
_ = now
if modified {
return s.save()
}
return nil
}
// ListSessions returns all stored sessions for debugging/management
func (s *FileStore) ListSessions() map[string]*oauth.ClientSessionData {
s.mu.RLock()
defer s.mu.RUnlock()
// Return a copy to prevent external modification
result := make(map[string]*oauth.ClientSessionData)
maps.Copy(result, s.sessions)
return result
}
// load reads data from disk
func (s *FileStore) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
return err
}
var storeData FileStoreData
if err := json.Unmarshal(data, &storeData); err != nil {
return fmt.Errorf("failed to parse store: %w", err)
}
if storeData.Sessions != nil {
s.sessions = storeData.Sessions
}
if storeData.Requests != nil {
s.requests = storeData.Requests
}
return nil
}
// save writes data to disk
func (s *FileStore) save() error {
storeData := FileStoreData{
Sessions: s.sessions,
Requests: s.requests,
}
data, err := json.MarshalIndent(storeData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal store: %w", err)
}
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// Write with restrictive permissions
if err := os.WriteFile(s.path, data, 0600); err != nil {
return fmt.Errorf("failed to write store: %w", err)
}
return nil
}
// makeSessionKey creates a composite key for session storage
func makeSessionKey(did, sessionID string) string {
return fmt.Sprintf("%s:%s", did, sessionID)
}