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