cleanup more auth

This commit is contained in:
Evan Jarrett
2025-10-07 10:58:11 -05:00
parent 5b18538a8b
commit 2d16bbfee3
31 changed files with 2524 additions and 918 deletions

826
docs/API_KEY_MIGRATION.md Normal file
View File

@@ -0,0 +1,826 @@
# 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/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
<!-- 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`)
```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/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.