cleanup more auth
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
826
docs/API_KEY_MIGRATION.md
Normal file
826
docs/API_KEY_MIGRATION.md
Normal file
@@ -0,0 +1,826 @@
|
||||
# API Key Migration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the session token system (used only by credential helper) with API keys that link to OAuth sessions. This simplifies authentication while maintaining all use cases.
|
||||
|
||||
## Current State
|
||||
|
||||
### Three Separate Auth Systems
|
||||
|
||||
1. **Session Tokens** (`pkg/auth/session/`)
|
||||
- JWT-like tokens: `<base64_claims>.<base64_signature>`
|
||||
- Created after OAuth callback, shown to user to copy
|
||||
- User manually pastes into credential helper config
|
||||
- Validated in `/auth/token` and `/auth/exchange`
|
||||
- 30-day TTL
|
||||
- **Problem:** Awkward UX, requires manual copy/paste
|
||||
|
||||
2. **UI Sessions** (`pkg/appview/session/`)
|
||||
- Cookie-based (`atcr_session`)
|
||||
- Random session ID, server-side store
|
||||
- 24-hour TTL
|
||||
- **Keep this - works well**
|
||||
|
||||
3. **App Password Auth** (via PDS)
|
||||
- Direct `com.atproto.server.createSession` call
|
||||
- No AppView involvement until token request
|
||||
- **Keep this - essential for non-UI users**
|
||||
|
||||
## Target State
|
||||
|
||||
### Two Auth Methods
|
||||
|
||||
1. **API Keys** (NEW - replaces session tokens)
|
||||
- Generated in UI after OAuth login
|
||||
- Format: `atcr_<32_bytes_base64>`
|
||||
- Linked to server-side OAuth refresh token
|
||||
- Multiple keys per user (laptop, CI/CD, etc.)
|
||||
- Revocable without re-auth
|
||||
|
||||
2. **App Passwords** (KEEP)
|
||||
- Direct PDS authentication
|
||||
- Works without UI/OAuth
|
||||
|
||||
### UI Sessions (UNCHANGED)
|
||||
- Cookie-based for web UI
|
||||
- Separate system, no changes needed
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: API Key System
|
||||
|
||||
#### 1.1 Create API Key Store (`pkg/appview/apikey/store.go`)
|
||||
|
||||
```go
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// APIKey represents a user's API key
|
||||
type APIKey struct {
|
||||
ID string `json:"id"` // UUID
|
||||
KeyHash string `json:"key_hash"` // bcrypt hash
|
||||
DID string `json:"did"` // Owner's DID
|
||||
Handle string `json:"handle"` // Owner's handle
|
||||
Name string `json:"name"` // User-provided name
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
}
|
||||
|
||||
// Store manages API keys
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]*APIKey // keyHash -> APIKey
|
||||
byDID map[string][]string // DID -> []keyHash
|
||||
filePath string // /var/lib/atcr/api-keys.json
|
||||
}
|
||||
|
||||
// NewStore creates a new API key store
|
||||
func NewStore(filePath string) (*Store, error)
|
||||
|
||||
// Generate creates a new API key and returns the plaintext key (shown once)
|
||||
func (s *Store) Generate(did, handle, name string) (key string, keyID string, err error)
|
||||
|
||||
// Validate checks if an API key is valid and returns the associated data
|
||||
func (s *Store) Validate(key string) (*APIKey, error)
|
||||
|
||||
// List returns all API keys for a DID (without plaintext keys)
|
||||
func (s *Store) List(did string) []*APIKey
|
||||
|
||||
// Delete removes an API key
|
||||
func (s *Store) Delete(did, keyID string) error
|
||||
|
||||
// UpdateLastUsed updates the last used timestamp
|
||||
func (s *Store) UpdateLastUsed(keyHash string) error
|
||||
```
|
||||
|
||||
**Key Generation:**
|
||||
```go
|
||||
func (s *Store) Generate(did, handle, name string) (string, string, error) {
|
||||
// Generate 32 random bytes
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Format: atcr_<base64>
|
||||
key := "atcr_" + base64.RawURLEncoding.EncodeToString(b)
|
||||
|
||||
// Hash for storage
|
||||
keyHash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Generate ID
|
||||
keyID := generateUUID()
|
||||
|
||||
apiKey := &APIKey{
|
||||
ID: keyID,
|
||||
KeyHash: string(keyHash),
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
LastUsed: time.Time{}, // Never used yet
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.keys[string(keyHash)] = apiKey
|
||||
s.byDID[did] = append(s.byDID[did], string(keyHash))
|
||||
s.mu.Unlock()
|
||||
|
||||
s.save()
|
||||
|
||||
// Return plaintext key (only time it's available)
|
||||
return key, keyID, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Key Validation:**
|
||||
```go
|
||||
func (s *Store) Validate(key string) (*APIKey, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Try to match against all stored hashes
|
||||
for hash, apiKey := range s.keys {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)); err == nil {
|
||||
// Update last used asynchronously
|
||||
go s.UpdateLastUsed(hash)
|
||||
return apiKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid API key")
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 Add API Key Handlers (`pkg/appview/handlers/apikeys.go`)
|
||||
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"github.com/gorilla/mux"
|
||||
"atcr.io/pkg/appview/apikey"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
)
|
||||
|
||||
// GenerateAPIKeyHandler handles POST /api/keys
|
||||
type GenerateAPIKeyHandler struct {
|
||||
Store *apikey.Store
|
||||
}
|
||||
|
||||
func (h *GenerateAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
name = "Unnamed Key"
|
||||
}
|
||||
|
||||
key, keyID, err := h.Store.Generate(user.DID, user.Handle, name)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return key (shown once!)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"id": keyID,
|
||||
"key": key,
|
||||
})
|
||||
}
|
||||
|
||||
// ListAPIKeysHandler handles GET /api/keys
|
||||
type ListAPIKeysHandler struct {
|
||||
Store *apikey.Store
|
||||
}
|
||||
|
||||
func (h *ListAPIKeysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
keys := h.Store.List(user.DID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(keys)
|
||||
}
|
||||
|
||||
// DeleteAPIKeyHandler handles DELETE /api/keys/{id}
|
||||
type DeleteAPIKeyHandler struct {
|
||||
Store *apikey.Store
|
||||
}
|
||||
|
||||
func (h *DeleteAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
keyID := vars["id"]
|
||||
|
||||
if err := h.Store.Delete(user.DID, keyID); err != nil {
|
||||
http.Error(w, "Failed to delete key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Update Token Handler
|
||||
|
||||
#### 2.1 Modify `/auth/token` Handler (`pkg/auth/token/handler.go`)
|
||||
|
||||
```go
|
||||
type Handler struct {
|
||||
issuer *Issuer
|
||||
validator *atproto.SessionValidator
|
||||
apiKeyStore *apikey.Store // NEW
|
||||
defaultHoldEndpoint string
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return unauthorized
|
||||
}
|
||||
|
||||
var did, handle, accessToken string
|
||||
|
||||
// 1. Check if it's an API key (NEW)
|
||||
if strings.HasPrefix(password, "atcr_") {
|
||||
apiKey, err := h.apiKeyStore.Validate(password)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG [token/handler]: API key validation failed: %v\n", err)
|
||||
return unauthorized
|
||||
}
|
||||
|
||||
did = apiKey.DID
|
||||
handle = apiKey.Handle
|
||||
fmt.Printf("DEBUG [token/handler]: API key validated for DID=%s, handle=%s\n", did, handle)
|
||||
|
||||
// API key is linked to OAuth session
|
||||
// OAuth refresher will provide access token when needed via middleware
|
||||
}
|
||||
// 2. Try app password (direct PDS)
|
||||
else {
|
||||
did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err)
|
||||
return unauthorized
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG [token/handler]: App password validated, DID=%s\n", did)
|
||||
|
||||
// Cache access token for manifest operations
|
||||
auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
|
||||
|
||||
// Ensure profile exists
|
||||
// ... existing code ...
|
||||
}
|
||||
|
||||
// Rest of handler: validate access, issue JWT, etc.
|
||||
// ... existing code ...
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- Remove session token validation (`sessionManager.Validate()`)
|
||||
- Add API key check as first priority
|
||||
- Keep app password as fallback
|
||||
- API keys use OAuth refresher (server-side), app passwords use token cache (client-side)
|
||||
|
||||
#### 2.2 Remove `/auth/exchange` Endpoint
|
||||
|
||||
The `/auth/exchange` endpoint was only used for exchanging session tokens for registry JWTs. With API keys, this is no longer needed.
|
||||
|
||||
**Files to delete:**
|
||||
- `pkg/auth/exchange/handler.go`
|
||||
|
||||
**Files to update:**
|
||||
- `cmd/registry/serve.go` - Remove exchange handler registration
|
||||
|
||||
### Phase 3: Update UI
|
||||
|
||||
#### 3.1 Add API Keys Section to Settings Page
|
||||
|
||||
**Template** (`pkg/appview/templates/settings.html`):
|
||||
|
||||
```html
|
||||
<!-- Add after existing profile settings -->
|
||||
<section class="api-keys">
|
||||
<h2>API Keys</h2>
|
||||
<p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p>
|
||||
|
||||
<!-- Generate New Key -->
|
||||
<div class="generate-key">
|
||||
<h3>Generate New API Key</h3>
|
||||
<form id="generate-key-form">
|
||||
<input type="text" id="key-name" placeholder="Key name (e.g., My Laptop)" required>
|
||||
<button type="submit">Generate Key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Key Generated Modal (shown once) -->
|
||||
<div id="key-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3>✓ API Key Generated!</h3>
|
||||
<p><strong>Copy this key now - it won't be shown again:</strong></p>
|
||||
<div class="key-display">
|
||||
<code id="generated-key"></code>
|
||||
<button onclick="copyKey()">Copy to Clipboard</button>
|
||||
</div>
|
||||
<div class="usage-instructions">
|
||||
<h4>Using with Docker:</h4>
|
||||
<pre>docker login atcr.io -u <span class="handle">{{.Profile.Handle}}</span> -p <span class="key-placeholder">[paste key here]</span></pre>
|
||||
</div>
|
||||
<button onclick="closeModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Keys List -->
|
||||
<div class="keys-list">
|
||||
<h3>Your API Keys</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keys-table">
|
||||
<!-- Populated via JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Generate key
|
||||
document.getElementById('generate-key-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('key-name').value;
|
||||
|
||||
const resp = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: `name=${encodeURIComponent(name)}`
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
// Show key in modal (only time it's available)
|
||||
document.getElementById('generated-key').textContent = data.key;
|
||||
document.getElementById('key-modal').classList.remove('hidden');
|
||||
|
||||
// Refresh keys list
|
||||
loadKeys();
|
||||
});
|
||||
|
||||
// Copy key to clipboard
|
||||
function copyKey() {
|
||||
const key = document.getElementById('generated-key').textContent;
|
||||
navigator.clipboard.writeText(key);
|
||||
alert('Copied to clipboard!');
|
||||
}
|
||||
|
||||
// Load existing keys
|
||||
async function loadKeys() {
|
||||
const resp = await fetch('/api/keys');
|
||||
const keys = await resp.json();
|
||||
|
||||
const tbody = document.getElementById('keys-table');
|
||||
tbody.innerHTML = keys.map(key => `
|
||||
<tr>
|
||||
<td>${key.name}</td>
|
||||
<td>${new Date(key.created_at).toLocaleDateString()}</td>
|
||||
<td>${key.last_used ? new Date(key.last_used).toLocaleDateString() : 'Never'}</td>
|
||||
<td><button onclick="deleteKey('${key.id}')">Revoke</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Delete key
|
||||
async function deleteKey(id) {
|
||||
if (!confirm('Are you sure you want to revoke this key?')) return;
|
||||
|
||||
await fetch(`/api/keys/${id}`, { method: 'DELETE' });
|
||||
loadKeys();
|
||||
}
|
||||
|
||||
// Load keys on page load
|
||||
loadKeys();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.modal.hidden { display: none; }
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.key-display {
|
||||
background: #f5f5f5;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.key-display code {
|
||||
word-break: break-all;
|
||||
font-size: 14px;
|
||||
}
|
||||
.usage-instructions {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.usage-instructions pre {
|
||||
background: #263238;
|
||||
color: #aed581;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.handle { color: #ffab40; }
|
||||
.key-placeholder { color: #64b5f6; }
|
||||
</style>
|
||||
```
|
||||
|
||||
#### 3.2 Register API Key Routes (`cmd/registry/serve.go`)
|
||||
|
||||
```go
|
||||
// In initializeUI() function, add:
|
||||
|
||||
// API key management routes (authenticated)
|
||||
authRouter.Handle("/api/keys", &uihandlers.GenerateAPIKeyHandler{
|
||||
Store: apiKeyStore,
|
||||
}).Methods("POST")
|
||||
|
||||
authRouter.Handle("/api/keys", &uihandlers.ListAPIKeysHandler{
|
||||
Store: apiKeyStore,
|
||||
}).Methods("GET")
|
||||
|
||||
authRouter.Handle("/api/keys/{id}", &uihandlers.DeleteAPIKeyHandler{
|
||||
Store: apiKeyStore,
|
||||
}).Methods("DELETE")
|
||||
```
|
||||
|
||||
### Phase 4: Update Credential Helper
|
||||
|
||||
#### 4.1 Simplify Configuration (`cmd/credential-helper/main.go`)
|
||||
|
||||
```go
|
||||
// SessionStore becomes CredentialStore
|
||||
type CredentialStore struct {
|
||||
Handle string `json:"handle"`
|
||||
APIKey string `json:"api_key"`
|
||||
AppViewURL string `json:"appview_url"`
|
||||
}
|
||||
|
||||
func handleConfigure(handle string) {
|
||||
fmt.Println("ATCR Credential Helper Configuration")
|
||||
fmt.Println("=====================================")
|
||||
fmt.Println()
|
||||
fmt.Println("You need an API key from the ATCR web UI.")
|
||||
fmt.Println()
|
||||
|
||||
appViewURL := os.Getenv("ATCR_APPVIEW_URL")
|
||||
if appViewURL == "" {
|
||||
appViewURL = defaultAppViewURL
|
||||
}
|
||||
|
||||
// Auto-open settings page
|
||||
settingsURL := appViewURL + "/settings"
|
||||
fmt.Printf("Opening settings page: %s\n", settingsURL)
|
||||
fmt.Println("Log in and generate an API key if you haven't already.")
|
||||
fmt.Println()
|
||||
|
||||
if err := oauth.OpenBrowser(settingsURL); err != nil {
|
||||
fmt.Printf("Could not open browser. Please visit: %s\n\n", settingsURL)
|
||||
}
|
||||
|
||||
// Prompt for credentials
|
||||
if handle == "" {
|
||||
fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ")
|
||||
fmt.Scanln(&handle)
|
||||
} else {
|
||||
fmt.Printf("Using handle: %s\n", handle)
|
||||
}
|
||||
|
||||
fmt.Print("Enter your API key (from settings page): ")
|
||||
var apiKey string
|
||||
fmt.Scanln(&apiKey)
|
||||
|
||||
// Validate key format
|
||||
if !strings.HasPrefix(apiKey, "atcr_") {
|
||||
fmt.Fprintf(os.Stderr, "Invalid API key format. Key should start with 'atcr_'\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
creds := &CredentialStore{
|
||||
Handle: handle,
|
||||
APIKey: apiKey,
|
||||
AppViewURL: appViewURL,
|
||||
}
|
||||
|
||||
if err := saveCredentials(getCredentialsPath(), creds); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✓ Configuration complete!")
|
||||
fmt.Println("You can now use docker push/pull with atcr.io")
|
||||
}
|
||||
|
||||
func handleGet() {
|
||||
var serverURL string
|
||||
fmt.Fscanln(os.Stdin, &serverURL)
|
||||
|
||||
// Load credentials
|
||||
creds, err := loadCredentials(getCredentialsPath())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading credentials: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Return credentials for Docker
|
||||
// Docker will send these as Basic Auth to /auth/token
|
||||
response := Credentials{
|
||||
ServerURL: serverURL,
|
||||
Username: creds.Handle,
|
||||
Secret: creds.APIKey, // API key as password
|
||||
}
|
||||
|
||||
json.NewEncoder(os.Stdout).Encode(response)
|
||||
}
|
||||
```
|
||||
|
||||
**File Rename:**
|
||||
- `~/.atcr/session.json` → `~/.atcr/credentials.json`
|
||||
|
||||
### Phase 5: Remove Session Token System
|
||||
|
||||
#### 5.1 Delete Session Token Files
|
||||
|
||||
**Files to delete:**
|
||||
- `pkg/auth/session/handler.go`
|
||||
- `pkg/auth/exchange/handler.go`
|
||||
|
||||
#### 5.2 Update OAuth Server (`pkg/auth/oauth/server.go`)
|
||||
|
||||
**Remove session token creation:**
|
||||
```go
|
||||
// OLD (delete this):
|
||||
sessionToken, err := s.sessionManager.Create(did, handle)
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to create session token: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a UI login...
|
||||
if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil {
|
||||
// UI flow...
|
||||
} else {
|
||||
// Render success page with session token (for credential helper)
|
||||
s.renderSuccess(w, sessionToken, handle)
|
||||
}
|
||||
```
|
||||
|
||||
**NEW (replace with):**
|
||||
```go
|
||||
// Check if this is a UI login
|
||||
if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil {
|
||||
// Create UI session
|
||||
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 24*time.Hour)
|
||||
// ... set cookie, redirect ...
|
||||
} else {
|
||||
// Non-UI flow: redirect to settings to get API key
|
||||
s.renderRedirectToSettings(w, handle)
|
||||
}
|
||||
```
|
||||
|
||||
**Add redirect to settings template:**
|
||||
```go
|
||||
func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) {
|
||||
tmpl := template.Must(template.New("redirect").Parse(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Successful - ATCR</title>
|
||||
<meta http-equiv="refresh" content="3;url=/settings">
|
||||
</head>
|
||||
<body>
|
||||
<h1>✓ Authorization Successful!</h1>
|
||||
<p>Redirecting to settings page to generate your API key...</p>
|
||||
<p>If not redirected, <a href="/settings">click here</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
tmpl.Execute(w, nil)
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3 Update Server Constructor
|
||||
|
||||
```go
|
||||
// Remove sessionManager parameter
|
||||
func NewServer(app *App) *Server {
|
||||
return &Server{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.4 Update Registry Initialization (`cmd/registry/serve.go`)
|
||||
|
||||
```go
|
||||
// REMOVE session manager creation:
|
||||
// sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour)
|
||||
|
||||
// Create API key store
|
||||
apiKeyStorePath := filepath.Join(filepath.Dir(storagePath), "api-keys.json")
|
||||
apiKeyStore, err := apikey.NewStore(apiKeyStorePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create API key store: %w", err)
|
||||
}
|
||||
|
||||
// OAuth server doesn't need session manager anymore
|
||||
oauthServer := oauth.NewServer(oauthApp)
|
||||
oauthServer.SetRefresher(refresher)
|
||||
if uiSessionStore != nil {
|
||||
oauthServer.SetUISessionStore(uiSessionStore)
|
||||
}
|
||||
|
||||
// Token handler gets API key store instead of session manager
|
||||
if issuer != nil {
|
||||
tokenHandler := token.NewHandler(issuer, apiKeyStore, defaultHoldEndpoint)
|
||||
tokenHandler.RegisterRoutes(mux)
|
||||
|
||||
// Remove exchange handler registration (no longer needed)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### For Existing Users
|
||||
|
||||
**Option 1: Smooth Migration (Recommended)**
|
||||
1. Keep session token validation temporarily with deprecation warning
|
||||
2. When session token is used, log warning and return special response header
|
||||
3. Docker client shows warning: "Session tokens deprecated, please regenerate API key"
|
||||
4. Remove session token support in next major version
|
||||
|
||||
**Option 2: Hard Cutover**
|
||||
1. Deploy new version with API keys
|
||||
2. Session tokens stop working immediately
|
||||
3. Users must reconfigure: `docker-credential-atcr configure`
|
||||
4. Cleaner but disruptive
|
||||
|
||||
### Rollout Plan
|
||||
|
||||
**Week 1: Deploy API Keys**
|
||||
- Add API key system
|
||||
- Keep session token validation
|
||||
- Add deprecation notice to OAuth callback
|
||||
|
||||
**Week 2-4: Migration Period**
|
||||
- Monitor API key adoption
|
||||
- Email users about migration
|
||||
- Provide migration guide
|
||||
|
||||
**Week 5: Remove Session Tokens**
|
||||
- Delete session token code
|
||||
- Force users to API keys
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit Tests
|
||||
|
||||
1. **API Key Store**
|
||||
- Test key generation (format, uniqueness)
|
||||
- Test key validation (correct/incorrect keys)
|
||||
- Test bcrypt hashing
|
||||
- Test key listing/deletion
|
||||
|
||||
2. **Token Handler**
|
||||
- Test API key authentication
|
||||
- Test app password authentication
|
||||
- Test invalid credentials
|
||||
- Test key format validation
|
||||
|
||||
### Integration Tests
|
||||
|
||||
1. **Full Auth Flow**
|
||||
- UI login → OAuth → API key generation
|
||||
- Credential helper → API key → registry JWT
|
||||
- App password → registry JWT
|
||||
|
||||
2. **Docker Client Tests**
|
||||
- `docker login -u handle -p api_key`
|
||||
- `docker login -u handle -p app_password`
|
||||
- `docker push` with API key
|
||||
- `docker pull` with API key
|
||||
|
||||
### Security Tests
|
||||
|
||||
1. **Key Security**
|
||||
- Verify bcrypt hashing (not plaintext storage)
|
||||
- Test key shown only once
|
||||
- Test key revocation
|
||||
- Test unauthorized key access
|
||||
|
||||
2. **OAuth Security**
|
||||
- Verify API key links to correct OAuth session
|
||||
- Test expired refresh token handling
|
||||
- Test multiple keys for same user
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New Files
|
||||
- `pkg/appview/apikey/store.go` - API key storage and validation
|
||||
- `pkg/appview/handlers/apikeys.go` - API key HTTP handlers
|
||||
- `docs/API_KEY_MIGRATION.md` - This document
|
||||
|
||||
### Modified Files
|
||||
- `pkg/auth/token/handler.go` - Add API key validation, remove session token
|
||||
- `pkg/auth/oauth/server.go` - Remove session token creation, redirect to settings
|
||||
- `pkg/appview/handlers/settings.go` - Add API key management UI
|
||||
- `pkg/appview/templates/settings.html` - Add API key section
|
||||
- `cmd/credential-helper/main.go` - Simplify to use API keys
|
||||
- `cmd/registry/serve.go` - Initialize API key store, remove session manager
|
||||
|
||||
### Deleted Files
|
||||
- `pkg/auth/session/handler.go` - Session token system
|
||||
- `pkg/auth/exchange/handler.go` - Exchange endpoint (no longer needed)
|
||||
|
||||
---
|
||||
|
||||
## Advantages
|
||||
|
||||
✅ **Simpler Auth:** Two methods instead of three (API keys + app passwords)
|
||||
✅ **Better UX:** No manual copy/paste of session tokens
|
||||
✅ **Multiple Keys:** Users can have laptop key, CI key, etc.
|
||||
✅ **Revocable:** Revoke individual keys without re-auth
|
||||
✅ **Server-Side OAuth:** Refresh tokens stay on server, not in client files
|
||||
✅ **Familiar Pattern:** Matches AWS ECR, GitHub tokens, etc.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
⚠️ **Breaking Change:** Session tokens will stop working
|
||||
✅ **App passwords:** Still work (no changes)
|
||||
✅ **UI sessions:** Still work (separate system)
|
||||
|
||||
**Migration Required:** Users with session tokens must run `docker-credential-atcr configure` again to get API keys.
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
38
go.mod
38
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
|
||||
)
|
||||
|
||||
90
go.sum
90
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=
|
||||
|
||||
249
pkg/appview/apikey/store.go
Normal file
249
pkg/appview/apikey/store.go
Normal file
@@ -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_<base64>
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
91
pkg/appview/handlers/apikeys.go
Normal file
91
pkg/appview/handlers/apikeys.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -57,6 +57,42 @@
|
||||
<div id="hold-status"></div>
|
||||
</section>
|
||||
|
||||
<!-- API Keys Section -->
|
||||
<section class="settings-section api-keys-section">
|
||||
<h2>API Keys</h2>
|
||||
<p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p>
|
||||
|
||||
<!-- Generate New Key -->
|
||||
<div class="generate-key">
|
||||
<h3>Generate New API Key</h3>
|
||||
<form id="generate-key-form">
|
||||
<div class="form-group">
|
||||
<label for="key-name">Key Name:</label>
|
||||
<input type="text" id="key-name" name="key-name" placeholder="e.g., My Laptop, CI/CD" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Generate Key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Existing Keys List -->
|
||||
<div class="keys-list">
|
||||
<h3>Your API Keys</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keys-table">
|
||||
<tr><td colspan="4">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OAuth Session Section -->
|
||||
<section class="settings-section">
|
||||
<h2>OAuth Session</h2>
|
||||
@@ -78,7 +114,246 @@
|
||||
<!-- Modal container for HTMX -->
|
||||
<div id="modal"></div>
|
||||
|
||||
<!-- API Key Modal (shown once after generation) -->
|
||||
<div id="key-modal" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="closeKeyModal()"></div>
|
||||
<div class="modal-content">
|
||||
<h3>✓ API Key Generated!</h3>
|
||||
<p><strong>Copy this key now - it won't be shown again:</strong></p>
|
||||
<div class="key-display">
|
||||
<code id="generated-key"></code>
|
||||
<button class="btn-secondary" onclick="copyKey()">Copy to Clipboard</button>
|
||||
</div>
|
||||
<div class="usage-instructions">
|
||||
<h4>Using with Docker:</h4>
|
||||
<p><strong>Direct login (quick start)</strong></p>
|
||||
<pre><code>docker login atcr.io -u {{ .Profile.Handle }} -p [paste key here]</code></pre>
|
||||
<p><strong>Credential helper (if you opened this from configure)</strong></p>
|
||||
<p>Just paste your handle and this key when prompted in the terminal.</p>
|
||||
</div>
|
||||
<button class="btn-primary" onclick="closeKeyModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
|
||||
<script>
|
||||
// API Key Management JavaScript
|
||||
(function() {
|
||||
// Generate key
|
||||
document.getElementById('generate-key-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('key-name').value;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: `name=${encodeURIComponent(name)}`
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to generate key');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
// Show key in modal (only time it's available)
|
||||
document.getElementById('generated-key').textContent = data.key;
|
||||
document.getElementById('key-modal').classList.remove('hidden');
|
||||
|
||||
// Clear form
|
||||
document.getElementById('key-name').value = '';
|
||||
|
||||
// Refresh keys list
|
||||
loadKeys();
|
||||
} catch (err) {
|
||||
alert('Error generating key: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Copy key to clipboard
|
||||
window.copyKey = function() {
|
||||
const key = document.getElementById('generated-key').textContent;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
alert('Copied to clipboard!');
|
||||
}).catch(err => {
|
||||
alert('Failed to copy: ' + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
// Close modal
|
||||
window.closeKeyModal = function() {
|
||||
document.getElementById('key-modal').classList.add('hidden');
|
||||
};
|
||||
|
||||
// Load existing keys
|
||||
async function loadKeys() {
|
||||
try {
|
||||
const resp = await fetch('/api/keys');
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to load keys');
|
||||
}
|
||||
|
||||
const keys = await resp.json();
|
||||
const tbody = document.getElementById('keys-table');
|
||||
|
||||
if (keys.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">No API keys yet. Generate one above!</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = keys.map(key => {
|
||||
const createdDate = new Date(key.created_at).toLocaleDateString();
|
||||
const lastUsed = key.last_used && key.last_used !== '0001-01-01T00:00:00Z'
|
||||
? new Date(key.last_used).toLocaleDateString()
|
||||
: 'Never';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(key.name)}</td>
|
||||
<td>${createdDate}</td>
|
||||
<td>${lastUsed}</td>
|
||||
<td><button class="btn-danger" onclick="deleteKey('${key.id}')">Revoke</button></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Error loading keys:', err);
|
||||
document.getElementById('keys-table').innerHTML =
|
||||
'<tr><td colspan="4">Error loading keys</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Delete key
|
||||
window.deleteKey = async function(id) {
|
||||
if (!confirm('Are you sure you want to revoke this key? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/keys/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to delete key');
|
||||
}
|
||||
loadKeys();
|
||||
} catch (err) {
|
||||
alert('Error revoking key: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Escape HTML helper
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load keys on page load
|
||||
loadKeys();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* API Key Modal Styles */
|
||||
.modal.hidden { display: none; }
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
z-index: 1001;
|
||||
}
|
||||
.key-display {
|
||||
background: #f5f5f5;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.key-display code {
|
||||
word-break: break-all;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.usage-instructions {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.usage-instructions h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.usage-instructions pre {
|
||||
background: #263238;
|
||||
color: #aed581;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
.usage-instructions code {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* API Keys Section Styles */
|
||||
.api-keys-section table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.api-keys-section th,
|
||||
.api-keys-section td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.api-keys-section th {
|
||||
background: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
.api-keys-section .btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.api-keys-section .btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
.generate-key {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
||||
@@ -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://<did>/<collection>/<rkey>
|
||||
|
||||
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=<your-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)
|
||||
|
||||
@@ -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.<handle>
|
||||
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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
25
pkg/auth/oauth/browser.go
Normal file
25
pkg/auth/oauth/browser.go
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
187
pkg/auth/oauth/interactive.go
Normal file
187
pkg/auth/oauth/interactive.go
Normal file
@@ -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, "<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>")
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Successful - ATCR</title>
|
||||
<meta http-equiv="refresh" content="3;url=/settings">
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 5px; }
|
||||
code { background: #f5f5f5; padding: 10px; display: block; margin: 10px 0; word-break: break-all; }
|
||||
.copy-btn { background: #007bff; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 5px; }
|
||||
.copy-btn:hover { background: #0056b3; }
|
||||
.info { background: #d1ecf1; border: 1px solid #bee5eb; padding: 15px; border-radius: 5px; margin-top: 15px; }
|
||||
a { color: #007bff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="success">
|
||||
<h1>✓ Authorization Successful!</h1>
|
||||
<p>You have successfully authorized ATCR to access your ATProto account: <strong>{{.Handle}}</strong></p>
|
||||
<p>Copy the session token below and paste it into your credential helper:</p>
|
||||
<code id="token">{{.SessionToken}}</code>
|
||||
<button class="copy-btn" onclick="copyToken()">Copy Token</button>
|
||||
<p>Redirecting to settings page to generate your API key...</p>
|
||||
<p>If not redirected, <a href="/settings">click here</a>.</p>
|
||||
</div>
|
||||
<div class="info">
|
||||
<h3>Next Steps:</h3>
|
||||
<ol>
|
||||
<li>Generate an API key on the settings page</li>
|
||||
<li>Copy the API key (shown once!)</li>
|
||||
<li>Use it with: <code>docker login atcr.io -u {{.Handle}} -p [your-api-key]</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
<script>
|
||||
function copyToken() {
|
||||
const token = document.getElementById('token').textContent;
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
alert('Token copied to clipboard!');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
237
pkg/auth/oauth/store.go
Normal file
237
pkg/auth/oauth/store.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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: <claims>.<signature>
|
||||
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
|
||||
}
|
||||
@@ -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: <base64_claims>.<base64_signature>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user