From 2d16bbfee30f22cb85a3ccd4e15bbf5c9e3cc5de Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 7 Oct 2025 10:58:11 -0500 Subject: [PATCH] cleanup more auth --- cmd/credential-helper/main.go | 143 ++-- cmd/hold/main.go | 51 +- cmd/registry/main.go | 4 - cmd/registry/serve.go | 47 +- docs/API_KEY_MIGRATION.md | 826 ++++++++++++++++++++++ docs/APPVIEW-UI-IMPLEMENTATION.md | 2 +- go.mod | 38 +- go.sum | 90 +++ pkg/appview/apikey/store.go | 249 +++++++ pkg/appview/db/queries.go | 6 +- pkg/appview/handlers/apikeys.go | 91 +++ pkg/appview/handlers/settings.go | 26 +- pkg/appview/jetstream/backfill.go | 62 +- pkg/appview/jetstream/worker.go | 62 +- pkg/appview/session/session.go | 21 + pkg/appview/templates/pages/settings.html | 275 +++++++ pkg/atproto/client.go | 141 ++-- pkg/atproto/resolver.go | 243 ------- pkg/auth/atproto/session.go | 33 +- pkg/auth/atproto/validator.go | 17 +- pkg/auth/exchange/handler.go | 116 --- pkg/auth/oauth/browser.go | 25 + pkg/auth/oauth/client.go | 5 +- pkg/auth/oauth/interactive.go | 187 +++++ pkg/auth/oauth/server.go | 92 ++- pkg/auth/oauth/store.go | 237 +++++++ pkg/auth/session/handler.go | 170 ----- pkg/auth/token/handler.go | 71 +- pkg/middleware/registry.go | 45 +- pkg/middleware/repository.go | 57 -- pkg/server/handler.go | 10 +- 31 files changed, 2524 insertions(+), 918 deletions(-) create mode 100644 docs/API_KEY_MIGRATION.md create mode 100644 pkg/appview/apikey/store.go create mode 100644 pkg/appview/handlers/apikeys.go delete mode 100644 pkg/atproto/resolver.go delete mode 100644 pkg/auth/exchange/handler.go create mode 100644 pkg/auth/oauth/browser.go create mode 100644 pkg/auth/oauth/interactive.go create mode 100644 pkg/auth/oauth/store.go delete mode 100644 pkg/auth/session/handler.go delete mode 100644 pkg/middleware/repository.go diff --git a/cmd/credential-helper/main.go b/cmd/credential-helper/main.go index b2f140a..dae7880 100644 --- a/cmd/credential-helper/main.go +++ b/cmd/credential-helper/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "atcr.io/pkg/auth/oauth" ) @@ -14,11 +15,11 @@ const ( defaultAppViewURL = "http://127.0.0.1:5000" ) -// SessionStore represents the stored session token -type SessionStore struct { - SessionToken string `json:"session_token"` - Handle string `json:"handle"` - AppViewURL string `json:"appview_url"` +// CredentialStore represents the stored API key credentials +type CredentialStore struct { + APIKey string `json:"api_key"` + Handle string `json:"handle"` + AppViewURL string `json:"appview_url"` } // Docker credential helper protocol @@ -68,22 +69,22 @@ func handleGet() { os.Exit(1) } - // Load session from storage - sessionPath := getSessionPath() - session, err := loadSession(sessionPath) + // Load credentials from storage + credsPath := getCredentialsPath() + storedCreds, err := loadCredentials(credsPath) if err != nil { - fmt.Fprintf(os.Stderr, "Error loading session: %v\n", err) + 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 session token as credentials - // Docker will call /auth/token with this, and the token handler - // will validate the session token and issue a registry JWT + // Return credentials for Docker + // Docker will send these as Basic Auth to /auth/token + // The token handler will validate the API key and issue a registry JWT creds := Credentials{ ServerURL: serverURL, - Username: "oauth2", // Signals token-based auth to Docker - Secret: session.SessionToken, // Return session token directly + Username: storedCreds.Handle, // Use handle as username + Secret: storedCreds.APIKey, // API key as password } if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil { @@ -114,28 +115,39 @@ func handleErase() { os.Exit(1) } - // Remove session file - sessionPath := getSessionPath() - if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error removing session: %v\n", err) + // Remove credentials file + credsPath := getCredentialsPath() + if err := os.Remove(credsPath); err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error removing credentials: %v\n", err) os.Exit(1) } } -// handleConfigure runs the OAuth flow to get initial credentials +// handleConfigure prompts for API key and saves credentials 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() // Get AppView URL from environment or use default appViewURL := os.Getenv("ATCR_APPVIEW_URL") if appViewURL == "" { appViewURL = defaultAppViewURL } - fmt.Printf("AppView URL: %s\n\n", appViewURL) - // Ask for handle if not provided as argument + // 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): ") if _, err := fmt.Scanln(&handle); err != nil { @@ -146,45 +158,38 @@ func handleConfigure(handle string) { fmt.Printf("Using handle: %s\n", handle) } - // Open browser to AppView OAuth authorization - authURL := fmt.Sprintf("%s/auth/oauth/authorize?handle=%s", appViewURL, handle) - fmt.Printf("\nOpening browser to: %s\n", authURL) - fmt.Println("Please complete the authorization in your browser.") - fmt.Println("After authorization, you will receive a session token.") + fmt.Print("Enter your API key (from settings page): ") + var apiKey string + if _, err := fmt.Scanln(&apiKey); err != nil { + fmt.Fprintf(os.Stderr, "Error reading API key: %v\n", err) + os.Exit(1) + } + + // 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() - - if err := oauth.OpenBrowser(authURL); err != nil { - fmt.Printf("Failed to open browser automatically.\nPlease open this URL manually:\n%s\n\n", authURL) - } - - // Prompt user to paste session token - fmt.Print("Enter the session token from the browser: ") - var sessionToken string - if _, err := fmt.Scanln(&sessionToken); err != nil { - fmt.Fprintf(os.Stderr, "Error reading session token: %v\n", err) - os.Exit(1) - } - - // Create session store - session := &SessionStore{ - SessionToken: sessionToken, - Handle: handle, - AppViewURL: appViewURL, - } - - // Save session - sessionPath := getSessionPath() - if err := saveSession(sessionPath, session); err != nil { - fmt.Fprintf(os.Stderr, "Error saving session: %v\n", err) - os.Exit(1) - } - - fmt.Println("\n✓ Configuration complete!") + fmt.Println("✓ Configuration complete!") fmt.Println("You can now use docker push/pull with atcr.io") } -// getSessionPath returns the path to the session file -func getSessionPath() string { +// getCredentialsPath returns the path to the credentials file +func getCredentialsPath() string { homeDir, err := os.UserHomeDir() if err != nil { fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) @@ -197,33 +202,33 @@ func getSessionPath() string { os.Exit(1) } - return filepath.Join(atcrDir, "session.json") + return filepath.Join(atcrDir, "credentials.json") } -// loadSession loads the session from disk -func loadSession(path string) (*SessionStore, error) { +// loadCredentials loads the credentials from disk +func loadCredentials(path string) (*CredentialStore, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("failed to read session file: %w", err) + return nil, fmt.Errorf("failed to read credentials file: %w", err) } - var session SessionStore - if err := json.Unmarshal(data, &session); err != nil { - return nil, fmt.Errorf("failed to parse session file: %w", err) + var creds CredentialStore + if err := json.Unmarshal(data, &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials file: %w", err) } - return &session, nil + return &creds, nil } -// saveSession saves the session to disk -func saveSession(path string, session *SessionStore) error { - data, err := json.MarshalIndent(session, "", " ") +// saveCredentials saves the credentials to disk +func saveCredentials(path string, creds *CredentialStore) error { + data, err := json.MarshalIndent(creds, "", " ") if err != nil { - return fmt.Errorf("failed to marshal session: %w", err) + return fmt.Errorf("failed to marshal credentials: %w", err) } if err := os.WriteFile(path, data, 0600); err != nil { - return fmt.Errorf("failed to write session file: %w", err) + return fmt.Errorf("failed to write credentials file: %w", err) } return nil diff --git a/cmd/hold/main.go b/cmd/hold/main.go index d2a38c1..040718d 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -19,6 +19,8 @@ import ( "atcr.io/pkg/atproto" "atcr.io/pkg/auth/oauth" indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" // Import storage drivers _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" @@ -437,13 +439,23 @@ func (s *HoldService) isCrewMember(did string) (bool, error) { ctx := context.Background() - // Resolve owner's PDS endpoint - resolver := atproto.NewResolver() - pdsEndpoint, err := resolver.ResolvePDS(ctx, ownerDID) + // Resolve owner's PDS endpoint using indigo + directory := identity.DefaultDirectory() + ownerDIDParsed, err := syntax.ParseDID(ownerDID) + if err != nil { + return false, fmt.Errorf("invalid owner DID: %w", err) + } + + ident, err := directory.LookupDID(ctx, ownerDIDParsed) if err != nil { return false, fmt.Errorf("failed to resolve owner PDS: %w", err) } + pdsEndpoint := ident.PDSEndpoint() + if pdsEndpoint == "" { + return false, fmt.Errorf("no PDS endpoint found for owner") + } + // Create unauthenticated client to read public records client := atproto.NewClient(pdsEndpoint, ownerDID, "") @@ -827,13 +839,23 @@ func (s *HoldService) AutoRegister() error { log.Printf("Checking registration status for DID: %s", reg.OwnerDID) - // Resolve DID to PDS endpoint - resolver := atproto.NewResolver() - pdsEndpoint, err := resolver.ResolvePDS(ctx, reg.OwnerDID) + // Resolve DID to PDS endpoint using indigo + directory := identity.DefaultDirectory() + didParsed, err := syntax.ParseDID(reg.OwnerDID) + if err != nil { + return fmt.Errorf("invalid owner DID: %w", err) + } + + ident, err := directory.LookupDID(ctx, didParsed) if err != nil { return fmt.Errorf("failed to resolve PDS for DID: %w", err) } + pdsEndpoint := ident.PDSEndpoint() + if pdsEndpoint == "" { + return fmt.Errorf("no PDS endpoint found for DID") + } + log.Printf("PDS endpoint: %s", pdsEndpoint) // Check if hold is already registered @@ -850,10 +872,10 @@ func (s *HoldService) AutoRegister() error { // Not registered, need to do OAuth log.Printf("Hold not registered, starting OAuth flow...") - // Get handle from DID document - handle, err := resolver.ResolveHandleFromDID(ctx, reg.OwnerDID) - if err != nil { - return fmt.Errorf("failed to get handle from DID: %w", err) + // Get handle from DID document (already resolved above) + handle := ident.Handle.String() + if handle == "" || handle == "handle.invalid" { + return fmt.Errorf("no valid handle found for DID") } log.Printf("Resolved handle: %s", handle) @@ -932,12 +954,9 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri log.Printf("DID: %s", did) log.Printf("PDS: %s", pdsEndpoint) - // Extract access token and HTTP client from session - accessToken, _ := result.Session.GetHostAccessData() - httpClient := result.Session.APIClient().Client - - // Create ATProto client with indigo's DPoP-configured HTTP client - client := atproto.NewClientWithHTTPClient(pdsEndpoint, did, accessToken, httpClient) + // Create ATProto client with indigo's API client (handles DPoP automatically) + apiClient := result.Session.APIClient() + client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) return s.registerWithClient(publicURL, did, client) } diff --git a/cmd/registry/main.go b/cmd/registry/main.go index 1abd816..ac9603e 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -14,9 +14,7 @@ import ( // Register our custom middleware _ "atcr.io/pkg/middleware" - "atcr.io/pkg/auth/exchange" "atcr.io/pkg/auth/oauth" - "atcr.io/pkg/auth/session" "atcr.io/pkg/auth/token" "atcr.io/pkg/middleware" ) @@ -34,7 +32,5 @@ var _ = fmt.Sprint var _ = os.Stdout var _ = time.Now var _ = oauth.NewRefresher -var _ = session.NewManager var _ = token.NewIssuer -var _ = exchange.NewHandler var _ = middleware.SetGlobalRefresher diff --git a/cmd/registry/serve.go b/cmd/registry/serve.go index d4dfac9..c10e473 100644 --- a/cmd/registry/serve.go +++ b/cmd/registry/serve.go @@ -17,14 +17,13 @@ import ( "github.com/distribution/distribution/v3/registry/handlers" "github.com/spf13/cobra" - "atcr.io/pkg/auth/exchange" "atcr.io/pkg/auth/oauth" - "atcr.io/pkg/auth/session" "atcr.io/pkg/auth/token" "atcr.io/pkg/middleware" // UI components "atcr.io/pkg/appview" + "atcr.io/pkg/appview/apikey" "atcr.io/pkg/appview/db" uihandlers "atcr.io/pkg/appview/handlers" "atcr.io/pkg/appview/jetstream" @@ -93,17 +92,13 @@ func serveRegistry(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create OAuth store: %w", err) } - // 2. Create session manager with 30-day TTL - // Use persistent secret so session tokens remain valid across container restarts - secretPath := os.Getenv("ATCR_SESSION_SECRET_PATH") - if secretPath == "" { - // Default to same directory as tokens - secretPath = filepath.Join(filepath.Dir(storagePath), "session-secret.key") - } - sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour) + // 2. 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 session manager: %w", err) + return fmt.Errorf("failed to create API key store: %w", err) } + fmt.Printf("Using API key storage path: %s\n", apiKeyStorePath) // 3. Get base URL from config or environment baseURL := os.Getenv("ATCR_BASE_URL") @@ -132,10 +127,10 @@ func serveRegistry(cmd *cobra.Command, args []string) error { middleware.SetGlobalRefresher(refresher) // 7. Initialize UI components (get session store for OAuth integration) - uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, refresher, baseURL) + uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, refresher, baseURL, apiKeyStore) // 8. Create OAuth server - oauthServer := oauth.NewServer(oauthApp, sessionManager) + oauthServer := oauth.NewServer(oauthApp) // Connect server to refresher for cache invalidation oauthServer.SetRefresher(refresher) // Connect UI session store for web login @@ -192,19 +187,14 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Extract default hold endpoint from middleware config defaultHoldEndpoint := extractDefaultHoldEndpoint(config) - // Basic Auth token endpoint (also supports session tokens) - tokenHandler := token.NewHandler(issuer, sessionManager, defaultHoldEndpoint) + // Basic Auth token endpoint (supports API keys and app passwords) + tokenHandler := token.NewHandler(issuer, apiKeyStore, defaultHoldEndpoint) tokenHandler.RegisterRoutes(mux) - // OAuth exchange endpoint (session token → registry JWT) - exchangeHandler := exchange.NewHandler(issuer, sessionManager) - exchangeHandler.RegisterRoutes(mux) - fmt.Printf("Auth endpoints enabled:\n") - fmt.Printf(" - Basic Auth: /auth/token\n") + fmt.Printf(" - Basic Auth: /auth/token (API keys + app passwords)\n") fmt.Printf(" - OAuth: /auth/oauth/authorize\n") fmt.Printf(" - OAuth: /auth/oauth/callback\n") - fmt.Printf(" - Exchange: /auth/exchange\n") } // Create HTTP server @@ -336,7 +326,7 @@ func extractDefaultHoldEndpoint(config *configuration.Configuration) string { } // initializeUI initializes the web UI components -func initializeUI(config *configuration.Configuration, refresher *oauth.Refresher, baseURL string) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) { +func initializeUI(config *configuration.Configuration, refresher *oauth.Refresher, baseURL string, apiKeyStore *apikey.Store) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) { // Check if UI is enabled (optional configuration) uiEnabled := os.Getenv("ATCR_UI_ENABLED") if uiEnabled == "false" { @@ -442,6 +432,19 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe DB: database, }).Methods("DELETE") + // API key management routes + 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") + // Logout endpoint router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) { if sessionID, ok := appsession.GetSessionID(r); ok { diff --git a/docs/API_KEY_MIGRATION.md b/docs/API_KEY_MIGRATION.md new file mode 100644 index 0000000..c9ab97c --- /dev/null +++ b/docs/API_KEY_MIGRATION.md @@ -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: `.` + - 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

