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
-
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/tokenand/auth/exchange - 30-day TTL
- Problem: Awkward UX, requires manual copy/paste
- JWT-like tokens:
-
UI Sessions (
pkg/appview/session/)- Cookie-based (
atcr_session) - Random session ID, server-side store
- 24-hour TTL
- Keep this - works well
- Cookie-based (
-
App Password Auth (via PDS)
- Direct
com.atproto.server.createSessioncall - No AppView involvement until token request
- Keep this - essential for non-UI users
- Direct
Target State
Two Auth Methods
-
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
-
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.gopkg/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)
- Keep session token validation temporarily with deprecation warning
- When session token is used, log warning and return special response header
- Docker client shows warning: "Session tokens deprecated, please regenerate API key"
- Remove session token support in next major version
Option 2: Hard Cutover
- Deploy new version with API keys
- Session tokens stop working immediately
- Users must reconfigure:
docker-credential-atcr configure - 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
-
API Key Store
- Test key generation (format, uniqueness)
- Test key validation (correct/incorrect keys)
- Test bcrypt hashing
- Test key listing/deletion
-
Token Handler
- Test API key authentication
- Test app password authentication
- Test invalid credentials
- Test key format validation
Integration Tests
-
Full Auth Flow
- UI login → OAuth → API key generation
- Credential helper → API key → registry JWT
- App password → registry JWT
-
Docker Client Tests
docker login -u handle -p api_keydocker login -u handle -p app_passworddocker pushwith API keydocker pullwith API key
Security Tests
-
Key Security
- Verify bcrypt hashing (not plaintext storage)
- Test key shown only once
- Test key revocation
- Test unauthorized key access
-
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 validationpkg/appview/handlers/apikeys.go- API key HTTP handlersdocs/API_KEY_MIGRATION.md- This document
Modified Files
pkg/auth/token/handler.go- Add API key validation, remove session tokenpkg/auth/oauth/server.go- Remove session token creation, redirect to settingspkg/appview/handlers/settings.go- Add API key management UIpkg/appview/templates/settings.html- Add API key sectioncmd/credential-helper/main.go- Simplify to use API keyscmd/registry/serve.go- Initialize API key store, remove session manager
Deleted Files
pkg/auth/session/handler.go- Session token systempkg/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.