clean up old docs add quotas prelim spec

This commit is contained in:
Evan Jarrett
2025-10-09 22:45:05 -05:00
parent e7daf3c9b5
commit b9619ad762
5 changed files with 1289 additions and 1901 deletions
-826
View File
@@ -1,826 +0,0 @@
# API Key Migration Plan
## Overview
Replace the session token system (used only by credential helper) with API keys that link to OAuth sessions. This simplifies authentication while maintaining all use cases.
## Current State
### Three Separate Auth Systems
1. **Session Tokens** (`pkg/auth/session/`)
- JWT-like tokens: `<base64_claims>.<base64_signature>`
- Created after OAuth callback, shown to user to copy
- User manually pastes into credential helper config
- Validated in `/auth/token` and `/auth/exchange`
- 30-day TTL
- **Problem:** Awkward UX, requires manual copy/paste
2. **UI Sessions** (`pkg/appview/session/`)
- Cookie-based (`atcr_session`)
- Random session ID, server-side store
- 24-hour TTL
- **Keep this - works well**
3. **App Password Auth** (via PDS)
- Direct `com.atproto.server.createSession` call
- No AppView involvement until token request
- **Keep this - essential for non-UI users**
## Target State
### Two Auth Methods
1. **API Keys** (NEW - replaces session tokens)
- Generated in UI after OAuth login
- Format: `atcr_<32_bytes_base64>`
- Linked to server-side OAuth refresh token
- Multiple keys per user (laptop, CI/CD, etc.)
- Revocable without re-auth
2. **App Passwords** (KEEP)
- Direct PDS authentication
- Works without UI/OAuth
### UI Sessions (UNCHANGED)
- Cookie-based for web UI
- Separate system, no changes needed
---
## Implementation Plan
### Phase 1: API Key System
#### 1.1 Create API Key Store (`pkg/appview/apikey/store.go`)
```go
package apikey
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// APIKey represents a user's API key
type APIKey struct {
ID string `json:"id"` // UUID
KeyHash string `json:"key_hash"` // bcrypt hash
DID string `json:"did"` // Owner's DID
Handle string `json:"handle"` // Owner's handle
Name string `json:"name"` // User-provided name
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used"`
}
// Store manages API keys
type Store struct {
mu sync.RWMutex
keys map[string]*APIKey // keyHash -> APIKey
byDID map[string][]string // DID -> []keyHash
filePath string // /var/lib/atcr/api-keys.json
}
// NewStore creates a new API key store
func NewStore(filePath string) (*Store, error)
// Generate creates a new API key and returns the plaintext key (shown once)
func (s *Store) Generate(did, handle, name string) (key string, keyID string, err error)
// Validate checks if an API key is valid and returns the associated data
func (s *Store) Validate(key string) (*APIKey, error)
// List returns all API keys for a DID (without plaintext keys)
func (s *Store) List(did string) []*APIKey
// Delete removes an API key
func (s *Store) Delete(did, keyID string) error
// UpdateLastUsed updates the last used timestamp
func (s *Store) UpdateLastUsed(keyHash string) error
```
**Key Generation:**
```go
func (s *Store) Generate(did, handle, name string) (string, string, error) {
// Generate 32 random bytes
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
// Format: atcr_<base64>
key := "atcr_" + base64.RawURLEncoding.EncodeToString(b)
// Hash for storage
keyHash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
if err != nil {
return "", "", err
}
// Generate ID
keyID := generateUUID()
apiKey := &APIKey{
ID: keyID,
KeyHash: string(keyHash),
DID: did,
Handle: handle,
Name: name,
CreatedAt: time.Now(),
LastUsed: time.Time{}, // Never used yet
}
s.mu.Lock()
s.keys[string(keyHash)] = apiKey
s.byDID[did] = append(s.byDID[did], string(keyHash))
s.mu.Unlock()
s.save()
// Return plaintext key (only time it's available)
return key, keyID, nil
}
```
**Key Validation:**
```go
func (s *Store) Validate(key string) (*APIKey, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Try to match against all stored hashes
for hash, apiKey := range s.keys {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)); err == nil {
// Update last used asynchronously
go s.UpdateLastUsed(hash)
return apiKey, nil
}
}
return nil, fmt.Errorf("invalid API key")
}
```
#### 1.2 Add API Key Handlers (`pkg/appview/handlers/apikeys.go`)
```go
package handlers
import (
"encoding/json"
"html/template"
"net/http"
"github.com/gorilla/mux"
"atcr.io/pkg/appview/apikey"
"atcr.io/pkg/appview/middleware"
)
// GenerateAPIKeyHandler handles POST /api/keys
type GenerateAPIKeyHandler struct {
Store *apikey.Store
}
func (h *GenerateAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
name := r.FormValue("name")
if name == "" {
name = "Unnamed Key"
}
key, keyID, err := h.Store.Generate(user.DID, user.Handle, name)
if err != nil {
http.Error(w, "Failed to generate key", http.StatusInternalServerError)
return
}
// Return key (shown once!)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"id": keyID,
"key": key,
})
}
// ListAPIKeysHandler handles GET /api/keys
type ListAPIKeysHandler struct {
Store *apikey.Store
}
func (h *ListAPIKeysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
keys := h.Store.List(user.DID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(keys)
}
// DeleteAPIKeyHandler handles DELETE /api/keys/{id}
type DeleteAPIKeyHandler struct {
Store *apikey.Store
}
func (h *DeleteAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
keyID := vars["id"]
if err := h.Store.Delete(user.DID, keyID); err != nil {
http.Error(w, "Failed to delete key", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
```
### Phase 2: Update Token Handler
#### 2.1 Modify `/auth/token` Handler (`pkg/auth/token/handler.go`)
```go
type Handler struct {
issuer *Issuer
validator *atproto.SessionValidator
apiKeyStore *apikey.Store // NEW
defaultHoldEndpoint string
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok {
return unauthorized
}
var did, handle, accessToken string
// 1. Check if it's an API key (NEW)
if strings.HasPrefix(password, "atcr_") {
apiKey, err := h.apiKeyStore.Validate(password)
if err != nil {
fmt.Printf("DEBUG [token/handler]: API key validation failed: %v\n", err)
return unauthorized
}
did = apiKey.DID
handle = apiKey.Handle
fmt.Printf("DEBUG [token/handler]: API key validated for DID=%s, handle=%s\n", did, handle)
// API key is linked to OAuth session
// OAuth refresher will provide access token when needed via middleware
}
// 2. Try app password (direct PDS)
else {
did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
if err != nil {
fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err)
return unauthorized
}
fmt.Printf("DEBUG [token/handler]: App password validated, DID=%s\n", did)
// Cache access token for manifest operations
auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
// Ensure profile exists
// ... existing code ...
}
// Rest of handler: validate access, issue JWT, etc.
// ... existing code ...
}
```
**Key Changes:**
- Remove session token validation (`sessionManager.Validate()`)
- Add API key check as first priority
- Keep app password as fallback
- API keys use OAuth refresher (server-side), app passwords use token cache (client-side)
#### 2.2 Remove `/auth/exchange` Endpoint
The `/auth/exchange` endpoint was only used for exchanging session tokens for registry JWTs. With API keys, this is no longer needed.
**Files to delete:**
- `pkg/auth/exchange/handler.go`
**Files to update:**
- `cmd/appview/serve.go` - Remove exchange handler registration
### Phase 3: Update UI
#### 3.1 Add API Keys Section to Settings Page
**Template** (`pkg/appview/templates/settings.html`):
```html
<!-- Add after existing profile settings -->
<section class="api-keys">
<h2>API Keys</h2>
<p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p>
<!-- Generate New Key -->
<div class="generate-key">
<h3>Generate New API Key</h3>
<form id="generate-key-form">
<input type="text" id="key-name" placeholder="Key name (e.g., My Laptop)" required>
<button type="submit">Generate Key</button>
</form>
</div>
<!-- Key Generated Modal (shown once) -->
<div id="key-modal" class="modal hidden">
<div class="modal-content">
<h3>✓ API Key Generated!</h3>
<p><strong>Copy this key now - it won't be shown again:</strong></p>
<div class="key-display">
<code id="generated-key"></code>
<button onclick="copyKey()">Copy to Clipboard</button>
</div>
<div class="usage-instructions">
<h4>Using with Docker:</h4>
<pre>docker login atcr.io -u <span class="handle">{{.Profile.Handle}}</span> -p <span class="key-placeholder">[paste key here]</span></pre>
</div>
<button onclick="closeModal()">Done</button>
</div>
</div>
<!-- Existing Keys List -->
<div class="keys-list">
<h3>Your API Keys</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="keys-table">
<!-- Populated via JavaScript -->
</tbody>
</table>
</div>
</section>
<script>
// Generate key
document.getElementById('generate-key-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('key-name').value;
const resp = await fetch('/api/keys', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `name=${encodeURIComponent(name)}`
});
const data = await resp.json();
// Show key in modal (only time it's available)
document.getElementById('generated-key').textContent = data.key;
document.getElementById('key-modal').classList.remove('hidden');
// Refresh keys list
loadKeys();
});
// Copy key to clipboard
function copyKey() {
const key = document.getElementById('generated-key').textContent;
navigator.clipboard.writeText(key);
alert('Copied to clipboard!');
}
// Load existing keys
async function loadKeys() {
const resp = await fetch('/api/keys');
const keys = await resp.json();
const tbody = document.getElementById('keys-table');
tbody.innerHTML = keys.map(key => `
<tr>
<td>${key.name}</td>
<td>${new Date(key.created_at).toLocaleDateString()}</td>
<td>${key.last_used ? new Date(key.last_used).toLocaleDateString() : 'Never'}</td>
<td><button onclick="deleteKey('${key.id}')">Revoke</button></td>
</tr>
`).join('');
}
// Delete key
async function deleteKey(id) {
if (!confirm('Are you sure you want to revoke this key?')) return;
await fetch(`/api/keys/${id}`, { method: 'DELETE' });
loadKeys();
}
// Load keys on page load
loadKeys();
</script>
<style>
.modal.hidden { display: none; }
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 600px;
}
.key-display {
background: #f5f5f5;
padding: 1rem;
margin: 1rem 0;
border-radius: 4px;
}
.key-display code {
word-break: break-all;
font-size: 14px;
}
.usage-instructions {
margin-top: 1rem;
padding: 1rem;
background: #e3f2fd;
border-radius: 4px;
}
.usage-instructions pre {
background: #263238;
color: #aed581;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
}
.handle { color: #ffab40; }
.key-placeholder { color: #64b5f6; }
</style>
```
#### 3.2 Register API Key Routes (`cmd/appview/serve.go`)
```go
// In initializeUI() function, add:
// API key management routes (authenticated)
authRouter.Handle("/api/keys", &uihandlers.GenerateAPIKeyHandler{
Store: apiKeyStore,
}).Methods("POST")
authRouter.Handle("/api/keys", &uihandlers.ListAPIKeysHandler{
Store: apiKeyStore,
}).Methods("GET")
authRouter.Handle("/api/keys/{id}", &uihandlers.DeleteAPIKeyHandler{
Store: apiKeyStore,
}).Methods("DELETE")
```
### Phase 4: Update Credential Helper
#### 4.1 Simplify Configuration (`cmd/credential-helper/main.go`)
```go
// SessionStore becomes CredentialStore
type CredentialStore struct {
Handle string `json:"handle"`
APIKey string `json:"api_key"`
AppViewURL string `json:"appview_url"`
}
func handleConfigure(handle string) {
fmt.Println("ATCR Credential Helper Configuration")
fmt.Println("=====================================")
fmt.Println()
fmt.Println("You need an API key from the ATCR web UI.")
fmt.Println()
appViewURL := os.Getenv("ATCR_APPVIEW_URL")
if appViewURL == "" {
appViewURL = defaultAppViewURL
}
// Auto-open settings page
settingsURL := appViewURL + "/settings"
fmt.Printf("Opening settings page: %s\n", settingsURL)
fmt.Println("Log in and generate an API key if you haven't already.")
fmt.Println()
if err := oauth.OpenBrowser(settingsURL); err != nil {
fmt.Printf("Could not open browser. Please visit: %s\n\n", settingsURL)
}
// Prompt for credentials
if handle == "" {
fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ")
fmt.Scanln(&handle)
} else {
fmt.Printf("Using handle: %s\n", handle)
}
fmt.Print("Enter your API key (from settings page): ")
var apiKey string
fmt.Scanln(&apiKey)
// Validate key format
if !strings.HasPrefix(apiKey, "atcr_") {
fmt.Fprintf(os.Stderr, "Invalid API key format. Key should start with 'atcr_'\n")
os.Exit(1)
}
// Save credentials
creds := &CredentialStore{
Handle: handle,
APIKey: apiKey,
AppViewURL: appViewURL,
}
if err := saveCredentials(getCredentialsPath(), creds); err != nil {
fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err)
os.Exit(1)
}
fmt.Println()
fmt.Println("✓ Configuration complete!")
fmt.Println("You can now use docker push/pull with atcr.io")
}
func handleGet() {
var serverURL string
fmt.Fscanln(os.Stdin, &serverURL)
// Load credentials
creds, err := loadCredentials(getCredentialsPath())
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading credentials: %v\n", err)
fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n")
os.Exit(1)
}
// Return credentials for Docker
// Docker will send these as Basic Auth to /auth/token
response := Credentials{
ServerURL: serverURL,
Username: creds.Handle,
Secret: creds.APIKey, // API key as password
}
json.NewEncoder(os.Stdout).Encode(response)
}
```
**File Rename:**
- `~/.atcr/session.json``~/.atcr/credentials.json`
### Phase 5: Remove Session Token System
#### 5.1 Delete Session Token Files
**Files to delete:**
- `pkg/auth/session/handler.go`
- `pkg/auth/exchange/handler.go`
#### 5.2 Update OAuth Server (`pkg/auth/oauth/server.go`)
**Remove session token creation:**
```go
// OLD (delete this):
sessionToken, err := s.sessionManager.Create(did, handle)
if err != nil {
s.renderError(w, fmt.Sprintf("Failed to create session token: %v", err))
return
}
// Check if this is a UI login...
if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil {
// UI flow...
} else {
// Render success page with session token (for credential helper)
s.renderSuccess(w, sessionToken, handle)
}
```
**NEW (replace with):**
```go
// Check if this is a UI login
if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil {
// Create UI session
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 24*time.Hour)
// ... set cookie, redirect ...
} else {
// Non-UI flow: redirect to settings to get API key
s.renderRedirectToSettings(w, handle)
}
```
**Add redirect to settings template:**
```go
func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) {
tmpl := template.Must(template.New("redirect").Parse(`
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful - ATCR</title>
<meta http-equiv="refresh" content="3;url=/settings">
</head>
<body>
<h1>✓ Authorization Successful!</h1>
<p>Redirecting to settings page to generate your API key...</p>
<p>If not redirected, <a href="/settings">click here</a>.</p>
</body>
</html>
`))
w.Header().Set("Content-Type", "text/html")
tmpl.Execute(w, nil)
}
```
#### 5.3 Update Server Constructor
```go
// Remove sessionManager parameter
func NewServer(app *App) *Server {
return &Server{
app: app,
}
}
```
#### 5.4 Update Registry Initialization (`cmd/appview/serve.go`)
```go
// REMOVE session manager creation:
// sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour)
// Create API key store
apiKeyStorePath := filepath.Join(filepath.Dir(storagePath), "api-keys.json")
apiKeyStore, err := apikey.NewStore(apiKeyStorePath)
if err != nil {
return fmt.Errorf("failed to create API key store: %w", err)
}
// OAuth server doesn't need session manager anymore
oauthServer := oauth.NewServer(oauthApp)
oauthServer.SetRefresher(refresher)
if uiSessionStore != nil {
oauthServer.SetUISessionStore(uiSessionStore)
}
// Token handler gets API key store instead of session manager
if issuer != nil {
tokenHandler := token.NewHandler(issuer, apiKeyStore, defaultHoldEndpoint)
tokenHandler.RegisterRoutes(mux)
// Remove exchange handler registration (no longer needed)
}
```
---
## Migration Path
### For Existing Users
**Option 1: Smooth Migration (Recommended)**
1. Keep session token validation temporarily with deprecation warning
2. When session token is used, log warning and return special response header
3. Docker client shows warning: "Session tokens deprecated, please regenerate API key"
4. Remove session token support in next major version
**Option 2: Hard Cutover**
1. Deploy new version with API keys
2. Session tokens stop working immediately
3. Users must reconfigure: `docker-credential-atcr configure`
4. Cleaner but disruptive
### Rollout Plan
**Week 1: Deploy API Keys**
- Add API key system
- Keep session token validation
- Add deprecation notice to OAuth callback
**Week 2-4: Migration Period**
- Monitor API key adoption
- Email users about migration
- Provide migration guide
**Week 5: Remove Session Tokens**
- Delete session token code
- Force users to API keys
---
## Testing Plan
### Unit Tests
1. **API Key Store**
- Test key generation (format, uniqueness)
- Test key validation (correct/incorrect keys)
- Test bcrypt hashing
- Test key listing/deletion
2. **Token Handler**
- Test API key authentication
- Test app password authentication
- Test invalid credentials
- Test key format validation
### Integration Tests
1. **Full Auth Flow**
- UI login → OAuth → API key generation
- Credential helper → API key → registry JWT
- App password → registry JWT
2. **Docker Client Tests**
- `docker login -u handle -p api_key`
- `docker login -u handle -p app_password`
- `docker push` with API key
- `docker pull` with API key
### Security Tests
1. **Key Security**
- Verify bcrypt hashing (not plaintext storage)
- Test key shown only once
- Test key revocation
- Test unauthorized key access
2. **OAuth Security**
- Verify API key links to correct OAuth session
- Test expired refresh token handling
- Test multiple keys for same user
---
## Files Changed
### New Files
- `pkg/appview/apikey/store.go` - API key storage and validation
- `pkg/appview/handlers/apikeys.go` - API key HTTP handlers
- `docs/API_KEY_MIGRATION.md` - This document
### Modified Files
- `pkg/auth/token/handler.go` - Add API key validation, remove session token
- `pkg/auth/oauth/server.go` - Remove session token creation, redirect to settings
- `pkg/appview/handlers/settings.go` - Add API key management UI
- `pkg/appview/templates/settings.html` - Add API key section
- `cmd/credential-helper/main.go` - Simplify to use API keys
- `cmd/appview/serve.go` - Initialize API key store, remove session manager
### Deleted Files
- `pkg/auth/session/handler.go` - Session token system
- `pkg/auth/exchange/handler.go` - Exchange endpoint (no longer needed)
---
## Advantages
**Simpler Auth:** Two methods instead of three (API keys + app passwords)
**Better UX:** No manual copy/paste of session tokens
**Multiple Keys:** Users can have laptop key, CI key, etc.
**Revocable:** Revoke individual keys without re-auth
**Server-Side OAuth:** Refresh tokens stay on server, not in client files
**Familiar Pattern:** Matches AWS ECR, GitHub tokens, etc.
## Backward Compatibility
⚠️ **Breaking Change:** Session tokens will stop working
**App passwords:** Still work (no changes)
**UI sessions:** Still work (separate system)
**Migration Required:** Users with session tokens must run `docker-credential-atcr configure` again to get API keys.
-281
View File
@@ -1,281 +0,0 @@
# ATCR OAuth Implementation
## Overview
ATCR now supports ATProto OAuth authentication via Docker credential helpers. This allows users to authenticate with their ATProto identity (Bluesky account) and use Docker push/pull commands seamlessly.
## Architecture
### Components
1. **OAuth Client** (`pkg/auth/oauth/`)
- Full ATProto OAuth implementation with DPoP support
- Uses `authelia.com/client/oauth2` for OAuth + PAR
- Uses `github.com/AxisCommunications/go-dpop` for DPoP proof generation
- Automatic authorization server discovery
- PKCE support for security
2. **Credential Helper** (`cmd/credential-helper/`)
- Standalone binary: `docker-credential-atcr`
- Implements Docker credential helper protocol
- Manages OAuth flow with browser
- Stores tokens securely in `~/.atcr/oauth-token.json`
3. **Registry Integration**
- `/auth/exchange` endpoint exchanges OAuth tokens for registry JWTs
- Existing `/auth/token` endpoint for standard Docker auth
## Dependencies
- `authelia.com/client/oauth2` - OAuth client with PAR support (2⭐, Authelia-backed)
- `github.com/AxisCommunications/go-dpop` - DPoP implementation (10⭐, RFC 9449 compliant)
- `github.com/golang-jwt/jwt/v5` - JWT library (transitive, 11k+⭐)
## Usage
### Setup
1. Build the credential helper:
```bash
go build -o docker-credential-atcr ./cmd/credential-helper
```
2. Install it in your PATH:
```bash
sudo mv docker-credential-atcr /usr/local/bin/
```
3. Configure Docker to use it by editing `~/.docker/config.json`:
```json
{
"credsStore": "atcr"
}
```
### Configuration
Run the OAuth flow:
```bash
docker-credential-atcr configure
```
This will:
1. Prompt for your ATProto handle (e.g., `alice.bsky.social`)
2. Open your browser for OAuth authorization
3. Store the OAuth token and DPoP key in `~/.atcr/oauth-token.json`
### Using with Docker
Once configured, use Docker normally:
```bash
# Push an image
docker push atcr.io/alice/myapp:latest
# Pull an image
docker pull atcr.io/alice/myapp:latest
```
The credential helper automatically:
1. Loads your stored OAuth token
2. Refreshes it if expired
3. Exchanges it for a registry JWT
4. Provides the JWT to Docker
## How It Works
### OAuth Flow
1. **User runs** `docker-credential-atcr configure`
2. **Resolve identity**: alice.bsky.social → DID → PDS endpoint
3. **Discover auth server**: GET `{pds}/.well-known/oauth-authorization-server`
4. **Generate DPoP key**: ECDSA P-256 key pair
5. **PAR request**: POST to PAR endpoint with DPoP header + PKCE challenge
6. **Open browser**: User authorizes on their PDS
7. **Receive code**: Callback to `localhost:8888/callback`
8. **Exchange code**: POST to token endpoint with DPoP header + PKCE verifier
9. **Save tokens**: Store OAuth token + DPoP key + DID/handle
### Docker Push/Pull Flow
1. **Docker needs credentials** for `atcr.io`
2. **Calls credential helper**: `docker-credential-atcr get`
3. **Helper loads token** from `~/.atcr/oauth-token.json`
4. **Refresh if needed**: Uses refresh token + DPoP if expired
5. **Exchange for registry JWT**: POST to `/auth/exchange` with OAuth token + handle
6. **Registry validates token**: Calls `getSession` on PDS to validate token
7. **Registry issues JWT**: Creates registry JWT with validated DID/handle
8. **Return to Docker**: `{"Username": "oauth2", "Secret": "<jwt>"}`
9. **Docker uses JWT**: For authentication to registry API
## Security
### DPoP (Demonstrating Proof-of-Possession)
Every OAuth request includes a DPoP proof:
- Unique JWT signed with ECDSA private key
- Contains HTTP method, URL, timestamp, nonce
- Public key (JWK) included in JWT header
- Binds the token to the specific client
### PKCE (Proof Key for Code Exchange)
- Code verifier generated locally
- Code challenge sent in authorization request
- Verifier sent in token exchange
- Prevents authorization code interception
### Token Storage
- Tokens stored in `~/.atcr/oauth-token.json`
- File permissions: 0600 (owner read/write only)
- DPoP key stored in PEM format
- Refresh tokens for long-term access
## Implementation Details
### Code Structure
```
pkg/auth/oauth/
├── client.go # OAuth client with DPoP
├── discovery.go # Authorization server discovery
├── metadata.go # Client metadata document
├── storage.go # Token persistence
└── transport.go # DPoP HTTP transport
pkg/auth/atproto/
├── session.go # ATProto session validation (Basic auth)
└── validator.go # OAuth token validation via getSession
cmd/credential-helper/
├── main.go # Docker credential helper protocol
├── oauth.go # OAuth flow orchestration
└── token.go # Token management
pkg/auth/exchange/
└── handler.go # OAuth → Registry JWT exchange
```
### Key Classes
**OAuth Client** (`pkg/auth/oauth/client.go`)
- `NewClient()` - Create client with DPoP key
- `InitializeForHandle()` - Discover auth server
- `AuthorizeURL()` - Generate authorization URL with PAR + PKCE
- `Exchange()` - Exchange code for token with DPoP
- `RefreshToken()` - Refresh expired token with DPoP
**DPoP Transport** (`pkg/auth/oauth/transport.go`)
- Implements `http.RoundTripper`
- Automatically adds DPoP header to all requests
- Handles nonce management and retries
- Used by OAuth client for all HTTP requests
**Token Store** (`pkg/auth/oauth/storage.go`)
- Persists OAuth tokens and DPoP key
- PEM encoding for private key
- Expiration checking
- Secure file permissions
**Token Validator** (`pkg/auth/atproto/validator.go`)
- `ValidateToken()` - Validate token via PDS getSession
- `ValidateTokenWithResolver()` - Auto-resolve PDS from handle
- Returns validated DID and handle
- Used by registry to verify OAuth tokens
## Testing
### Manual Testing
1. Configure the helper:
```bash
./docker-credential-atcr configure
# Enter handle: alice.bsky.social
# Browser opens for authorization
# Token saved to ~/.atcr/oauth-token.json
```
2. Test credential retrieval:
```bash
echo '{"ServerURL": "atcr.io"}' | ./docker-credential-atcr get
# Should return: {"Username":"oauth2","Secret":"<jwt>"}
```
3. Test with Docker:
```bash
docker push atcr.io/alice/test:latest
```
### Integration Testing
TODO: Add automated tests for:
- OAuth flow with mock PDS
- DPoP proof generation
- Token exchange
- Credential helper protocol
## Security Features
### OAuth Token Validation
The registry validates ATProto OAuth tokens by calling `com.atproto.server.getSession` on the user's PDS. This ensures:
- Token is valid and not expired
- Token belongs to the claimed user
- User's DID and handle are extracted from the PDS response
- No trust in client-provided identity information
**Flow:**
1. Client sends OAuth token + handle to `/auth/exchange`
2. Registry resolves handle → PDS endpoint
3. Registry calls `{pds}/xrpc/com.atproto.server.getSession` with token
4. PDS validates token and returns session info (DID, handle)
5. Registry uses validated DID/handle to issue registry JWT
## Future Improvements
1. **Token refresh in background**
- Proactively refresh before expiry
- Reduce latency on Docker commands
3. **Multiple account support**
- Store tokens for multiple handles
- Allow selecting which account to use
4. **Revocation support**
- Implement token revocation
- Clean up on logout
5. **Better error messages**
- User-friendly OAuth error handling
- Guide users through common issues
## Troubleshooting
### "Failed to resolve identity"
- Check internet connection
- Verify handle is correct (e.g., `alice.bsky.social`)
- Ensure PDS is accessible
### "Authorization timed out"
- Complete authorization within 5 minutes
- Check if browser opened correctly
- Try running `configure` again
### "Token expired"
- Credential helper should auto-refresh
- If persistent, run `configure` again
- Check `~/.atcr/oauth-token.json` permissions
### "Failed to exchange token"
- Ensure registry is running
- Check `/auth/exchange` endpoint is accessible
- Verify token hasn't been revoked
## References
- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
- [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449)
- [RFC 9126: PAR](https://datatracker.ietf.org/doc/html/rfc9126)
- [RFC 7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
- [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)
+1289
View File
File diff suppressed because it is too large Load Diff
-460
View File
@@ -1,460 +0,0 @@
ATProto Container Registry (atcr.io) Implementation Plan
Project Structure
/home/data/atcr.io/
├── cmd/
│ └── registry/
│ └── main.go # Entrypoint that imports distribution
├── pkg/
│ ├── atproto/
│ │ ├── client.go # ATProto client wrapper (using indigo)
│ │ ├── manifest_store.go # Implements distribution.ManifestService
│ │ ├── resolver.go # DID/handle resolution (alice → did:plc:...)
│ │ └── lexicon.go # ATProto record schemas for manifests
│ ├── storage/
│ │ ├── s3_blob_store.go # Wraps distribution's S3 driver for blobs
│ │ └── routing_repository.go # Routes manifests→ATProto, blobs→S3
│ ├── middleware/
│ │ ├── repository.go # Repository middleware registration
│ │ └── registry.go # Registry middleware for name resolution
│ └── server/
│ └── handler.go # HTTP wrapper for custom name resolution
├── config/
│ └── config.yml # Registry configuration
├── go.mod
├── go.sum
├── Dockerfile
├── README.md
└── CLAUDE.md # Updated with architecture docs
Implementation Steps
Phase 1: Project Setup
1. Initialize Go module with github.com/distribution/distribution/v3 and github.com/bluesky-social/indigo
2. Create basic project structure
3. Set up cmd/appview/main.go that imports distribution and registers middleware
Phase 2: Core ATProto Integration
4. Implement DID/handle resolver (pkg/atproto/resolver.go)
- Resolve handles to DIDs (alice.bsky.social → did:plc:xyz)
- Discover PDS endpoints from DID documents
5. Create ATProto client wrapper (pkg/atproto/client.go)
- Wrap indigo SDK for manifest storage
- Handle authentication with PDS
6. Design ATProto lexicon for manifest records (pkg/atproto/lexicon.go)
- Define schema for storing OCI manifests as ATProto records
Phase 3: Storage Layer
7. Implement ATProto manifest store (pkg/atproto/manifest_store.go)
- Implements distribution.ManifestService
- Stores/retrieves manifests from PDS
8. Implement S3 blob store wrapper (pkg/storage/s3_blob_store.go)
- Wraps distribution's built-in S3 driver
9. Create routing repository (pkg/storage/routing_repository.go)
- Returns ATProto store for Manifests()
- Returns S3 store for Blobs()
Phase 4: Middleware Layer
10. Implement repository middleware (pkg/middleware/repository.go)
- Registers routing repository
- Configurable via YAML
11. Implement registry/namespace middleware (pkg/middleware/registry.go)
- Intercepts Repository(name) calls
- Performs name resolution before repository creation
Phase 5: HTTP Layer (if needed)
12. Create custom HTTP handler (pkg/server/handler.go)
- Wraps distribution's HTTP handlers
- Performs early name resolution: atcr.io/alice/myimage → resolve alice
- Delegates to distribution handlers
Phase 6: Configuration & Deployment
13. Create registry configuration (config/config.yml)
14. Create Dockerfile for building atcr-appview binary
16. Write README.md with usage instructions
Phase 7: Documentation
17. Update CLAUDE.md with:
- Architecture overview (ATProto for manifests, S3 for blobs)
- Build/run/test commands
- How name resolution works
- Middleware registration flow
- Key design decisions
Key Technical Decisions
ATProto Storage Design:
- Manifests stored as ATProto records in user's PDS
- Each image manifest is a record in a io.atcr.manifest collection
- Record key = image digest (sha256:...)
Name Resolution:
- atcr.io/alice/myimage → resolve alice to DID → discover PDS
- Support both handles (alice.bsky.social) and DIDs (did:plc:xyz)
Blob Storage:
- All layers/blobs in S3 (content-addressable by digest)
- Manifests reference S3-stored blobs by digest
- S3 provides cheap, durable blob storage
Middleware Strategy:
- Repository middleware for storage routing
- Registry middleware (or HTTP wrapper) for name resolution
- No fork of distribution core needed
Perfect. To match Docker Hub/ghcr.io/gcr.io, here's what we need:
● Implementation Plan (Drop-in replacement for Docker Hub/ghcr.io)
Flow 1: Standard Token Auth (Like Docker Hub) - PRIMARY
# User experience
docker login atcr.io -u alice.bsky.social -p <atproto-app-password>
docker push atcr.io/alice/myapp:latest
# Behind the scenes
1. docker login stores credentials locally
2. docker push → Registry returns 401 with WWW-Authenticate: Bearer realm="https://atcr.io/auth/token"...
3. Docker auto-calls /auth/token with Basic auth (alice.bsky.social:app-password)
4. Auth service validates against ATProto createSession
5. Returns JWT token with scope for alice/myapp
6. Docker uses JWT for manifest/blob uploads
7. Registry validates JWT signature and scope
Components:
- /auth/token endpoint (standalone service or embedded)
- ATProto session validator (username/password → validate via PDS)
- JWT issuer/signer
- JWT validator middleware for registry
Flow 2: Credential Helper (Like gcr.io) - ADVANCED
# User experience
docker-credential-atcr configure
# Opens browser for ATProto OAuth
docker push atcr.io/alice/myapp:latest
# No manual login needed
# Behind the scenes
1. Helper does OAuth flow → gets ATProto access token
2. Caches token securely
3. When Docker needs credentials, calls helper via stdin/stdout
4. Helper exchanges ATProto token for registry JWT at /auth/exchange
5. Returns JWT to Docker
6. Docker uses JWT for requests
Components:
- cmd/credential-helper/main.go - Standalone binary
- ATProto OAuth client
- Token exchange endpoint (/auth/exchange)
- Secure token cache
Architecture:
pkg/auth/
├── token/
│ ├── service.go # HTTP handler for /auth/token
│ ├── claims.go # JWT claims structure
│ ├── issuer.go # Signs JWTs
│ └── validator.go # Validates JWTs (middleware for registry)
├── atproto/
│ ├── session.go # Validates username/password via ATProto
│ └── oauth.go # OAuth flow implementation
├── exchange/
│ └── handler.go # /auth/exchange endpoint (OAuth → JWT)
└── scope.go # Parses/validates Docker scopes
cmd/
├── registry/main.go # Registry server (existing)
├── auth/main.go # Standalone auth service (optional)
└── credential-helper/
└── main.go # docker-credential-atcr binary
Config:
auth:
token:
realm: https://atcr.io/auth/token # Where Docker gets tokens
service: atcr.io
issuer: atcr.io
rootcertbundle: /etc/atcr/token-signing.crt
privatekey: /etc/atcr/token-signing.pem
expiration: 300
atproto:
# Used by auth service to validate credentials
pds_endpoint: https://bsky.social
client_id: atcr-appview
oauth_redirect: http://localhost:8888/callback
ATProto OAuth Implementation Plan
Architecture
Dependencies:
- authelia.com/client/oauth2 - OAuth + PAR support
- github.com/AxisCommunications/go-dpop - DPoP proof generation (handles JWK automatically)
- github.com/golang-jwt/jwt/v5 - JWT library (transitive via go-dpop)
- Our existing pkg/atproto/resolver.go - ATProto identity resolution
Implementation Components
1. OAuth Client (pkg/auth/oauth/client.go) - ~100 lines
type Client struct {
config *oauth2.Config
dpopKey *ecdsa.PrivateKey
resolver *atproto.Resolver
clientID string // URL to our metadata document
redirectURI string
dpopNonce string // Server-provided nonce
}
func NewClient(clientID, redirectURI string) (*Client, error)
func (c *Client) AuthorizeURL(handle string, scopes []string) (string, error)
func (c *Client) Exchange(code string) (*Token, error)
func (c *Client) addDPoPHeader(req *http.Request, method, url string) error
Flow:
1. Generate ECDSA P-256 key for DPoP
2. Discover authorization server from handle/DID
3. Use authelia's PushedAuth() for PAR with DPoP header
4. Exchange code for token with DPoP proof
2. Authorization Server Discovery (pkg/auth/oauth/discovery.go) - ~30 lines
type AuthServerMetadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
}
func DiscoverAuthServer(pdsEndpoint string) (*AuthServerMetadata, error)
Implementation:
- GET {pds}/.well-known/oauth-authorization-server
- Parse JSON metadata
- Validate required endpoints exist
3. Client Metadata Server (pkg/auth/oauth/metadata.go) - ~40 lines
type ClientMetadata struct {
ClientID string `json:"client_id"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
Scope string `json:"scope"`
DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
}
func ServeMetadata(clientID string, redirectURIs []string) http.Handler
Serves: https://atcr.io/oauth/client-metadata.json
4. Token Storage (pkg/auth/oauth/storage.go) - ~50 lines
type TokenStore struct {
AccessToken string
RefreshToken string
DPoPKey *ecdsa.PrivateKey // Persist for refresh
ExpiresAt time.Time
}
func (s *TokenStore) Save(path string) error
func LoadTokenStore(path string) (*TokenStore, error)
Storage location: ~/.atcr/oauth-tokens.json
5. Credential Helper (cmd/credential-helper/main.go) - ~80 lines
// Docker credential helper protocol
// Reads JSON from stdin, writes to stdout
func main() {
if len(os.Args) < 2 {
os.Exit(1)
}
switch os.Args[1] {
case "get":
handleGet() // Return credentials for registry
case "store":
handleStore() // Store credentials
case "erase":
handleErase() // Remove credentials
}
}
func handleGet() {
var request struct {
ServerURL string `json:"ServerURL"`
}
json.NewDecoder(os.Stdin).Decode(&request)
// Load token from storage
// Exchange for registry JWT if needed
// Output: {"Username": "oauth2", "Secret": "<jwt>"}
}
6. OAuth Flow (cmd/credential-helper/oauth.go) - ~60 lines
func RunOAuthFlow(handle string) (*TokenStore, error) {
// 1. Start local HTTP server on :8888
// 2. Open browser to authorization URL
// 3. Wait for callback with code
// 4. Exchange code for token
// 5. Save token store
// 6. Return token
}
func startCallbackServer() (chan string, *http.Server)
Complete Flow Example
User runs:
docker-credential-atcr configure
What happens:
1. Generate DPoP key (client.go)
dpopKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
2. Resolve handle → DID → PDS (using our resolver)
did, pds, _ := resolver.ResolveIdentity(ctx, "alice.bsky.social")
3. Discover auth server (discovery.go)
metadata, _ := DiscoverAuthServer(pds)
// Returns: PAR endpoint, token endpoint, etc.
4. Create PAR request with DPoP (client.go + go-dpop)
// Generate DPoP proof for PAR endpoint
claims := &dpop.ProofTokenClaims{
Method: dpop.POST,
URL: metadata.PushedAuthorizationRequestEndpoint,
RegisteredClaims: &jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey)
// Use authelia for PAR
config := &oauth2.Config{
ClientID: "https://atcr.io/oauth/client-metadata.json",
Endpoint: oauth2.Endpoint{
AuthURL: metadata.AuthorizationEndpoint,
TokenURL: metadata.TokenEndpoint,
},
}
// Create custom HTTP client that adds DPoP header
client := &http.Client{
Transport: &dpopTransport{
base: http.DefaultTransport,
dpopKey: dpopKey,
},
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
// PAR request (authelia handles this)
authURL, parResp, _ := config.PushedAuth(ctx, state,
oauth2.SetAuthURLParam("code_challenge", pkceChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
5. Open browser, get code (oauth.go)
exec.Command("open", authURL).Run()
// User authorizes
// Callback: http://localhost:8888?code=xyz&state=abc
6. Exchange code for token with DPoP (client.go + go-dpop)
// Generate DPoP proof for token endpoint
claims := &dpop.ProofTokenClaims{
Method: dpop.POST,
URL: metadata.TokenEndpoint,
RegisteredClaims: &jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey)
// Exchange (with DPoP header added by our transport)
token, _ := config.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", pkceVerifier),
)
7. Save token + DPoP key (storage.go)
store := &TokenStore{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
DPoPKey: dpopKey,
ExpiresAt: token.Expiry,
}
store.Save("~/.atcr/oauth-tokens.json")
Later, when docker push happens:
docker push atcr.io/alice/myapp:latest
1. Docker calls credential helper: docker-credential-atcr get
2. Helper loads stored token
3. Helper calls /auth/exchange with OAuth token → gets registry JWT
4. Returns JWT to Docker
5. Docker uses JWT for push
Directory Structure
pkg/auth/oauth/
├── client.go # OAuth client with DPoP integration
├── discovery.go # Authorization server discovery
├── metadata.go # Client metadata server
├── storage.go # Token persistence
└── transport.go # HTTP transport that adds DPoP headers
cmd/credential-helper/
├── main.go # Docker credential helper protocol
├── oauth.go # OAuth flow (browser, callback)
└── config.go # Configuration
go.mod additions:
authelia.com/client/oauth2 v0.25.0
github.com/AxisCommunications/go-dpop v1.1.2
Unified Model
Every hold service requires HOLD_OWNER:
- Owner's PDS has the io.atcr.hold record
- Owner's PDS has all io.atcr.hold.crew records
- Authorization is always governed by PDS records
For "public" hold (like Tangled's public knot):
- Owner creates hold with public: true
- Anyone can push/pull without being crew
- Owner can add crew records for special privileges/tracking if desired
Config has emergency override:
auth:
# Emergency freeze: ignore public setting, restrict to crew only
# Use this to stop abuse without changing PDS records
freeze: false
Authorization logic:
1. Check freeze in config → if true, skip to crew check
2. Query owner's PDS for io.atcr.hold record
3. If public: true → allow all operations (unless frozen)
4. If public: false OR frozen → query io.atcr.hold.crew records, check membership
Remove from config:
- allow_all (replaced by public: true in PDS)
- allowed_dids (replaced by crew records in PDS)
This way the hold owner at atcr.io can run a public hold at hold1.atcr.io that anyone can use, but can freeze it instantly if needed without touching PDS records.
-334
View File
@@ -1,334 +0,0 @@
# Local Testing Guide
## Quick Start
```bash
./test-local.sh
```
This automated script will:
1. Create storage directories
2. Build all binaries
3. Start both services
4. Show test commands
## Manual Testing Steps
### 1. Setup Directories
```bash
sudo mkdir -p /var/lib/atcr/{blobs,hold,auth}
sudo chown -R $USER:$USER /var/lib/atcr
```
### 2. Build Binaries
```bash
go build -o atcr-appview ./cmd/appview
go build -o atcr-hold ./cmd/hold
go build -o docker-credential-atcr ./cmd/credential-helper
```
### 3. Configure Environment
Create a `.env` file in the project root:
```bash
cp .env.example .env
```
Edit `.env` with your credentials:
```env
# Your ATProto handle
ATPROTO_HANDLE=your-handle.bsky.social
# Hold service public URL (hostname becomes the hold name)
HOLD_PUBLIC_URL=http://127.0.0.1:8080
# Enable OAuth registration on startup
HOLD_AUTO_REGISTER=true
```
**Notes:**
- Use your Bluesky handle (e.g., `alice.bsky.social`)
- For localhost, use `127.0.0.1` instead of `localhost` for OAuth
- The hostname from the URL becomes the hold name (e.g., `127.0.0.1` or `hold1.atcr.io`)
**Load environment:**
```bash
export $(cat .env | xargs)
```
### 4. Start Services
**Terminal 1 - AppView:**
```bash
./atcr-appview serve config/config.yml
```
**Terminal 2 - Hold:**
```bash
./atcr-hold config/hold.yml
```
### 5. Start Services and OAuth Registration
**Terminal 1 - AppView:**
```bash
./atcr-appview serve config/config.yml
```
**Terminal 2 - Hold (OAuth registration):**
```bash
./atcr-hold config/hold.yml
```
The hold service will start an OAuth flow. You'll see output like:
```
================================================================================
OAUTH AUTHORIZATION REQUIRED
================================================================================
Please visit this URL to authorize the hold service:
https://bsky.social/oauth/authorize?...
Waiting for authorization...
================================================================================
```
**Steps:**
1. Copy the OAuth URL from the logs
2. Open it in your browser
3. Sign in to Bluesky and authorize
4. The callback will complete automatically
5. Hold service registers in your PDS
After successful OAuth, you'll see:
```
✓ Created hold record: at://did:plc:.../io.atcr.hold/127.0.0.1
✓ Created crew record: at://did:plc:.../io.atcr.hold.crew/127.0.0.1-did:plc:...
================================================================================
REGISTRATION COMPLETE
================================================================================
Hold service is now registered and ready to use!
```
This creates two records in your PDS:
- `io.atcr.hold` - Defines the storage endpoint URL
- `io.atcr.hold.crew` - Grants you admin access
### 6. Test Docker Push/Pull
**Test 1: Basic Push**
```bash
# Tag an image
docker tag alpine:latest localhost:5000/alice/alpine:test
# Push to local registry
docker push localhost:5000/alice/alpine:test
```
**Test 2: Pull**
```bash
# Remove local image
docker rmi localhost:5000/alice/alpine:test
# Pull from registry
docker pull localhost:5000/alice/alpine:test
```
**Test 3: Verify Storage**
```bash
# Check manifests were stored in ATProto
# (Check your PDS for io.atcr.manifest records)
# Check blobs were stored locally
ls -lh /var/lib/atcr/blobs/docker/registry/v2/
```
## OAuth Testing (Optional)
### Setup Credential Helper
```bash
# Configure OAuth
./docker-credential-atcr configure
# Follow the browser flow to authorize
# Verify token was saved
ls -la ~/.atcr/oauth-token.json
```
### Configure Docker to Use Helper
Edit `~/.docker/config.json`:
```json
{
"credHelpers": {
"localhost:5000": "atcr"
}
}
```
### Test with OAuth
```bash
# Push should now use OAuth automatically
docker push localhost:5000/alice/myapp:latest
```
## Troubleshooting
### Registry won't start
**Error:** `failed to create storage driver`
```bash
# Check directory permissions
ls -ld /var/lib/atcr/blobs
# Should be owned by your user
# Fix permissions
sudo chown -R $USER:$USER /var/lib/atcr
```
**Error:** `address already in use`
```bash
# Check what's using port 5000
lsof -i :5000
# Kill existing process
kill $(lsof -t -i :5000)
```
### Hold service won't start
**Error:** `failed to create storage driver`
```bash
# Check hold directory
ls -ld /var/lib/atcr/hold
sudo chown -R $USER:$USER /var/lib/atcr/hold
```
**Error:** `address already in use`
```bash
# Check port 8080
lsof -i :8080
kill $(lsof -t -i :8080)
```
### Docker push fails
**Error:** `unauthorized: authentication required`
- Check `ATPROTO_DID` and `ATPROTO_ACCESS_TOKEN` are set
- Verify token is valid (not expired)
- Check registry logs for auth errors
**Error:** `denied: requested access to the resource is denied`
- Check the identity in the image name matches your DID
- Example: If your handle is `alice.bsky.social`, use:
```bash
docker push localhost:5000/alice/myapp:test
# NOT localhost:5000/bob/myapp:test
```
**Error:** `failed to resolve identity`
- Check internet connection (needs to resolve DIDs)
- Verify handle is correct
- Try using DID directly instead of handle
### OAuth issues
**Error:** `Failed to exchange token`
- Ensure registry is running and accessible
- Check `/auth/exchange` endpoint is responding
- Verify OAuth token hasn't expired
**Error:** `Token validation failed`
- Token might be expired
- Run `./docker-credential-atcr configure` again
- Check PDS is accessible
## Verifying the Flow
### Check Registry is Running
```bash
curl http://localhost:5000/v2/
# Should return: {}
```
### Check Hold is Running
```bash
curl http://localhost:8080/health
# Should return: {"status":"ok"}
```
### Check Auth Endpoint
```bash
curl -v http://localhost:5000/v2/
# Should return 401 with WWW-Authenticate header
```
### Inspect Stored Data
**Manifests (in ATProto):**
- Check your PDS web interface
- Look for `io.atcr.manifest` collection records
**Blobs (local filesystem):**
```bash
# List blobs
find /var/lib/atcr/blobs -type f
# Check blob content (should be binary)
ls -lh /var/lib/atcr/blobs/docker/registry/v2/blobs/sha256/
```
## Clean Up
### Stop Services
```bash
# If using test script
kill $(cat .atcr-pids)
# Or manually
pkill atcr-appview
pkill atcr-hold
```
### Remove Test Data
```bash
# Remove all stored data
sudo rm -rf /var/lib/atcr/*
# Remove OAuth tokens
rm -rf ~/.atcr/
```
### Reset Docker Config
```bash
# Remove credential helper config
# Edit ~/.docker/config.json and remove "credHelpers" section
```
## Next Steps
Once local testing works:
1. **Deploy to production:**
- Use S3/Storj for blob storage
- Deploy registry and hold to separate hosts
- Configure DNS for `atcr.io`
2. **Enable BYOS:**
- Users create `io.atcr.hold` records
- Deploy their own hold service
- AppView automatically routes to their storage
3. **Add monitoring:**
- Registry metrics
- Hold service metrics
- Storage usage tracking