A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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 }