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

more logging

evan.jarrett.net 2b0501a4 e2d65c62

verified
Changed files
+41 -21
docs
pkg
hold
+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
··· 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
··· 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
··· 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
··· 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
··· 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