A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

remove unused filestore. replace it with memstore for tests

evan.jarrett.net 8956568e c1f2ae0f

verified
Changed files
+118 -1000
docs
pkg
+84
docs/HOLD_XRPC_ENDPOINTS.md
··· 1 + # Hold Service XRPC Endpoints 2 + 3 + This document lists all XRPC endpoints implemented in the Hold service (`pkg/hold/`). 4 + 5 + ## PDS Endpoints (`pkg/hold/pds/xrpc.go`) 6 + 7 + ### Public (No Auth Required) 8 + 9 + | Endpoint | Method | Description | 10 + |----------|--------|-------------| 11 + | `/xrpc/_health` | GET | Health check | 12 + | `/xrpc/com.atproto.server.describeServer` | GET | Server metadata | 13 + | `/xrpc/com.atproto.repo.describeRepo` | GET | Repository information | 14 + | `/xrpc/com.atproto.repo.getRecord` | GET | Retrieve a single record | 15 + | `/xrpc/com.atproto.repo.listRecords` | GET | List records in a collection (paginated) | 16 + | `/xrpc/com.atproto.sync.listRepos` | GET | List all repositories | 17 + | `/xrpc/com.atproto.sync.getRecord` | GET | Get record as CAR file | 18 + | `/xrpc/com.atproto.sync.getRepo` | GET | Full repository as CAR file | 19 + | `/xrpc/com.atproto.sync.getRepoStatus` | GET | Repository hosting status | 20 + | `/xrpc/com.atproto.sync.subscribeRepos` | GET | WebSocket firehose | 21 + | `/xrpc/com.atproto.identity.resolveHandle` | GET | Resolve handle to DID | 22 + | `/xrpc/app.bsky.actor.getProfile` | GET | Get actor profile | 23 + | `/xrpc/app.bsky.actor.getProfiles` | GET | Get multiple profiles | 24 + | `/.well-known/did.json` | GET | DID document | 25 + | `/.well-known/atproto-did` | GET | DID for handle resolution | 26 + 27 + ### Conditional Auth (based on captain.public) 28 + 29 + | Endpoint | Method | Description | 30 + |----------|--------|-------------| 31 + | `/xrpc/com.atproto.sync.getBlob` | GET/HEAD | Get blob (routes OCI vs ATProto) | 32 + 33 + ### Owner/Crew Admin Required 34 + 35 + | Endpoint | Method | Description | 36 + |----------|--------|-------------| 37 + | `/xrpc/com.atproto.repo.deleteRecord` | POST | Delete a record | 38 + | `/xrpc/com.atproto.repo.uploadBlob` | POST | Upload ATProto blob | 39 + 40 + ### DPoP Auth Required 41 + 42 + | Endpoint | Method | Description | 43 + |----------|--------|-------------| 44 + | `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership | 45 + 46 + --- 47 + 48 + ## OCI Multipart Upload Endpoints (`pkg/hold/oci/xrpc.go`) 49 + 50 + All require `blob:write` permission via service token: 51 + 52 + | Endpoint | Method | Description | 53 + |----------|--------|-------------| 54 + | `/xrpc/io.atcr.hold.initiateUpload` | POST | Start multipart upload | 55 + | `/xrpc/io.atcr.hold.getPartUploadUrl` | POST | Get presigned URL for part | 56 + | `/xrpc/io.atcr.hold.uploadPart` | PUT | Direct buffered part upload | 57 + | `/xrpc/io.atcr.hold.completeUpload` | POST | Finalize multipart upload | 58 + | `/xrpc/io.atcr.hold.abortUpload` | POST | Cancel multipart upload | 59 + | `/xrpc/io.atcr.hold.notifyManifest` | POST | Notify manifest push (creates layer records + optional Bluesky post) | 60 + 61 + --- 62 + 63 + ## Standard ATProto Endpoints (excluding io.atcr.hold.*) 64 + 65 + | Endpoint | 66 + |----------| 67 + | /xrpc/_health | 68 + | /xrpc/com.atproto.server.describeServer | 69 + | /xrpc/com.atproto.repo.describeRepo | 70 + | /xrpc/com.atproto.repo.getRecord | 71 + | /xrpc/com.atproto.repo.listRecords | 72 + | /xrpc/com.atproto.repo.deleteRecord | 73 + | /xrpc/com.atproto.repo.uploadBlob | 74 + | /xrpc/com.atproto.sync.listRepos | 75 + | /xrpc/com.atproto.sync.getRecord | 76 + | /xrpc/com.atproto.sync.getRepo | 77 + | /xrpc/com.atproto.sync.getRepoStatus | 78 + | /xrpc/com.atproto.sync.getBlob | 79 + | /xrpc/com.atproto.sync.subscribeRepos | 80 + | /xrpc/com.atproto.identity.resolveHandle | 81 + | /xrpc/app.bsky.actor.getProfile | 82 + | /xrpc/app.bsky.actor.getProfiles | 83 + | /.well-known/did.json | 84 + | /.well-known/atproto-did |
+3 -3
pkg/appview/middleware/registry.go
··· 460 460 Repository: repositoryName, 461 461 ServiceToken: serviceToken, // Cached service token from puller's PDS 462 462 ATProtoClient: atprotoClient, 463 - AuthMethod: authMethod, // Auth method from JWT token 464 - PullerDID: pullerDID, // Authenticated user making the request 465 - PullerPDSEndpoint: pullerPDSEndpoint, // Puller's PDS for service token refresh 463 + AuthMethod: authMethod, // Auth method from JWT token 464 + PullerDID: pullerDID, // Authenticated user making the request 465 + PullerPDSEndpoint: pullerPDSEndpoint, // Puller's PDS for service token refresh 466 466 Database: nr.database, 467 467 Authorizer: nr.authorizer, 468 468 Refresher: nr.refresher,
+10 -10
pkg/appview/storage/context.go
··· 20 20 // Per-request identity and routing information 21 21 // Owner = the user whose repository is being accessed 22 22 // Puller = the authenticated user making the request (from JWT Subject) 23 - DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123") 24 - Handle string // Owner's handle (e.g., "alice.bsky.social") 25 - HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io") 26 - PDSEndpoint string // Owner's PDS endpoint URL 27 - Repository string // Image repository name (e.g., "debian") 28 - ServiceToken string // Service token for hold authentication (from puller's PDS) 29 - ATProtoClient *atproto.Client // Authenticated ATProto client for the owner 30 - AuthMethod string // Auth method used ("oauth" or "app_password") 31 - PullerDID string // Puller's DID - who is making the request (from JWT Subject) 32 - PullerPDSEndpoint string // Puller's PDS endpoint URL 23 + DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123") 24 + Handle string // Owner's handle (e.g., "alice.bsky.social") 25 + HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io") 26 + PDSEndpoint string // Owner's PDS endpoint URL 27 + Repository string // Image repository name (e.g., "debian") 28 + ServiceToken string // Service token for hold authentication (from puller's PDS) 29 + ATProtoClient *atproto.Client // Authenticated ATProto client for the owner 30 + AuthMethod string // Auth method used ("oauth" or "app_password") 31 + PullerDID string // Puller's DID - who is making the request (from JWT Subject) 32 + PullerPDSEndpoint string // Puller's PDS endpoint URL 33 33 34 34 // Shared services (same for all requests) 35 35 Database DatabaseMetrics // Metrics tracking database
-1
pkg/atproto/lexicon.go
··· 310 310 CreatedAt time.Time `json:"createdAt"` 311 311 } 312 312 313 - 314 313 // SailorProfileRecord represents a user's profile with registry preferences 315 314 // Stored in the user's PDS to configure default hold and other settings 316 315 type SailorProfileRecord struct {
+7 -30
pkg/auth/oauth/client_test.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 4 5 "testing" 5 6 ) 6 7 7 8 func TestNewClientApp(t *testing.T) { 8 - tmpDir := t.TempDir() 9 - storePath := tmpDir + "/oauth-test.json" 10 - keyPath := tmpDir + "/oauth-key.bin" 11 - 12 - store, err := NewFileStore(storePath) 13 - if err != nil { 14 - t.Fatalf("NewFileStore() error = %v", err) 15 - } 9 + keyPath := t.TempDir() + "/oauth-key.bin" 10 + store := oauth.NewMemStore() 16 11 17 12 baseURL := "http://localhost:5000" 18 13 scopes := GetDefaultScopes("*") ··· 32 27 } 33 28 34 29 func TestNewClientAppWithCustomScopes(t *testing.T) { 35 - tmpDir := t.TempDir() 36 - storePath := tmpDir + "/oauth-test.json" 37 - keyPath := tmpDir + "/oauth-key.bin" 38 - 39 - store, err := NewFileStore(storePath) 40 - if err != nil { 41 - t.Fatalf("NewFileStore() error = %v", err) 42 - } 30 + keyPath := t.TempDir() + "/oauth-key.bin" 31 + store := oauth.NewMemStore() 43 32 44 33 baseURL := "http://localhost:5000" 45 34 scopes := []string{"atproto", "custom:scope"} ··· 128 117 // ---------------------------------------------------------------------------- 129 118 130 119 func TestNewRefresher(t *testing.T) { 131 - tmpDir := t.TempDir() 132 - storePath := tmpDir + "/oauth-test.json" 133 - 134 - store, err := NewFileStore(storePath) 135 - if err != nil { 136 - t.Fatalf("NewFileStore() error = %v", err) 137 - } 120 + store := oauth.NewMemStore() 138 121 139 122 scopes := GetDefaultScopes("*") 140 123 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 153 136 } 154 137 155 138 func TestRefresher_SetUISessionStore(t *testing.T) { 156 - tmpDir := t.TempDir() 157 - storePath := tmpDir + "/oauth-test.json" 158 - 159 - store, err := NewFileStore(storePath) 160 - if err != nil { 161 - t.Fatalf("NewFileStore() error = %v", err) 162 - } 139 + store := oauth.NewMemStore() 163 140 164 141 scopes := GetDefaultScopes("*") 165 142 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
+1 -5
pkg/auth/oauth/interactive.go
··· 26 26 registerCallback func(handler http.HandlerFunc) error, 27 27 displayAuthURL func(string) error, 28 28 ) (*InteractiveResult, error) { 29 - // Create temporary file store for this flow 30 - store, err := NewFileStore("/tmp/atcr-oauth-temp.json") 31 - if err != nil { 32 - return nil, fmt.Errorf("failed to create OAuth store: %w", err) 33 - } 29 + store := oauth.NewMemStore() 34 30 35 31 // Create OAuth client app with custom scopes (or defaults if nil) 36 32 // Interactive flows are typically for production use (credential helper, etc.)
+13 -84
pkg/auth/oauth/server_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 5 6 "net/http" 6 7 "net/http/httptest" 7 8 "strings" ··· 11 12 12 13 func TestNewServer(t *testing.T) { 13 14 // Create a basic OAuth app for testing 14 - tmpDir := t.TempDir() 15 - storePath := tmpDir + "/oauth-test.json" 16 - 17 - store, err := NewFileStore(storePath) 18 - if err != nil { 19 - t.Fatalf("NewFileStore() error = %v", err) 20 - } 15 + store := oauth.NewMemStore() 21 16 22 17 scopes := GetDefaultScopes("*") 23 18 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 36 31 } 37 32 38 33 func TestServer_SetRefresher(t *testing.T) { 39 - tmpDir := t.TempDir() 40 - storePath := tmpDir + "/oauth-test.json" 41 - 42 - store, err := NewFileStore(storePath) 43 - if err != nil { 44 - t.Fatalf("NewFileStore() error = %v", err) 45 - } 34 + store := oauth.NewMemStore() 46 35 47 36 scopes := GetDefaultScopes("*") 48 37 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 60 49 } 61 50 62 51 func TestServer_SetPostAuthCallback(t *testing.T) { 63 - tmpDir := t.TempDir() 64 - storePath := tmpDir + "/oauth-test.json" 65 - 66 - store, err := NewFileStore(storePath) 67 - if err != nil { 68 - t.Fatalf("NewFileStore() error = %v", err) 69 - } 52 + store := oauth.NewMemStore() 70 53 71 54 scopes := GetDefaultScopes("*") 72 55 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 87 70 } 88 71 89 72 func TestServer_SetUISessionStore(t *testing.T) { 90 - tmpDir := t.TempDir() 91 - storePath := tmpDir + "/oauth-test.json" 92 - 93 - store, err := NewFileStore(storePath) 94 - if err != nil { 95 - t.Fatalf("NewFileStore() error = %v", err) 96 - } 73 + store := oauth.NewMemStore() 97 74 98 75 scopes := GetDefaultScopes("*") 99 76 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 151 128 // ServeAuthorize tests 152 129 153 130 func TestServer_ServeAuthorize_MissingHandle(t *testing.T) { 154 - tmpDir := t.TempDir() 155 - storePath := tmpDir + "/oauth-test.json" 156 - 157 - store, err := NewFileStore(storePath) 158 - if err != nil { 159 - t.Fatalf("NewFileStore() error = %v", err) 160 - } 131 + store := oauth.NewMemStore() 161 132 162 133 scopes := GetDefaultScopes("*") 163 134 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 179 150 } 180 151 181 152 func TestServer_ServeAuthorize_InvalidMethod(t *testing.T) { 182 - tmpDir := t.TempDir() 183 - storePath := tmpDir + "/oauth-test.json" 184 - 185 - store, err := NewFileStore(storePath) 186 - if err != nil { 187 - t.Fatalf("NewFileStore() error = %v", err) 188 - } 153 + store := oauth.NewMemStore() 189 154 190 155 scopes := GetDefaultScopes("*") 191 156 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 209 174 // ServeCallback tests 210 175 211 176 func TestServer_ServeCallback_InvalidMethod(t *testing.T) { 212 - tmpDir := t.TempDir() 213 - storePath := tmpDir + "/oauth-test.json" 214 - 215 - store, err := NewFileStore(storePath) 216 - if err != nil { 217 - t.Fatalf("NewFileStore() error = %v", err) 218 - } 177 + store := oauth.NewMemStore() 219 178 220 179 scopes := GetDefaultScopes("*") 221 180 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 237 196 } 238 197 239 198 func TestServer_ServeCallback_OAuthError(t *testing.T) { 240 - tmpDir := t.TempDir() 241 - storePath := tmpDir + "/oauth-test.json" 242 - 243 - store, err := NewFileStore(storePath) 244 - if err != nil { 245 - t.Fatalf("NewFileStore() error = %v", err) 246 - } 199 + store := oauth.NewMemStore() 247 200 248 201 scopes := GetDefaultScopes("*") 249 202 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 270 223 } 271 224 272 225 func TestServer_ServeCallback_WithPostAuthCallback(t *testing.T) { 273 - tmpDir := t.TempDir() 274 - storePath := tmpDir + "/oauth-test.json" 275 - 276 - store, err := NewFileStore(storePath) 277 - if err != nil { 278 - t.Fatalf("NewFileStore() error = %v", err) 279 - } 226 + store := oauth.NewMemStore() 280 227 281 228 scopes := GetDefaultScopes("*") 282 229 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 315 262 }, 316 263 } 317 264 318 - tmpDir := t.TempDir() 319 - storePath := tmpDir + "/oauth-test.json" 320 - 321 - store, err := NewFileStore(storePath) 322 - if err != nil { 323 - t.Fatalf("NewFileStore() error = %v", err) 324 - } 265 + store := oauth.NewMemStore() 325 266 326 267 scopes := GetDefaultScopes("*") 327 268 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 345 286 } 346 287 347 288 func TestServer_RenderError(t *testing.T) { 348 - tmpDir := t.TempDir() 349 - storePath := tmpDir + "/oauth-test.json" 350 - 351 - store, err := NewFileStore(storePath) 352 - if err != nil { 353 - t.Fatalf("NewFileStore() error = %v", err) 354 - } 289 + store := oauth.NewMemStore() 355 290 356 291 scopes := GetDefaultScopes("*") 357 292 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") ··· 380 315 } 381 316 382 317 func TestServer_RenderRedirectToSettings(t *testing.T) { 383 - tmpDir := t.TempDir() 384 - storePath := tmpDir + "/oauth-test.json" 385 - 386 - store, err := NewFileStore(storePath) 387 - if err != nil { 388 - t.Fatalf("NewFileStore() error = %v", err) 389 - } 318 + store := oauth.NewMemStore() 390 319 391 320 scopes := GetDefaultScopes("*") 392 321 clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
-236
pkg/auth/oauth/store.go
··· 1 - package oauth 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "maps" 8 - "os" 9 - "path/filepath" 10 - "sync" 11 - "time" 12 - 13 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - ) 16 - 17 - // FileStore implements oauth.ClientAuthStore with file-based persistence 18 - type FileStore struct { 19 - path string 20 - sessions map[string]*oauth.ClientSessionData // Key: "did:sessionID" 21 - requests map[string]*oauth.AuthRequestData // Key: state 22 - mu sync.RWMutex 23 - } 24 - 25 - // FileStoreData represents the JSON structure stored on disk 26 - type FileStoreData struct { 27 - Sessions map[string]*oauth.ClientSessionData `json:"sessions"` 28 - Requests map[string]*oauth.AuthRequestData `json:"requests"` 29 - } 30 - 31 - // NewFileStore creates a new file-based OAuth store 32 - func NewFileStore(path string) (*FileStore, error) { 33 - store := &FileStore{ 34 - path: path, 35 - sessions: make(map[string]*oauth.ClientSessionData), 36 - requests: make(map[string]*oauth.AuthRequestData), 37 - } 38 - 39 - // Load existing data if file exists 40 - if err := store.load(); err != nil { 41 - if !os.IsNotExist(err) { 42 - return nil, fmt.Errorf("failed to load store: %w", err) 43 - } 44 - // File doesn't exist yet, that's ok 45 - } 46 - 47 - return store, nil 48 - } 49 - 50 - // GetDefaultStorePath returns the default storage path for OAuth data 51 - func GetDefaultStorePath() (string, error) { 52 - // For AppView: /var/lib/atcr/oauth-sessions.json 53 - // For CLI tools: ~/.atcr/oauth-sessions.json 54 - 55 - // Check if running as a service (has write access to /var/lib) 56 - servicePath := "/var/lib/atcr/oauth-sessions.json" 57 - if err := os.MkdirAll(filepath.Dir(servicePath), 0700); err == nil { 58 - // Can write to /var/lib, use service path 59 - return servicePath, nil 60 - } 61 - 62 - // Fall back to user home directory 63 - homeDir, err := os.UserHomeDir() 64 - if err != nil { 65 - return "", fmt.Errorf("failed to get home directory: %w", err) 66 - } 67 - 68 - atcrDir := filepath.Join(homeDir, ".atcr") 69 - if err := os.MkdirAll(atcrDir, 0700); err != nil { 70 - return "", fmt.Errorf("failed to create .atcr directory: %w", err) 71 - } 72 - 73 - return filepath.Join(atcrDir, "oauth-sessions.json"), nil 74 - } 75 - 76 - // GetSession retrieves a session by DID and session ID 77 - func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 78 - s.mu.RLock() 79 - defer s.mu.RUnlock() 80 - 81 - key := makeSessionKey(did.String(), sessionID) 82 - session, ok := s.sessions[key] 83 - if !ok { 84 - return nil, fmt.Errorf("session not found: %s/%s", did, sessionID) 85 - } 86 - 87 - return session, nil 88 - } 89 - 90 - // SaveSession saves or updates a session (upsert) 91 - func (s *FileStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 92 - s.mu.Lock() 93 - defer s.mu.Unlock() 94 - 95 - key := makeSessionKey(sess.AccountDID.String(), sess.SessionID) 96 - s.sessions[key] = &sess 97 - 98 - return s.save() 99 - } 100 - 101 - // DeleteSession removes a session 102 - func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 103 - s.mu.Lock() 104 - defer s.mu.Unlock() 105 - 106 - key := makeSessionKey(did.String(), sessionID) 107 - delete(s.sessions, key) 108 - 109 - return s.save() 110 - } 111 - 112 - // GetAuthRequestInfo retrieves authentication request data by state 113 - func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 114 - s.mu.RLock() 115 - defer s.mu.RUnlock() 116 - 117 - request, ok := s.requests[state] 118 - if !ok { 119 - return nil, fmt.Errorf("auth request not found: %s", state) 120 - } 121 - 122 - return request, nil 123 - } 124 - 125 - // SaveAuthRequestInfo saves authentication request data 126 - func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 127 - s.mu.Lock() 128 - defer s.mu.Unlock() 129 - 130 - s.requests[info.State] = &info 131 - 132 - return s.save() 133 - } 134 - 135 - // DeleteAuthRequestInfo removes authentication request data 136 - func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 137 - s.mu.Lock() 138 - defer s.mu.Unlock() 139 - 140 - delete(s.requests, state) 141 - 142 - return s.save() 143 - } 144 - 145 - // CleanupExpired removes expired sessions and auth requests 146 - // Should be called periodically (e.g., every hour) 147 - func (s *FileStore) CleanupExpired() error { 148 - s.mu.Lock() 149 - defer s.mu.Unlock() 150 - 151 - now := time.Now() 152 - modified := false 153 - 154 - // Clean up auth requests older than 10 minutes 155 - // (OAuth flows should complete quickly) 156 - for state := range s.requests { 157 - // Note: AuthRequestData doesn't have a timestamp in indigo's implementation 158 - // For now, we'll rely on the OAuth server's cleanup routine 159 - // or we could extend AuthRequestData with metadata 160 - _ = state // Placeholder for future expiration logic 161 - } 162 - 163 - // Sessions don't have expiry in the data structure 164 - // Cleanup would need to be token-based (check token expiry) 165 - // For now, manual cleanup via DeleteSession 166 - _ = now 167 - 168 - if modified { 169 - return s.save() 170 - } 171 - 172 - return nil 173 - } 174 - 175 - // ListSessions returns all stored sessions for debugging/management 176 - func (s *FileStore) ListSessions() map[string]*oauth.ClientSessionData { 177 - s.mu.RLock() 178 - defer s.mu.RUnlock() 179 - 180 - // Return a copy to prevent external modification 181 - result := make(map[string]*oauth.ClientSessionData) 182 - maps.Copy(result, s.sessions) 183 - return result 184 - } 185 - 186 - // load reads data from disk 187 - func (s *FileStore) load() error { 188 - data, err := os.ReadFile(s.path) 189 - if err != nil { 190 - return err 191 - } 192 - 193 - var storeData FileStoreData 194 - if err := json.Unmarshal(data, &storeData); err != nil { 195 - return fmt.Errorf("failed to parse store: %w", err) 196 - } 197 - 198 - if storeData.Sessions != nil { 199 - s.sessions = storeData.Sessions 200 - } 201 - if storeData.Requests != nil { 202 - s.requests = storeData.Requests 203 - } 204 - 205 - return nil 206 - } 207 - 208 - // save writes data to disk 209 - func (s *FileStore) save() error { 210 - storeData := FileStoreData{ 211 - Sessions: s.sessions, 212 - Requests: s.requests, 213 - } 214 - 215 - data, err := json.MarshalIndent(storeData, "", " ") 216 - if err != nil { 217 - return fmt.Errorf("failed to marshal store: %w", err) 218 - } 219 - 220 - // Ensure directory exists 221 - if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { 222 - return fmt.Errorf("failed to create directory: %w", err) 223 - } 224 - 225 - // Write with restrictive permissions 226 - if err := os.WriteFile(s.path, data, 0600); err != nil { 227 - return fmt.Errorf("failed to write store: %w", err) 228 - } 229 - 230 - return nil 231 - } 232 - 233 - // makeSessionKey creates a composite key for session storage 234 - func makeSessionKey(did, sessionID string) string { 235 - return fmt.Sprintf("%s:%s", did, sessionID) 236 - }
-631
pkg/auth/oauth/store_test.go
··· 1 - package oauth 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "os" 7 - "testing" 8 - "time" 9 - 10 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - ) 13 - 14 - func TestNewFileStore(t *testing.T) { 15 - tmpDir := t.TempDir() 16 - storePath := tmpDir + "/oauth-test.json" 17 - 18 - store, err := NewFileStore(storePath) 19 - if err != nil { 20 - t.Fatalf("NewFileStore() error = %v", err) 21 - } 22 - 23 - if store == nil { 24 - t.Fatal("Expected non-nil store") 25 - } 26 - 27 - if store.path != storePath { 28 - t.Errorf("Expected path %q, got %q", storePath, store.path) 29 - } 30 - 31 - if store.sessions == nil { 32 - t.Error("Expected sessions map to be initialized") 33 - } 34 - 35 - if store.requests == nil { 36 - t.Error("Expected requests map to be initialized") 37 - } 38 - } 39 - 40 - func TestFileStore_LoadNonExistent(t *testing.T) { 41 - tmpDir := t.TempDir() 42 - storePath := tmpDir + "/nonexistent.json" 43 - 44 - // Should succeed even if file doesn't exist 45 - store, err := NewFileStore(storePath) 46 - if err != nil { 47 - t.Fatalf("NewFileStore() should succeed with non-existent file, got error: %v", err) 48 - } 49 - 50 - if store == nil { 51 - t.Fatal("Expected non-nil store") 52 - } 53 - } 54 - 55 - func TestFileStore_LoadCorruptedFile(t *testing.T) { 56 - tmpDir := t.TempDir() 57 - storePath := tmpDir + "/corrupted.json" 58 - 59 - // Create corrupted JSON file 60 - if err := os.WriteFile(storePath, []byte("invalid json {{{"), 0600); err != nil { 61 - t.Fatalf("Failed to create corrupted file: %v", err) 62 - } 63 - 64 - // Should fail to load corrupted file 65 - _, err := NewFileStore(storePath) 66 - if err == nil { 67 - t.Error("Expected error when loading corrupted file") 68 - } 69 - } 70 - 71 - func TestFileStore_GetSession_NotFound(t *testing.T) { 72 - tmpDir := t.TempDir() 73 - storePath := tmpDir + "/oauth-test.json" 74 - 75 - store, err := NewFileStore(storePath) 76 - if err != nil { 77 - t.Fatalf("NewFileStore() error = %v", err) 78 - } 79 - 80 - ctx := context.Background() 81 - did, _ := syntax.ParseDID("did:plc:test123") 82 - sessionID := "session123" 83 - 84 - // Should return error for non-existent session 85 - session, err := store.GetSession(ctx, did, sessionID) 86 - if err == nil { 87 - t.Error("Expected error for non-existent session") 88 - } 89 - if session != nil { 90 - t.Error("Expected nil session for non-existent entry") 91 - } 92 - } 93 - 94 - func TestFileStore_SaveAndGetSession(t *testing.T) { 95 - tmpDir := t.TempDir() 96 - storePath := tmpDir + "/oauth-test.json" 97 - 98 - store, err := NewFileStore(storePath) 99 - if err != nil { 100 - t.Fatalf("NewFileStore() error = %v", err) 101 - } 102 - 103 - ctx := context.Background() 104 - did, _ := syntax.ParseDID("did:plc:alice123") 105 - 106 - // Create test session 107 - sessionData := oauth.ClientSessionData{ 108 - AccountDID: did, 109 - SessionID: "test-session-123", 110 - HostURL: "https://pds.example.com", 111 - Scopes: []string{"atproto", "blob:read"}, 112 - } 113 - 114 - // Save session 115 - if err := store.SaveSession(ctx, sessionData); err != nil { 116 - t.Fatalf("SaveSession() error = %v", err) 117 - } 118 - 119 - // Retrieve session 120 - retrieved, err := store.GetSession(ctx, did, "test-session-123") 121 - if err != nil { 122 - t.Fatalf("GetSession() error = %v", err) 123 - } 124 - 125 - if retrieved == nil { 126 - t.Fatal("Expected non-nil session") 127 - } 128 - 129 - if retrieved.SessionID != sessionData.SessionID { 130 - t.Errorf("Expected sessionID %q, got %q", sessionData.SessionID, retrieved.SessionID) 131 - } 132 - 133 - if retrieved.AccountDID.String() != did.String() { 134 - t.Errorf("Expected DID %q, got %q", did.String(), retrieved.AccountDID.String()) 135 - } 136 - 137 - if retrieved.HostURL != sessionData.HostURL { 138 - t.Errorf("Expected hostURL %q, got %q", sessionData.HostURL, retrieved.HostURL) 139 - } 140 - } 141 - 142 - func TestFileStore_UpdateSession(t *testing.T) { 143 - tmpDir := t.TempDir() 144 - storePath := tmpDir + "/oauth-test.json" 145 - 146 - store, err := NewFileStore(storePath) 147 - if err != nil { 148 - t.Fatalf("NewFileStore() error = %v", err) 149 - } 150 - 151 - ctx := context.Background() 152 - did, _ := syntax.ParseDID("did:plc:alice123") 153 - 154 - // Save initial session 155 - sessionData := oauth.ClientSessionData{ 156 - AccountDID: did, 157 - SessionID: "test-session-123", 158 - HostURL: "https://pds.example.com", 159 - Scopes: []string{"atproto"}, 160 - } 161 - 162 - if err := store.SaveSession(ctx, sessionData); err != nil { 163 - t.Fatalf("SaveSession() error = %v", err) 164 - } 165 - 166 - // Update session with new scopes 167 - sessionData.Scopes = []string{"atproto", "blob:read", "blob:write"} 168 - if err := store.SaveSession(ctx, sessionData); err != nil { 169 - t.Fatalf("SaveSession() (update) error = %v", err) 170 - } 171 - 172 - // Retrieve updated session 173 - retrieved, err := store.GetSession(ctx, did, "test-session-123") 174 - if err != nil { 175 - t.Fatalf("GetSession() error = %v", err) 176 - } 177 - 178 - if len(retrieved.Scopes) != 3 { 179 - t.Errorf("Expected 3 scopes, got %d", len(retrieved.Scopes)) 180 - } 181 - } 182 - 183 - func TestFileStore_DeleteSession(t *testing.T) { 184 - tmpDir := t.TempDir() 185 - storePath := tmpDir + "/oauth-test.json" 186 - 187 - store, err := NewFileStore(storePath) 188 - if err != nil { 189 - t.Fatalf("NewFileStore() error = %v", err) 190 - } 191 - 192 - ctx := context.Background() 193 - did, _ := syntax.ParseDID("did:plc:alice123") 194 - 195 - // Save session 196 - sessionData := oauth.ClientSessionData{ 197 - AccountDID: did, 198 - SessionID: "test-session-123", 199 - HostURL: "https://pds.example.com", 200 - } 201 - 202 - if err := store.SaveSession(ctx, sessionData); err != nil { 203 - t.Fatalf("SaveSession() error = %v", err) 204 - } 205 - 206 - // Verify it exists 207 - if _, err := store.GetSession(ctx, did, "test-session-123"); err != nil { 208 - t.Fatalf("GetSession() should succeed before delete, got error: %v", err) 209 - } 210 - 211 - // Delete session 212 - if err := store.DeleteSession(ctx, did, "test-session-123"); err != nil { 213 - t.Fatalf("DeleteSession() error = %v", err) 214 - } 215 - 216 - // Verify it's gone 217 - _, err = store.GetSession(ctx, did, "test-session-123") 218 - if err == nil { 219 - t.Error("Expected error after deleting session") 220 - } 221 - } 222 - 223 - func TestFileStore_DeleteNonExistentSession(t *testing.T) { 224 - tmpDir := t.TempDir() 225 - storePath := tmpDir + "/oauth-test.json" 226 - 227 - store, err := NewFileStore(storePath) 228 - if err != nil { 229 - t.Fatalf("NewFileStore() error = %v", err) 230 - } 231 - 232 - ctx := context.Background() 233 - did, _ := syntax.ParseDID("did:plc:alice123") 234 - 235 - // Delete non-existent session should not error 236 - if err := store.DeleteSession(ctx, did, "nonexistent"); err != nil { 237 - t.Errorf("DeleteSession() on non-existent session should not error, got: %v", err) 238 - } 239 - } 240 - 241 - func TestFileStore_SaveAndGetAuthRequestInfo(t *testing.T) { 242 - tmpDir := t.TempDir() 243 - storePath := tmpDir + "/oauth-test.json" 244 - 245 - store, err := NewFileStore(storePath) 246 - if err != nil { 247 - t.Fatalf("NewFileStore() error = %v", err) 248 - } 249 - 250 - ctx := context.Background() 251 - 252 - // Create test auth request 253 - did, _ := syntax.ParseDID("did:plc:alice123") 254 - authRequest := oauth.AuthRequestData{ 255 - State: "test-state-123", 256 - AuthServerURL: "https://pds.example.com", 257 - AccountDID: &did, 258 - Scopes: []string{"atproto", "blob:read"}, 259 - RequestURI: "urn:ietf:params:oauth:request_uri:test123", 260 - AuthServerTokenEndpoint: "https://pds.example.com/oauth/token", 261 - } 262 - 263 - // Save auth request 264 - if err := store.SaveAuthRequestInfo(ctx, authRequest); err != nil { 265 - t.Fatalf("SaveAuthRequestInfo() error = %v", err) 266 - } 267 - 268 - // Retrieve auth request 269 - retrieved, err := store.GetAuthRequestInfo(ctx, "test-state-123") 270 - if err != nil { 271 - t.Fatalf("GetAuthRequestInfo() error = %v", err) 272 - } 273 - 274 - if retrieved == nil { 275 - t.Fatal("Expected non-nil auth request") 276 - } 277 - 278 - if retrieved.State != authRequest.State { 279 - t.Errorf("Expected state %q, got %q", authRequest.State, retrieved.State) 280 - } 281 - 282 - if retrieved.AuthServerURL != authRequest.AuthServerURL { 283 - t.Errorf("Expected authServerURL %q, got %q", authRequest.AuthServerURL, retrieved.AuthServerURL) 284 - } 285 - } 286 - 287 - func TestFileStore_GetAuthRequestInfo_NotFound(t *testing.T) { 288 - tmpDir := t.TempDir() 289 - storePath := tmpDir + "/oauth-test.json" 290 - 291 - store, err := NewFileStore(storePath) 292 - if err != nil { 293 - t.Fatalf("NewFileStore() error = %v", err) 294 - } 295 - 296 - ctx := context.Background() 297 - 298 - // Should return error for non-existent request 299 - _, err = store.GetAuthRequestInfo(ctx, "nonexistent-state") 300 - if err == nil { 301 - t.Error("Expected error for non-existent auth request") 302 - } 303 - } 304 - 305 - func TestFileStore_DeleteAuthRequestInfo(t *testing.T) { 306 - tmpDir := t.TempDir() 307 - storePath := tmpDir + "/oauth-test.json" 308 - 309 - store, err := NewFileStore(storePath) 310 - if err != nil { 311 - t.Fatalf("NewFileStore() error = %v", err) 312 - } 313 - 314 - ctx := context.Background() 315 - 316 - // Save auth request 317 - authRequest := oauth.AuthRequestData{ 318 - State: "test-state-123", 319 - AuthServerURL: "https://pds.example.com", 320 - } 321 - 322 - if err := store.SaveAuthRequestInfo(ctx, authRequest); err != nil { 323 - t.Fatalf("SaveAuthRequestInfo() error = %v", err) 324 - } 325 - 326 - // Verify it exists 327 - if _, err := store.GetAuthRequestInfo(ctx, "test-state-123"); err != nil { 328 - t.Fatalf("GetAuthRequestInfo() should succeed before delete, got error: %v", err) 329 - } 330 - 331 - // Delete auth request 332 - if err := store.DeleteAuthRequestInfo(ctx, "test-state-123"); err != nil { 333 - t.Fatalf("DeleteAuthRequestInfo() error = %v", err) 334 - } 335 - 336 - // Verify it's gone 337 - _, err = store.GetAuthRequestInfo(ctx, "test-state-123") 338 - if err == nil { 339 - t.Error("Expected error after deleting auth request") 340 - } 341 - } 342 - 343 - func TestFileStore_ListSessions(t *testing.T) { 344 - tmpDir := t.TempDir() 345 - storePath := tmpDir + "/oauth-test.json" 346 - 347 - store, err := NewFileStore(storePath) 348 - if err != nil { 349 - t.Fatalf("NewFileStore() error = %v", err) 350 - } 351 - 352 - ctx := context.Background() 353 - 354 - // Initially empty 355 - sessions := store.ListSessions() 356 - if len(sessions) != 0 { 357 - t.Errorf("Expected 0 sessions, got %d", len(sessions)) 358 - } 359 - 360 - // Add multiple sessions 361 - did1, _ := syntax.ParseDID("did:plc:alice123") 362 - did2, _ := syntax.ParseDID("did:plc:bob456") 363 - 364 - session1 := oauth.ClientSessionData{ 365 - AccountDID: did1, 366 - SessionID: "session-1", 367 - HostURL: "https://pds1.example.com", 368 - } 369 - 370 - session2 := oauth.ClientSessionData{ 371 - AccountDID: did2, 372 - SessionID: "session-2", 373 - HostURL: "https://pds2.example.com", 374 - } 375 - 376 - if err := store.SaveSession(ctx, session1); err != nil { 377 - t.Fatalf("SaveSession() error = %v", err) 378 - } 379 - 380 - if err := store.SaveSession(ctx, session2); err != nil { 381 - t.Fatalf("SaveSession() error = %v", err) 382 - } 383 - 384 - // List sessions 385 - sessions = store.ListSessions() 386 - if len(sessions) != 2 { 387 - t.Errorf("Expected 2 sessions, got %d", len(sessions)) 388 - } 389 - 390 - // Verify we got both sessions 391 - key1 := makeSessionKey(did1.String(), "session-1") 392 - key2 := makeSessionKey(did2.String(), "session-2") 393 - 394 - if sessions[key1] == nil { 395 - t.Error("Expected session1 in list") 396 - } 397 - 398 - if sessions[key2] == nil { 399 - t.Error("Expected session2 in list") 400 - } 401 - } 402 - 403 - func TestFileStore_Persistence_Across_Instances(t *testing.T) { 404 - tmpDir := t.TempDir() 405 - storePath := tmpDir + "/oauth-test.json" 406 - 407 - ctx := context.Background() 408 - did, _ := syntax.ParseDID("did:plc:alice123") 409 - 410 - // Create first store and save data 411 - store1, err := NewFileStore(storePath) 412 - if err != nil { 413 - t.Fatalf("NewFileStore() error = %v", err) 414 - } 415 - 416 - sessionData := oauth.ClientSessionData{ 417 - AccountDID: did, 418 - SessionID: "persistent-session", 419 - HostURL: "https://pds.example.com", 420 - } 421 - 422 - if err := store1.SaveSession(ctx, sessionData); err != nil { 423 - t.Fatalf("SaveSession() error = %v", err) 424 - } 425 - 426 - authRequest := oauth.AuthRequestData{ 427 - State: "persistent-state", 428 - AuthServerURL: "https://pds.example.com", 429 - } 430 - 431 - if err := store1.SaveAuthRequestInfo(ctx, authRequest); err != nil { 432 - t.Fatalf("SaveAuthRequestInfo() error = %v", err) 433 - } 434 - 435 - // Create second store from same file 436 - store2, err := NewFileStore(storePath) 437 - if err != nil { 438 - t.Fatalf("Second NewFileStore() error = %v", err) 439 - } 440 - 441 - // Verify session persisted 442 - retrievedSession, err := store2.GetSession(ctx, did, "persistent-session") 443 - if err != nil { 444 - t.Fatalf("GetSession() from second store error = %v", err) 445 - } 446 - 447 - if retrievedSession.SessionID != "persistent-session" { 448 - t.Errorf("Expected persistent session ID, got %q", retrievedSession.SessionID) 449 - } 450 - 451 - // Verify auth request persisted 452 - retrievedAuth, err := store2.GetAuthRequestInfo(ctx, "persistent-state") 453 - if err != nil { 454 - t.Fatalf("GetAuthRequestInfo() from second store error = %v", err) 455 - } 456 - 457 - if retrievedAuth.State != "persistent-state" { 458 - t.Errorf("Expected persistent state, got %q", retrievedAuth.State) 459 - } 460 - } 461 - 462 - func TestFileStore_FileSecurity(t *testing.T) { 463 - tmpDir := t.TempDir() 464 - storePath := tmpDir + "/oauth-test.json" 465 - 466 - store, err := NewFileStore(storePath) 467 - if err != nil { 468 - t.Fatalf("NewFileStore() error = %v", err) 469 - } 470 - 471 - ctx := context.Background() 472 - did, _ := syntax.ParseDID("did:plc:alice123") 473 - 474 - // Save some data to trigger file creation 475 - sessionData := oauth.ClientSessionData{ 476 - AccountDID: did, 477 - SessionID: "test-session", 478 - HostURL: "https://pds.example.com", 479 - } 480 - 481 - if err := store.SaveSession(ctx, sessionData); err != nil { 482 - t.Fatalf("SaveSession() error = %v", err) 483 - } 484 - 485 - // Check file permissions (should be 0600) 486 - info, err := os.Stat(storePath) 487 - if err != nil { 488 - t.Fatalf("Failed to stat file: %v", err) 489 - } 490 - 491 - mode := info.Mode() 492 - if mode.Perm() != 0600 { 493 - t.Errorf("Expected file permissions 0600, got %o", mode.Perm()) 494 - } 495 - } 496 - 497 - func TestFileStore_JSONFormat(t *testing.T) { 498 - tmpDir := t.TempDir() 499 - storePath := tmpDir + "/oauth-test.json" 500 - 501 - store, err := NewFileStore(storePath) 502 - if err != nil { 503 - t.Fatalf("NewFileStore() error = %v", err) 504 - } 505 - 506 - ctx := context.Background() 507 - did, _ := syntax.ParseDID("did:plc:alice123") 508 - 509 - // Save data 510 - sessionData := oauth.ClientSessionData{ 511 - AccountDID: did, 512 - SessionID: "test-session", 513 - HostURL: "https://pds.example.com", 514 - } 515 - 516 - if err := store.SaveSession(ctx, sessionData); err != nil { 517 - t.Fatalf("SaveSession() error = %v", err) 518 - } 519 - 520 - // Read and verify JSON format 521 - data, err := os.ReadFile(storePath) 522 - if err != nil { 523 - t.Fatalf("Failed to read file: %v", err) 524 - } 525 - 526 - var storeData FileStoreData 527 - if err := json.Unmarshal(data, &storeData); err != nil { 528 - t.Fatalf("Failed to parse JSON: %v", err) 529 - } 530 - 531 - if storeData.Sessions == nil { 532 - t.Error("Expected sessions in JSON") 533 - } 534 - 535 - if storeData.Requests == nil { 536 - t.Error("Expected requests in JSON") 537 - } 538 - } 539 - 540 - func TestFileStore_CleanupExpired(t *testing.T) { 541 - tmpDir := t.TempDir() 542 - storePath := tmpDir + "/oauth-test.json" 543 - 544 - store, err := NewFileStore(storePath) 545 - if err != nil { 546 - t.Fatalf("NewFileStore() error = %v", err) 547 - } 548 - 549 - // CleanupExpired should not error even with no data 550 - if err := store.CleanupExpired(); err != nil { 551 - t.Errorf("CleanupExpired() error = %v", err) 552 - } 553 - 554 - // Note: Current implementation doesn't actually clean anything 555 - // since AuthRequestData and ClientSessionData don't have expiry timestamps 556 - // This test verifies the method doesn't panic 557 - } 558 - 559 - func TestGetDefaultStorePath(t *testing.T) { 560 - path, err := GetDefaultStorePath() 561 - if err != nil { 562 - t.Fatalf("GetDefaultStorePath() error = %v", err) 563 - } 564 - 565 - if path == "" { 566 - t.Fatal("Expected non-empty path") 567 - } 568 - 569 - // Path should either be /var/lib/atcr or ~/.atcr 570 - // We can't assert exact path since it depends on permissions 571 - t.Logf("Default store path: %s", path) 572 - } 573 - 574 - func TestMakeSessionKey(t *testing.T) { 575 - did := "did:plc:alice123" 576 - sessionID := "session-456" 577 - 578 - key := makeSessionKey(did, sessionID) 579 - expected := "did:plc:alice123:session-456" 580 - 581 - if key != expected { 582 - t.Errorf("Expected key %q, got %q", expected, key) 583 - } 584 - } 585 - 586 - func TestFileStore_ConcurrentAccess(t *testing.T) { 587 - tmpDir := t.TempDir() 588 - storePath := tmpDir + "/oauth-test.json" 589 - 590 - store, err := NewFileStore(storePath) 591 - if err != nil { 592 - t.Fatalf("NewFileStore() error = %v", err) 593 - } 594 - 595 - ctx := context.Background() 596 - 597 - // Run concurrent operations 598 - done := make(chan bool) 599 - 600 - // Writer goroutine 601 - go func() { 602 - for i := 0; i < 10; i++ { 603 - did, _ := syntax.ParseDID("did:plc:alice123") 604 - sessionData := oauth.ClientSessionData{ 605 - AccountDID: did, 606 - SessionID: "session-1", 607 - HostURL: "https://pds.example.com", 608 - } 609 - store.SaveSession(ctx, sessionData) 610 - time.Sleep(1 * time.Millisecond) 611 - } 612 - done <- true 613 - }() 614 - 615 - // Reader goroutine 616 - go func() { 617 - for i := 0; i < 10; i++ { 618 - did, _ := syntax.ParseDID("did:plc:alice123") 619 - store.GetSession(ctx, did, "session-1") 620 - time.Sleep(1 * time.Millisecond) 621 - } 622 - done <- true 623 - }() 624 - 625 - // Wait for both goroutines 626 - <-done 627 - <-done 628 - 629 - // If we got here without panicking, the locking works 630 - t.Log("Concurrent access test passed") 631 - }