1package remote
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "os"
10
11 "github.com/bnema/gordon/internal/adapters/dto"
12)
13
14// ExchangeRegistryToken exchanges the client's long-lived Gordon token for a
15// short-lived registry access token via the Docker v2 token endpoint.
16// The subject must match the token's sub claim (use VerifyAuth to obtain it).
17// Returns the raw short-lived token string (without a "Bearer " prefix).
18func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) {
19 // Wildcard scope (repository:*:push,pull) grants push/pull to all repositories.
20 // The server downsopes this to the parent token's actual scopes during exchange.
21 url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry"
22
23 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
24 if err != nil {
25 return "", fmt.Errorf("failed to create request: %w", err)
26 }
27
28 req.SetBasicAuth(subject, c.token)
29 req.Header.Set("Accept", "application/json")
30
31 resp, err := c.httpClient.Do(req)
32 if err != nil {
33 return "", fmt.Errorf("failed to connect: %w", err)
34 }
35 defer resp.Body.Close()
36
37 if resp.StatusCode >= 400 {
38 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
39 var errResp struct {
40 Error string `json:"error"`
41 }
42 if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
43 return "", fmt.Errorf("%s: %s", resp.Status, errResp.Error)
44 }
45 return "", fmt.Errorf("%s: %s", resp.Status, string(body))
46 }
47
48 var result dto.TokenResponse
49 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
50 return "", fmt.Errorf("failed to decode response: %w", err)
51 }
52
53 token := result.Token
54 if token == "" {
55 token = result.AccessToken
56 }
57 if token == "" {
58 return "", fmt.Errorf("registry token exchange returned empty token")
59 }
60
61 return token, nil
62}
63
64// UpdateRemoteToken updates the token for a named remote.
65func UpdateRemoteToken(name, token string) error {
66 // Try pass first
67 if passAvailable() {
68 if err := passWriteToken(name, token); err == nil {
69 // Successfully stored in pass. Remove plaintext token from TOML (best-effort).
70 config, err := LoadRemotes("")
71 if err != nil {
72 return nil
73 }
74 remote, ok := config.Remotes[name]
75 if !ok {
76 return nil
77 }
78 remote.Token = ""
79 remote.TokenEnv = ""
80 config.Remotes[name] = remote
81 _ = SaveRemotes("", config)
82 return nil
83 } else {
84 fmt.Fprintf(os.Stderr, "Warning: failed to store token in pass: %v. Falling back to plaintext config.\n", err)
85 }
86 } else {
87 warnPassUnavailable()
88 }
89
90 config, err := LoadRemotes("")
91 if err != nil {
92 return err
93 }
94
95 remote, ok := config.Remotes[name]
96 if !ok {
97 return fmt.Errorf("remote '%s' not found", name)
98 }
99
100 remote.Token = token
101 remote.TokenEnv = "" // Clear token_env when setting token directly
102 config.Remotes[name] = remote
103
104 return SaveRemotes("", config)
105}