Files
at-container-registry/docs/API_KEY_MIGRATION.md
2025-10-07 10:58:11 -05:00

22 KiB

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)

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:

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:

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)

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)

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):

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

// 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:

// 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):

// 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:

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

// Remove sessionManager parameter
func NewServer(app *App) *Server {
    return &Server{
        app: app,
    }
}

5.4 Update Registry Initialization (cmd/registry/serve.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.