fix: address CodeRabbit review — context propagation, sentinels, mutex, CLI patterns
- commit
- 2c896149d4634e19110bca93f4a9f0a9baa80546
- author
- brice · · 2026-03-23 05:13 UTC
- parent
- 7c8965b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +60 -39
M internal/adapters/in/cli/auth.go +15 -15
| @@ -223,7 +223,7 @@ Examples: | |||
| 223 | 223 | if token == "" { | |
| 224 | 224 | return fmt.Errorf("--token is required; generate a token on the server with: gordon auth token generate") | |
| 225 | 225 | } | |
| 226 | - | return runAuthLoginWithToken(remoteName, token) | |
| 226 | + | return runAuthLoginWithToken(cmd.Context(), remoteName, token, cmd.OutOrStdout()) | |
| 227 | 227 | }, | |
| 228 | 228 | } | |
| 229 | 229 | ||
| @@ -233,7 +233,7 @@ Examples: | |||
| 233 | 233 | return cmd | |
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | - | func runAuthLoginWithToken(remoteName, token string) error { | |
| 236 | + | func runAuthLoginWithToken(ctx context.Context, remoteName, token string, out io.Writer) error { | |
| 237 | 237 | token = strings.TrimSpace(token) | |
| 238 | 238 | if token == "" { | |
| 239 | 239 | return fmt.Errorf("token cannot be empty") | |
| @@ -246,11 +246,11 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||
| 246 | 246 | ||
| 247 | 247 | insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName) | |
| 248 | 248 | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...) | |
| 249 | - | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 249 | + | verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second) | |
| 250 | 250 | defer cancel() | |
| 251 | 251 | verified := true | |
| 252 | - | if _, err := client.GetStatus(ctx); err != nil { | |
| 253 | - | fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |
| 252 | + | if _, err := client.GetStatus(verifyCtx); err != nil { | |
| 253 | + | fmt.Fprintln(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |
| 254 | 254 | verified = false | |
| 255 | 255 | } | |
| 256 | 256 | ||
| @@ -258,10 +258,10 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||
| 258 | 258 | return fmt.Errorf("failed to save token: %w", err) | |
| 259 | 259 | } | |
| 260 | 260 | ||
| 261 | - | fmt.Println() | |
| 262 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) | |
| 261 | + | fmt.Fprintln(out) | |
| 262 | + | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) | |
| 263 | 263 | if verified { | |
| 264 | - | fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint")) | |
| 264 | + | fmt.Fprintln(out, styles.RenderSuccess("Token verified with remote status endpoint")) | |
| 265 | 265 | } | |
| 266 | 266 | return nil | |
| 267 | 267 | } | |
| @@ -285,7 +285,7 @@ Examples: | |||
| 285 | 285 | gordon auth show-token --remote prod Show token for specific remote | |
| 286 | 286 | gordon auth show-token | pbcopy Copy token to clipboard`, | |
| 287 | 287 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 288 | - | return runAuthShowToken(remoteName) | |
| 288 | + | return runAuthShowToken(remoteName, cmd.OutOrStdout()) | |
| 289 | 289 | }, | |
| 290 | 290 | } | |
| 291 | 291 | ||
| @@ -294,7 +294,7 @@ Examples: | |||
| 294 | 294 | return cmd | |
| 295 | 295 | } | |
| 296 | 296 | ||
| 297 | - | func runAuthShowToken(remoteName string) error { | |
| 297 | + | func runAuthShowToken(remoteName string, out io.Writer) error { | |
| 298 | 298 | resolvedName, entry, err := resolveRemoteEntry(remoteName) | |
| 299 | 299 | if err != nil { | |
| 300 | 300 | return err | |
| @@ -305,7 +305,7 @@ func runAuthShowToken(remoteName string) error { | |||
| 305 | 305 | return fmt.Errorf("no token found for remote '%s'", resolvedName) | |
| 306 | 306 | } | |
| 307 | 307 | ||
| 308 | - | fmt.Println(token) | |
| 308 | + | fmt.Fprintln(out, token) | |
| 309 | 309 | return nil | |
| 310 | 310 | } | |
| 311 | 311 | ||
| @@ -330,7 +330,7 @@ Examples: | |||
| 330 | 330 | gordon auth logout --remote prod Logout from specific remote | |
| 331 | 331 | gordon auth logout --revoke Logout and revoke token on server`, | |
| 332 | 332 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 333 | - | return runAuthLogout(remoteName, revoke) | |
| 333 | + | return runAuthLogout(remoteName, revoke, cmd.OutOrStdout()) | |
| 334 | 334 | }, | |
| 335 | 335 | } | |
| 336 | 336 | ||
| @@ -340,21 +340,21 @@ Examples: | |||
| 340 | 340 | return cmd | |
| 341 | 341 | } | |
| 342 | 342 | ||
| 343 | - | func runAuthLogout(remoteName string, revoke bool) error { | |
| 343 | + | func runAuthLogout(remoteName string, revoke bool, out io.Writer) error { | |
| 344 | 344 | resolvedName, _, err := resolveRemoteEntry(remoteName) | |
| 345 | 345 | if err != nil { | |
| 346 | 346 | return err | |
| 347 | 347 | } | |
| 348 | 348 | ||
| 349 | 349 | if revoke { | |
| 350 | - | fmt.Println(styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only")) | |
| 350 | + | fmt.Fprintln(out, styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only")) | |
| 351 | 351 | } | |
| 352 | 352 | ||
| 353 | 353 | if err := remote.ClearRemoteToken(resolvedName); err != nil { | |
| 354 | 354 | return fmt.Errorf("failed to clear token: %w", err) | |
| 355 | 355 | } | |
| 356 | 356 | ||
| 357 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName))) | |
| 357 | + | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName))) | |
| 358 | 358 | return nil | |
| 359 | 359 | } | |
| 360 | 360 | ||
| @@ -223,7 +223,7 @@ Examples: | |||||
| 223 | if token == "" { | 223 | if token == "" { | ||
| 224 | return fmt.Errorf("--token is required; generate a token on the server with: gordon auth token generate") | 224 | return fmt.Errorf("--token is required; generate a token on the server with: gordon auth token generate") | ||
| 225 | } | 225 | } | ||
| 226 | - | return runAuthLoginWithToken(remoteName, token) | 226 | + | return runAuthLoginWithToken(cmd.Context(), remoteName, token, cmd.OutOrStdout()) |
| 227 | }, | 227 | }, | ||
| 228 | } | 228 | } | ||
| 229 | 229 | ||||
| @@ -233,7 +233,7 @@ Examples: | |||||
| 233 | return cmd | 233 | return cmd | ||
| 234 | } | 234 | } | ||
| 235 | 235 | ||||
| 236 | - | func runAuthLoginWithToken(remoteName, token string) error { | 236 | + | func runAuthLoginWithToken(ctx context.Context, remoteName, token string, out io.Writer) error { |
| 237 | token = strings.TrimSpace(token) | 237 | token = strings.TrimSpace(token) | ||
| 238 | if token == "" { | 238 | if token == "" { | ||
| 239 | return fmt.Errorf("token cannot be empty") | 239 | return fmt.Errorf("token cannot be empty") | ||
| @@ -246,11 +246,11 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||||
| 246 | 246 | ||||
| 247 | insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName) | 247 | insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName) | ||
| 248 | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...) | 248 | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...) | ||
| 249 | - | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | 249 | + | verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| 250 | defer cancel() | 250 | defer cancel() | ||
| 251 | verified := true | 251 | verified := true | ||
| 252 | - | if _, err := client.GetStatus(ctx); err != nil { | 252 | + | if _, err := client.GetStatus(verifyCtx); err != nil { |
| 253 | - | fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | 253 | + | fmt.Fprintln(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) |
| 254 | verified = false | 254 | verified = false | ||
| 255 | } | 255 | } | ||
| 256 | 256 | ||||
| @@ -258,10 +258,10 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||||
| 258 | return fmt.Errorf("failed to save token: %w", err) | 258 | return fmt.Errorf("failed to save token: %w", err) | ||
| 259 | } | 259 | } | ||
| 260 | 260 | ||||
| 261 | - | fmt.Println() | 261 | + | fmt.Fprintln(out) |
| 262 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) | 262 | + | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) |
| 263 | if verified { | 263 | if verified { | ||
| 264 | - | fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint")) | 264 | + | fmt.Fprintln(out, styles.RenderSuccess("Token verified with remote status endpoint")) |
| 265 | } | 265 | } | ||
| 266 | return nil | 266 | return nil | ||
| 267 | } | 267 | } | ||
| @@ -285,7 +285,7 @@ Examples: | |||||
| 285 | gordon auth show-token --remote prod Show token for specific remote | 285 | gordon auth show-token --remote prod Show token for specific remote | ||
| 286 | gordon auth show-token | pbcopy Copy token to clipboard`, | 286 | gordon auth show-token | pbcopy Copy token to clipboard`, | ||
| 287 | RunE: func(cmd *cobra.Command, args []string) error { | 287 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 288 | - | return runAuthShowToken(remoteName) | 288 | + | return runAuthShowToken(remoteName, cmd.OutOrStdout()) |
| 289 | }, | 289 | }, | ||
| 290 | } | 290 | } | ||
| 291 | 291 | ||||
| @@ -294,7 +294,7 @@ Examples: | |||||
| 294 | return cmd | 294 | return cmd | ||
| 295 | } | 295 | } | ||
| 296 | 296 | ||||
| 297 | - | func runAuthShowToken(remoteName string) error { | 297 | + | func runAuthShowToken(remoteName string, out io.Writer) error { |
| 298 | resolvedName, entry, err := resolveRemoteEntry(remoteName) | 298 | resolvedName, entry, err := resolveRemoteEntry(remoteName) | ||
| 299 | if err != nil { | 299 | if err != nil { | ||
| 300 | return err | 300 | return err | ||
| @@ -305,7 +305,7 @@ func runAuthShowToken(remoteName string) error { | |||||
| 305 | return fmt.Errorf("no token found for remote '%s'", resolvedName) | 305 | return fmt.Errorf("no token found for remote '%s'", resolvedName) | ||
| 306 | } | 306 | } | ||
| 307 | 307 | ||||
| 308 | - | fmt.Println(token) | 308 | + | fmt.Fprintln(out, token) |
| 309 | return nil | 309 | return nil | ||
| 310 | } | 310 | } | ||
| 311 | 311 | ||||
| @@ -330,7 +330,7 @@ Examples: | |||||
| 330 | gordon auth logout --remote prod Logout from specific remote | 330 | gordon auth logout --remote prod Logout from specific remote | ||
| 331 | gordon auth logout --revoke Logout and revoke token on server`, | 331 | gordon auth logout --revoke Logout and revoke token on server`, | ||
| 332 | RunE: func(cmd *cobra.Command, args []string) error { | 332 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 333 | - | return runAuthLogout(remoteName, revoke) | 333 | + | return runAuthLogout(remoteName, revoke, cmd.OutOrStdout()) |
| 334 | }, | 334 | }, | ||
| 335 | } | 335 | } | ||
| 336 | 336 | ||||
| @@ -340,21 +340,21 @@ Examples: | |||||
| 340 | return cmd | 340 | return cmd | ||
| 341 | } | 341 | } | ||
| 342 | 342 | ||||
| 343 | - | func runAuthLogout(remoteName string, revoke bool) error { | 343 | + | func runAuthLogout(remoteName string, revoke bool, out io.Writer) error { |
| 344 | resolvedName, _, err := resolveRemoteEntry(remoteName) | 344 | resolvedName, _, err := resolveRemoteEntry(remoteName) | ||
| 345 | if err != nil { | 345 | if err != nil { | ||
| 346 | return err | 346 | return err | ||
| 347 | } | 347 | } | ||
| 348 | 348 | ||||
| 349 | if revoke { | 349 | if revoke { | ||
| 350 | - | fmt.Println(styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only")) | 350 | + | fmt.Fprintln(out, styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only")) |
| 351 | } | 351 | } | ||
| 352 | 352 | ||||
| 353 | if err := remote.ClearRemoteToken(resolvedName); err != nil { | 353 | if err := remote.ClearRemoteToken(resolvedName); err != nil { | ||
| 354 | return fmt.Errorf("failed to clear token: %w", err) | 354 | return fmt.Errorf("failed to clear token: %w", err) | ||
| 355 | } | 355 | } | ||
| 356 | 356 | ||||
| 357 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName))) | 357 | + | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName))) |
| 358 | return nil | 358 | return nil | ||
| 359 | } | 359 | } | ||
| 360 | 360 | ||||
M internal/adapters/in/cli/auth_test.go +6 -2
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 4 | 6 | "net/http" | |
| 5 | 7 | "net/http/httptest" | |
| 6 | 8 | "path/filepath" | |
| @@ -36,7 +38,8 @@ func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) { | |||
| 36 | 38 | } | |
| 37 | 39 | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | |
| 38 | 40 | ||
| 39 | - | err := runAuthLoginWithToken("", "token123") | |
| 41 | + | var out bytes.Buffer | |
| 42 | + | err := runAuthLoginWithToken(context.Background(), "", "token123", &out) | |
| 40 | 43 | assert.NoError(t, err) | |
| 41 | 44 | ||
| 42 | 45 | loaded, err := remote.LoadRemotes("") | |
| @@ -67,7 +70,8 @@ func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) { | |||
| 67 | 70 | } | |
| 68 | 71 | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | |
| 69 | 72 | ||
| 70 | - | err := runAuthLoginWithToken("", "token123") | |
| 73 | + | var out bytes.Buffer | |
| 74 | + | err := runAuthLoginWithToken(context.Background(), "", "token123", &out) | |
| 71 | 75 | assert.NoError(t, err) | |
| 72 | 76 | ||
| 73 | 77 | loaded, err := remote.LoadRemotes("") | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 4 | "net/http" | 6 | "net/http" | ||
| 5 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 6 | "path/filepath" | 8 | "path/filepath" | ||
| @@ -36,7 +38,8 @@ func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) { | |||||
| 36 | } | 38 | } | ||
| 37 | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | 39 | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | ||
| 38 | 40 | ||||
| 39 | - | err := runAuthLoginWithToken("", "token123") | 41 | + | var out bytes.Buffer |
| 42 | + | err := runAuthLoginWithToken(context.Background(), "", "token123", &out) | |||
| 40 | assert.NoError(t, err) | 43 | assert.NoError(t, err) | ||
| 41 | 44 | ||||
| 42 | loaded, err := remote.LoadRemotes("") | 45 | loaded, err := remote.LoadRemotes("") | ||
| @@ -67,7 +70,8 @@ func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) { | |||||
| 67 | } | 70 | } | ||
| 68 | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | 71 | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | ||
| 69 | 72 | ||||
| 70 | - | err := runAuthLoginWithToken("", "token123") | 73 | + | var out bytes.Buffer |
| 74 | + | err := runAuthLoginWithToken(context.Background(), "", "token123", &out) | |||
| 71 | assert.NoError(t, err) | 75 | assert.NoError(t, err) | ||
| 72 | 76 | ||||
| 73 | loaded, err := remote.LoadRemotes("") | 77 | loaded, err := remote.LoadRemotes("") | ||
M internal/adapters/in/cli/remote/client.go +22 -10
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "net/url" | |
| 14 | 14 | "os" | |
| 15 | 15 | "strings" | |
| 16 | + | "sync" | |
| 16 | 17 | "time" | |
| 17 | 18 | ||
| 18 | 19 | "github.com/golang-jwt/jwt/v5" | |
| @@ -29,9 +30,10 @@ type Client struct { | |||
| 29 | 30 | insecureTLS bool | |
| 30 | 31 | ||
| 31 | 32 | // Ephemeral admin token exchange fields. | |
| 32 | - | subject string // JWT subject extracted from long-lived token | |
| 33 | - | ephemeral string // cached ephemeral admin token | |
| 34 | - | ephemeralExp time.Time // expiry of cached ephemeral token | |
| 33 | + | mu sync.Mutex // protects ephemeral and ephemeralExp | |
| 34 | + | subject string // JWT subject extracted from long-lived token | |
| 35 | + | ephemeral string // cached ephemeral admin token | |
| 36 | + | ephemeralExp time.Time // expiry of cached ephemeral token | |
| 35 | 37 | } | |
| 36 | 38 | ||
| 37 | 39 | var ( | |
| @@ -125,10 +127,11 @@ func (c *Client) ephemeralValid() bool { | |||
| 125 | 127 | ||
| 126 | 128 | // exchangeToken exchanges the long-lived token for a short-lived ephemeral | |
| 127 | 129 | // admin token via /auth/token. Follows the same pattern as ExchangeRegistryToken. | |
| 128 | - | func (c *Client) exchangeToken() error { | |
| 130 | + | // The caller must hold c.mu. | |
| 131 | + | func (c *Client) exchangeToken(ctx context.Context) error { | |
| 129 | 132 | url := c.baseURL + "/auth/token?scope=admin:*:*&service=gordon-registry" | |
| 130 | 133 | ||
| 131 | - | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) | |
| 134 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| 132 | 135 | if err != nil { | |
| 133 | 136 | return fmt.Errorf("ephemeral token exchange: %w", err) | |
| 134 | 137 | } | |
| @@ -166,6 +169,10 @@ func (c *Client) exchangeToken() error { | |||
| 166 | 169 | return fmt.Errorf("ephemeral token exchange returned empty token") | |
| 167 | 170 | } | |
| 168 | 171 | ||
| 172 | + | if result.ExpiresIn <= 0 { | |
| 173 | + | return fmt.Errorf("ephemeral token exchange: invalid expires_in value: %d", result.ExpiresIn) | |
| 174 | + | } | |
| 175 | + | ||
| 169 | 176 | c.ephemeral = token | |
| 170 | 177 | c.ephemeralExp = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) | |
| 171 | 178 | return nil | |
| @@ -174,7 +181,7 @@ func (c *Client) exchangeToken() error { | |||
| 174 | 181 | // bearerToken returns the token to use in Authorization headers. | |
| 175 | 182 | // If the client has a subject (valid JWT), it exchanges for an ephemeral token. | |
| 176 | 183 | // Otherwise it falls back to sending the long-lived token directly. | |
| 177 | - | func (c *Client) bearerToken() (string, error) { | |
| 184 | + | func (c *Client) bearerToken(ctx context.Context) (string, error) { | |
| 178 | 185 | if c.token == "" { | |
| 179 | 186 | return "", nil | |
| 180 | 187 | } | |
| @@ -182,8 +189,10 @@ func (c *Client) bearerToken() (string, error) { | |||
| 182 | 189 | if c.subject == "" { | |
| 183 | 190 | return c.token, nil | |
| 184 | 191 | } | |
| 192 | + | c.mu.Lock() | |
| 193 | + | defer c.mu.Unlock() | |
| 185 | 194 | if !c.ephemeralValid() { | |
| 186 | - | if err := c.exchangeToken(); err != nil { | |
| 195 | + | if err := c.exchangeToken(ctx); err != nil { | |
| 187 | 196 | return "", err | |
| 188 | 197 | } | |
| 189 | 198 | } | |
| @@ -244,11 +253,14 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h | |||
| 244 | 253 | _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBodySize)) | |
| 245 | 254 | resp.Body.Close() | |
| 246 | 255 | ||
| 256 | + | c.mu.Lock() | |
| 247 | 257 | c.ephemeral = "" | |
| 248 | 258 | c.ephemeralExp = time.Time{} | |
| 249 | - | if exErr := c.exchangeToken(); exErr != nil { | |
| 259 | + | if exErr := c.exchangeToken(ctx); exErr != nil { | |
| 260 | + | c.mu.Unlock() | |
| 250 | 261 | return nil, fmt.Errorf("token re-exchange after 401: %w", exErr) | |
| 251 | 262 | } | |
| 263 | + | c.mu.Unlock() | |
| 252 | 264 | return c.doRequest(ctx, method, path, jsonBody) | |
| 253 | 265 | } | |
| 254 | 266 | ||
| @@ -272,7 +284,7 @@ func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody [] | |||
| 272 | 284 | req.Header.Set("Content-Type", "application/json") | |
| 273 | 285 | req.Header.Set("Accept", "application/json") | |
| 274 | 286 | ||
| 275 | - | bearer, err := c.bearerToken() | |
| 287 | + | bearer, err := c.bearerToken(ctx) | |
| 276 | 288 | if err != nil { | |
| 277 | 289 | return nil, err | |
| 278 | 290 | } | |
| @@ -1112,7 +1124,7 @@ func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response | |||
| 1112 | 1124 | } | |
| 1113 | 1125 | ||
| 1114 | 1126 | req.Header.Set("Accept", "text/event-stream") | |
| 1115 | - | bearer, err := c.bearerToken() | |
| 1127 | + | bearer, err := c.bearerToken(ctx) | |
| 1116 | 1128 | if err != nil { | |
| 1117 | 1129 | return nil, err | |
| 1118 | 1130 | } | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "net/url" | 13 | "net/url" | ||
| 14 | "os" | 14 | "os" | ||
| 15 | "strings" | 15 | "strings" | ||
| 16 | + | "sync" | |||
| 16 | "time" | 17 | "time" | ||
| 17 | 18 | ||||
| 18 | "github.com/golang-jwt/jwt/v5" | 19 | "github.com/golang-jwt/jwt/v5" | ||
| @@ -29,9 +30,10 @@ type Client struct { | |||||
| 29 | insecureTLS bool | 30 | insecureTLS bool | ||
| 30 | 31 | ||||
| 31 | // Ephemeral admin token exchange fields. | 32 | // Ephemeral admin token exchange fields. | ||
| 32 | - | subject string // JWT subject extracted from long-lived token | 33 | + | mu sync.Mutex // protects ephemeral and ephemeralExp |
| 33 | - | ephemeral string // cached ephemeral admin token | 34 | + | subject string // JWT subject extracted from long-lived token |
| 34 | - | ephemeralExp time.Time // expiry of cached ephemeral token | 35 | + | ephemeral string // cached ephemeral admin token |
| 36 | + | ephemeralExp time.Time // expiry of cached ephemeral token | |||
| 35 | } | 37 | } | ||
| 36 | 38 | ||||
| 37 | var ( | 39 | var ( | ||
| @@ -125,10 +127,11 @@ func (c *Client) ephemeralValid() bool { | |||||
| 125 | 127 | ||||
| 126 | // exchangeToken exchanges the long-lived token for a short-lived ephemeral | 128 | // exchangeToken exchanges the long-lived token for a short-lived ephemeral | ||
| 127 | // admin token via /auth/token. Follows the same pattern as ExchangeRegistryToken. | 129 | // admin token via /auth/token. Follows the same pattern as ExchangeRegistryToken. | ||
| 128 | - | func (c *Client) exchangeToken() error { | 130 | + | // The caller must hold c.mu. |
| 131 | + | func (c *Client) exchangeToken(ctx context.Context) error { | |||
| 129 | url := c.baseURL + "/auth/token?scope=admin:*:*&service=gordon-registry" | 132 | url := c.baseURL + "/auth/token?scope=admin:*:*&service=gordon-registry" | ||
| 130 | 133 | ||||
| 131 | - | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) | 134 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) |
| 132 | if err != nil { | 135 | if err != nil { | ||
| 133 | return fmt.Errorf("ephemeral token exchange: %w", err) | 136 | return fmt.Errorf("ephemeral token exchange: %w", err) | ||
| 134 | } | 137 | } | ||
| @@ -166,6 +169,10 @@ func (c *Client) exchangeToken() error { | |||||
| 166 | return fmt.Errorf("ephemeral token exchange returned empty token") | 169 | return fmt.Errorf("ephemeral token exchange returned empty token") | ||
| 167 | } | 170 | } | ||
| 168 | 171 | ||||
| 172 | + | if result.ExpiresIn <= 0 { | |||
| 173 | + | return fmt.Errorf("ephemeral token exchange: invalid expires_in value: %d", result.ExpiresIn) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 169 | c.ephemeral = token | 176 | c.ephemeral = token | ||
| 170 | c.ephemeralExp = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) | 177 | c.ephemeralExp = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) | ||
| 171 | return nil | 178 | return nil | ||
| @@ -174,7 +181,7 @@ func (c *Client) exchangeToken() error { | |||||
| 174 | // bearerToken returns the token to use in Authorization headers. | 181 | // bearerToken returns the token to use in Authorization headers. | ||
| 175 | // If the client has a subject (valid JWT), it exchanges for an ephemeral token. | 182 | // If the client has a subject (valid JWT), it exchanges for an ephemeral token. | ||
| 176 | // Otherwise it falls back to sending the long-lived token directly. | 183 | // Otherwise it falls back to sending the long-lived token directly. | ||
| 177 | - | func (c *Client) bearerToken() (string, error) { | 184 | + | func (c *Client) bearerToken(ctx context.Context) (string, error) { |
| 178 | if c.token == "" { | 185 | if c.token == "" { | ||
| 179 | return "", nil | 186 | return "", nil | ||
| 180 | } | 187 | } | ||
| @@ -182,8 +189,10 @@ func (c *Client) bearerToken() (string, error) { | |||||
| 182 | if c.subject == "" { | 189 | if c.subject == "" { | ||
| 183 | return c.token, nil | 190 | return c.token, nil | ||
| 184 | } | 191 | } | ||
| 192 | + | c.mu.Lock() | |||
| 193 | + | defer c.mu.Unlock() | |||
| 185 | if !c.ephemeralValid() { | 194 | if !c.ephemeralValid() { | ||
| 186 | - | if err := c.exchangeToken(); err != nil { | 195 | + | if err := c.exchangeToken(ctx); err != nil { |
| 187 | return "", err | 196 | return "", err | ||
| 188 | } | 197 | } | ||
| 189 | } | 198 | } | ||
| @@ -244,11 +253,14 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h | |||||
| 244 | _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBodySize)) | 253 | _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBodySize)) | ||
| 245 | resp.Body.Close() | 254 | resp.Body.Close() | ||
| 246 | 255 | ||||
| 256 | + | c.mu.Lock() | |||
| 247 | c.ephemeral = "" | 257 | c.ephemeral = "" | ||
| 248 | c.ephemeralExp = time.Time{} | 258 | c.ephemeralExp = time.Time{} | ||
| 249 | - | if exErr := c.exchangeToken(); exErr != nil { | 259 | + | if exErr := c.exchangeToken(ctx); exErr != nil { |
| 260 | + | c.mu.Unlock() | |||
| 250 | return nil, fmt.Errorf("token re-exchange after 401: %w", exErr) | 261 | return nil, fmt.Errorf("token re-exchange after 401: %w", exErr) | ||
| 251 | } | 262 | } | ||
| 263 | + | c.mu.Unlock() | |||
| 252 | return c.doRequest(ctx, method, path, jsonBody) | 264 | return c.doRequest(ctx, method, path, jsonBody) | ||
| 253 | } | 265 | } | ||
| 254 | 266 | ||||
| @@ -272,7 +284,7 @@ func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody [] | |||||
| 272 | req.Header.Set("Content-Type", "application/json") | 284 | req.Header.Set("Content-Type", "application/json") | ||
| 273 | req.Header.Set("Accept", "application/json") | 285 | req.Header.Set("Accept", "application/json") | ||
| 274 | 286 | ||||
| 275 | - | bearer, err := c.bearerToken() | 287 | + | bearer, err := c.bearerToken(ctx) |
| 276 | if err != nil { | 288 | if err != nil { | ||
| 277 | return nil, err | 289 | return nil, err | ||
| 278 | } | 290 | } | ||
| @@ -1112,7 +1124,7 @@ func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response | |||||
| 1112 | } | 1124 | } | ||
| 1113 | 1125 | ||||
| 1114 | req.Header.Set("Accept", "text/event-stream") | 1126 | req.Header.Set("Accept", "text/event-stream") | ||
| 1115 | - | bearer, err := c.bearerToken() | 1127 | + | bearer, err := c.bearerToken(ctx) |
| 1116 | if err != nil { | 1128 | if err != nil { | ||
| 1117 | return nil, err | 1129 | return nil, err | ||
| 1118 | } | 1130 | } | ||
M internal/adapters/in/cli/remote/config.go +4 -2
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | 7 | "strconv" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/pelletier/go-toml/v2" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 12 | 14 | // ClientConfig represents the client-mode configuration. | |
| @@ -345,7 +347,7 @@ func SetActiveRemote(name string) error { | |||
| 345 | 347 | ||
| 346 | 348 | // Verify remote exists | |
| 347 | 349 | if _, ok := config.Remotes[name]; !ok { | |
| 348 | - | return fmt.Errorf("remote '%s' not found", name) | |
| 350 | + | return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name) | |
| 349 | 351 | } | |
| 350 | 352 | ||
| 351 | 353 | config.Active = name | |
| @@ -367,7 +369,7 @@ func ClearRemoteToken(name string) error { | |||
| 367 | 369 | ||
| 368 | 370 | entry, ok := config.Remotes[name] | |
| 369 | 371 | if !ok { | |
| 370 | - | return fmt.Errorf("remote '%s' not found", name) | |
| 372 | + | return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name) | |
| 371 | 373 | } | |
| 372 | 374 | ||
| 373 | 375 | entry.Token = "" | |
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | "strconv" | 7 | "strconv" | ||
| 8 | 8 | ||||
| 9 | "github.com/pelletier/go-toml/v2" | 9 | "github.com/pelletier/go-toml/v2" | ||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| 12 | // ClientConfig represents the client-mode configuration. | 14 | // ClientConfig represents the client-mode configuration. | ||
| @@ -345,7 +347,7 @@ func SetActiveRemote(name string) error { | |||||
| 345 | 347 | ||||
| 346 | // Verify remote exists | 348 | // Verify remote exists | ||
| 347 | if _, ok := config.Remotes[name]; !ok { | 349 | if _, ok := config.Remotes[name]; !ok { | ||
| 348 | - | return fmt.Errorf("remote '%s' not found", name) | 350 | + | return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name) |
| 349 | } | 351 | } | ||
| 350 | 352 | ||||
| 351 | config.Active = name | 353 | config.Active = name | ||
| @@ -367,7 +369,7 @@ func ClearRemoteToken(name string) error { | |||||
| 367 | 369 | ||||
| 368 | entry, ok := config.Remotes[name] | 370 | entry, ok := config.Remotes[name] | ||
| 369 | if !ok { | 371 | if !ok { | ||
| 370 | - | return fmt.Errorf("remote '%s' not found", name) | 372 | + | return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name) |
| 371 | } | 373 | } | ||
| 372 | 374 | ||||
| 373 | entry.Token = "" | 375 | entry.Token = "" | ||
M internal/adapters/in/cli/remote/pass.go +6 -1
| @@ -104,7 +104,12 @@ func passDeleteToken(name string) error { | |||
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 106 | cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated | |
| 107 | - | return cmd.Run() | |
| 107 | + | var stderr bytes.Buffer | |
| 108 | + | cmd.Stderr = &stderr | |
| 109 | + | if err := cmd.Run(); err != nil { | |
| 110 | + | return fmt.Errorf("pass rm failed: %w: %s", err, stderr.String()) | |
| 111 | + | } | |
| 112 | + | return nil | |
| 108 | 113 | } | |
| 109 | 114 | ||
| 110 | 115 | func warnPassUnavailable() { | |
| @@ -104,7 +104,12 @@ func passDeleteToken(name string) error { | |||||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated | 106 | cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated | ||
| 107 | - | return cmd.Run() | 107 | + | var stderr bytes.Buffer |
| 108 | + | cmd.Stderr = &stderr | |||
| 109 | + | if err := cmd.Run(); err != nil { | |||
| 110 | + | return fmt.Errorf("pass rm failed: %w: %s", err, stderr.String()) | |||
| 111 | + | } | |||
| 112 | + | return nil | |||
| 108 | } | 113 | } | ||
| 109 | 114 | ||||
| 110 | func warnPassUnavailable() { | 115 | func warnPassUnavailable() { | ||
M internal/adapters/in/http/middleware/auth.go +5 -9
| @@ -16,10 +16,6 @@ import ( | |||
| 16 | 16 | "github.com/bnema/gordon/internal/domain" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | - | // errLongLivedToken is returned when a long-lived (stored) token is used on an | |
| 20 | - | // endpoint that requires an ephemeral access token from /auth/token. | |
| 21 | - | var errLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token") | |
| 22 | - | ||
| 23 | 19 | // TokenClaimsKey is context key for storing token claims. | |
| 24 | 20 | // Using domain key for consistency across all auth flows. | |
| 25 | 21 | const TokenClaimsKey = domain.TokenClaimsKey | |
| @@ -99,8 +95,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 99 | 95 | tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log) | |
| 100 | 96 | ||
| 101 | 97 | if authErr != nil { | |
| 102 | - | if errors.Is(authErr, errLongLivedToken) { | |
| 103 | - | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, errLongLivedToken.Error()) | |
| 98 | + | if errors.Is(authErr, domain.ErrLongLivedToken) { | |
| 99 | + | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, domain.ErrLongLivedToken.Error()) | |
| 104 | 100 | } else { | |
| 105 | 101 | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | |
| 106 | 102 | } | |
| @@ -127,7 +123,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 127 | 123 | // authenticateToken handles token-based authentication. | |
| 128 | 124 | // It supports both Bearer token in Authorization header and token-as-password for CI. | |
| 129 | 125 | // Returns (tokenClaims, error). A nil error means authentication succeeded. | |
| 130 | - | // Returns errLongLivedToken when a stored (non-ephemeral) token is used. | |
| 126 | + | // Returns domain.ErrLongLivedToken when a stored (non-ephemeral) token is used. | |
| 131 | 127 | func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) { | |
| 132 | 128 | // First, check for Bearer token | |
| 133 | 129 | authHeader := r.Header.Get("Authorization") | |
| @@ -149,7 +145,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 149 | 145 | Str(zerowrap.FieldMethod, r.Method). | |
| 150 | 146 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 151 | 147 | Msg("long-lived token rejected on registry endpoint") | |
| 152 | - | return nil, errLongLivedToken | |
| 148 | + | return nil, domain.ErrLongLivedToken | |
| 153 | 149 | } | |
| 154 | 150 | ||
| 155 | 151 | log.Debug(). | |
| @@ -200,7 +196,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 200 | 196 | Str(zerowrap.FieldMethod, r.Method). | |
| 201 | 197 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 202 | 198 | Msg("long-lived token rejected on registry endpoint") | |
| 203 | - | return nil, errLongLivedToken | |
| 199 | + | return nil, domain.ErrLongLivedToken | |
| 204 | 200 | } | |
| 205 | 201 | ||
| 206 | 202 | log.Debug(). | |
| @@ -16,10 +16,6 @@ import ( | |||||
| 16 | "github.com/bnema/gordon/internal/domain" | 16 | "github.com/bnema/gordon/internal/domain" | ||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | - | // errLongLivedToken is returned when a long-lived (stored) token is used on an | |||
| 20 | - | // endpoint that requires an ephemeral access token from /auth/token. | |||
| 21 | - | var errLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token") | |||
| 22 | - | ||||
| 23 | // TokenClaimsKey is context key for storing token claims. | 19 | // TokenClaimsKey is context key for storing token claims. | ||
| 24 | // Using domain key for consistency across all auth flows. | 20 | // Using domain key for consistency across all auth flows. | ||
| 25 | const TokenClaimsKey = domain.TokenClaimsKey | 21 | const TokenClaimsKey = domain.TokenClaimsKey | ||
| @@ -99,8 +95,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 99 | tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log) | 95 | tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log) | ||
| 100 | 96 | ||||
| 101 | if authErr != nil { | 97 | if authErr != nil { | ||
| 102 | - | if errors.Is(authErr, errLongLivedToken) { | 98 | + | if errors.Is(authErr, domain.ErrLongLivedToken) { |
| 103 | - | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, errLongLivedToken.Error()) | 99 | + | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, domain.ErrLongLivedToken.Error()) |
| 104 | } else { | 100 | } else { | ||
| 105 | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | 101 | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | ||
| 106 | } | 102 | } | ||
| @@ -127,7 +123,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 127 | // authenticateToken handles token-based authentication. | 123 | // authenticateToken handles token-based authentication. | ||
| 128 | // It supports both Bearer token in Authorization header and token-as-password for CI. | 124 | // It supports both Bearer token in Authorization header and token-as-password for CI. | ||
| 129 | // Returns (tokenClaims, error). A nil error means authentication succeeded. | 125 | // Returns (tokenClaims, error). A nil error means authentication succeeded. | ||
| 130 | - | // Returns errLongLivedToken when a stored (non-ephemeral) token is used. | 126 | + | // Returns domain.ErrLongLivedToken when a stored (non-ephemeral) token is used. |
| 131 | func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) { | 127 | func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) { | ||
| 132 | // First, check for Bearer token | 128 | // First, check for Bearer token | ||
| 133 | authHeader := r.Header.Get("Authorization") | 129 | authHeader := r.Header.Get("Authorization") | ||
| @@ -149,7 +145,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 149 | Str(zerowrap.FieldMethod, r.Method). | 145 | Str(zerowrap.FieldMethod, r.Method). | ||
| 150 | Str(zerowrap.FieldPath, r.URL.Path). | 146 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 151 | Msg("long-lived token rejected on registry endpoint") | 147 | Msg("long-lived token rejected on registry endpoint") | ||
| 152 | - | return nil, errLongLivedToken | 148 | + | return nil, domain.ErrLongLivedToken |
| 153 | } | 149 | } | ||
| 154 | 150 | ||||
| 155 | log.Debug(). | 151 | log.Debug(). | ||
| @@ -200,7 +196,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 200 | Str(zerowrap.FieldMethod, r.Method). | 196 | Str(zerowrap.FieldMethod, r.Method). | ||
| 201 | Str(zerowrap.FieldPath, r.URL.Path). | 197 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 202 | Msg("long-lived token rejected on registry endpoint") | 198 | Msg("long-lived token rejected on registry endpoint") | ||
| 203 | - | return nil, errLongLivedToken | 199 | + | return nil, domain.ErrLongLivedToken |
| 204 | } | 200 | } | ||
| 205 | 201 | ||||
| 206 | log.Debug(). | 202 | log.Debug(). | ||
M internal/domain/errors.go +2 -0
| @@ -69,12 +69,14 @@ var ( | |||
| 69 | 69 | ErrInvalidScope = errors.New("invalid scope format") | |
| 70 | 70 | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | |
| 71 | 71 | ErrInvalidEnvKey = errors.New("invalid environment variable key") | |
| 72 | + | ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token") | |
| 72 | 73 | ||
| 73 | 74 | // Remote errors | |
| 74 | 75 | ErrEmptyRemoteName = errors.New("remote name must not be empty") | |
| 75 | 76 | ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters") | |
| 76 | 77 | ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots") | |
| 77 | 78 | ErrPassUnavailable = errors.New("pass is not available") | |
| 79 | + | ErrRemoteNotFound = errors.New("remote not found") | |
| 78 | 80 | ||
| 79 | 81 | // Attachment errors | |
| 80 | 82 | ErrAttachmentNotFound = errors.New("attachment not found") | |
| @@ -69,12 +69,14 @@ var ( | |||||
| 69 | ErrInvalidScope = errors.New("invalid scope format") | 69 | ErrInvalidScope = errors.New("invalid scope format") | ||
| 70 | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | 70 | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | ||
| 71 | ErrInvalidEnvKey = errors.New("invalid environment variable key") | 71 | ErrInvalidEnvKey = errors.New("invalid environment variable key") | ||
| 72 | + | ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token") | |||
| 72 | 73 | ||||
| 73 | // Remote errors | 74 | // Remote errors | ||
| 74 | ErrEmptyRemoteName = errors.New("remote name must not be empty") | 75 | ErrEmptyRemoteName = errors.New("remote name must not be empty") | ||
| 75 | ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters") | 76 | ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters") | ||
| 76 | ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots") | 77 | ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots") | ||
| 77 | ErrPassUnavailable = errors.New("pass is not available") | 78 | ErrPassUnavailable = errors.New("pass is not available") | ||
| 79 | + | ErrRemoteNotFound = errors.New("remote not found") | |||
| 78 | 80 | ||||
| 79 | // Attachment errors | 81 | // Attachment errors | ||
| 80 | ErrAttachmentNotFound = errors.New("attachment not found") | 82 | ErrAttachmentNotFound = errors.New("attachment not found") | ||