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) }