A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
fork

Configure Feed

Select the types of activity you want to include in your feed.

fixup credential helper and device auth flow

+206 -31
+170 -21
cmd/credential-helper/main.go
··· 22 22 AppViewURL string `json:"appview_url"` 23 23 } 24 24 25 + // DeviceCredentials stores multiple device configurations keyed by AppView URL 26 + type DeviceCredentials struct { 27 + Credentials map[string]DeviceConfig `json:"credentials"` 28 + } 29 + 25 30 // DockerDaemonConfig represents Docker's daemon.json configuration 26 31 type DockerDaemonConfig struct { 27 32 InsecureRegistries []string `json:"insecure-registries"` ··· 92 97 os.Exit(1) 93 98 } 94 99 95 - // Load device configuration 100 + // Build AppView URL to use as lookup key 101 + appViewURL := buildAppViewURL(serverURL) 102 + 103 + // Load all device credentials 96 104 configPath := getConfigPath() 97 - deviceConfig, err := loadDeviceConfig(configPath) 98 - if err != nil || deviceConfig.DeviceSecret == "" { 99 - // First time - trigger device authorization 100 - fmt.Fprintf(os.Stderr, "No device configuration found. Starting device authorization...\n") 105 + allCreds, err := loadDeviceCredentials(configPath) 106 + if err != nil { 107 + // No credentials file exists yet 108 + allCreds = &DeviceCredentials{ 109 + Credentials: make(map[string]DeviceConfig), 110 + } 111 + } 112 + 113 + // Look up device config for this specific AppView URL 114 + deviceConfig, found := getDeviceConfig(allCreds, appViewURL) 115 + 116 + // If credentials exist, validate them 117 + if found && deviceConfig.DeviceSecret != "" { 118 + if !validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret) { 119 + fmt.Fprintf(os.Stderr, "Stored credentials for %s are invalid or expired\n", appViewURL) 120 + // Delete the invalid credentials 121 + delete(allCreds.Credentials, appViewURL) 122 + saveDeviceCredentials(configPath, allCreds) 123 + // Mark as not found so we re-authorize below 124 + found = false 125 + } 126 + } 127 + 128 + if !found || deviceConfig.DeviceSecret == "" { 129 + // No credentials for this AppView 130 + // Check if we should attempt interactive authorization 131 + // We only do this if: 132 + // 1. ATCR_AUTO_AUTH environment variable is set to "1", OR 133 + // 2. We're in an interactive terminal (stderr is a terminal) 134 + shouldAutoAuth := os.Getenv("ATCR_AUTO_AUTH") == "1" || isTerminal(os.Stderr) 101 135 102 - deviceConfig, err = authorizeDevice(serverURL) 136 + if !shouldAutoAuth { 137 + fmt.Fprintf(os.Stderr, "No valid credentials found for %s\n", appViewURL) 138 + fmt.Fprintf(os.Stderr, "\nTo authenticate, run:\n") 139 + fmt.Fprintf(os.Stderr, " export ATCR_AUTO_AUTH=1\n") 140 + fmt.Fprintf(os.Stderr, " docker push %s/<user>/<image>:<tag>\n", serverURL) 141 + fmt.Fprintf(os.Stderr, "\nThis will trigger device authorization in your browser.\n") 142 + os.Exit(1) 143 + } 144 + 145 + // Auto-auth enabled - trigger device authorization 146 + fmt.Fprintf(os.Stderr, "Starting device authorization for %s...\n", appViewURL) 147 + 148 + newConfig, err := authorizeDevice(serverURL) 103 149 if err != nil { 104 150 fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err) 105 151 fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login %s' with your ATProto app-password\n", serverURL) ··· 107 153 } 108 154 109 155 // Save device configuration 110 - if err := saveDeviceConfig(configPath, deviceConfig); err != nil { 156 + if err := saveDeviceConfig(configPath, newConfig); err != nil { 111 157 fmt.Fprintf(os.Stderr, "Failed to save device config: %v\n", err) 112 158 os.Exit(1) 113 159 } 114 160 115 - fmt.Fprintf(os.Stderr, "✓ Device authorized successfully!\n") 161 + fmt.Fprintf(os.Stderr, "✓ Device authorized successfully for %s!\n", appViewURL) 162 + deviceConfig = newConfig 116 163 } 117 164 118 165 // Return credentials for Docker ··· 141 188 // If they use docker login with app-password, that goes through /auth/token directly 142 189 } 143 190 144 - // handleErase removes stored credentials 191 + // handleErase removes stored credentials for a specific AppView 145 192 func handleErase() { 146 193 // Docker sends the server URL as a plain string on stdin (not JSON) 147 194 var serverURL string ··· 150 197 os.Exit(1) 151 198 } 152 199 153 - // Remove device configuration file 200 + // Build AppView URL to use as lookup key 201 + appViewURL := buildAppViewURL(serverURL) 202 + 203 + // Load all device credentials 154 204 configPath := getConfigPath() 155 - if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { 156 - fmt.Fprintf(os.Stderr, "Error removing device config: %v\n", err) 205 + allCreds, err := loadDeviceCredentials(configPath) 206 + if err != nil { 207 + // No credentials file exists, nothing to erase 208 + return 209 + } 210 + 211 + // Remove the specific AppView URL's credentials 212 + delete(allCreds.Credentials, appViewURL) 213 + 214 + // If no credentials remain, remove the file entirely 215 + if len(allCreds.Credentials) == 0 { 216 + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { 217 + fmt.Fprintf(os.Stderr, "Error removing device config: %v\n", err) 218 + os.Exit(1) 219 + } 220 + return 221 + } 222 + 223 + // Otherwise, save the updated credentials 224 + if err := saveDeviceCredentials(configPath, allCreds); err != nil { 225 + fmt.Fprintf(os.Stderr, "Error saving device config: %v\n", err) 157 226 os.Exit(1) 158 227 } 159 228 } ··· 273 342 return filepath.Join(atcrDir, "device.json") 274 343 } 275 344 276 - // loadDeviceConfig loads the device configuration from disk 277 - func loadDeviceConfig(path string) (*DeviceConfig, error) { 345 + // loadDeviceCredentials loads all device credentials from disk 346 + func loadDeviceCredentials(path string) (*DeviceCredentials, error) { 278 347 data, err := os.ReadFile(path) 279 348 if err != nil { 280 349 return nil, err 281 350 } 282 351 283 - var config DeviceConfig 284 - if err := json.Unmarshal(data, &config); err != nil { 285 - return nil, err 352 + // Try to unmarshal as new format (map of credentials) 353 + var creds DeviceCredentials 354 + if err := json.Unmarshal(data, &creds); err == nil && creds.Credentials != nil { 355 + return &creds, nil 286 356 } 287 357 288 - return &config, nil 358 + // Backward compatibility: Try to unmarshal as old format (single config) 359 + var oldConfig DeviceConfig 360 + if err := json.Unmarshal(data, &oldConfig); err == nil && oldConfig.DeviceSecret != "" { 361 + // Migrate old format to new format 362 + creds = DeviceCredentials{ 363 + Credentials: map[string]DeviceConfig{ 364 + oldConfig.AppViewURL: oldConfig, 365 + }, 366 + } 367 + return &creds, nil 368 + } 369 + 370 + return nil, fmt.Errorf("invalid device credentials format") 371 + } 372 + 373 + // getDeviceConfig retrieves a specific device config for an AppView URL 374 + func getDeviceConfig(creds *DeviceCredentials, appViewURL string) (*DeviceConfig, bool) { 375 + if creds == nil || creds.Credentials == nil { 376 + return nil, false 377 + } 378 + config, found := creds.Credentials[appViewURL] 379 + return &config, found 289 380 } 290 381 291 - // saveDeviceConfig saves the device configuration to disk 292 - func saveDeviceConfig(path string, config *DeviceConfig) error { 293 - data, err := json.MarshalIndent(config, "", " ") 382 + // saveDeviceCredentials saves all device credentials to disk 383 + func saveDeviceCredentials(path string, creds *DeviceCredentials) error { 384 + data, err := json.MarshalIndent(creds, "", " ") 294 385 if err != nil { 295 386 return err 296 387 } 297 388 298 389 return os.WriteFile(path, data, 0600) 390 + } 391 + 392 + // saveDeviceConfig saves a single device config by adding/updating it in the credentials map 393 + func saveDeviceConfig(path string, config *DeviceConfig) error { 394 + // Load existing credentials (or create new) 395 + creds, err := loadDeviceCredentials(path) 396 + if err != nil { 397 + // Create new credentials structure 398 + creds = &DeviceCredentials{ 399 + Credentials: make(map[string]DeviceConfig), 400 + } 401 + } 402 + 403 + // Add or update the config for this AppView URL 404 + creds.Credentials[config.AppViewURL] = *config 405 + 406 + // Save back to disk 407 + return saveDeviceCredentials(path, creds) 299 408 } 300 409 301 410 // openBrowser opens the specified URL in the default browser ··· 418 527 } 419 528 return hostPort 420 529 } 530 + 531 + // isTerminal checks if the file is a terminal 532 + func isTerminal(f *os.File) bool { 533 + // Use file stat to check if it's a character device (terminal) 534 + stat, err := f.Stat() 535 + if err != nil { 536 + return false 537 + } 538 + // On Unix, terminals are character devices with mode & ModeCharDevice set 539 + return (stat.Mode() & os.ModeCharDevice) != 0 540 + } 541 + 542 + // validateCredentials checks if the credentials are still valid by making a test request 543 + func validateCredentials(appViewURL, handle, deviceSecret string) bool { 544 + // Make a request to /v2/ which requires authentication 545 + client := &http.Client{ 546 + Timeout: 5 * time.Second, 547 + } 548 + 549 + req, err := http.NewRequest("GET", appViewURL+"/v2/", nil) 550 + if err != nil { 551 + return false 552 + } 553 + 554 + // Set basic auth with device credentials 555 + req.SetBasicAuth(handle, deviceSecret) 556 + 557 + resp, err := client.Do(req) 558 + if err != nil { 559 + // Network error - assume credentials are valid but server unreachable 560 + // Don't trigger re-auth on network issues 561 + return true 562 + } 563 + defer resp.Body.Close() 564 + 565 + // 200 = valid credentials 566 + // 401 = invalid/expired credentials 567 + // Any other error = assume valid (don't re-auth on server issues) 568 + return resp.StatusCode == http.StatusOK 569 + }
+4 -1
pkg/appview/handlers/auth.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "fmt" 4 5 "html/template" 5 6 "net/http" 6 7 ) ··· 12 13 13 14 func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 14 15 returnTo := r.URL.Query().Get("return_to") 16 + fmt.Printf("DEBUG [login]: GET request. return_to param=%s, full query=%s\n", returnTo, r.URL.RawQuery) 15 17 if returnTo == "" { 16 18 returnTo = "/" 17 19 } ··· 52 54 } 53 55 54 56 // Store return_to in cookie so callback can use it 57 + // Note: Secure flag depends on the request scheme (HTTP vs HTTPS) 55 58 http.SetCookie(w, &http.Cookie{ 56 59 Name: "oauth_return_to", 57 60 Value: returnTo, 58 61 Path: "/", 59 62 MaxAge: 600, // 10 minutes 60 63 HttpOnly: true, 61 - Secure: true, 64 + Secure: r.URL.Scheme == "https" || r.Header.Get("X-Forwarded-Proto") == "https", 62 65 SameSite: http.SameSiteLaxMode, 63 66 }) 64 67
+15 -5
pkg/appview/handlers/device.go
··· 5 5 "fmt" 6 6 "html/template" 7 7 "net/http" 8 + "net/url" 8 9 "strings" 9 10 10 11 "github.com/gorilla/mux" ··· 165 166 sessionID, ok := db.GetSessionID(r) 166 167 if !ok { 167 168 // Not logged in - redirect to login with return URL 169 + // Explicitly build the return URL with query parameters 170 + returnTo := r.URL.Path 171 + if r.URL.RawQuery != "" { 172 + returnTo = r.URL.Path + "?" + r.URL.RawQuery 173 + } 168 174 http.SetCookie(w, &http.Cookie{ 169 175 Name: "oauth_return_to", 170 - Value: r.URL.RequestURI(), 176 + Value: returnTo, 171 177 Path: "/", 172 178 MaxAge: 600, // 10 minutes 173 179 HttpOnly: true, 174 180 }) 175 - http.Redirect(w, r, "/login", http.StatusFound) 181 + http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound) 176 182 return 177 183 } 178 184 179 185 sess, ok := h.SessionStore.Get(sessionID) 180 186 if !ok { 181 - // Invalid session 187 + // Invalid session - explicitly build return URL with query parameters 188 + returnTo := r.URL.Path 189 + if r.URL.RawQuery != "" { 190 + returnTo = r.URL.Path + "?" + r.URL.RawQuery 191 + } 182 192 http.SetCookie(w, &http.Cookie{ 183 193 Name: "oauth_return_to", 184 - Value: r.URL.RequestURI(), 194 + Value: returnTo, 185 195 Path: "/", 186 196 MaxAge: 600, 187 197 HttpOnly: true, 188 198 }) 189 - http.Redirect(w, r, "/login", http.StatusFound) 199 + http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound) 190 200 return 191 201 } 192 202
+13 -2
pkg/appview/middleware/auth.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "net/http" 7 + "net/url" 7 8 8 9 "atcr.io/pkg/appview/db" 9 10 ) ··· 18 19 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 20 sessionID, ok := getSessionID(r) 20 21 if !ok { 21 - http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 22 + // Build return URL with query parameters preserved 23 + returnTo := r.URL.Path 24 + if r.URL.RawQuery != "" { 25 + returnTo = r.URL.Path + "?" + r.URL.RawQuery 26 + } 27 + http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound) 22 28 return 23 29 } 24 30 25 31 sess, ok := store.Get(sessionID) 26 32 if !ok { 27 - http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 33 + // Build return URL with query parameters preserved 34 + returnTo := r.URL.Path 35 + if r.URL.RawQuery != "" { 36 + returnTo = r.URL.Path + "?" + r.URL.RawQuery 37 + } 38 + http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound) 28 39 return 29 40 } 30 41
+4 -2
pkg/auth/oauth/server.go
··· 157 157 return 158 158 } 159 159 // Set UI session cookie and redirect (code below) 160 + // Note: Secure flag depends on the request scheme (HTTP vs HTTPS) 160 161 http.SetCookie(w, &http.Cookie{ 161 162 Name: "atcr_session", 162 163 Value: uiSessionID, 163 164 Path: "/", 164 165 MaxAge: 30 * 86400, // 30 days 165 166 HttpOnly: true, 166 - Secure: true, 167 + Secure: r.URL.Scheme == "https" || r.Header.Get("X-Forwarded-Proto") == "https", 167 168 SameSite: http.SameSiteLaxMode, 168 169 }) 169 170 } else { ··· 174 175 return 175 176 } 176 177 // Set UI session cookie 178 + // Note: Secure flag depends on the request scheme (HTTP vs HTTPS) 177 179 http.SetCookie(w, &http.Cookie{ 178 180 Name: "atcr_session", 179 181 Value: uiSessionID, 180 182 Path: "/", 181 183 MaxAge: 30 * 86400, // 30 days 182 184 HttpOnly: true, 183 - Secure: true, 185 + Secure: r.URL.Scheme == "https" || r.Header.Get("X-Forwarded-Proto") == "https", 184 186 SameSite: http.SameSiteLaxMode, 185 187 }) 186 188 }