A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

remove unused filestore. replace it with memstore for tests

+118 -1000
+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 - }