fix: registry push token retry, empty subject guard, and docstring accuracy
- Add single-retry logic on HTTP 401 during push to handle token expiry mid-upload for large images (re-exchanges credentials and retries once) - Guard against empty Subject after VerifyAuth returns Valid=true, surfacing a clear error instead of sending empty BasicAuth username - Fix ExchangeRegistryToken docstring to clarify it returns the raw token string without a "Bearer " prefix
- commit
- 3148fa0f3a645ffc2867b9b69912c05f26dd4bc1
- author
- brice · · 2026-03-19 04:12 UTC
- parent
- a9c1886
- signature
- unsigned
2 files · +32 -1
M internal/adapters/in/cli/push_image_ops.go +31 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "io" | |
| 7 | 7 | "os" | |
| 8 | 8 | "os/exec" | |
| 9 | + | "strings" | |
| 9 | 10 | ||
| 10 | 11 | cerrdefs "github.com/containerd/errdefs" | |
| 11 | 12 | "github.com/docker/docker/client" | |
| @@ -86,12 +87,39 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | |||
| 86 | 87 | return fmt.Errorf("failed to obtain registry credentials: %w", err) | |
| 87 | 88 | } | |
| 88 | 89 | opts = append(opts, registrypush.WithAuth(authHeader)) | |
| 90 | + | ||
| 91 | + | pusher := registrypush.New(opts...) | |
| 92 | + | err = pusher.Push(ctx, ref) | |
| 93 | + | if err != nil && isAuthError(err) { | |
| 94 | + | // Token may have expired mid-push; refresh once and retry. | |
| 95 | + | authHeader, refreshErr := d.exchangeRegistryAuth(ctx) | |
| 96 | + | if refreshErr != nil { | |
| 97 | + | return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err) | |
| 98 | + | } | |
| 99 | + | retryOpts := []registrypush.Option{ | |
| 100 | + | registrypush.WithProgress(d.progress), | |
| 101 | + | registrypush.WithInsecureTLS(d.insecureTLS), | |
| 102 | + | registrypush.WithAuth(authHeader), | |
| 103 | + | } | |
| 104 | + | pusher = registrypush.New(retryOpts...) | |
| 105 | + | return pusher.Push(ctx, ref) | |
| 106 | + | } | |
| 107 | + | return err | |
| 89 | 108 | } | |
| 90 | 109 | ||
| 91 | 110 | pusher := registrypush.New(opts...) | |
| 92 | 111 | return pusher.Push(ctx, ref) | |
| 93 | 112 | } | |
| 94 | 113 | ||
| 114 | + | // isAuthError returns true if the error looks like an HTTP 401 authentication failure. | |
| 115 | + | func isAuthError(err error) bool { | |
| 116 | + | if err == nil { | |
| 117 | + | return false | |
| 118 | + | } | |
| 119 | + | msg := err.Error() | |
| 120 | + | return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized") | |
| 121 | + | } | |
| 122 | + | ||
| 95 | 123 | // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived | |
| 96 | 124 | // registry access token. Returns the Authorization header value (e.g. "Bearer <token>"). | |
| 97 | 125 | func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) { | |
| @@ -103,6 +131,9 @@ func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, erro | |||
| 103 | 131 | if !verifyResp.Valid { | |
| 104 | 132 | return "", fmt.Errorf("token is not valid") | |
| 105 | 133 | } | |
| 134 | + | if verifyResp.Subject == "" { | |
| 135 | + | return "", fmt.Errorf("empty subject in verified token") | |
| 136 | + | } | |
| 106 | 137 | ||
| 107 | 138 | // Step 2: Exchange long-lived token for short-lived registry token | |
| 108 | 139 | shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "io" | 6 | "io" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "os/exec" | 8 | "os/exec" | ||
| 9 | + | "strings" | |||
| 9 | 10 | ||||
| 10 | cerrdefs "github.com/containerd/errdefs" | 11 | cerrdefs "github.com/containerd/errdefs" | ||
| 11 | "github.com/docker/docker/client" | 12 | "github.com/docker/docker/client" | ||
| @@ -86,12 +87,39 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | |||||
| 86 | return fmt.Errorf("failed to obtain registry credentials: %w", err) | 87 | return fmt.Errorf("failed to obtain registry credentials: %w", err) | ||
| 87 | } | 88 | } | ||
| 88 | opts = append(opts, registrypush.WithAuth(authHeader)) | 89 | opts = append(opts, registrypush.WithAuth(authHeader)) | ||
| 90 | + | ||||
| 91 | + | pusher := registrypush.New(opts...) | |||
| 92 | + | err = pusher.Push(ctx, ref) | |||
| 93 | + | if err != nil && isAuthError(err) { | |||
| 94 | + | // Token may have expired mid-push; refresh once and retry. | |||
| 95 | + | authHeader, refreshErr := d.exchangeRegistryAuth(ctx) | |||
| 96 | + | if refreshErr != nil { | |||
| 97 | + | return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err) | |||
| 98 | + | } | |||
| 99 | + | retryOpts := []registrypush.Option{ | |||
| 100 | + | registrypush.WithProgress(d.progress), | |||
| 101 | + | registrypush.WithInsecureTLS(d.insecureTLS), | |||
| 102 | + | registrypush.WithAuth(authHeader), | |||
| 103 | + | } | |||
| 104 | + | pusher = registrypush.New(retryOpts...) | |||
| 105 | + | return pusher.Push(ctx, ref) | |||
| 106 | + | } | |||
| 107 | + | return err | |||
| 89 | } | 108 | } | ||
| 90 | 109 | ||||
| 91 | pusher := registrypush.New(opts...) | 110 | pusher := registrypush.New(opts...) | ||
| 92 | return pusher.Push(ctx, ref) | 111 | return pusher.Push(ctx, ref) | ||
| 93 | } | 112 | } | ||
| 94 | 113 | ||||
| 114 | + | // isAuthError returns true if the error looks like an HTTP 401 authentication failure. | |||
| 115 | + | func isAuthError(err error) bool { | |||
| 116 | + | if err == nil { | |||
| 117 | + | return false | |||
| 118 | + | } | |||
| 119 | + | msg := err.Error() | |||
| 120 | + | return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 95 | // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived | 123 | // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived | ||
| 96 | // registry access token. Returns the Authorization header value (e.g. "Bearer <token>"). | 124 | // registry access token. Returns the Authorization header value (e.g. "Bearer <token>"). | ||
| 97 | func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) { | 125 | func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) { | ||
| @@ -103,6 +131,9 @@ func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, erro | |||||
| 103 | if !verifyResp.Valid { | 131 | if !verifyResp.Valid { | ||
| 104 | return "", fmt.Errorf("token is not valid") | 132 | return "", fmt.Errorf("token is not valid") | ||
| 105 | } | 133 | } | ||
| 134 | + | if verifyResp.Subject == "" { | |||
| 135 | + | return "", fmt.Errorf("empty subject in verified token") | |||
| 136 | + | } | |||
| 106 | 137 | ||||
| 107 | // Step 2: Exchange long-lived token for short-lived registry token | 138 | // Step 2: Exchange long-lived token for short-lived registry token | ||
| 108 | shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject) | 139 | shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject) | ||
M internal/adapters/in/cli/remote/auth.go +1 -1
| @@ -76,7 +76,7 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (* | |||
| 76 | 76 | // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a | |
| 77 | 77 | // short-lived registry access token via the Docker v2 token endpoint. | |
| 78 | 78 | // The subject must match the token's sub claim (use VerifyAuth to obtain it). | |
| 79 | - | // Returns the short-lived Bearer token string. | |
| 79 | + | // Returns the raw short-lived token string (without a "Bearer " prefix). | |
| 80 | 80 | func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) { | |
| 81 | 81 | url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry" | |
| 82 | 82 | ||
| @@ -76,7 +76,7 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (* | |||||
| 76 | // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a | 76 | // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a | ||
| 77 | // short-lived registry access token via the Docker v2 token endpoint. | 77 | // short-lived registry access token via the Docker v2 token endpoint. | ||
| 78 | // The subject must match the token's sub claim (use VerifyAuth to obtain it). | 78 | // The subject must match the token's sub claim (use VerifyAuth to obtain it). | ||
| 79 | - | // Returns the short-lived Bearer token string. | 79 | + | // Returns the raw short-lived token string (without a "Bearer " prefix). |
| 80 | func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) { | 80 | func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) { | ||
| 81 | url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry" | 81 | url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry" | ||
| 82 | 82 | ||||