+ + + + + + + + + + + + +
NameCreatedLast UsedActions
+
+
+ + + + +``` + +#### 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. diff --git a/docs/APPVIEW-UI-IMPLEMENTATION.md b/docs/APPVIEW-UI-IMPLEMENTATION.md index dc2d6f4..4ecb60d 100644 --- a/docs/APPVIEW-UI-IMPLEMENTATION.md +++ b/docs/APPVIEW-UI-IMPLEMENTATION.md @@ -758,7 +758,7 @@ func (h *SettingsHandler) UpdateDefaultHold(w http.ResponseWriter, r *http.Reque } // Update profile in PDS - err := h.ATProtoClient.UpdateProfile(user.DID, map[string]interface{}{ + err := h.ATProtoClient.UpdateProfile(user.DID, map[string]any{ "defaultHold": holdEndpoint, }) diff --git a/go.mod b/go.mod index 190238b..fa551d3 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,14 @@ require ( github.com/distribution/distribution/v3 v3.0.0 github.com/distribution/reference v0.6.0 github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 github.com/klauspost/compress v1.18.0 github.com/mattn/go-sqlite3 v1.14.32 github.com/opencontainers/go-digest v1.0.0 github.com/spf13/cobra v1.8.0 + golang.org/x/crypto v0.39.0 ) require ( @@ -31,18 +33,44 @@ require ( github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/ipfs/bbloom v0.0.4 // indirect + github.com/ipfs/go-block-format v0.2.0 // indirect + github.com/ipfs/go-cid v0.4.1 // indirect + github.com/ipfs/go-datastore v0.6.0 // indirect + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect + github.com/ipfs/go-ipfs-util v0.0.3 // indirect + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect + github.com/ipfs/go-ipld-format v0.6.0 // indirect + github.com/ipfs/go-log v1.0.5 // indirect + github.com/ipfs/go-log/v2 v2.5.1 // indirect + github.com/ipfs/go-metrics-interface v0.0.1 // indirect + github.com/jbenet/goprocess v0.1.4 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-varint v0.0.7 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.60.1 // indirect @@ -51,7 +79,9 @@ require ( github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect github.com/redis/go-redis/v9 v9.7.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect @@ -76,15 +106,19 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect go.opentelemetry.io/otel/trace v1.32.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/crypto v0.39.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect golang.org/x/net v0.37.0 // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.6.0 // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect google.golang.org/grpc v1.68.0 // indirect google.golang.org/protobuf v1.35.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + lukechampine.com/blake3 v1.2.1 // indirect ) diff --git a/go.sum b/go.sum index 3170802..656ca76 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,11 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak= github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h1:i9fr2JpcEcY/IHEvzCM3qXUZYOQHgR89dt4es1CgMhc= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -27,6 +29,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= @@ -58,6 +61,7 @@ github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -76,8 +80,11 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -88,6 +95,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= @@ -106,6 +115,8 @@ github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= +github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= +github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= @@ -118,10 +129,12 @@ github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= +github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= +github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -130,19 +143,27 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= @@ -176,6 +197,7 @@ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2sz github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= @@ -205,12 +227,19 @@ github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnA github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= +github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= +github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= @@ -221,11 +250,18 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= @@ -274,39 +310,88 @@ go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQD go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= @@ -319,14 +404,19 @@ google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFyt google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= diff --git a/pkg/appview/apikey/store.go b/pkg/appview/apikey/store.go new file mode 100644 index 0000000..ea4c7cd --- /dev/null +++ b/pkg/appview/apikey/store.go @@ -0,0 +1,249 @@ +package apikey + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "sync" + "time" + + "github.com/google/uuid" + "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 +} + +// persistentData is the structure saved to disk +type persistentData struct { + Keys []*APIKey `json:"keys"` +} + +// NewStore creates a new API key store +func NewStore(filePath string) (*Store, error) { + s := &Store{ + keys: make(map[string]*APIKey), + byDID: make(map[string][]string), + filePath: filePath, + } + + // Load existing keys from file + if err := s.load(); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load API keys: %w", err) + } + + return s, nil +} + +// 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) { + // Generate 32 random bytes + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Format: atcr_ + key = "atcr_" + base64.RawURLEncoding.EncodeToString(b) + + // Hash for storage + keyHashBytes, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost) + if err != nil { + return "", "", fmt.Errorf("failed to hash key: %w", err) + } + keyHash := string(keyHashBytes) + + // Generate ID + keyID = uuid.New().String() + + apiKey := &APIKey{ + ID: keyID, + KeyHash: keyHash, + DID: did, + Handle: handle, + Name: name, + CreatedAt: time.Now(), + LastUsed: time.Time{}, // Never used yet + } + + s.mu.Lock() + s.keys[keyHash] = apiKey + s.byDID[did] = append(s.byDID[did], keyHash) + s.mu.Unlock() + + if err := s.save(); err != nil { + return "", "", fmt.Errorf("failed to save keys: %w", err) + } + + // Return plaintext key (only time it's available) + return key, keyID, nil +} + +// Validate checks if an API key is valid and returns the associated data +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 a copy to prevent external modifications + keyCopy := *apiKey + return &keyCopy, nil + } + } + + return nil, fmt.Errorf("invalid API key") +} + +// List returns all API keys for a DID (without plaintext keys) +func (s *Store) List(did string) []*APIKey { + s.mu.RLock() + defer s.mu.RUnlock() + + keyHashes, ok := s.byDID[did] + if !ok { + return []*APIKey{} + } + + result := make([]*APIKey, 0, len(keyHashes)) + for _, hash := range keyHashes { + if apiKey, ok := s.keys[hash]; ok { + // Return copy without hash + keyCopy := *apiKey + keyCopy.KeyHash = "" // Don't expose hash + result = append(result, &keyCopy) + } + } + + return result +} + +// Delete removes an API key +func (s *Store) Delete(did, keyID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Find the key by DID and ID + keyHashes, ok := s.byDID[did] + if !ok { + return fmt.Errorf("no keys found for DID: %s", did) + } + + var foundHash string + for _, hash := range keyHashes { + if apiKey, ok := s.keys[hash]; ok && apiKey.ID == keyID { + foundHash = hash + break + } + } + + if foundHash == "" { + return fmt.Errorf("key not found: %s", keyID) + } + + // Remove from keys map + delete(s.keys, foundHash) + + // Remove from byDID index + newHashes := make([]string, 0, len(keyHashes)-1) + for _, hash := range keyHashes { + if hash != foundHash { + newHashes = append(newHashes, hash) + } + } + + if len(newHashes) == 0 { + delete(s.byDID, did) + } else { + s.byDID[did] = newHashes + } + + return s.save() +} + +// UpdateLastUsed updates the last used timestamp +func (s *Store) UpdateLastUsed(keyHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + + apiKey, ok := s.keys[keyHash] + if !ok { + return fmt.Errorf("key not found") + } + + apiKey.LastUsed = time.Now() + return s.save() +} + +// load reads keys from disk +func (s *Store) load() error { + data, err := os.ReadFile(s.filePath) + if err != nil { + return err + } + + var pd persistentData + if err := json.Unmarshal(data, &pd); err != nil { + return fmt.Errorf("failed to unmarshal keys: %w", err) + } + + // Rebuild in-memory structures + for _, apiKey := range pd.Keys { + s.keys[apiKey.KeyHash] = apiKey + s.byDID[apiKey.DID] = append(s.byDID[apiKey.DID], apiKey.KeyHash) + } + + return nil +} + +// save writes keys to disk +func (s *Store) save() error { + // Collect all keys + allKeys := make([]*APIKey, 0, len(s.keys)) + for _, apiKey := range s.keys { + allKeys = append(allKeys, apiKey) + } + + pd := persistentData{ + Keys: allKeys, + } + + data, err := json.MarshalIndent(pd, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal keys: %w", err) + } + + // Write atomically with temp file + rename + tmpPath := s.filePath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0600); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + if err := os.Rename(tmpPath, s.filePath); err != nil { + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil +} diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 00c30ea..e87bf17 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -16,7 +16,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest ` - args := []interface{}{} + args := []any{} if userFilter != "" { query += " WHERE u.handle = ? OR u.did = ?" @@ -43,7 +43,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, // Get total count countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did" - countArgs := []interface{}{} + countArgs := []any{} if userFilter != "" { countQuery += " WHERE u.handle = ? OR u.did = ?" @@ -228,7 +228,7 @@ func DeleteManifestsNotInList(db *sql.DB, did string, keepDigests []string) erro // Build placeholders for IN clause placeholders := make([]string, len(keepDigests)) - args := []interface{}{did} + args := []any{did} for i, digest := range keepDigests { placeholders[i] = "?" args = append(args, digest) diff --git a/pkg/appview/handlers/apikeys.go b/pkg/appview/handlers/apikeys.go new file mode 100644 index 0000000..fb69265 --- /dev/null +++ b/pkg/appview/handlers/apikeys.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "atcr.io/pkg/appview/apikey" + "atcr.io/pkg/appview/middleware" + "github.com/gorilla/mux" +) + +// 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 { + fmt.Printf("ERROR [apikeys]: Failed to generate key for DID=%s: %v\n", user.DID, err) + http.Error(w, "Failed to generate key", http.StatusInternalServerError) + return + } + + fmt.Printf("INFO [apikeys]: Generated API key for DID=%s, handle=%s, name=%s, keyID=%s\n", + user.DID, user.Handle, name, keyID) + + // 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 { + fmt.Printf("ERROR [apikeys]: Failed to delete key for DID=%s, keyID=%s: %v\n", + user.DID, keyID, err) + http.Error(w, "Failed to delete key", http.StatusInternalServerError) + return + } + + fmt.Printf("INFO [apikeys]: Deleted API key for DID=%s, keyID=%s\n", user.DID, keyID) + + w.WriteHeader(http.StatusNoContent) +} diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index 123dfdb..a32ad15 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -28,16 +28,17 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Get OAuth session for the user session, err := h.Refresher.GetSession(r.Context(), user.DID) if err != nil { - http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError) + // OAuth session not found or expired - redirect to re-authenticate + fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err) + http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound) return } - // Extract access token and HTTP client from session - accessToken, _ := session.GetHostAccessData() - httpClient := session.APIClient().Client + // Use indigo's API client directly - it handles all auth automatically + apiClient := session.APIClient() - // Create ATProto client with indigo's DPoP-configured HTTP client - client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient) + // Create ATProto client with indigo's XRPC client + client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) // Fetch sailor profile profile, err := atproto.GetProfile(r.Context(), client) @@ -93,16 +94,17 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ // Get OAuth session for the user session, err := h.Refresher.GetSession(r.Context(), user.DID) if err != nil { - http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError) + // OAuth session not found or expired - redirect to re-authenticate + fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err) + http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound) return } - // Extract access token and HTTP client from session - accessToken, _ := session.GetHostAccessData() - httpClient := session.APIClient().Client + // Use indigo's API client directly - it handles all auth automatically + apiClient := session.APIClient() - // Create ATProto client with indigo's DPoP-configured HTTP client - client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient) + // Create ATProto client with indigo's XRPC client + client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) // Fetch existing profile or create new one profile, err := atproto.GetProfile(r.Context(), client) diff --git a/pkg/appview/jetstream/backfill.go b/pkg/appview/jetstream/backfill.go index d73c1b5..29b4b76 100644 --- a/pkg/appview/jetstream/backfill.go +++ b/pkg/appview/jetstream/backfill.go @@ -8,15 +8,18 @@ import ( "strings" "time" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" ) // BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data type BackfillWorker struct { - db *sql.DB - client *atproto.Client - resolver *atproto.Resolver + db *sql.DB + client *atproto.Client + directory identity.Directory } // BackfillState tracks backfill progress @@ -36,9 +39,9 @@ func NewBackfillWorker(database *sql.DB, relayEndpoint string) (*BackfillWorker, client := atproto.NewClient(relayEndpoint, "", "") return &BackfillWorker{ - db: database, - client: client, // This points to the relay - resolver: atproto.NewResolver(), + db: database, + client: client, // This points to the relay + directory: identity.DefaultDirectory(), }, nil } @@ -117,11 +120,21 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin } // Resolve DID to get user's PDS endpoint - _, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did) + didParsed, err := syntax.ParseDID(did) + if err != nil { + return 0, fmt.Errorf("invalid DID %s: %w", did, err) + } + + ident, err := b.directory.LookupDID(ctx, didParsed) if err != nil { return 0, fmt.Errorf("failed to resolve DID to PDS: %w", err) } + pdsEndpoint := ident.PDSEndpoint() + if pdsEndpoint == "" { + return 0, fmt.Errorf("no PDS endpoint found for DID %s", did) + } + // Create a client for this user's PDS pdsClient := atproto.NewClient(pdsEndpoint, "", "") @@ -314,17 +327,40 @@ func (b *BackfillWorker) ensureUser(ctx context.Context, did string) error { } // Resolve DID to get handle and PDS endpoint - resolvedDID, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did) + didParsed, err := syntax.ParseDID(did) if err != nil { // Fallback: use DID as handle - resolvedDID = did - pdsEndpoint = "https://bsky.social" + user := &db.User{ + DID: did, + Handle: did, + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + return db.UpsertUser(b.db, user) } - // Get handle from DID document - handle, err := b.resolver.ResolveHandleFromDID(ctx, resolvedDID) + ident, err := b.directory.LookupDID(ctx, didParsed) if err != nil { - handle = resolvedDID // Fallback to DID + // Fallback: use DID as handle + user := &db.User{ + DID: did, + Handle: did, + PDSEndpoint: "https://bsky.social", + LastSeen: time.Now(), + } + return db.UpsertUser(b.db, user) + } + + resolvedDID := ident.DID.String() + handle := ident.Handle.String() + pdsEndpoint := ident.PDSEndpoint() + + // If handle is invalid or PDS is missing, use defaults + if handle == "handle.invalid" || handle == "" { + handle = resolvedDID + } + if pdsEndpoint == "" { + pdsEndpoint = "https://bsky.social" } // Upsert to database diff --git a/pkg/appview/jetstream/worker.go b/pkg/appview/jetstream/worker.go index ba6f8f5..c897776 100644 --- a/pkg/appview/jetstream/worker.go +++ b/pkg/appview/jetstream/worker.go @@ -9,6 +9,9 @@ import ( "strings" "time" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" "github.com/gorilla/websocket" @@ -31,7 +34,7 @@ type Worker struct { wantedCollections []string debugCollectionCount int userCache *UserCache - resolver *atproto.Resolver + directory identity.Directory eventCallback EventCallback } @@ -53,7 +56,7 @@ func NewWorker(database *sql.DB, jetstreamURL string, startCursor int64) *Worker userCache: &UserCache{ cache: make(map[string]*db.User), }, - resolver: atproto.NewResolver(), + directory: identity.DefaultDirectory(), } } @@ -198,19 +201,44 @@ func (w *Worker) ensureUser(ctx context.Context, did string) error { } // Resolve DID to get handle and PDS endpoint - resolvedDID, pdsEndpoint, err := w.resolver.ResolveIdentity(ctx, did) + didParsed, err := syntax.ParseDID(did) + if err != nil { + fmt.Printf("WARNING: Invalid DID %s: %v (using DID as handle)\n", did, err) + // Fallback: use DID as handle + user := &db.User{ + DID: did, + Handle: did, + PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback + LastSeen: time.Now(), + } + w.userCache.cache[did] = user + return db.UpsertUser(w.db, user) + } + + ident, err := w.directory.LookupDID(ctx, didParsed) if err != nil { fmt.Printf("WARNING: Failed to resolve DID %s: %v (using DID as handle)\n", did, err) // Fallback: use DID as handle - resolvedDID = did - pdsEndpoint = "https://bsky.social" // Default PDS endpoint as fallback + user := &db.User{ + DID: did, + Handle: did, + PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback + LastSeen: time.Now(), + } + w.userCache.cache[did] = user + return db.UpsertUser(w.db, user) } - // Get handle from DID document - handle, err := w.resolver.ResolveHandleFromDID(ctx, resolvedDID) - if err != nil { - fmt.Printf("WARNING: Failed to get handle for DID %s: %v (using DID as handle)\n", resolvedDID, err) - handle = resolvedDID // Fallback to DID + resolvedDID := ident.DID.String() + handle := ident.Handle.String() + pdsEndpoint := ident.PDSEndpoint() + + // If handle is invalid or PDS is missing, use defaults + if handle == "handle.invalid" || handle == "" { + handle = resolvedDID + } + if pdsEndpoint == "" { + pdsEndpoint = "https://bsky.social" } // Cache the user @@ -349,13 +377,13 @@ type JetstreamEvent struct { // CommitEvent represents a commit event (create/update/delete) type CommitEvent struct { - Rev string `json:"rev"` - Operation string `json:"operation"` // "create", "update", "delete" - Collection string `json:"collection"` - RKey string `json:"rkey"` - Record map[string]interface{} `json:"record,omitempty"` - CID string `json:"cid,omitempty"` - DID string `json:"-"` // Set from parent event + Rev string `json:"rev"` + Operation string `json:"operation"` // "create", "update", "delete" + Collection string `json:"collection"` + RKey string `json:"rkey"` + Record map[string]any `json:"record,omitempty"` + CID string `json:"cid,omitempty"` + DID string `json:"-"` // Set from parent event } // IdentityInfo represents an identity event diff --git a/pkg/appview/session/session.go b/pkg/appview/session/session.go index 08af7d7..1b5ac3b 100644 --- a/pkg/appview/session/session.go +++ b/pkg/appview/session/session.go @@ -129,6 +129,27 @@ func (s *Store) Get(id string) (*Session, bool) { return sess, true } +// Extend extends a session's expiration time +func (s *Store) Extend(id string, duration time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + + sess, ok := s.sessions[id] + if !ok { + return fmt.Errorf("session not found: %s", id) + } + + // Extend the expiration + sess.ExpiresAt = time.Now().Add(duration) + + // Save to disk + if err := s.save(); err != nil { + fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err) + } + + return nil +} + // Delete removes a session func (s *Store) Delete(id string) { s.mu.Lock() diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html index 3c89c5d..ae17842 100644 --- a/pkg/appview/templates/pages/settings.html +++ b/pkg/appview/templates/pages/settings.html @@ -57,6 +57,42 @@
+ +
+

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

+ + + + + + + + + + + + +
NameCreatedLast UsedActions
Loading...
+
+
+

OAuth Session

@@ -78,7 +114,246 @@ + + + + + + + {{ end }} diff --git a/pkg/atproto/client.go b/pkg/atproto/client.go index 45c06fd..6dc1cf9 100644 --- a/pkg/atproto/client.go +++ b/pkg/atproto/client.go @@ -3,69 +3,47 @@ package atproto import ( "bytes" "context" - "crypto/ecdsa" "encoding/json" "fmt" "io" "net/http" + "strings" + + "github.com/bluesky-social/indigo/atproto/client" ) // Client wraps ATProto operations for the registry type Client struct { - pdsEndpoint string - did string - accessToken string - httpClient *http.Client - useDPoP bool // true if using DPoP-bound tokens (OAuth) + pdsEndpoint string + did string + accessToken string // For Basic Auth only + httpClient *http.Client + useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically) + indigoClient *client.APIClient // indigo's API client for OAuth requests } -// NewClient creates a new ATProto client for Basic Auth tokens +// NewClient creates a new ATProto client for Basic Auth tokens (app passwords) func NewClient(pdsEndpoint, did, accessToken string) *Client { return &Client{ pdsEndpoint: pdsEndpoint, did: did, accessToken: accessToken, httpClient: &http.Client{}, - useDPoP: false, // Basic Auth uses Bearer tokens } } -// NewClientWithDPoP creates a new ATProto client with DPoP support -// This is required for OAuth tokens -func NewClientWithDPoP(pdsEndpoint, did, accessToken string, dpopKey *ecdsa.PrivateKey, transport http.RoundTripper) *Client { +// NewClientWithIndigoClient creates an ATProto client using indigo's API client +// This uses indigo's native XRPC methods with automatic DPoP handling +func NewClientWithIndigoClient(pdsEndpoint, did string, indigoClient *client.APIClient) *Client { return &Client{ - pdsEndpoint: pdsEndpoint, - did: did, - accessToken: accessToken, - httpClient: &http.Client{ - Transport: transport, - }, - useDPoP: true, // OAuth uses DPoP tokens + pdsEndpoint: pdsEndpoint, + did: did, + useIndigoClient: true, + indigoClient: indigoClient, + httpClient: indigoClient.Client, // Keep for any fallback cases } } -// NewClientWithHTTPClient creates a new ATProto client with a pre-configured HTTP client -// This is useful when using indigo's OAuth session which provides a DPoP-configured client -// The access token will be used for Authorization headers, while the HTTP client -// handles transport-level concerns (like DPoP proofs) -func NewClientWithHTTPClient(pdsEndpoint, did, accessToken string, httpClient *http.Client) *Client { - return &Client{ - pdsEndpoint: pdsEndpoint, - did: did, - accessToken: accessToken, - httpClient: httpClient, - useDPoP: true, // Assume DPoP when using custom client - } -} - -// authHeader returns the appropriate Authorization header value -func (c *Client) authHeader() string { - if c.useDPoP { - return "DPoP " + c.accessToken - } - return "Bearer " + c.accessToken -} - // Record represents a generic ATProto record type Record struct { URI string `json:"uri"` @@ -75,9 +53,6 @@ type Record struct { // PutRecord stores a record in the ATProto repository func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (*Record, error) { - // Construct the record URI - // Format: at://// - payload := map[string]any{ "repo": c.did, "collection": collection, @@ -85,6 +60,17 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record "record": record, } + // Use indigo API client (OAuth with DPoP) + if c.useIndigoClient && c.indigoClient != nil { + var result Record + err := c.indigoClient.Post(ctx, "com.atproto.repo.putRecord", payload, &result) + if err != nil { + return nil, fmt.Errorf("putRecord failed: %w", err) + } + return &result, nil + } + + // Basic Auth (app passwords) body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("failed to marshal record: %w", err) @@ -96,7 +82,7 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record return nil, err } - req.Header.Set("Authorization", c.authHeader()) + req.Header.Set("Authorization", "Bearer "+c.accessToken) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) @@ -120,6 +106,26 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record // GetRecord retrieves a record from the ATProto repository func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) { + // Use indigo API client (OAuth with DPoP) + if c.useIndigoClient && c.indigoClient != nil { + params := map[string]any{ + "repo": c.did, + "collection": collection, + "rkey": rkey, + } + + var result Record + err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result) + if err != nil { + if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") { + return nil, fmt.Errorf("record not found") + } + return nil, fmt.Errorf("getRecord failed: %w", err) + } + return &result, nil + } + + // Basic Auth (app passwords) url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", c.pdsEndpoint, c.did, collection, rkey) @@ -128,7 +134,7 @@ func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Recor return nil, err } - req.Header.Set("Authorization", c.authHeader()) + req.Header.Set("Authorization", "Bearer "+c.accessToken) resp, err := c.httpClient.Do(req) if err != nil { @@ -172,7 +178,7 @@ func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) erro return err } - req.Header.Set("Authorization", c.authHeader()) + req.Header.Set("Authorization", "Bearer "+c.accessToken) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) @@ -199,7 +205,7 @@ func (c *Client) ListRecords(ctx context.Context, collection string, limit int) return nil, err } - req.Header.Set("Authorization", c.authHeader()) + req.Header.Set("Authorization", "Bearer "+c.accessToken) resp, err := c.httpClient.Do(req) if err != nil { @@ -238,22 +244,35 @@ type Link struct { // UploadBlob uploads binary data to the PDS and returns a blob reference func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) { - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.pdsEndpoint) + // Use indigo API client (OAuth with DPoP) + if c.useIndigoClient && c.indigoClient != nil { + var result struct { + Blob ATProtoBlobRef `json:"blob"` + } + err := c.indigoClient.LexDo(ctx, + "POST", + mimeType, + "com.atproto.repo.uploadBlob", + nil, + data, + &result, + ) + if err != nil { + return nil, fmt.Errorf("uploadBlob failed: %w", err) + } + + return &result.Blob, nil + } + + // Basic Auth (app passwords) + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.pdsEndpoint) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) if err != nil { return nil, err } - // Only set Authorization header if we have an access token - if c.accessToken != "" { - authHeader := c.authHeader() - fmt.Printf("DEBUG [atproto/client]: UploadBlob Authorization header: %q (useDPoP=%v, token_length=%d)\n", authHeader, c.useDPoP, len(c.accessToken)) - req.Header.Set("Authorization", authHeader) - } else { - fmt.Printf("DEBUG [atproto/client]: UploadBlob: No access token available, sending unauthenticated request\n") - return nil, fmt.Errorf("no access token available for authenticated PDS operation - please complete OAuth flow at: http://127.0.0.1:5000/auth/oauth/authorize?handle=") - } + req.Header.Set("Authorization", "Bearer "+c.accessToken) req.Header.Set("Content-Type", mimeType) resp, err := c.httpClient.Do(req) @@ -288,7 +307,9 @@ func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) { } // Note: getBlob may not require auth for public repos, but we include it anyway - req.Header.Set("Authorization", c.authHeader()) + if c.accessToken != "" { + req.Header.Set("Authorization", "Bearer "+c.accessToken) + } resp, err := c.httpClient.Do(req) if err != nil { @@ -346,7 +367,7 @@ func (c *Client) ListReposByCollection(ctx context.Context, collection string, l // This endpoint typically doesn't require auth for public data // but we include it if available if c.accessToken != "" { - req.Header.Set("Authorization", c.authHeader()) + req.Header.Set("Authorization", "Bearer "+c.accessToken) } resp, err := c.httpClient.Do(req) @@ -388,7 +409,7 @@ func (c *Client) ListRecordsForRepo(ctx context.Context, repoDID, collection str // This endpoint typically doesn't require auth for public records if c.accessToken != "" { - req.Header.Set("Authorization", c.authHeader()) + req.Header.Set("Authorization", "Bearer "+c.accessToken) } resp, err := c.httpClient.Do(req) diff --git a/pkg/atproto/resolver.go b/pkg/atproto/resolver.go deleted file mode 100644 index f76602c..0000000 --- a/pkg/atproto/resolver.go +++ /dev/null @@ -1,243 +0,0 @@ -package atproto - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "strings" -) - -// Resolver handles DID/handle resolution for ATProto -type Resolver struct { - httpClient *http.Client -} - -// NewResolver creates a new DID/handle resolver -func NewResolver() *Resolver { - return &Resolver{ - httpClient: &http.Client{}, - } -} - -// ResolveIdentity resolves a handle or DID to a DID and PDS endpoint -// Input can be: -// - Handle: "alice.bsky.social" or "alice" -// - DID: "did:plc:xyz123abc" -func (r *Resolver) ResolveIdentity(ctx context.Context, identity string) (did string, pdsEndpoint string, err error) { - // Check if it's already a DID - if strings.HasPrefix(identity, "did:") { - did = identity - pdsEndpoint, err = r.ResolvePDS(ctx, did) - return did, pdsEndpoint, err - } - - // Otherwise, resolve handle to DID - did, err = r.ResolveHandle(ctx, identity) - if err != nil { - return "", "", fmt.Errorf("failed to resolve handle %s: %w", identity, err) - } - - // Then resolve DID to PDS - pdsEndpoint, err = r.ResolvePDS(ctx, did) - if err != nil { - return "", "", fmt.Errorf("failed to resolve PDS for DID %s: %w", did, err) - } - - return did, pdsEndpoint, nil -} - -// ResolveHandle resolves a handle to a DID using DNS TXT records or .well-known -func (r *Resolver) ResolveHandle(ctx context.Context, handle string) (string, error) { - // Normalize handle - if !strings.Contains(handle, ".") { - // Default to .bsky.social if no domain provided - handle = handle + ".bsky.social" - } - - // Try DNS TXT record first (faster) - if did, err := r.resolveHandleViaDNS(handle); err == nil && did != "" { - return did, nil - } - - // Fall back to HTTPS .well-known method - url := fmt.Sprintf("https://%s/.well-known/atproto-did", handle) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return "", err - } - - resp, err := r.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to fetch .well-known: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - did := strings.TrimSpace(string(body)) - if strings.HasPrefix(did, "did:") { - return did, nil - } - } - - return "", fmt.Errorf("could not resolve handle %s to DID", handle) -} - -// resolveHandleViaDNS attempts to resolve handle via DNS TXT record at _atproto. -func (r *Resolver) resolveHandleViaDNS(handle string) (string, error) { - txtRecords, err := net.LookupTXT("_atproto." + handle) - if err != nil { - return "", err - } - - // Look for a TXT record that starts with "did=" - for _, record := range txtRecords { - if strings.HasPrefix(record, "did=") { - did := strings.TrimPrefix(record, "did=") - if strings.HasPrefix(did, "did:") { - return did, nil - } - } - } - - return "", fmt.Errorf("no valid DID found in DNS TXT records") -} - -// DIDDocument represents a simplified ATProto DID document -type DIDDocument struct { - ID string `json:"id"` - AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` - Service []struct { - ID string `json:"id"` - Type string `json:"type"` - ServiceEndpoint string `json:"serviceEndpoint"` - } `json:"service"` -} - -// ResolvePDS resolves a DID to its PDS endpoint -func (r *Resolver) ResolvePDS(ctx context.Context, did string) (string, error) { - if !strings.HasPrefix(did, "did:") { - return "", fmt.Errorf("invalid DID format: %s", did) - } - - // Parse DID method - parts := strings.Split(did, ":") - if len(parts) < 3 { - return "", fmt.Errorf("invalid DID format: %s", did) - } - - method := parts[1] - - var resolverURL string - switch method { - case "plc": - // Use PLC directory - resolverURL = fmt.Sprintf("https://plc.directory/%s", did) - case "web": - // For did:web, convert to HTTPS URL - domain := parts[2] - resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) - default: - return "", fmt.Errorf("unsupported DID method: %s", method) - } - - req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil) - if err != nil { - return "", err - } - - resp, err := r.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to fetch DID document: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("DID resolution failed with status %d", resp.StatusCode) - } - - var didDoc DIDDocument - if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil { - return "", fmt.Errorf("failed to parse DID document: %w", err) - } - - // Find PDS service endpoint - for _, service := range didDoc.Service { - if service.Type == "AtprotoPersonalDataServer" { - return service.ServiceEndpoint, nil - } - } - - return "", fmt.Errorf("no PDS endpoint found in DID document") -} - -// ResolveDIDDocument fetches the full DID document for a DID -func (r *Resolver) ResolveDIDDocument(ctx context.Context, did string) (*DIDDocument, error) { - if !strings.HasPrefix(did, "did:") { - return nil, fmt.Errorf("invalid DID format: %s", did) - } - - parts := strings.Split(did, ":") - if len(parts) < 3 { - return nil, fmt.Errorf("invalid DID format: %s", did) - } - - method := parts[1] - - var resolverURL string - switch method { - case "plc": - resolverURL = fmt.Sprintf("https://plc.directory/%s", did) - case "web": - domain := parts[2] - resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) - default: - return nil, fmt.Errorf("unsupported DID method: %s", method) - } - - req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil) - if err != nil { - return nil, err - } - - resp, err := r.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch DID document: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("DID resolution failed with status %d", resp.StatusCode) - } - - var didDoc DIDDocument - if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil { - return nil, fmt.Errorf("failed to parse DID document: %w", err) - } - - return &didDoc, nil -} - -// ResolveHandle extracts the handle from a DID's alsoKnownAs field -func (r *Resolver) ResolveHandleFromDID(ctx context.Context, did string) (string, error) { - didDoc, err := r.ResolveDIDDocument(ctx, did) - if err != nil { - return "", err - } - - // Look for handle in alsoKnownAs (format: "at://handle.bsky.social") - for _, aka := range didDoc.AlsoKnownAs { - if strings.HasPrefix(aka, "at://") { - handle := strings.TrimPrefix(aka, "at://") - return handle, nil - } - } - - return "", fmt.Errorf("no handle found in DID document") -} diff --git a/pkg/auth/atproto/session.go b/pkg/auth/atproto/session.go index a958693..9873a7f 100644 --- a/pkg/auth/atproto/session.go +++ b/pkg/auth/atproto/session.go @@ -12,7 +12,8 @@ import ( "sync" "time" - atprotoclient "atcr.io/pkg/atproto" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" ) // CachedSession represents a cached session @@ -25,7 +26,7 @@ type CachedSession struct { // SessionValidator validates ATProto credentials type SessionValidator struct { - resolver *atprotoclient.Resolver + directory identity.Directory httpClient *http.Client cache map[string]*CachedSession cacheMu sync.RWMutex @@ -34,7 +35,7 @@ type SessionValidator struct { // NewSessionValidator creates a new ATProto session validator func NewSessionValidator() *SessionValidator { return &SessionValidator{ - resolver: atprotoclient.NewResolver(), + directory: identity.DefaultDirectory(), httpClient: &http.Client{}, cache: make(map[string]*CachedSession), } @@ -86,11 +87,22 @@ type SessionResponse struct { // Returns the user's DID and PDS endpoint if valid func (v *SessionValidator) ValidateCredentials(ctx context.Context, identifier, password string) (did, pdsEndpoint string, err error) { // Resolve identifier (handle or DID) to PDS endpoint - resolvedDID, pds, err := v.resolver.ResolveIdentity(ctx, identifier) + atID, err := syntax.ParseAtIdentifier(identifier) + if err != nil { + return "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err) + } + + ident, err := v.directory.Lookup(ctx, *atID) if err != nil { return "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err) } + resolvedDID := ident.DID.String() + pds := ident.PDSEndpoint() + if pds == "" { + return "", "", fmt.Errorf("no PDS endpoint found for %q", identifier) + } + fmt.Printf("DEBUG: Resolved %s to DID=%s, PDS=%s\n", identifier, resolvedDID, pds) // Create session with the PDS @@ -119,11 +131,22 @@ func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identif fmt.Printf("DEBUG [atproto/session]: No cached session for %s, creating new session\n", identifier) // Resolve identifier to PDS endpoint - did, pds, err := v.resolver.ResolveIdentity(ctx, identifier) + atID, err := syntax.ParseAtIdentifier(identifier) + if err != nil { + return "", "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err) + } + + ident, err := v.directory.Lookup(ctx, *atID) if err != nil { return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err) } + did = ident.DID.String() + pds := ident.PDSEndpoint() + if pds == "" { + return "", "", "", fmt.Errorf("no PDS endpoint found for %q", identifier) + } + // Create session sessionResp, err := v.createSession(ctx, pds, identifier, password) if err != nil { diff --git a/pkg/auth/atproto/validator.go b/pkg/auth/atproto/validator.go index 92f56c6..1a1147e 100644 --- a/pkg/auth/atproto/validator.go +++ b/pkg/auth/atproto/validator.go @@ -7,7 +7,8 @@ import ( "io" "net/http" - mainAtproto "atcr.io/pkg/atproto" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" ) // TokenValidator validates ATProto OAuth access tokens @@ -90,12 +91,22 @@ func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessT // dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) { // Resolve handle to PDS endpoint - resolver := mainAtproto.NewResolver() - _, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle) + directory := identity.DefaultDirectory() + atID, err := syntax.ParseAtIdentifier(handle) + if err != nil { + return nil, fmt.Errorf("invalid identifier %q: %w", handle, err) + } + + ident, err := directory.Lookup(ctx, *atID) if err != nil { return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err) } + pdsEndpoint := ident.PDSEndpoint() + if pdsEndpoint == "" { + return nil, fmt.Errorf("no PDS endpoint found for %q", handle) + } + // Validate token against the PDS return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof) } diff --git a/pkg/auth/exchange/handler.go b/pkg/auth/exchange/handler.go deleted file mode 100644 index 4dec5a7..0000000 --- a/pkg/auth/exchange/handler.go +++ /dev/null @@ -1,116 +0,0 @@ -package exchange - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - - "atcr.io/pkg/auth" - "atcr.io/pkg/auth/session" - "atcr.io/pkg/auth/token" -) - -// Handler handles /auth/exchange requests (session token -> registry JWT) -type Handler struct { - issuer *token.Issuer - sessionManager *session.Manager -} - -// NewHandler creates a new exchange handler -func NewHandler(issuer *token.Issuer, sessionManager *session.Manager) *Handler { - return &Handler{ - issuer: issuer, - sessionManager: sessionManager, - } -} - -// ExchangeRequest represents the request to exchange a session token for registry JWT -type ExchangeRequest struct { - Scope []string `json:"scope"` // Requested Docker scopes -} - -// ExchangeResponse represents the response from /auth/exchange -type ExchangeResponse struct { - Token string `json:"token"` - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` -} - -// ServeHTTP handles the exchange request -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // Extract session token from Authorization header - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - http.Error(w, "authorization header required", http.StatusUnauthorized) - return - } - - // Parse Bearer token - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { - http.Error(w, "invalid authorization header format", http.StatusUnauthorized) - return - } - sessionToken := parts[1] - - // Validate session token - sessionClaims, err := h.sessionManager.Validate(sessionToken) - if err != nil { - fmt.Printf("DEBUG [exchange]: session validation failed: %v\n", err) - http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusUnauthorized) - return - } - - fmt.Printf("DEBUG [exchange]: session validated for DID=%s, handle=%s\n", sessionClaims.DID, sessionClaims.Handle) - - // Parse request body for scopes - var req ExchangeRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) - return - } - - // Parse and validate scopes - access, err := auth.ParseScope(req.Scope) - if err != nil { - http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest) - return - } - - // Validate access permissions - if err := auth.ValidateAccess(sessionClaims.DID, sessionClaims.Handle, access); err != nil { - http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) - return - } - - // Issue registry JWT token - tokenString, err := h.issuer.Issue(sessionClaims.DID, access) - if err != nil { - http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) - return - } - - // Return response - resp := ExchangeResponse{ - Token: tokenString, - AccessToken: tokenString, - ExpiresIn: int(h.issuer.Expiration().Seconds()), - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) - return - } -} - -// RegisterRoutes registers the exchange handler with the provided mux -func (h *Handler) RegisterRoutes(mux *http.ServeMux) { - mux.Handle("/auth/exchange", h) -} diff --git a/pkg/auth/oauth/browser.go b/pkg/auth/oauth/browser.go new file mode 100644 index 0000000..bc57425 --- /dev/null +++ b/pkg/auth/oauth/browser.go @@ -0,0 +1,25 @@ +package oauth + +import ( + "fmt" + "os/exec" + "runtime" +) + +// OpenBrowser opens the default browser to the given URL +func OpenBrowser(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + return cmd.Start() +} diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go index 67b1ca6..f007e6c 100644 --- a/pkg/auth/oauth/client.go +++ b/pkg/auth/oauth/client.go @@ -8,6 +8,7 @@ import ( "atcr.io/pkg/atproto" "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" ) @@ -15,7 +16,7 @@ import ( type App struct { clientApp *oauth.ClientApp baseURL string - resolver *atproto.Resolver + directory identity.Directory } // NewApp creates a new OAuth app for ATCR @@ -26,7 +27,7 @@ func NewApp(baseURL string, store oauth.ClientAuthStore) (*App, error) { return &App{ clientApp: clientApp, baseURL: baseURL, - resolver: atproto.NewResolver(), + directory: identity.DefaultDirectory(), }, nil } diff --git a/pkg/auth/oauth/interactive.go b/pkg/auth/oauth/interactive.go new file mode 100644 index 0000000..36bd1d5 --- /dev/null +++ b/pkg/auth/oauth/interactive.go @@ -0,0 +1,187 @@ +package oauth + +import ( + "context" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" +) + +// InteractiveResult contains the result of an interactive OAuth flow +type InteractiveResult struct { + SessionData *oauth.ClientSessionData + Session *oauth.ClientSession + App *App +} + +// RunInteractiveFlow runs an interactive OAuth flow for CLI tools +// This is a simplified wrapper around indigo's OAuth flow +func RunInteractiveFlow( + ctx context.Context, + baseURL string, + handle string, + scopes []string, + onAuthURL func(string) error, +) (*InteractiveResult, error) { + // Create temporary file store for this flow + store, err := NewFileStore("/tmp/atcr-oauth-temp.json") + if err != nil { + return nil, fmt.Errorf("failed to create OAuth store: %w", err) + } + + // Create OAuth app + app, err := NewApp(baseURL, store) + if err != nil { + return nil, fmt.Errorf("failed to create OAuth app: %w", err) + } + + // Set custom scopes if provided + if len(scopes) > 0 { + // Note: indigo's ClientApp doesn't expose SetScopes, so we need to use default scopes + // This is a limitation of the current implementation + // TODO: Enhance if custom scopes are needed + } + + // Start auth flow + authURL, err := app.StartAuthFlow(ctx, handle) + if err != nil { + return nil, fmt.Errorf("failed to start auth flow: %w", err) + } + + // Call the callback to display the auth URL + if err := onAuthURL(authURL); err != nil { + return nil, fmt.Errorf("auth URL callback failed: %w", err) + } + + // Wait for OAuth callback + // The callback will be handled by the http.HandleFunc registered by the caller + // We need to wait for ProcessCallback to be called + // This is a bit awkward, but matches the old pattern + + // Setup a channel to receive callback params + callbackChan := make(chan url.Values, 1) + var setupOnce sync.Once + + // Return a function that the caller can use to process the callback + // This is called from the HTTP handler + processCallback := func(params url.Values) (*oauth.ClientSessionData, error) { + setupOnce.Do(func() { + callbackChan <- params + }) + sessionData, err := app.ProcessCallback(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to process callback: %w", err) + } + return sessionData, nil + } + + // Wait for callback with timeout + select { + case params := <-callbackChan: + sessionData, err := processCallback(params) + if err != nil { + return nil, err + } + + // Resume session to get ClientSession + session, err := app.ResumeSession(ctx, sessionData.AccountDID, sessionData.SessionID) + if err != nil { + return nil, fmt.Errorf("failed to resume session: %w", err) + } + + return &InteractiveResult{ + SessionData: sessionData, + Session: session, + App: app, + }, nil + case <-time.After(5 * time.Minute): + return nil, fmt.Errorf("OAuth flow timed out after 5 minutes") + } +} + +// InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling +// This version allows the caller to register the callback handler before starting the flow +func InteractiveFlowWithCallback( + ctx context.Context, + baseURL string, + handle string, + scopes []string, + registerCallback func(handler http.HandlerFunc) error, + displayAuthURL func(string) error, +) (*InteractiveResult, error) { + // Create temporary file store for this flow + store, err := NewFileStore("/tmp/atcr-oauth-temp.json") + if err != nil { + return nil, fmt.Errorf("failed to create OAuth store: %w", err) + } + + // Create OAuth app + app, err := NewApp(baseURL, store) + if err != nil { + return nil, fmt.Errorf("failed to create OAuth app: %w", err) + } + + // Channel to receive callback result + resultChan := make(chan *InteractiveResult, 1) + errorChan := make(chan error, 1) + + // Create callback handler + callbackHandler := func(w http.ResponseWriter, r *http.Request) { + // Process callback + sessionData, err := app.ProcessCallback(r.Context(), r.URL.Query()) + if err != nil { + errorChan <- fmt.Errorf("failed to process callback: %w", err) + http.Error(w, "OAuth callback failed", http.StatusInternalServerError) + return + } + + // Resume session + session, err := app.ResumeSession(r.Context(), sessionData.AccountDID, sessionData.SessionID) + if err != nil { + errorChan <- fmt.Errorf("failed to resume session: %w", err) + http.Error(w, "Failed to resume session", http.StatusInternalServerError) + return + } + + // Send result + resultChan <- &InteractiveResult{ + SessionData: sessionData, + Session: session, + App: app, + } + + // Return success to browser + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, "

Authorization Successful!

You can close this window and return to the terminal.

") + } + + // Register callback handler + if err := registerCallback(callbackHandler); err != nil { + return nil, fmt.Errorf("failed to register callback: %w", err) + } + + // Start auth flow + authURL, err := app.StartAuthFlow(ctx, handle) + if err != nil { + return nil, fmt.Errorf("failed to start auth flow: %w", err) + } + + // Display auth URL + if err := displayAuthURL(authURL); err != nil { + return nil, fmt.Errorf("failed to display auth URL: %w", err) + } + + // Wait for callback result + select { + case result := <-resultChan: + return result, nil + case err := <-errorChan: + return nil, err + case <-time.After(5 * time.Minute): + return nil, fmt.Errorf("OAuth flow timed out after 5 minutes") + } +} diff --git a/pkg/auth/oauth/server.go b/pkg/auth/oauth/server.go index daf9a75..1fb1007 100644 --- a/pkg/auth/oauth/server.go +++ b/pkg/auth/oauth/server.go @@ -7,7 +7,7 @@ import ( "net/http" "time" - "atcr.io/pkg/auth/session" + "github.com/bluesky-social/indigo/atproto/syntax" ) // UISessionStore is the interface for UI session management @@ -18,16 +18,14 @@ type UISessionStore interface { // Server handles OAuth authorization for the AppView type Server struct { app *App - sessionManager *session.Manager refresher *Refresher uiSessionStore UISessionStore } // NewServer creates a new OAuth server -func NewServer(app *App, sessionManager *session.Manager) *Server { +func NewServer(app *App) *Server { return &Server{ - app: app, - sessionManager: sessionManager, + app: app, } } @@ -104,7 +102,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) { fmt.Printf("DEBUG [oauth/server]: Invalidated cached session for DID=%s after creating new session\n", did) } - // We need to get the handle for the session token + // We need to get the handle for UI sessions and settings redirect // Resolve DID to handle using our resolver handle, err := s.resolveHandle(r.Context(), did) if err != nil { @@ -112,17 +110,10 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) { handle = did // Fallback to DID if resolution fails } - // Create session token for credential helper - 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 (has oauth_return_to cookie) 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) + // Create UI session (30 days to match OAuth refresh token lifetime) + uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour) if err != nil { s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) return @@ -133,7 +124,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) { Name: "atcr_session", Value: uiSessionID, Path: "/", - MaxAge: 86400, // 24 hours + MaxAge: 30 * 86400, // 30 days HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, @@ -157,39 +148,36 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) { return } - // Render success page with session token (for credential helper) - s.renderSuccess(w, sessionToken, handle) + // Non-UI flow: redirect to settings to get API key + s.renderRedirectToSettings(w, handle) } // resolveHandle attempts to resolve a DID to a handle -// This is a best-effort helper - we use the resolver to look up the handle -func (s *Server) resolveHandle(ctx context.Context, did string) (string, error) { - // Parse the DID document to get the handle - // Note: This is a simple implementation - in production we might want to cache this - doc, err := s.app.resolver.ResolveDIDDocument(ctx, did) +// This is a best-effort helper - we use the directory to look up the handle +func (s *Server) resolveHandle(ctx context.Context, didStr string) (string, error) { + // Parse DID + did, err := syntax.ParseDID(didStr) if err != nil { - return "", fmt.Errorf("failed to resolve DID document: %w", err) + return "", fmt.Errorf("invalid DID: %w", err) } - // Try to find a handle in the alsoKnownAs field - for _, aka := range doc.AlsoKnownAs { - if len(aka) > 5 && aka[:5] == "at://" { - return aka[5:], nil - } + // Look up identity + ident, err := s.app.directory.LookupDID(ctx, did) + if err != nil { + return "", fmt.Errorf("failed to lookup DID: %w", err) } - return "", fmt.Errorf("no handle found in DID document") + // Return handle (may be handle.invalid if verification failed) + return ident.Handle.String(), nil } -// renderSuccess renders the success page -func (s *Server) renderSuccess(w http.ResponseWriter, sessionToken, handle string) { - tmpl := template.Must(template.New("success").Parse(successTemplate)) +// renderRedirectToSettings redirects to the settings page to generate an API key +func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) { + tmpl := template.Must(template.New("redirect").Parse(redirectToSettingsTemplate)) data := struct { - SessionToken string - Handle string + Handle string }{ - SessionToken: sessionToken, - Handle: handle, + Handle: handle, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -216,35 +204,35 @@ func (s *Server) renderError(w http.ResponseWriter, message string) { // HTML templates -const successTemplate = ` +const redirectToSettingsTemplate = ` Authorization Successful - ATCR +

✓ Authorization Successful!

You have successfully authorized ATCR to access your ATProto account: {{.Handle}}

-

Copy the session token below and paste it into your credential helper:

- {{.SessionToken}} - +

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

+

If not redirected, click here.

+
+
+

Next Steps:

+
    +
  1. Generate an API key on the settings page
  2. +
  3. Copy the API key (shown once!)
  4. +
  5. Use it with: docker login atcr.io -u {{.Handle}} -p [your-api-key]
  6. +
- ` diff --git a/pkg/auth/oauth/store.go b/pkg/auth/oauth/store.go new file mode 100644 index 0000000..02744a7 --- /dev/null +++ b/pkg/auth/oauth/store.go @@ -0,0 +1,237 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// FileStore implements oauth.ClientAuthStore with file-based persistence +type FileStore struct { + path string + sessions map[string]*oauth.ClientSessionData // Key: "did:sessionID" + requests map[string]*oauth.AuthRequestData // Key: state + mu sync.RWMutex +} + +// FileStoreData represents the JSON structure stored on disk +type FileStoreData struct { + Sessions map[string]*oauth.ClientSessionData `json:"sessions"` + Requests map[string]*oauth.AuthRequestData `json:"requests"` +} + +// NewFileStore creates a new file-based OAuth store +func NewFileStore(path string) (*FileStore, error) { + store := &FileStore{ + path: path, + sessions: make(map[string]*oauth.ClientSessionData), + requests: make(map[string]*oauth.AuthRequestData), + } + + // Load existing data if file exists + if err := store.load(); err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load store: %w", err) + } + // File doesn't exist yet, that's ok + } + + return store, nil +} + +// GetDefaultStorePath returns the default storage path for OAuth data +func GetDefaultStorePath() (string, error) { + // For AppView: /var/lib/atcr/oauth-sessions.json + // For CLI tools: ~/.atcr/oauth-sessions.json + + // Check if running as a service (has write access to /var/lib) + servicePath := "/var/lib/atcr/oauth-sessions.json" + if err := os.MkdirAll(filepath.Dir(servicePath), 0700); err == nil { + // Can write to /var/lib, use service path + return servicePath, nil + } + + // Fall back to user home directory + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + atcrDir := filepath.Join(homeDir, ".atcr") + if err := os.MkdirAll(atcrDir, 0700); err != nil { + return "", fmt.Errorf("failed to create .atcr directory: %w", err) + } + + return filepath.Join(atcrDir, "oauth-sessions.json"), nil +} + +// GetSession retrieves a session by DID and session ID +func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + key := makeSessionKey(did.String(), sessionID) + session, ok := s.sessions[key] + if !ok { + return nil, fmt.Errorf("session not found: %s/%s", did, sessionID) + } + + return session, nil +} + +// SaveSession saves or updates a session (upsert) +func (s *FileStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { + s.mu.Lock() + defer s.mu.Unlock() + + key := makeSessionKey(sess.AccountDID.String(), sess.SessionID) + s.sessions[key] = &sess + + return s.save() +} + +// DeleteSession removes a session +func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + key := makeSessionKey(did.String(), sessionID) + delete(s.sessions, key) + + return s.save() +} + +// GetAuthRequestInfo retrieves authentication request data by state +func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + request, ok := s.requests[state] + if !ok { + return nil, fmt.Errorf("auth request not found: %s", state) + } + + return request, nil +} + +// SaveAuthRequestInfo saves authentication request data +func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.requests[info.State] = &info + + return s.save() +} + +// DeleteAuthRequestInfo removes authentication request data +func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.requests, state) + + return s.save() +} + +// CleanupExpired removes expired sessions and auth requests +// Should be called periodically (e.g., every hour) +func (s *FileStore) CleanupExpired() error { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + modified := false + + // Clean up auth requests older than 10 minutes + // (OAuth flows should complete quickly) + for state := range s.requests { + // Note: AuthRequestData doesn't have a timestamp in indigo's implementation + // For now, we'll rely on the OAuth server's cleanup routine + // or we could extend AuthRequestData with metadata + _ = state // Placeholder for future expiration logic + } + + // Sessions don't have expiry in the data structure + // Cleanup would need to be token-based (check token expiry) + // For now, manual cleanup via DeleteSession + _ = now + + if modified { + return s.save() + } + + return nil +} + +// ListSessions returns all stored sessions for debugging/management +func (s *FileStore) ListSessions() map[string]*oauth.ClientSessionData { + s.mu.RLock() + defer s.mu.RUnlock() + + // Return a copy to prevent external modification + result := make(map[string]*oauth.ClientSessionData) + for k, v := range s.sessions { + result[k] = v + } + return result +} + +// load reads data from disk +func (s *FileStore) load() error { + data, err := os.ReadFile(s.path) + if err != nil { + return err + } + + var storeData FileStoreData + if err := json.Unmarshal(data, &storeData); err != nil { + return fmt.Errorf("failed to parse store: %w", err) + } + + if storeData.Sessions != nil { + s.sessions = storeData.Sessions + } + if storeData.Requests != nil { + s.requests = storeData.Requests + } + + return nil +} + +// save writes data to disk +func (s *FileStore) save() error { + storeData := FileStoreData{ + Sessions: s.sessions, + Requests: s.requests, + } + + data, err := json.MarshalIndent(storeData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal store: %w", err) + } + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Write with restrictive permissions + if err := os.WriteFile(s.path, data, 0600); err != nil { + return fmt.Errorf("failed to write store: %w", err) + } + + return nil +} + +// makeSessionKey creates a composite key for session storage +func makeSessionKey(did, sessionID string) string { + return fmt.Sprintf("%s:%s", did, sessionID) +} diff --git a/pkg/auth/session/handler.go b/pkg/auth/session/handler.go deleted file mode 100644 index de07b8f..0000000 --- a/pkg/auth/session/handler.go +++ /dev/null @@ -1,170 +0,0 @@ -package session - -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "os" - "strings" - "time" -) - -// SessionClaims represents the data stored in a session token -type SessionClaims struct { - DID string `json:"did"` - Handle string `json:"handle"` - IssuedAt time.Time `json:"issued_at"` - ExpiresAt time.Time `json:"expires_at"` -} - -// Manager handles session token creation and validation -type Manager struct { - secret []byte - ttl time.Duration -} - -// NewManager creates a new session manager -func NewManager(secret []byte, ttl time.Duration) *Manager { - return &Manager{ - secret: secret, - ttl: ttl, - } -} - -// NewManagerWithRandomSecret creates a session manager with a random secret -func NewManagerWithRandomSecret(ttl time.Duration) (*Manager, error) { - secret := make([]byte, 32) - if _, err := rand.Read(secret); err != nil { - return nil, fmt.Errorf("failed to generate secret: %w", err) - } - return NewManager(secret, ttl), nil -} - -// NewManagerWithPersistentSecret creates a session manager with a persistent secret -// The secret is stored at secretPath and reused across restarts -func NewManagerWithPersistentSecret(secretPath string, ttl time.Duration) (*Manager, error) { - var secret []byte - - // Try to load existing secret - if data, err := os.ReadFile(secretPath); err == nil { - secret = data - fmt.Printf("Loaded existing session secret from %s\n", secretPath) - } else if os.IsNotExist(err) { - // Generate new secret - secret = make([]byte, 32) - if _, err := rand.Read(secret); err != nil { - return nil, fmt.Errorf("failed to generate secret: %w", err) - } - - // Save secret for future restarts - if err := os.WriteFile(secretPath, secret, 0600); err != nil { - return nil, fmt.Errorf("failed to save secret: %w", err) - } - fmt.Printf("Generated and saved new session secret to %s\n", secretPath) - } else { - return nil, fmt.Errorf("failed to read secret file: %w", err) - } - - return NewManager(secret, ttl), nil -} - -// Create generates a new session token for a DID -func (m *Manager) Create(did, handle string) (string, error) { - now := time.Now() - claims := SessionClaims{ - DID: did, - Handle: handle, - IssuedAt: now, - ExpiresAt: now.Add(m.ttl), - } - - // Marshal claims to JSON - claimsJSON, err := json.Marshal(claims) - if err != nil { - return "", fmt.Errorf("failed to marshal claims: %w", err) - } - - // Base64 encode claims - claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) - - // Generate HMAC signature - sig := m.sign(claimsB64) - sigB64 := base64.RawURLEncoding.EncodeToString(sig) - - // Token format: . - token := claimsB64 + "." + sigB64 - - return token, nil -} - -// Validate validates a session token and returns the claims -func (m *Manager) Validate(token string) (*SessionClaims, error) { - // Split token into claims and signature - parts := strings.Split(token, ".") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid token format") - } - - claimsB64 := parts[0] - sigB64 := parts[1] - - // Verify signature - expectedSig := m.sign(claimsB64) - providedSig, err := base64.RawURLEncoding.DecodeString(sigB64) - if err != nil { - return nil, fmt.Errorf("invalid signature encoding: %w", err) - } - - if !hmac.Equal(expectedSig, providedSig) { - return nil, fmt.Errorf("invalid signature") - } - - // Decode claims - claimsJSON, err := base64.RawURLEncoding.DecodeString(claimsB64) - if err != nil { - return nil, fmt.Errorf("invalid claims encoding: %w", err) - } - - var claims SessionClaims - if err := json.Unmarshal(claimsJSON, &claims); err != nil { - return nil, fmt.Errorf("invalid claims format: %w", err) - } - - // Check expiration - if time.Now().After(claims.ExpiresAt) { - return nil, fmt.Errorf("token expired") - } - - return &claims, nil -} - -// sign generates HMAC-SHA256 signature for data -func (m *Manager) sign(data string) []byte { - h := hmac.New(sha256.New, m.secret) - h.Write([]byte(data)) - return h.Sum(nil) -} - -// GetDID extracts the DID from a token without full validation -// Useful for logging/debugging -func (m *Manager) GetDID(token string) (string, error) { - parts := strings.Split(token, ".") - if len(parts) != 2 { - return "", fmt.Errorf("invalid token format") - } - - claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - return "", fmt.Errorf("invalid claims encoding: %w", err) - } - - var claims SessionClaims - if err := json.Unmarshal(claimsJSON, &claims); err != nil { - return "", fmt.Errorf("invalid claims format: %w", err) - } - - return claims.DID, nil -} diff --git a/pkg/auth/token/handler.go b/pkg/auth/token/handler.go index 793eadd..b4a914b 100644 --- a/pkg/auth/token/handler.go +++ b/pkg/auth/token/handler.go @@ -7,26 +7,29 @@ import ( "strings" "time" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + + "atcr.io/pkg/appview/apikey" mainAtproto "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "atcr.io/pkg/auth/atproto" - "atcr.io/pkg/auth/session" ) // Handler handles /auth/token requests type Handler struct { issuer *Issuer validator *atproto.SessionValidator - sessionManager *session.Manager // For validating session tokens + apiKeyStore *apikey.Store // For validating API keys defaultHoldEndpoint string } // NewHandler creates a new token handler -func NewHandler(issuer *Issuer, sessionManager *session.Manager, defaultHoldEndpoint string) *Handler { +func NewHandler(issuer *Issuer, apiKeyStore *apikey.Store, defaultHoldEndpoint string) *Handler { return &Handler{ issuer: issuer, validator: atproto.NewSessionValidator(), - sessionManager: sessionManager, + apiKeyStore: apiKeyStore, defaultHoldEndpoint: defaultHoldEndpoint, } } @@ -80,19 +83,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var handle string var accessToken string - // Try to validate as session token first (our OAuth flow) - // Session tokens have format: . - sessionClaims, sessionErr := h.sessionManager.Validate(password) - if sessionErr == nil { - // Successfully validated as session token - did = sessionClaims.DID - handle = sessionClaims.Handle - fmt.Printf("DEBUG [token/handler]: Session token validated for DID=%s, handle=%s\n", did, handle) - // For session tokens, we don't have a PDS access token here - // The registry will use OAuth refresh tokens to get one when needed + // 1. Check if it's an API key (starts with "atcr_") + 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) + w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) + http.Error(w, "authentication failed", http.StatusUnauthorized) + return + } + + 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 } else { - // Not a session token, try app password (Basic Auth flow) - fmt.Printf("DEBUG [token/handler]: Not a session token, trying app password for %s\n", username) + // 2. Try app password (direct PDS authentication) + fmt.Printf("DEBUG [token/handler]: Not an API key, trying app password for %s\n", username) 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) @@ -110,19 +119,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Ensure user profile exists (creates with default hold if needed) // Resolve PDS endpoint for profile management - resolver := mainAtproto.NewResolver() - _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username) - if err != nil { - // Log error but don't fail auth - profile management is not critical - fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) - } else { - // Create ATProto client with validated token - atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) - - // Ensure profile exists (will create with default hold if not exists and default is configured) - if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { + directory := identity.DefaultDirectory() + atID, err := syntax.ParseAtIdentifier(username) + if err == nil { + ident, err := directory.Lookup(r.Context(), *atID) + if err != nil { // Log error but don't fail auth - profile management is not critical - fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) + fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) + } else { + pdsEndpoint := ident.PDSEndpoint() + if pdsEndpoint != "" { + // Create ATProto client with validated token + atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) + + // Ensure profile exists (will create with default hold if not exists and default is configured) + if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { + // Log error but don't fail auth - profile management is not critical + fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) + } + } } } } diff --git a/pkg/middleware/registry.go b/pkg/middleware/registry.go index e81ecbc..8e11d8d 100644 --- a/pkg/middleware/registry.go +++ b/pkg/middleware/registry.go @@ -7,6 +7,8 @@ import ( "strings" "sync" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/distribution/distribution/v3" registrymw "github.com/distribution/distribution/v3/registry/middleware/registry" "github.com/distribution/distribution/v3/registry/storage/driver" @@ -34,14 +36,15 @@ func init() { // NamespaceResolver wraps a namespace and resolves names type NamespaceResolver struct { distribution.Namespace - resolver *atproto.Resolver + directory identity.Directory defaultStorageEndpoint string repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame) } // initATProtoResolver initializes the name resolution middleware func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]any) (distribution.Namespace, error) { - resolver := atproto.NewResolver() + // Use indigo's default directory (includes caching) + directory := identity.DefaultDirectory() // Get default storage endpoint from config (optional) defaultStorageEndpoint := "" @@ -51,7 +54,7 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive return &NamespaceResolver{ Namespace: ns, - resolver: resolver, + directory: directory, defaultStorageEndpoint: defaultStorageEndpoint, }, nil } @@ -70,21 +73,28 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name return nil, fmt.Errorf("repository name must include user: %s", repoPath) } - identity := parts[0] + identityStr := parts[0] imageName := parts[1] - // Resolve identity to DID and PDS - did, pdsEndpoint, err := nr.resolver.ResolveIdentity(ctx, identity) + // Parse identity (handle or DID) + atID, err := syntax.ParseAtIdentifier(identityStr) if err != nil { - return nil, fmt.Errorf("failed to resolve identity %s: %w", identity, err) + return nil, fmt.Errorf("invalid identity %s: %w", identityStr, err) } - // Store resolved DID and PDS in context for downstream use - ctx = context.WithValue(ctx, "atproto.did", did) - ctx = context.WithValue(ctx, "atproto.pds", pdsEndpoint) - ctx = context.WithValue(ctx, "atproto.identity", identity) + // Resolve identity to DID and PDS using indigo's directory + ident, err := nr.directory.Lookup(ctx, *atID) + if err != nil { + return nil, fmt.Errorf("failed to resolve identity %s: %w", identityStr, err) + } - fmt.Printf("DEBUG [registry/middleware]: Set context values: did=%s, pds=%s, identity=%s\n", did, pdsEndpoint, identity) + did := ident.DID.String() + pdsEndpoint := ident.PDSEndpoint() + if pdsEndpoint == "" { + return nil, fmt.Errorf("no PDS endpoint found for %s", identityStr) + } + + fmt.Printf("DEBUG [registry/middleware]: Resolved identity: did=%s, pds=%s, handle=%s\n", did, pdsEndpoint, ident.Handle.String()) // Query for storage endpoint - either user's hold or default hold service storageEndpoint := nr.findStorageEndpoint(ctx, did, pdsEndpoint) @@ -98,7 +108,7 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name // Create a new reference with identity/image format // Use the identity (or DID) as the namespace to ensure canonical format // This transforms: evan.jarrett.net/debian -> evan.jarrett.net/debian (keeps full path) - canonicalName := fmt.Sprintf("%s/%s", identity, imageName) + canonicalName := fmt.Sprintf("%s/%s", identityStr, imageName) ref, err := reference.ParseNamed(canonicalName) if err != nil { return nil, fmt.Errorf("invalid image name %s: %w", imageName, err) @@ -119,11 +129,10 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name // Try OAuth flow first session, err := globalRefresher.GetSession(ctx, did) if err == nil { - // OAuth session available - accessToken, _ := session.GetHostAccessData() - httpClient := session.APIClient().Client - fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s (length=%d, first_20=%q)\n", did, len(accessToken), accessToken[:min(20, len(accessToken))]) - atprotoClient = atproto.NewClientWithHTTPClient(pdsEndpoint, did, accessToken, httpClient) + // OAuth session available - use indigo's API client (handles DPoP automatically) + apiClient := session.APIClient() + fmt.Printf("DEBUG [registry/middleware]: Using OAuth session with indigo API client for DID=%s\n", did) + atprotoClient = atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) } else { fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err) } diff --git a/pkg/middleware/repository.go b/pkg/middleware/repository.go deleted file mode 100644 index 2931100..0000000 --- a/pkg/middleware/repository.go +++ /dev/null @@ -1,57 +0,0 @@ -package middleware - -import ( - "context" - "fmt" - - "github.com/distribution/distribution/v3" - repositorymw "github.com/distribution/distribution/v3/registry/middleware/repository" - - "atcr.io/pkg/atproto" - "atcr.io/pkg/storage" -) - -func init() { - // Register the ATProto routing middleware - repositorymw.Register("atproto-router", initATProtoRouter) -} - -// initATProtoRouter initializes the ATProto routing middleware -func initATProtoRouter(ctx context.Context, repo distribution.Repository, options map[string]any) (distribution.Repository, error) { - fmt.Printf("DEBUG [repository/middleware]: Initializing atproto-router for repo=%s\n", repo.Named().Name()) - fmt.Printf("DEBUG [repository/middleware]: Context values: atproto.did=%v, atproto.pds=%v\n", - ctx.Value("atproto.did"), ctx.Value("atproto.pds")) - - // Extract DID and PDS from context (set by registry middleware) - did, ok := ctx.Value("atproto.did").(string) - if !ok || did == "" { - fmt.Printf("DEBUG [repository/middleware]: DID not found in context, ok=%v, did=%q\n", ok, did) - return nil, fmt.Errorf("did is required for atproto-router middleware") - } - - pdsEndpoint, ok := ctx.Value("atproto.pds").(string) - if !ok || pdsEndpoint == "" { - return nil, fmt.Errorf("pds is required for atproto-router middleware") - } - - // For now, use empty access token (we'll add auth later) - accessToken := "" - - // Create ATProto client - atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken) - - // Get repository name - repoName := repo.Named().Name() - - // Get storage endpoint from context - storageEndpoint, ok := ctx.Value("storage.endpoint").(string) - if !ok || storageEndpoint == "" { - return nil, fmt.Errorf("storage.endpoint not found in context") - } - - // Create routing repository - no longer uses storage driver - // All blobs are routed through hold service - routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repoName, storageEndpoint, did) - - return routingRepo, nil -} diff --git a/pkg/server/handler.go b/pkg/server/handler.go index 9d136fc..38b247e 100644 --- a/pkg/server/handler.go +++ b/pkg/server/handler.go @@ -4,21 +4,21 @@ import ( "net/http" "strings" - "atcr.io/pkg/atproto" + "github.com/bluesky-social/indigo/atproto/identity" ) // ATProtoHandler wraps an HTTP handler to provide name resolution // This is an optional layer if middleware doesn't provide enough control type ATProtoHandler struct { - handler http.Handler - resolver *atproto.Resolver + handler http.Handler + directory identity.Directory } // NewATProtoHandler creates a new HTTP handler wrapper func NewATProtoHandler(handler http.Handler) *ATProtoHandler { return &ATProtoHandler{ - handler: handler, - resolver: atproto.NewResolver(), + handler: handler, + directory: identity.DefaultDirectory(), } }