mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 23:36:57 +00:00
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
|
||||
|
||||
+35
-16
@@ -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
|
||||
|
||||
+25
-22
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user