mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-06-07 15:52:35 +00:00
cleanup more auth
This commit is contained in:
826
docs/API_KEY_MIGRATION.md
Normal file
826
docs/API_KEY_MIGRATION.md
Normal 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.
|
||||
Reference in New Issue
Block a user