Files
at-container-registry/cmd/credential-helper/main.go
T

305 lines
8.2 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
const (
// Default AppView URL - can be overridden via environment variable
defaultAppViewURL = "http://127.0.0.1:5000"
)
// DeviceConfig represents the stored device configuration
type DeviceConfig struct {
Handle string `json:"handle"`
DeviceSecret string `json:"device_secret"`
AppViewURL string `json:"appview_url"`
}
// Docker credential helper protocol
// https://github.com/docker/docker-credential-helpers
// Credentials represents docker credentials
type Credentials struct {
ServerURL string `json:"ServerURL,omitempty"`
Username string `json:"Username,omitempty"`
Secret string `json:"Secret,omitempty"`
}
// Device authorization API types
type DeviceCodeRequest struct {
DeviceName string `json:"device_name"`
}
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
type DeviceTokenRequest struct {
DeviceCode string `json:"device_code"`
}
type DeviceTokenResponse struct {
DeviceSecret string `json:"device_secret,omitempty"`
Handle string `json:"handle,omitempty"`
DID string `json:"did,omitempty"`
Error string `json:"error,omitempty"`
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase>\n")
os.Exit(1)
}
command := os.Args[1]
switch command {
case "get":
handleGet()
case "store":
handleStore()
case "erase":
handleErase()
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
os.Exit(1)
}
}
// handleGet retrieves credentials for the given server
func handleGet() {
// Docker sends the server URL as a plain string on stdin (not JSON)
var serverURL string
if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err)
os.Exit(1)
}
// Load device configuration
configPath := getConfigPath()
deviceConfig, err := loadDeviceConfig(configPath)
if err != nil || deviceConfig.DeviceSecret == "" {
// First time - trigger device authorization
fmt.Fprintf(os.Stderr, "No device configuration found. Starting device authorization...\n")
deviceConfig, err = authorizeDevice()
if err != nil {
fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err)
fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login atcr.io' with your ATProto app-password\n")
os.Exit(1)
}
// Save device configuration
if err := saveDeviceConfig(configPath, deviceConfig); err != nil {
fmt.Fprintf(os.Stderr, "Failed to save device config: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "✓ Device authorized successfully!\n")
}
// Return credentials for Docker
creds := Credentials{
ServerURL: serverURL,
Username: deviceConfig.Handle,
Secret: deviceConfig.DeviceSecret,
}
if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding response: %v\n", err)
os.Exit(1)
}
}
// handleStore stores credentials (Docker calls this after login)
func handleStore() {
var creds Credentials
if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding credentials: %v\n", err)
os.Exit(1)
}
// This is a no-op for the device auth flow
// Users should use the automatic device authorization, not docker login
// If they use docker login with app-password, that goes through /auth/token directly
}
// handleErase removes stored credentials
func handleErase() {
// Docker sends the server URL as a plain string on stdin (not JSON)
var serverURL string
if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err)
os.Exit(1)
}
// Remove device configuration file
configPath := getConfigPath()
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error removing device config: %v\n", err)
os.Exit(1)
}
}
// authorizeDevice performs the device authorization flow
func authorizeDevice() (*DeviceConfig, error) {
// Get AppView URL
appViewURL := os.Getenv("ATCR_APPVIEW_URL")
if appViewURL == "" {
appViewURL = defaultAppViewURL
}
// Get device name (hostname)
deviceName, err := os.Hostname()
if err != nil {
deviceName = "Unknown Device"
}
// 1. Request device code
fmt.Fprintf(os.Stderr, "Requesting device authorization...\n")
reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName})
resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to request device code: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("device code request failed: %s", string(body))
}
var codeResp DeviceCodeResponse
if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil {
return nil, fmt.Errorf("failed to decode device code response: %w", err)
}
// 2. Open browser for user to approve
verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode
fmt.Fprintf(os.Stderr, "\nOpening browser for device authorization...\n")
fmt.Fprintf(os.Stderr, "User code: %s\n", codeResp.UserCode)
fmt.Fprintf(os.Stderr, "\nIf browser doesn't open, visit: %s\n\n", verificationURL)
if err := openBrowser(verificationURL); err != nil {
fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err)
}
fmt.Fprintf(os.Stderr, "Waiting for authorization...\n")
// 3. Poll for authorization completion
pollInterval := time.Duration(codeResp.Interval) * time.Second
timeout := time.Duration(codeResp.ExpiresIn) * time.Second
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
// Poll token endpoint
tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode})
tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody))
if err != nil {
fmt.Fprintf(os.Stderr, "Poll failed: %v\n", err)
continue
}
var tokenResult DeviceTokenResponse
json.NewDecoder(tokenResp.Body).Decode(&tokenResult)
tokenResp.Body.Close()
if tokenResult.Error == "authorization_pending" {
// Still waiting
continue
}
if tokenResult.Error != "" {
return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error)
}
// Success!
return &DeviceConfig{
Handle: tokenResult.Handle,
DeviceSecret: tokenResult.DeviceSecret,
AppViewURL: appViewURL,
}, nil
}
return nil, fmt.Errorf("authorization timeout")
}
// getConfigPath returns the path to the device configuration file
func getConfigPath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
os.Exit(1)
}
atcrDir := filepath.Join(homeDir, ".atcr")
if err := os.MkdirAll(atcrDir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err)
os.Exit(1)
}
return filepath.Join(atcrDir, "device.json")
}
// loadDeviceConfig loads the device configuration from disk
func loadDeviceConfig(path string) (*DeviceConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var config DeviceConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// saveDeviceConfig saves the device configuration to disk
func saveDeviceConfig(path string, config *DeviceConfig) error {
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// openBrowser opens the specified URL in the default browser
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("xdg-open", url)
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
return fmt.Errorf("unsupported platform")
}
return cmd.Start()
}