+84
docs/HOLD_XRPC_ENDPOINTS.md
+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
+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
+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
-1
pkg/atproto/lexicon.go
+7
-30
pkg/auth/oauth/client_test.go
+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
+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
+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
-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
-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
-
}