# 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: `.` - 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_ 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/registry/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

API Keys

Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.

Generate New API Key

Your API Keys

Name Created Last Used Actions
``` #### 3.2 Register API Key Routes (`cmd/registry/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(` Authorization Successful - ATCR

✓ Authorization Successful!

Redirecting to settings page to generate your API key...

If not redirected, click here.

`)) 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/registry/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/registry/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.