cleanup more auth

This commit is contained in:
Evan Jarrett
2025-10-07 10:58:11 -05:00
parent 5b18538a8b
commit 2d16bbfee3
31 changed files with 2524 additions and 918 deletions
+74 -69
View File
@@ -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
+35 -16
View File
@@ -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)
}
-4
View File
@@ -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
+25 -22
View File
@@ -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 {