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 - `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
··· 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
··· 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
··· 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
··· 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
··· 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