+10
-12
CLAUDE.md
+10
-12
CLAUDE.md
···
228
228
- `GetDefaultScopes()` - returns ATCR registry scopes
229
229
- All OAuth flows (authorization, token exchange, refresh) in one place
230
230
231
-
2. **DPoP Transport** (`transport.go`) - HTTP RoundTripper that auto-adds DPoP headers
232
-
233
-
3. **Token Storage** (`tokenstorage.go`) - Persists refresh tokens and DPoP keys for AppView
231
+
2. **Token Storage** (`store.go`) - Persists OAuth sessions for AppView
234
232
- File-based storage in `/var/lib/atcr/refresh-tokens.json` (AppView)
235
233
- Client uses `~/.atcr/oauth-token.json` (credential helper)
236
234
237
-
4. **Refresher** (`refresher.go`) - Token refresh manager for AppView
238
-
- Caches access tokens with automatic refresh
235
+
3. **Refresher** (`refresher.go`) - Token refresh manager for AppView
236
+
- Caches OAuth sessions with automatic token refresh (handled by indigo library)
239
237
- Per-DID locking prevents concurrent refresh races
240
238
- Uses Client methods for consistency
241
239
242
-
5. **Server** (`server.go`) - OAuth authorization endpoints for AppView
240
+
4. **Server** (`server.go`) - OAuth authorization endpoints for AppView
243
241
- `GET /auth/oauth/authorize` - starts OAuth flow
244
242
- `GET /auth/oauth/callback` - handles OAuth callback
245
243
- Uses Client methods for authorization and token exchange
246
244
247
-
6. **Interactive Flow** (`flow.go`) - Reusable OAuth flow for CLI tools
245
+
5. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools
248
246
- Used by credential helper and hold service registration
249
247
- Two-phase callback setup ensures PAR metadata availability
250
248
···
259
257
- PAR request with DPoP header → get request_uri
260
258
- User authorizes in browser
261
259
- AppView exchanges code for OAuth token with DPoP proof
262
-
- AppView stores: OAuth token, refresh token, DPoP key, DID, handle
260
+
- AppView stores: OAuth session (tokens managed by indigo library with DPoP), DID, handle
263
261
5. AppView shows device approval page: "Can [device] push to your account?"
264
262
6. User approves device
265
263
7. AppView issues registry JWT with validated DID
···
272
270
12. Helper returns cached registry JWT (or re-authenticates if expired)
273
271
```
274
272
275
-
**Key distinction:** The credential helper never manages OAuth tokens or DPoP keys directly. AppView owns the OAuth session and issues registry JWTs to the credential helper. This means AppView has access to user OAuth tokens and DPoP keys, which it needs for:
276
-
- Writing manifests to user's PDS
277
-
- Validating user sessions
278
-
- Delegating access to hold services
273
+
**Key distinction:** The credential helper never manages OAuth tokens directly. AppView owns the OAuth session (including DPoP handling via indigo library) and issues registry JWTs to the credential helper. AppView needs the OAuth session for:
274
+
- Writing manifests to user's PDS (with DPoP authentication)
275
+
- Getting service tokens from user's PDS (with DPoP authentication)
276
+
- Service tokens are then used to authenticate to hold services (Bearer tokens, not DPoP)
279
277
280
278
**Security:**
281
279
- Tokens validated against authoritative source (user's PDS)
+2
-2
README.md
+2
-2
README.md
···
31
31
- Users can deploy their own storage and control access via crew membership
32
32
33
33
3. **Credential Helper** - Client authentication
34
-
- ATProto OAuth with DPoP
34
+
- ATProto OAuth (DPoP handled transparently)
35
35
- Automatic authentication on first push/pull
36
36
37
37
**Storage model:**
···
43
43
44
44
- ✅ **OCI-compliant** - Works with Docker, containerd, podman
45
45
- ✅ **Decentralized** - You own your manifest data via your PDS
46
-
- ✅ **ATProto OAuth** - Secure authentication with DPoP
46
+
- ✅ **ATProto OAuth** - Secure authentication (DPoP-compliant)
47
47
- ✅ **BYOS** - Deploy your own storage service
48
48
- ✅ **Web UI** - Browse, search, star repositories
49
49
- ✅ **Multi-backend** - S3, Storj, Minio, Azure, GCS, filesystem
+2
-2
docs/APPVIEW-UI-V1.md
+2
-2
docs/APPVIEW-UI-V1.md
···
16
16
- **Frontend:** TBD (Go templates/Templ or separate SPA)
17
17
- **Database:** SQLite (firehose data cache)
18
18
- **Styling:** TBD (plain CSS, Tailwind, etc.)
19
-
- **Authentication:** OAuth with DPoP (reuse existing implementation)
19
+
- **Authentication:** ATProto OAuth (DPoP handled by indigo library)
20
20
21
21
### Components
22
22
···
501
501
2. Redirects to `/auth/oauth/login?return_to=/ui/images`
502
502
3. User enters handle (e.g., "alice.bsky.social")
503
503
4. Server resolves handle → DID → PDS → OAuth server
504
-
5. Server initiates OAuth flow with PAR + DPoP
504
+
5. Server initiates ATProto OAuth flow with PAR (DPoP handled by indigo library)
505
505
6. User redirected to PDS for authorization
506
506
7. OAuth callback to `/auth/oauth/callback`
507
507
8. Server exchanges code for token, validates with PDS
+7
-5
docs/EMBEDDED_PDS.md
+7
-5
docs/EMBEDDED_PDS.md
···
250
250
251
251
### Potential Solutions
252
252
253
-
#### Option A: Direct User-to-Hold Authentication
253
+
#### Option A: Direct User-to-Hold Authentication (NOT IMPLEMENTED)
254
254
255
-
Users authenticate directly to holds (bypassing AppView service tokens).
255
+
**Note:** This option was considered but NOT implemented. ATCR uses service tokens exclusively for AppView→Hold authentication.
256
+
257
+
Users would authenticate directly to holds (bypassing AppView service tokens).
256
258
257
259
**Pros:**
258
260
- ✅ Clear trust model (user ↔ hold)
···
315
317
2. Clear security model for hold operators
316
318
317
319
**Long-term:**
318
-
1. Explore direct user-to-hold OAuth
319
-
2. Credential helper manages multiple hold sessions
320
-
3. Auto-discover and authenticate to new holds
320
+
1. Continue using service tokens (current implementation)
321
+
2. Explore optimizations for service token caching
322
+
3. Document security model more clearly
321
323
322
324
### Understanding getServiceAuth
323
325
+5
pkg/hold/pds/auth.go
+5
pkg/hold/pds/auth.go
···
10
10
"slices"
11
11
"strings"
12
12
"time"
13
+
"log"
13
14
14
15
"atcr.io/pkg/atproto"
15
16
"github.com/bluesky-social/indigo/atproto/atcrypto"
···
425
426
return nil, fmt.Errorf("missing token")
426
427
}
427
428
429
+
log.Printf("[ValidateServiceToken] Validating service token for hold %s", holdDID)
430
+
428
431
// Manually parse JWT (bypass golang-jwt since it doesn't support ES256K algorithm used by ATProto)
429
432
// Split token: header.payload.signature
430
433
tokenParts := strings.Split(tokenString, ".")
···
489
492
if err := publicKey.HashAndVerify(signedData, signature); err != nil {
490
493
return nil, fmt.Errorf("signature verification failed: %w", err)
491
494
}
495
+
496
+
log.Printf("[ValidateServiceToken] Successfully validated service token for user %s", issuerDID)
492
497
493
498
// Return validated user
494
499
return &ValidatedUser{
+15
pkg/hold/pds/xrpc.go
+15
pkg/hold/pds/xrpc.go
···
1128
1128
// This endpoint allows authenticated users to request crew membership
1129
1129
// Authorization is checked against captain record settings
1130
1130
func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request) {
1131
+
log.Printf("[HandleRequestCrew] Starting crew membership request")
1132
+
1131
1133
// Get authenticated user from context (if coming through middleware)
1132
1134
// Otherwise validate directly (for tests or direct handler calls)
1133
1135
user := getUserFromContext(r)
···
1135
1137
var err error
1136
1138
user, err = ValidateDPoPRequest(r, h.httpClient)
1137
1139
if err != nil {
1140
+
log.Printf("[HandleRequestCrew] Authentication failed: %v", err)
1138
1141
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
1139
1142
return
1140
1143
}
1141
1144
}
1145
+
log.Printf("[HandleRequestCrew] Authenticated user: %s", user.DID)
1142
1146
1143
1147
// Parse request body (optional parameters)
1144
1148
var req struct {
···
1149
1153
// Body is optional - if empty, just use defaults
1150
1154
if r.Body != nil && r.ContentLength > 0 {
1151
1155
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1156
+
log.Printf("[HandleRequestCrew] Failed to parse request body: %v", err)
1152
1157
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
1153
1158
return
1154
1159
}
1155
1160
}
1156
1161
1157
1162
// Get captain record to check authorization settings
1163
+
log.Printf("[HandleRequestCrew] Getting captain record...")
1158
1164
_, captain, err := h.pds.GetCaptainRecord(r.Context())
1159
1165
if err != nil {
1166
+
log.Printf("[HandleRequestCrew] Failed to get captain record: %v", err)
1160
1167
http.Error(w, fmt.Sprintf("failed to get captain record: %v", err), http.StatusInternalServerError)
1161
1168
return
1162
1169
}
1170
+
log.Printf("[HandleRequestCrew] Captain record retrieved: owner=%s, allowAllCrew=%v", captain.Owner, captain.AllowAllCrew)
1163
1171
1164
1172
// Check authorization:
1165
1173
// 1. If allowAllCrew is true, any authenticated user can join
···
1181
1189
1182
1190
// Check if user is already a crew member
1183
1191
// List all crew members and check if this DID is already present
1192
+
log.Printf("[HandleRequestCrew] Checking existing crew membership...")
1184
1193
crew, err := h.pds.ListCrewMembers(r.Context())
1185
1194
if err != nil {
1195
+
log.Printf("[HandleRequestCrew] Failed to list crew members: %v", err)
1186
1196
http.Error(w, fmt.Sprintf("failed to list crew members: %v", err), http.StatusInternalServerError)
1187
1197
return
1188
1198
}
1199
+
log.Printf("[HandleRequestCrew] Found %d existing crew members", len(crew))
1189
1200
1190
1201
for _, member := range crew {
1191
1202
if member.Record.Member == user.DID {
1192
1203
// Already a crew member, return success with existing record
1204
+
log.Printf("[HandleRequestCrew] User is already a crew member (rkey=%s)", member.Rkey)
1193
1205
response := map[string]any{
1194
1206
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey),
1195
1207
"cid": member.Cid.String(),
···
1204
1216
}
1205
1217
1206
1218
// Create new crew record
1219
+
log.Printf("[HandleRequestCrew] Creating new crew record for user %s (role=%s, permissions=%v)", user.DID, req.Role, req.Permissions)
1207
1220
recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions)
1208
1221
if err != nil {
1222
+
log.Printf("[HandleRequestCrew] Failed to create crew record: %v", err)
1209
1223
http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError)
1210
1224
return
1211
1225
}
1226
+
log.Printf("[HandleRequestCrew] Successfully created crew record (CID=%s)", recordCID.String())
1212
1227
1213
1228
// Return success response
1214
1229
// Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it