detached 98678d9

feat(push): auto-exchange token for registry auth in remote mode

When $GORDON_TOKEN or --token is available during gordon push --remote,
the CLI now automatically exchanges the long-lived token for a short-lived
(5 min) registry access token via /auth/token. This eliminates the need
for a separate docker login step in CI/CD pipelines.

Changes:
- registrypush: add WithAuth option for explicit auth headers
- remote: add ExchangeRegistryToken for short-lived token exchange
- push_image_ops: wire VerifyAuth + ExchangeRegistryToken into Push flow
commit
98678d94766b9368999f2731aef694525591151f
author
bnema · · 2026-03-18 21:25 UTC
parent
a28bceb
signature
unsigned
6 files · +378 -10
M internal/adapters/in/cli/push_image_ops.go +51 -7
@@ -30,9 +30,10 @@ type pushImageOps interface {
3030
3131 // dockerImageOps implements pushImageOps using the Docker SDK and registrypush.
3232 type dockerImageOps struct {
33- cli client.APIClient
34- insecureTLS bool
35- progress io.Writer
33+ cli client.APIClient
34+ insecureTLS bool
35+ progress io.Writer
36+ remoteClient *remote.Client // nil when local
3637 }
3738
3839 // newDockerImageOps creates a dockerImageOps from the Docker environment.
@@ -50,8 +51,20 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e
5051
5152 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.
5253 func newImageOpsFromFlags() (pushImageOps, error) {
53- _, _, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
54- return newDockerImageOps(insecureTLS, os.Stderr)
54+ remoteURL, token, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
55+ ops, err := newDockerImageOps(insecureTLS, os.Stderr)
56+ if err != nil {
57+ return nil, err
58+ }
59+ if remoteURL != "" && token != "" {
60+ var clientOpts []remote.ClientOption
61+ clientOpts = append(clientOpts, remote.WithToken(token))
62+ if insecureTLS {
63+ clientOpts = append(clientOpts, remote.WithInsecureTLS(true))
64+ }
65+ ops.remoteClient = remote.NewClient(remoteURL, clientOpts...)
66+ }
67+ return ops, nil
5568 }
5669
5770 func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {
@@ -62,13 +75,44 @@ func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) e
6275 }
6376
6477 func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
65- pusher := registrypush.New(
78+ opts := []registrypush.Option{
6679 registrypush.WithProgress(d.progress),
6780 registrypush.WithInsecureTLS(d.insecureTLS),
68- )
81+ }
82+
83+ if d.remoteClient != nil {
84+ authHeader, err := d.exchangeRegistryAuth(ctx)
85+ if err != nil {
86+ return fmt.Errorf("failed to obtain registry credentials: %w", err)
87+ }
88+ opts = append(opts, registrypush.WithAuth(authHeader))
89+ }
90+
91+ pusher := registrypush.New(opts...)
6992 return pusher.Push(ctx, ref)
7093 }
7194
95+// exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived
96+// registry access token. Returns the Authorization header value (e.g. "Bearer <token>").
97+func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) {
98+ // Step 1: Get subject from server (validates token is still active)
99+ verifyResp, err := d.remoteClient.VerifyAuth(ctx)
100+ if err != nil {
101+ return "", fmt.Errorf("failed to verify token: %w", err)
102+ }
103+ if !verifyResp.Valid {
104+ return "", fmt.Errorf("token is not valid")
105+ }
106+
107+ // Step 2: Exchange long-lived token for short-lived registry token
108+ shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject)
109+ if err != nil {
110+ return "", fmt.Errorf("failed to exchange registry token: %w", err)
111+ }
112+
113+ return "Bearer " + shortToken, nil
114+}
115+
72116 func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) {
73117 _, err := d.cli.ImageInspect(ctx, imageRef)
74118 if err != nil {
@@ -30,9 +30,10 @@ type pushImageOps interface {
30 30
31 // dockerImageOps implements pushImageOps using the Docker SDK and registrypush.31 // dockerImageOps implements pushImageOps using the Docker SDK and registrypush.
32 type dockerImageOps struct {32 type dockerImageOps struct {
33- cli client.APIClient33+ cli client.APIClient
34- insecureTLS bool34+ insecureTLS bool
35- progress io.Writer35+ progress io.Writer
36+ remoteClient *remote.Client // nil when local
36 }37 }
37 38
38 // newDockerImageOps creates a dockerImageOps from the Docker environment.39 // newDockerImageOps creates a dockerImageOps from the Docker environment.
@@ -50,8 +51,20 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e
50 51
51 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.52 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.
52 func newImageOpsFromFlags() (pushImageOps, error) {53 func newImageOpsFromFlags() (pushImageOps, error) {
53- _, _, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)54+ remoteURL, token, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
54- return newDockerImageOps(insecureTLS, os.Stderr)55+ ops, err := newDockerImageOps(insecureTLS, os.Stderr)
56+ if err != nil {
57+ return nil, err
58+ }
59+ if remoteURL != "" && token != "" {
60+ var clientOpts []remote.ClientOption
61+ clientOpts = append(clientOpts, remote.WithToken(token))
62+ if insecureTLS {
63+ clientOpts = append(clientOpts, remote.WithInsecureTLS(true))
64+ }
65+ ops.remoteClient = remote.NewClient(remoteURL, clientOpts...)
66+ }
67+ return ops, nil
55 }68 }
56 69
57 func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {70 func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {
@@ -62,13 +75,44 @@ func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) e
62 }75 }
63 76
64 func (d *dockerImageOps) Push(ctx context.Context, ref string) error {77 func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
65- pusher := registrypush.New(78+ opts := []registrypush.Option{
66 registrypush.WithProgress(d.progress),79 registrypush.WithProgress(d.progress),
67 registrypush.WithInsecureTLS(d.insecureTLS),80 registrypush.WithInsecureTLS(d.insecureTLS),
68- )81+ }
82+
83+ if d.remoteClient != nil {
84+ authHeader, err := d.exchangeRegistryAuth(ctx)
85+ if err != nil {
86+ return fmt.Errorf("failed to obtain registry credentials: %w", err)
87+ }
88+ opts = append(opts, registrypush.WithAuth(authHeader))
89+ }
90+
91+ pusher := registrypush.New(opts...)
69 return pusher.Push(ctx, ref)92 return pusher.Push(ctx, ref)
70 }93 }
71 94
95+// exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived
96+// registry access token. Returns the Authorization header value (e.g. "Bearer <token>").
97+func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) {
98+ // Step 1: Get subject from server (validates token is still active)
99+ verifyResp, err := d.remoteClient.VerifyAuth(ctx)
100+ if err != nil {
101+ return "", fmt.Errorf("failed to verify token: %w", err)
102+ }
103+ if !verifyResp.Valid {
104+ return "", fmt.Errorf("token is not valid")
105+ }
106+
107+ // Step 2: Exchange long-lived token for short-lived registry token
108+ shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject)
109+ if err != nil {
110+ return "", fmt.Errorf("failed to exchange registry token: %w", err)
111+ }
112+
113+ return "Bearer " + shortToken, nil
114+}
115+
72 func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) {116 func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) {
73 _, err := d.cli.ImageInspect(ctx, imageRef)117 _, err := d.cli.ImageInspect(ctx, imageRef)
74 if err != nil {118 if err != nil {
A internal/adapters/in/cli/push_image_ops_test.go +114 -0
@@ -0,0 +1,114 @@
1+package cli
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/dto"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15+)
16+
17+// TestExchangeRegistryAuth_Success verifies the happy path:
18+// VerifyAuth is called, then ExchangeRegistryToken is called with the
19+// subject from VerifyAuth, and the result is "Bearer <short-lived-token>".
20+func TestExchangeRegistryAuth_Success(t *testing.T) {
21+ const subject = "ci-bot"
22+ const shortToken = "short-lived-token-abc123"
23+
24+ verifyHandled := false
25+ tokenHandled := false
26+
27+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ switch r.URL.Path {
29+ case "/admin/auth/verify":
30+ verifyHandled = true
31+ w.Header().Set("Content-Type", "application/json")
32+ resp := dto.AuthVerifyResponse{
33+ Valid: true,
34+ Subject: subject,
35+ Scopes: []string{"push", "pull"},
36+ }
37+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
38+ case "/auth/token":
39+ tokenHandled = true
40+ // Verify basic auth was set with subject as username
41+ u, p, ok := r.BasicAuth()
42+ assert.True(t, ok, "expected basic auth")
43+ assert.Equal(t, subject, u, "expected subject as username")
44+ assert.NotEmpty(t, p, "expected token as password")
45+
46+ w.Header().Set("Content-Type", "application/json")
47+ resp := dto.TokenResponse{Token: shortToken}
48+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
49+ default:
50+ http.NotFound(w, r)
51+ }
52+ }))
53+ defer srv.Close()
54+
55+ client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))
56+ ops := &dockerImageOps{
57+ remoteClient: client,
58+ }
59+
60+ got, err := ops.exchangeRegistryAuth(context.Background())
61+
62+ require.NoError(t, err)
63+ assert.Equal(t, "Bearer "+shortToken, got)
64+ assert.True(t, verifyHandled, "expected /admin/auth/verify to be called")
65+ assert.True(t, tokenHandled, "expected /auth/token to be called")
66+}
67+
68+// TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,
69+// exchangeRegistryAuth returns an error.
70+func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
71+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72+ if r.URL.Path == "/admin/auth/verify" {
73+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
74+ return
75+ }
76+ http.NotFound(w, r)
77+ }))
78+ defer srv.Close()
79+
80+ client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))
81+ ops := &dockerImageOps{
82+ remoteClient: client,
83+ }
84+
85+ _, err := ops.exchangeRegistryAuth(context.Background())
86+
87+ require.Error(t, err)
88+ assert.Contains(t, err.Error(), "failed to verify token")
89+}
90+
91+// TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns
92+// Valid=false, exchangeRegistryAuth returns an error.
93+func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
94+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ if r.URL.Path == "/admin/auth/verify" {
96+ w.Header().Set("Content-Type", "application/json")
97+ resp := dto.AuthVerifyResponse{Valid: false}
98+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
99+ return
100+ }
101+ http.NotFound(w, r)
102+ }))
103+ defer srv.Close()
104+
105+ client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))
106+ ops := &dockerImageOps{
107+ remoteClient: client,
108+ }
109+
110+ _, err := ops.exchangeRegistryAuth(context.Background())
111+
112+ require.Error(t, err)
113+ assert.Contains(t, err.Error(), "token is not valid")
114+}
@@ -0,0 +1,114 @@
1+package cli
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/dto"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15+)
16+
17+// TestExchangeRegistryAuth_Success verifies the happy path:
18+// VerifyAuth is called, then ExchangeRegistryToken is called with the
19+// subject from VerifyAuth, and the result is "Bearer <short-lived-token>".
20+func TestExchangeRegistryAuth_Success(t *testing.T) {
21+ const subject = "ci-bot"
22+ const shortToken = "short-lived-token-abc123"
23+
24+ verifyHandled := false
25+ tokenHandled := false
26+
27+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ switch r.URL.Path {
29+ case "/admin/auth/verify":
30+ verifyHandled = true
31+ w.Header().Set("Content-Type", "application/json")
32+ resp := dto.AuthVerifyResponse{
33+ Valid: true,
34+ Subject: subject,
35+ Scopes: []string{"push", "pull"},
36+ }
37+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
38+ case "/auth/token":
39+ tokenHandled = true
40+ // Verify basic auth was set with subject as username
41+ u, p, ok := r.BasicAuth()
42+ assert.True(t, ok, "expected basic auth")
43+ assert.Equal(t, subject, u, "expected subject as username")
44+ assert.NotEmpty(t, p, "expected token as password")
45+
46+ w.Header().Set("Content-Type", "application/json")
47+ resp := dto.TokenResponse{Token: shortToken}
48+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
49+ default:
50+ http.NotFound(w, r)
51+ }
52+ }))
53+ defer srv.Close()
54+
55+ client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))
56+ ops := &dockerImageOps{
57+ remoteClient: client,
58+ }
59+
60+ got, err := ops.exchangeRegistryAuth(context.Background())
61+
62+ require.NoError(t, err)
63+ assert.Equal(t, "Bearer "+shortToken, got)
64+ assert.True(t, verifyHandled, "expected /admin/auth/verify to be called")
65+ assert.True(t, tokenHandled, "expected /auth/token to be called")
66+}
67+
68+// TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,
69+// exchangeRegistryAuth returns an error.
70+func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
71+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72+ if r.URL.Path == "/admin/auth/verify" {
73+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
74+ return
75+ }
76+ http.NotFound(w, r)
77+ }))
78+ defer srv.Close()
79+
80+ client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))
81+ ops := &dockerImageOps{
82+ remoteClient: client,
83+ }
84+
85+ _, err := ops.exchangeRegistryAuth(context.Background())
86+
87+ require.Error(t, err)
88+ assert.Contains(t, err.Error(), "failed to verify token")
89+}
90+
91+// TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns
92+// Valid=false, exchangeRegistryAuth returns an error.
93+func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
94+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ if r.URL.Path == "/admin/auth/verify" {
96+ w.Header().Set("Content-Type", "application/json")
97+ resp := dto.AuthVerifyResponse{Valid: false}
98+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
99+ return
100+ }
101+ http.NotFound(w, r)
102+ }))
103+ defer srv.Close()
104+
105+ client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))
106+ ops := &dockerImageOps{
107+ remoteClient: client,
108+ }
109+
110+ _, err := ops.exchangeRegistryAuth(context.Background())
111+
112+ require.Error(t, err)
113+ assert.Contains(t, err.Error(), "token is not valid")
114+}
M internal/adapters/in/cli/remote/auth.go +50 -0
@@ -8,6 +8,8 @@ import (
88 "io"
99 "net/http"
1010 "os"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
1113 )
1214
1315 // PasswordRequest represents the request body for POST /auth/password.
@@ -71,6 +73,54 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
7173 return &result, nil
7274 }
7375
76+// ExchangeRegistryToken exchanges the client's long-lived Gordon token for a
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).
79+// Returns the short-lived Bearer token string.
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"
82+
83+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
84+ if err != nil {
85+ return "", fmt.Errorf("failed to create request: %w", err)
86+ }
87+
88+ req.SetBasicAuth(subject, c.token)
89+ req.Header.Set("Accept", "application/json")
90+
91+ resp, err := c.httpClient.Do(req)
92+ if err != nil {
93+ return "", fmt.Errorf("failed to connect: %w", err)
94+ }
95+ defer resp.Body.Close()
96+
97+ if resp.StatusCode >= 400 {
98+ body, _ := io.ReadAll(resp.Body)
99+ var errResp struct {
100+ Error string `json:"error"`
101+ }
102+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
103+ return "", fmt.Errorf("%s: %s", resp.Status, errResp.Error)
104+ }
105+ return "", fmt.Errorf("%s: %s", resp.Status, string(body))
106+ }
107+
108+ var result dto.TokenResponse
109+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
110+ return "", fmt.Errorf("failed to decode response: %w", err)
111+ }
112+
113+ token := result.Token
114+ if token == "" {
115+ token = result.AccessToken
116+ }
117+ if token == "" {
118+ return "", fmt.Errorf("registry token exchange returned empty token")
119+ }
120+
121+ return token, nil
122+}
123+
74124 // UpdateRemoteToken updates the token for a named remote.
75125 func UpdateRemoteToken(name, token string) error {
76126 // Try pass first
@@ -8,6 +8,8 @@ import (
8 "io"8 "io"
9 "net/http"9 "net/http"
10 "os"10 "os"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
11 )13 )
12 14
13 // PasswordRequest represents the request body for POST /auth/password.15 // PasswordRequest represents the request body for POST /auth/password.
@@ -71,6 +73,54 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
71 return &result, nil73 return &result, nil
72 }74 }
73 75
76+// ExchangeRegistryToken exchanges the client's long-lived Gordon token for a
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).
79+// Returns the short-lived Bearer token string.
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"
82+
83+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
84+ if err != nil {
85+ return "", fmt.Errorf("failed to create request: %w", err)
86+ }
87+
88+ req.SetBasicAuth(subject, c.token)
89+ req.Header.Set("Accept", "application/json")
90+
91+ resp, err := c.httpClient.Do(req)
92+ if err != nil {
93+ return "", fmt.Errorf("failed to connect: %w", err)
94+ }
95+ defer resp.Body.Close()
96+
97+ if resp.StatusCode >= 400 {
98+ body, _ := io.ReadAll(resp.Body)
99+ var errResp struct {
100+ Error string `json:"error"`
101+ }
102+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
103+ return "", fmt.Errorf("%s: %s", resp.Status, errResp.Error)
104+ }
105+ return "", fmt.Errorf("%s: %s", resp.Status, string(body))
106+ }
107+
108+ var result dto.TokenResponse
109+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
110+ return "", fmt.Errorf("failed to decode response: %w", err)
111+ }
112+
113+ token := result.Token
114+ if token == "" {
115+ token = result.AccessToken
116+ }
117+ if token == "" {
118+ return "", fmt.Errorf("registry token exchange returned empty token")
119+ }
120+
121+ return token, nil
122+}
123+
74 // UpdateRemoteToken updates the token for a named remote.124 // UpdateRemoteToken updates the token for a named remote.
75 func UpdateRemoteToken(name, token string) error {125 func UpdateRemoteToken(name, token string) error {
76 // Try pass first126 // Try pass first
M internal/adapters/in/cli/remote/client_test.go +70 -0
@@ -250,3 +250,73 @@ func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
250250 require.Error(t, err)
251251 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
252252 }
253+
254+func TestExchangeRegistryToken_Success(t *testing.T) {
255+ const subject = "testuser"
256+ const gordonToken = "gordon-jwt-token"
257+ const shortToken = "short-lived-123"
258+
259+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
260+ require.Equal(t, "/auth/token", r.URL.Path)
261+ require.Equal(t, http.MethodGet, r.Method)
262+ require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope"))
263+ require.Equal(t, "gordon-registry", r.URL.Query().Get("service"))
264+
265+ u, p, ok := r.BasicAuth()
266+ require.True(t, ok, "expected Basic Auth")
267+ assert.Equal(t, subject, u)
268+ assert.Equal(t, gordonToken, p)
269+
270+ w.Header().Set("Content-Type", "application/json")
271+ _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`))
272+ }))
273+ defer srv.Close()
274+
275+ client := NewClient(srv.URL, WithToken(gordonToken))
276+ token, err := client.ExchangeRegistryToken(context.Background(), subject)
277+ require.NoError(t, err)
278+ assert.Equal(t, shortToken, token)
279+}
280+
281+func TestExchangeRegistryToken_Unauthorized(t *testing.T) {
282+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
283+ w.WriteHeader(http.StatusUnauthorized)
284+ _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
285+ }))
286+ defer srv.Close()
287+
288+ client := NewClient(srv.URL, WithToken("bad-token"))
289+ _, err := client.ExchangeRegistryToken(context.Background(), "user")
290+ require.Error(t, err)
291+ assert.Contains(t, err.Error(), "401")
292+}
293+
294+func TestExchangeRegistryToken_EmptyToken(t *testing.T) {
295+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
296+ w.Header().Set("Content-Type", "application/json")
297+ _, _ = w.Write([]byte(`{"token":"","expires_in":300}`))
298+ }))
299+ defer srv.Close()
300+
301+ client := NewClient(srv.URL, WithToken("some-token"))
302+ _, err := client.ExchangeRegistryToken(context.Background(), "user")
303+ require.Error(t, err)
304+ assert.Contains(t, err.Error(), "empty")
305+}
306+
307+func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
308+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
309+ w.Header().Set("Content-Type", "application/json")
310+ // Token field empty, but access_token has a value
311+ json.NewEncoder(w).Encode(map[string]any{
312+ "access_token": "fallback-token-456",
313+ "expires_in": 300,
314+ })
315+ }))
316+ defer srv.Close()
317+
318+ client := NewClient(srv.URL, WithToken("long-lived"))
319+ token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot")
320+ require.NoError(t, err)
321+ assert.Equal(t, "fallback-token-456", token)
322+}
@@ -250,3 +250,73 @@ func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
250 require.Error(t, err)250 require.Error(t, err)
251 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")251 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
252 }252 }
253+
254+func TestExchangeRegistryToken_Success(t *testing.T) {
255+ const subject = "testuser"
256+ const gordonToken = "gordon-jwt-token"
257+ const shortToken = "short-lived-123"
258+
259+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
260+ require.Equal(t, "/auth/token", r.URL.Path)
261+ require.Equal(t, http.MethodGet, r.Method)
262+ require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope"))
263+ require.Equal(t, "gordon-registry", r.URL.Query().Get("service"))
264+
265+ u, p, ok := r.BasicAuth()
266+ require.True(t, ok, "expected Basic Auth")
267+ assert.Equal(t, subject, u)
268+ assert.Equal(t, gordonToken, p)
269+
270+ w.Header().Set("Content-Type", "application/json")
271+ _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`))
272+ }))
273+ defer srv.Close()
274+
275+ client := NewClient(srv.URL, WithToken(gordonToken))
276+ token, err := client.ExchangeRegistryToken(context.Background(), subject)
277+ require.NoError(t, err)
278+ assert.Equal(t, shortToken, token)
279+}
280+
281+func TestExchangeRegistryToken_Unauthorized(t *testing.T) {
282+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
283+ w.WriteHeader(http.StatusUnauthorized)
284+ _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
285+ }))
286+ defer srv.Close()
287+
288+ client := NewClient(srv.URL, WithToken("bad-token"))
289+ _, err := client.ExchangeRegistryToken(context.Background(), "user")
290+ require.Error(t, err)
291+ assert.Contains(t, err.Error(), "401")
292+}
293+
294+func TestExchangeRegistryToken_EmptyToken(t *testing.T) {
295+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
296+ w.Header().Set("Content-Type", "application/json")
297+ _, _ = w.Write([]byte(`{"token":"","expires_in":300}`))
298+ }))
299+ defer srv.Close()
300+
301+ client := NewClient(srv.URL, WithToken("some-token"))
302+ _, err := client.ExchangeRegistryToken(context.Background(), "user")
303+ require.Error(t, err)
304+ assert.Contains(t, err.Error(), "empty")
305+}
306+
307+func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
308+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
309+ w.Header().Set("Content-Type", "application/json")
310+ // Token field empty, but access_token has a value
311+ json.NewEncoder(w).Encode(map[string]any{
312+ "access_token": "fallback-token-456",
313+ "expires_in": 300,
314+ })
315+ }))
316+ defer srv.Close()
317+
318+ client := NewClient(srv.URL, WithToken("long-lived"))
319+ token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot")
320+ require.NoError(t, err)
321+ assert.Equal(t, "fallback-token-456", token)
322+}
M pkg/registrypush/push.go +18 -3
@@ -42,6 +42,7 @@ type Pusher struct {
4242 imageSource func(ctx context.Context, ref string) (v1.Image, func(), error)
4343 cleanupFn func()
4444 progress io.Writer
45+ authHeader string
4546 }
4647
4748 // Option configures a Pusher.
@@ -89,6 +90,14 @@ func WithProgress(w io.Writer) Option {
8990 return func(p *Pusher) { p.progress = w }
9091 }
9192
93+// WithAuth sets an explicit Authorization header value (e.g. "Bearer <token>")
94+// to use for all registry requests. When set, the Docker credential keychain
95+// is not consulted. This is useful when a pre-computed token is available
96+// without requiring a local docker login.
97+func WithAuth(header string) Option {
98+ return func(p *Pusher) { p.authHeader = header }
99+}
100+
92101 // New creates a Pusher with the given options.
93102 func New(opts ...Option) *Pusher {
94103 p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout}
@@ -125,9 +134,15 @@ func (p *Pusher) Push(ctx context.Context, ref string) error {
125134 return fmt.Errorf("image ref %s must include a tag", ref)
126135 }
127136
128- authHeader, err := resolveAuthHeader(parsedRef)
129- if err != nil {
130- return err
137+ var authHeader string
138+ if p.authHeader != "" {
139+ authHeader = p.authHeader
140+ } else {
141+ var resolveErr error
142+ authHeader, resolveErr = resolveAuthHeader(parsedRef)
143+ if resolveErr != nil {
144+ return resolveErr
145+ }
131146 }
132147
133148 img, sourceCleanup, err := p.imageSource(ctx, ref)
@@ -42,6 +42,7 @@ type Pusher struct {
42 imageSource func(ctx context.Context, ref string) (v1.Image, func(), error)42 imageSource func(ctx context.Context, ref string) (v1.Image, func(), error)
43 cleanupFn func()43 cleanupFn func()
44 progress io.Writer44 progress io.Writer
45+ authHeader string
45 }46 }
46 47
47 // Option configures a Pusher.48 // Option configures a Pusher.
@@ -89,6 +90,14 @@ func WithProgress(w io.Writer) Option {
89 return func(p *Pusher) { p.progress = w }90 return func(p *Pusher) { p.progress = w }
90 }91 }
91 92
93+// WithAuth sets an explicit Authorization header value (e.g. "Bearer <token>")
94+// to use for all registry requests. When set, the Docker credential keychain
95+// is not consulted. This is useful when a pre-computed token is available
96+// without requiring a local docker login.
97+func WithAuth(header string) Option {
98+ return func(p *Pusher) { p.authHeader = header }
99+}
100+
92 // New creates a Pusher with the given options.101 // New creates a Pusher with the given options.
93 func New(opts ...Option) *Pusher {102 func New(opts ...Option) *Pusher {
94 p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout}103 p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout}
@@ -125,9 +134,15 @@ func (p *Pusher) Push(ctx context.Context, ref string) error {
125 return fmt.Errorf("image ref %s must include a tag", ref)134 return fmt.Errorf("image ref %s must include a tag", ref)
126 }135 }
127 136
128- authHeader, err := resolveAuthHeader(parsedRef)137+ var authHeader string
129- if err != nil {138+ if p.authHeader != "" {
130- return err139+ authHeader = p.authHeader
140+ } else {
141+ var resolveErr error
142+ authHeader, resolveErr = resolveAuthHeader(parsedRef)
143+ if resolveErr != nil {
144+ return resolveErr
145+ }
131 }146 }
132 147
133 img, sourceCleanup, err := p.imageSource(ctx, ref)148 img, sourceCleanup, err := p.imageSource(ctx, ref)
M pkg/registrypush/push_test.go +75 -0
@@ -660,3 +660,78 @@ func pathSegmentBetween(value, start, end string) string {
660660 func typesOCIManifestSchema1() string {
661661 return "application/vnd.oci.image.manifest.v1+json"
662662 }
663+
664+// TestPusher_WithAuth verifies that when WithAuth is provided, its header is
665+// used for all registry requests instead of the Docker keychain, and that
666+// omitting WithAuth leaves existing behavior unchanged.
667+func TestPusher_WithAuth(t *testing.T) {
668+ t.Run("WithAuth header is sent on every registry request", func(t *testing.T) {
669+ img, err := random.Image(1024, 1)
670+ require.NoError(t, err)
671+
672+ const explicitToken = "Bearer my-token"
673+
674+ // authAssertingRegistry fails the test if any request lacks the expected
675+ // Authorization header.
676+ var requestCount int
677+ registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount))
678+ defer registry.Close()
679+
680+ serverURL, err := url.Parse(registry.URL)
681+ require.NoError(t, err)
682+ ref := serverURL.Host + "/demo/app:v1"
683+
684+ p := registrypush.New(
685+ registrypush.WithTransport(registry.Client().Transport),
686+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
687+ return img, nil
688+ }),
689+ registrypush.WithAuth(explicitToken),
690+ )
691+
692+ err = p.Push(context.Background(), ref)
693+ require.NoError(t, err)
694+ assert.Positive(t, requestCount, "at least one request should have been made")
695+ })
696+
697+ t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) {
698+ img, err := random.Image(1024, 1)
699+ require.NoError(t, err)
700+
701+ serverState := newFakePushRegistry(t, img, map[string]bool{})
702+ registry := httptest.NewServer(serverState.handler())
703+ defer registry.Close()
704+
705+ serverURL, err := url.Parse(registry.URL)
706+ require.NoError(t, err)
707+ ref := serverURL.Host + "/demo/app:v1"
708+
709+ // No WithAuth — relies on Docker keychain (returns empty string for localhost).
710+ p := registrypush.New(
711+ registrypush.WithTransport(registry.Client().Transport),
712+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
713+ return img, nil
714+ }),
715+ )
716+
717+ err = p.Push(context.Background(), ref)
718+ require.NoError(t, err)
719+
720+ // Manifest should have been pushed — verifies the push completed normally.
721+ _, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
722+ assert.True(t, ok, "manifest should have been uploaded")
723+ })
724+}
725+
726+// handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that
727+// every request carries the expected Authorization header.
728+func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler {
729+ t.Helper()
730+ inner := f.handler()
731+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
732+ *requestCount++
733+ assert.Equal(t, expectedAuth, r.Header.Get("Authorization"),
734+ "expected Authorization header on %s %s", r.Method, r.URL.Path)
735+ inner.ServeHTTP(w, r)
736+ })
737+}
@@ -660,3 +660,78 @@ func pathSegmentBetween(value, start, end string) string {
660 func typesOCIManifestSchema1() string {660 func typesOCIManifestSchema1() string {
661 return "application/vnd.oci.image.manifest.v1+json"661 return "application/vnd.oci.image.manifest.v1+json"
662 }662 }
663+
664+// TestPusher_WithAuth verifies that when WithAuth is provided, its header is
665+// used for all registry requests instead of the Docker keychain, and that
666+// omitting WithAuth leaves existing behavior unchanged.
667+func TestPusher_WithAuth(t *testing.T) {
668+ t.Run("WithAuth header is sent on every registry request", func(t *testing.T) {
669+ img, err := random.Image(1024, 1)
670+ require.NoError(t, err)
671+
672+ const explicitToken = "Bearer my-token"
673+
674+ // authAssertingRegistry fails the test if any request lacks the expected
675+ // Authorization header.
676+ var requestCount int
677+ registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount))
678+ defer registry.Close()
679+
680+ serverURL, err := url.Parse(registry.URL)
681+ require.NoError(t, err)
682+ ref := serverURL.Host + "/demo/app:v1"
683+
684+ p := registrypush.New(
685+ registrypush.WithTransport(registry.Client().Transport),
686+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
687+ return img, nil
688+ }),
689+ registrypush.WithAuth(explicitToken),
690+ )
691+
692+ err = p.Push(context.Background(), ref)
693+ require.NoError(t, err)
694+ assert.Positive(t, requestCount, "at least one request should have been made")
695+ })
696+
697+ t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) {
698+ img, err := random.Image(1024, 1)
699+ require.NoError(t, err)
700+
701+ serverState := newFakePushRegistry(t, img, map[string]bool{})
702+ registry := httptest.NewServer(serverState.handler())
703+ defer registry.Close()
704+
705+ serverURL, err := url.Parse(registry.URL)
706+ require.NoError(t, err)
707+ ref := serverURL.Host + "/demo/app:v1"
708+
709+ // No WithAuth — relies on Docker keychain (returns empty string for localhost).
710+ p := registrypush.New(
711+ registrypush.WithTransport(registry.Client().Transport),
712+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
713+ return img, nil
714+ }),
715+ )
716+
717+ err = p.Push(context.Background(), ref)
718+ require.NoError(t, err)
719+
720+ // Manifest should have been pushed — verifies the push completed normally.
721+ _, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
722+ assert.True(t, ok, "manifest should have been uploaded")
723+ })
724+}
725+
726+// handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that
727+// every request carries the expected Authorization header.
728+func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler {
729+ t.Helper()
730+ inner := f.handler()
731+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
732+ *requestCount++
733+ assert.Equal(t, expectedAuth, r.Header.Get("Authorization"),
734+ "expected Authorization header on %s %s", r.Method, r.URL.Path)
735+ inner.ServeHTTP(w, r)
736+ })
737+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close