detached 8f94295

refactor(auth): remove password login and token rotation dead code

commit
8f94295dbfcb43133a0b155f886be76381865106
author
brice · · 2026-03-22 19:56 UTC
parent
76ac5f4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +15 -305
M internal/adapters/in/cli/auth.go +9 -112
@@ -1,18 +1,15 @@
11 package cli
22
33 import (
4- "bufio"
54 "context"
65 "fmt"
76 "io"
8- "os"
97 "strings"
108 "time"
119
1210 "github.com/bnema/zerowrap"
1311 "github.com/spf13/cobra"
1412 "github.com/spf13/viper"
15- "golang.org/x/term"
1613
1714 "github.com/bnema/gordon/internal/adapters/dto"
1815 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -25,16 +22,6 @@ import (
2522 "github.com/bnema/gordon/pkg/duration"
2623 )
2724
28-// stdinFD returns os.Stdin's file descriptor as an int, guarded against
29-// uintptr overflow on platforms where uintptr > int.
30-func stdinFD() (int, error) {
31- fd := os.Stdin.Fd()
32- if fd > uintptr(^uint(0)>>1) {
33- return 0, fmt.Errorf("stdin fd value %d overflows int", fd)
34- }
35- return int(fd), nil
36-}
37-
3825 // newAuthCmd creates the auth command group.
3926 func newAuthCmd() *cobra.Command {
4027 cmd := &cobra.Command{
@@ -214,126 +201,36 @@ available while Gordon is running.`,
214201 func newAuthLoginCmd() *cobra.Command {
215202 var (
216203 remoteName string
217- username string
218204 token string
219- password string
220205 )
221206
222207 cmd := &cobra.Command{
223208 Use: "login",
224209 Short: "Authenticate with a Gordon server",
225- Long: `Authenticate with a Gordon server using password authentication or a pre-generated token.
210+ Long: `Store a pre-generated token for a Gordon server remote.
226211
227-This command prompts for your username and password, authenticates with the
228-remote server's /auth/password endpoint, and stores the returned token.
229-
230-The token is stored in your remote configuration and used for subsequent
231-CLI operations.
232-
233-If --token is provided, the token is stored for the remote and verified
212+The token is stored in your remote configuration and verified
234213 against /admin/status on a best-effort basis.
235214
215+Generate a token on the server with: gordon auth token generate
216+
236217 Examples:
237- gordon auth login Login using active remote
238- gordon auth login --remote prod Login to specific remote
239- gordon auth login --username admin Pre-fill username
240- gordon auth login --token <token> Store a token for token-only servers`,
218+ gordon auth login --token <token> Store token for active remote
219+ gordon auth login --remote prod --token <tok> Store token for specific remote`,
241220 RunE: func(cmd *cobra.Command, args []string) error {
242- if token != "" {
243- return runAuthLoginWithToken(remoteName, token)
221+ if token == "" {
222+ return fmt.Errorf("--token is required; generate a token on the server with: gordon auth token generate")
244223 }
245- return runAuthLogin(remoteName, username, password)
224+ return runAuthLoginWithToken(remoteName, token)
246225 },
247226 }
248227
249228 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
250- cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
251- cmd.Flags().StringVarP(&password, "password", "p", "", "Password (visible in process listings; prefer interactive prompt)")
252229 cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
253230
254- cmd.MarkFlagsMutuallyExclusive("token", "password")
255- cmd.MarkFlagsMutuallyExclusive("token", "username")
256-
257231 return cmd
258232 }
259233
260-// runAuthLogin authenticates with a remote Gordon server.
261-func runAuthLogin(remoteName, username, password string) error {
262- resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
263- if err != nil {
264- return err
265- }
266-
267- // Prompt for username if not provided
268- if username == "" {
269- fmt.Print("Username: ")
270- reader := bufio.NewReader(os.Stdin)
271- input, err := reader.ReadString('\n')
272- if err != nil {
273- return fmt.Errorf("failed to read username: %w", err)
274- }
275- username = strings.TrimSpace(input)
276- }
277-
278- if username == "" {
279- return fmt.Errorf("username cannot be empty")
280- }
281-
282- if password == "" {
283- // Prompt for password (hidden input)
284- fmt.Print("Password: ")
285- fd, err := stdinFD()
286- if err != nil {
287- return fmt.Errorf("failed to get stdin fd: %w", err)
288- }
289- passwordBytes, err := term.ReadPassword(fd)
290- if err != nil {
291- // Fallback for non-terminal input
292- reader := bufio.NewReader(os.Stdin)
293- passwordInput, readErr := reader.ReadString('\n')
294- if readErr != nil {
295- return fmt.Errorf("failed to read password: %w", readErr)
296- }
297- // Only trim trailing newline from fallback input
298- passwordBytes = []byte(strings.TrimRight(passwordInput, "\r\n"))
299- }
300- fmt.Println()
301- password = string(passwordBytes)
302- } else {
303- // Only trim trailing newline from flag-provided password
304- password = strings.TrimRight(password, "\r\n")
305- }
306-
307- if password == "" {
308- return fmt.Errorf("password cannot be empty")
309- }
310-
311- // Create remote client
312- insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
313- client := remote.NewClient(remoteConfig.URL, remoteClientOptions("", insecureTLS)...)
314-
315- // Authenticate
316- ctx := context.Background()
317- fmt.Printf("Authenticating with %s...\n", resolvedName)
318-
319- resp, err := client.Authenticate(ctx, username, password)
320- if err != nil {
321- return fmt.Errorf("authentication failed: %w", err)
322- }
323-
324- // Update remote config with new token
325- if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil {
326- return fmt.Errorf("failed to save token: %w", err)
327- }
328-
329- fmt.Println()
330- fmt.Println(styles.RenderSuccess("Authentication successful!"))
331- fmt.Printf("Token stored for remote '%s'\n", resolvedName)
332- fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
333-
334- return nil
335-}
336-
337234 func runAuthLoginWithToken(remoteName, token string) error {
338235 token = strings.TrimSpace(token)
339236 if token == "" {
@@ -1,18 +1,15 @@
1 package cli1 package cli
2 2
3 import (3 import (
4- "bufio"
5 "context"4 "context"
6 "fmt"5 "fmt"
7 "io"6 "io"
8- "os"
9 "strings"7 "strings"
10 "time"8 "time"
11 9
12 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
13 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
14 "github.com/spf13/viper"12 "github.com/spf13/viper"
15- "golang.org/x/term"
16 13
17 "github.com/bnema/gordon/internal/adapters/dto"14 "github.com/bnema/gordon/internal/adapters/dto"
18 "github.com/bnema/gordon/internal/adapters/in/cli/remote"15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -25,16 +22,6 @@ import (
25 "github.com/bnema/gordon/pkg/duration"22 "github.com/bnema/gordon/pkg/duration"
26 )23 )
27 24
28-// stdinFD returns os.Stdin's file descriptor as an int, guarded against
29-// uintptr overflow on platforms where uintptr > int.
30-func stdinFD() (int, error) {
31- fd := os.Stdin.Fd()
32- if fd > uintptr(^uint(0)>>1) {
33- return 0, fmt.Errorf("stdin fd value %d overflows int", fd)
34- }
35- return int(fd), nil
36-}
37-
38 // newAuthCmd creates the auth command group.25 // newAuthCmd creates the auth command group.
39 func newAuthCmd() *cobra.Command {26 func newAuthCmd() *cobra.Command {
40 cmd := &cobra.Command{27 cmd := &cobra.Command{
@@ -214,126 +201,36 @@ available while Gordon is running.`,
214 func newAuthLoginCmd() *cobra.Command {201 func newAuthLoginCmd() *cobra.Command {
215 var (202 var (
216 remoteName string203 remoteName string
217- username string
218 token string204 token string
219- password string
220 )205 )
221 206
222 cmd := &cobra.Command{207 cmd := &cobra.Command{
223 Use: "login",208 Use: "login",
224 Short: "Authenticate with a Gordon server",209 Short: "Authenticate with a Gordon server",
225- Long: `Authenticate with a Gordon server using password authentication or a pre-generated token.210+ Long: `Store a pre-generated token for a Gordon server remote.
226 211
227-This command prompts for your username and password, authenticates with the212+The token is stored in your remote configuration and verified
228-remote server's /auth/password endpoint, and stores the returned token.
229-
230-The token is stored in your remote configuration and used for subsequent
231-CLI operations.
232-
233-If --token is provided, the token is stored for the remote and verified
234 against /admin/status on a best-effort basis.213 against /admin/status on a best-effort basis.
235 214
215+Generate a token on the server with: gordon auth token generate
216+
236 Examples:217 Examples:
237- gordon auth login Login using active remote218+ gordon auth login --token <token> Store token for active remote
238- gordon auth login --remote prod Login to specific remote219+ gordon auth login --remote prod --token <tok> Store token for specific remote`,
239- gordon auth login --username admin Pre-fill username
240- gordon auth login --token <token> Store a token for token-only servers`,
241 RunE: func(cmd *cobra.Command, args []string) error {220 RunE: func(cmd *cobra.Command, args []string) error {
242- if token != "" {221+ if token == "" {
243- return runAuthLoginWithToken(remoteName, token)222+ return fmt.Errorf("--token is required; generate a token on the server with: gordon auth token generate")
244 }223 }
245- return runAuthLogin(remoteName, username, password)224+ return runAuthLoginWithToken(remoteName, token)
246 },225 },
247 }226 }
248 227
249 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")228 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
250- cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
251- cmd.Flags().StringVarP(&password, "password", "p", "", "Password (visible in process listings; prefer interactive prompt)")
252 cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")229 cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
253 230
254- cmd.MarkFlagsMutuallyExclusive("token", "password")
255- cmd.MarkFlagsMutuallyExclusive("token", "username")
256-
257 return cmd231 return cmd
258 }232 }
259 233
260-// runAuthLogin authenticates with a remote Gordon server.
261-func runAuthLogin(remoteName, username, password string) error {
262- resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
263- if err != nil {
264- return err
265- }
266-
267- // Prompt for username if not provided
268- if username == "" {
269- fmt.Print("Username: ")
270- reader := bufio.NewReader(os.Stdin)
271- input, err := reader.ReadString('\n')
272- if err != nil {
273- return fmt.Errorf("failed to read username: %w", err)
274- }
275- username = strings.TrimSpace(input)
276- }
277-
278- if username == "" {
279- return fmt.Errorf("username cannot be empty")
280- }
281-
282- if password == "" {
283- // Prompt for password (hidden input)
284- fmt.Print("Password: ")
285- fd, err := stdinFD()
286- if err != nil {
287- return fmt.Errorf("failed to get stdin fd: %w", err)
288- }
289- passwordBytes, err := term.ReadPassword(fd)
290- if err != nil {
291- // Fallback for non-terminal input
292- reader := bufio.NewReader(os.Stdin)
293- passwordInput, readErr := reader.ReadString('\n')
294- if readErr != nil {
295- return fmt.Errorf("failed to read password: %w", readErr)
296- }
297- // Only trim trailing newline from fallback input
298- passwordBytes = []byte(strings.TrimRight(passwordInput, "\r\n"))
299- }
300- fmt.Println()
301- password = string(passwordBytes)
302- } else {
303- // Only trim trailing newline from flag-provided password
304- password = strings.TrimRight(password, "\r\n")
305- }
306-
307- if password == "" {
308- return fmt.Errorf("password cannot be empty")
309- }
310-
311- // Create remote client
312- insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
313- client := remote.NewClient(remoteConfig.URL, remoteClientOptions("", insecureTLS)...)
314-
315- // Authenticate
316- ctx := context.Background()
317- fmt.Printf("Authenticating with %s...\n", resolvedName)
318-
319- resp, err := client.Authenticate(ctx, username, password)
320- if err != nil {
321- return fmt.Errorf("authentication failed: %w", err)
322- }
323-
324- // Update remote config with new token
325- if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil {
326- return fmt.Errorf("failed to save token: %w", err)
327- }
328-
329- fmt.Println()
330- fmt.Println(styles.RenderSuccess("Authentication successful!"))
331- fmt.Printf("Token stored for remote '%s'\n", resolvedName)
332- fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
333-
334- return nil
335-}
336-
337 func runAuthLoginWithToken(remoteName, token string) error {234 func runAuthLoginWithToken(remoteName, token string) error {
338 token = strings.TrimSpace(token)235 token = strings.TrimSpace(token)
339 if token == "" {236 if token == "" {
M internal/adapters/in/cli/remote/auth.go +0 -62
@@ -1,7 +1,6 @@
11 package remote
22
33 import (
4- "bytes"
54 "context"
65 "encoding/json"
76 "fmt"
@@ -12,67 +11,6 @@ import (
1211 "github.com/bnema/gordon/internal/adapters/dto"
1312 )
1413
15-// PasswordRequest represents the request body for POST /auth/password.
16-type PasswordRequest struct {
17- Username string `json:"username"`
18- Password string `json:"password"` //nolint:gosec // intentional: CLI credential DTO for auth endpoint
19-}
20-
21-// PasswordResponse represents the response from POST /auth/password.
22-type PasswordResponse struct {
23- Token string `json:"token"`
24- ExpiresIn int `json:"expires_in"`
25- IssuedAt string `json:"issued_at"`
26-}
27-
28-// Authenticate calls POST /auth/password and returns a token.
29-// This method does NOT require an existing token since it's used to obtain one.
30-func (c *Client) Authenticate(ctx context.Context, username, password string) (*PasswordResponse, error) {
31- url := c.baseURL + "/auth/password"
32-
33- reqBody := PasswordRequest{
34- Username: username,
35- Password: password,
36- }
37-
38- jsonBody, err := json.Marshal(reqBody)
39- if err != nil {
40- return nil, fmt.Errorf("failed to marshal request body: %w", err)
41- }
42-
43- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
44- if err != nil {
45- return nil, fmt.Errorf("failed to create request: %w", err)
46- }
47-
48- req.Header.Set("Content-Type", "application/json")
49- req.Header.Set("Accept", "application/json")
50-
51- resp, err := c.httpClient.Do(req)
52- if err != nil {
53- return nil, fmt.Errorf("failed to connect: %w", err)
54- }
55- defer resp.Body.Close()
56-
57- if resp.StatusCode >= 400 {
58- body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
59- var errResp struct {
60- Error string `json:"error"`
61- }
62- if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
63- return nil, fmt.Errorf("%s: %s", resp.Status, errResp.Error)
64- }
65- return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
66- }
67-
68- var result PasswordResponse
69- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
70- return nil, fmt.Errorf("failed to decode response: %w", err)
71- }
72-
73- return &result, nil
74-}
75-
7614 // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a
7715 // short-lived registry access token via the Docker v2 token endpoint.
7816 // The subject must match the token's sub claim (use VerifyAuth to obtain it).
@@ -1,7 +1,6 @@
1 package remote1 package remote
2 2
3 import (3 import (
4- "bytes"
5 "context"4 "context"
6 "encoding/json"5 "encoding/json"
7 "fmt"6 "fmt"
@@ -12,67 +11,6 @@ import (
12 "github.com/bnema/gordon/internal/adapters/dto"11 "github.com/bnema/gordon/internal/adapters/dto"
13 )12 )
14 13
15-// PasswordRequest represents the request body for POST /auth/password.
16-type PasswordRequest struct {
17- Username string `json:"username"`
18- Password string `json:"password"` //nolint:gosec // intentional: CLI credential DTO for auth endpoint
19-}
20-
21-// PasswordResponse represents the response from POST /auth/password.
22-type PasswordResponse struct {
23- Token string `json:"token"`
24- ExpiresIn int `json:"expires_in"`
25- IssuedAt string `json:"issued_at"`
26-}
27-
28-// Authenticate calls POST /auth/password and returns a token.
29-// This method does NOT require an existing token since it's used to obtain one.
30-func (c *Client) Authenticate(ctx context.Context, username, password string) (*PasswordResponse, error) {
31- url := c.baseURL + "/auth/password"
32-
33- reqBody := PasswordRequest{
34- Username: username,
35- Password: password,
36- }
37-
38- jsonBody, err := json.Marshal(reqBody)
39- if err != nil {
40- return nil, fmt.Errorf("failed to marshal request body: %w", err)
41- }
42-
43- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
44- if err != nil {
45- return nil, fmt.Errorf("failed to create request: %w", err)
46- }
47-
48- req.Header.Set("Content-Type", "application/json")
49- req.Header.Set("Accept", "application/json")
50-
51- resp, err := c.httpClient.Do(req)
52- if err != nil {
53- return nil, fmt.Errorf("failed to connect: %w", err)
54- }
55- defer resp.Body.Close()
56-
57- if resp.StatusCode >= 400 {
58- body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
59- var errResp struct {
60- Error string `json:"error"`
61- }
62- if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
63- return nil, fmt.Errorf("%s: %s", resp.Status, errResp.Error)
64- }
65- return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
66- }
67-
68- var result PasswordResponse
69- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
70- return nil, fmt.Errorf("failed to decode response: %w", err)
71- }
72-
73- return &result, nil
74-}
75-
76 // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a14 // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a
77 // short-lived registry access token via the Docker v2 token endpoint.15 // 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).16 // The subject must match the token's sub claim (use VerifyAuth to obtain it).
M internal/adapters/in/cli/remote/client.go +4 -42
@@ -23,11 +23,10 @@ import (
2323
2424 // Client is an HTTP client for the Gordon admin API.
2525 type Client struct {
26- baseURL string
27- token string
28- httpClient *http.Client
29- insecureTLS bool
30- onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token
26+ baseURL string
27+ token string
28+ httpClient *http.Client
29+ insecureTLS bool
3130
3231 // Ephemeral admin token exchange fields.
3332 subject string // JWT subject extracted from long-lived token
@@ -115,37 +114,6 @@ func WithInsecureTLS(insecure bool) ClientOption {
115114 }
116115 }
117116
118-// WithTokenRefreshCallback sets a callback that is invoked when the server
119-// returns a refreshed token in the X-Gordon-Token response header.
120-// The CLI uses this to persist the new token to the remotes config atomically.
121-func WithTokenRefreshCallback(fn func(newToken string)) ClientOption {
122- return func(c *Client) {
123- c.onTokenRefreshed = fn
124- }
125-}
126-
127-// observeTokenRotation checks the X-Gordon-Token header on a response and, if
128-// the server issued a refreshed token, updates the client's in-memory token and
129-// invokes the optional persistence callback. Panics in the callback are caught
130-// so they never propagate to the caller.
131-func (c *Client) observeTokenRotation(resp *http.Response) {
132- newToken := resp.Header.Get("X-Gordon-Token")
133- if newToken == "" || newToken == c.token {
134- return
135- }
136- c.token = newToken
137- if c.onTokenRefreshed != nil {
138- func() {
139- defer func() {
140- if r := recover(); r != nil {
141- fmt.Fprintf(os.Stderr, "warning: token refresh callback panicked: %v\n", r)
142- }
143- }()
144- c.onTokenRefreshed(newToken)
145- }()
146- }
147-}
148-
149117 // ephemeralMargin is the safety margin before expiry at which
150118 // the cached ephemeral token is considered stale.
151119 const ephemeralMargin = 30 * time.Second
@@ -317,9 +285,6 @@ func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody []
317285 return nil, err
318286 }
319287
320- // Observe token rotation: the server re-signs and returns the token via X-Gordon-Token.
321- c.observeTokenRotation(resp)
322-
323288 return resp, nil
324289 }
325290
@@ -1165,9 +1130,6 @@ func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response
11651130 return nil, fmt.Errorf("failed to connect: %w", err)
11661131 }
11671132
1168- // Observe token rotation on streaming responses, same as request().
1169- c.observeTokenRotation(resp)
1170-
11711133 if resp.StatusCode >= 400 {
11721134 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
11731135 resp.Body.Close()
@@ -23,11 +23,10 @@ import (
23 23
24 // Client is an HTTP client for the Gordon admin API.24 // Client is an HTTP client for the Gordon admin API.
25 type Client struct {25 type Client struct {
26- baseURL string26+ baseURL string
27- token string27+ token string
28- httpClient *http.Client28+ httpClient *http.Client
29- insecureTLS bool29+ insecureTLS bool
30- onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token
31 30
32 // Ephemeral admin token exchange fields.31 // Ephemeral admin token exchange fields.
33 subject string // JWT subject extracted from long-lived token32 subject string // JWT subject extracted from long-lived token
@@ -115,37 +114,6 @@ func WithInsecureTLS(insecure bool) ClientOption {
115 }114 }
116 }115 }
117 116
118-// WithTokenRefreshCallback sets a callback that is invoked when the server
119-// returns a refreshed token in the X-Gordon-Token response header.
120-// The CLI uses this to persist the new token to the remotes config atomically.
121-func WithTokenRefreshCallback(fn func(newToken string)) ClientOption {
122- return func(c *Client) {
123- c.onTokenRefreshed = fn
124- }
125-}
126-
127-// observeTokenRotation checks the X-Gordon-Token header on a response and, if
128-// the server issued a refreshed token, updates the client's in-memory token and
129-// invokes the optional persistence callback. Panics in the callback are caught
130-// so they never propagate to the caller.
131-func (c *Client) observeTokenRotation(resp *http.Response) {
132- newToken := resp.Header.Get("X-Gordon-Token")
133- if newToken == "" || newToken == c.token {
134- return
135- }
136- c.token = newToken
137- if c.onTokenRefreshed != nil {
138- func() {
139- defer func() {
140- if r := recover(); r != nil {
141- fmt.Fprintf(os.Stderr, "warning: token refresh callback panicked: %v\n", r)
142- }
143- }()
144- c.onTokenRefreshed(newToken)
145- }()
146- }
147-}
148-
149 // ephemeralMargin is the safety margin before expiry at which117 // ephemeralMargin is the safety margin before expiry at which
150 // the cached ephemeral token is considered stale.118 // the cached ephemeral token is considered stale.
151 const ephemeralMargin = 30 * time.Second119 const ephemeralMargin = 30 * time.Second
@@ -317,9 +285,6 @@ func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody []
317 return nil, err285 return nil, err
318 }286 }
319 287
320- // Observe token rotation: the server re-signs and returns the token via X-Gordon-Token.
321- c.observeTokenRotation(resp)
322-
323 return resp, nil288 return resp, nil
324 }289 }
325 290
@@ -1165,9 +1130,6 @@ func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response
1165 return nil, fmt.Errorf("failed to connect: %w", err)1130 return nil, fmt.Errorf("failed to connect: %w", err)
1166 }1131 }
1167 1132
1168- // Observe token rotation on streaming responses, same as request().
1169- c.observeTokenRotation(resp)
1170-
1171 if resp.StatusCode >= 400 {1133 if resp.StatusCode >= 400 {
1172 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))1134 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
1173 resp.Body.Close()1135 resp.Body.Close()
M internal/adapters/in/cli/root.go +1 -11
@@ -168,22 +168,12 @@ Commands are organized by where they run:
168168 // GetRemoteClient returns a remote client if targeting a remote instance,
169169 // or nil if running locally.
170170 func GetRemoteClient() (*remote.Client, bool) {
171- url, token, insecureTLS, remoteName, isRemote := remote.ResolveRemoteFull(remoteFlag, tokenFlag, insecureTLSFlag)
171+ url, token, insecureTLS, _, isRemote := remote.ResolveRemoteFull(remoteFlag, tokenFlag, insecureTLSFlag)
172172 if !isRemote {
173173 return nil, false
174174 }
175175
176176 opts := remoteClientOptions(token, insecureTLS)
177-
178- // When using a named remote (from remotes.toml), register a callback to persist
179- // refreshed tokens returned by the server in the X-Gordon-Token header.
180- if remoteName != "" {
181- name := remoteName // capture for closure
182- opts = append(opts, remote.WithTokenRefreshCallback(func(newToken string) {
183- _ = remote.UpdateRemoteToken(name, newToken) // best-effort; errors are non-fatal
184- }))
185- }
186-
187177 client := remote.NewClient(url, opts...)
188178 return client, true
189179 }
@@ -168,22 +168,12 @@ Commands are organized by where they run:
168 // GetRemoteClient returns a remote client if targeting a remote instance,168 // GetRemoteClient returns a remote client if targeting a remote instance,
169 // or nil if running locally.169 // or nil if running locally.
170 func GetRemoteClient() (*remote.Client, bool) {170 func GetRemoteClient() (*remote.Client, bool) {
171- url, token, insecureTLS, remoteName, isRemote := remote.ResolveRemoteFull(remoteFlag, tokenFlag, insecureTLSFlag)171+ url, token, insecureTLS, _, isRemote := remote.ResolveRemoteFull(remoteFlag, tokenFlag, insecureTLSFlag)
172 if !isRemote {172 if !isRemote {
173 return nil, false173 return nil, false
174 }174 }
175 175
176 opts := remoteClientOptions(token, insecureTLS)176 opts := remoteClientOptions(token, insecureTLS)
177-
178- // When using a named remote (from remotes.toml), register a callback to persist
179- // refreshed tokens returned by the server in the X-Gordon-Token header.
180- if remoteName != "" {
181- name := remoteName // capture for closure
182- opts = append(opts, remote.WithTokenRefreshCallback(func(newToken string) {
183- _ = remote.UpdateRemoteToken(name, newToken) // best-effort; errors are non-fatal
184- }))
185- }
186-
187 client := remote.NewClient(url, opts...)177 client := remote.NewClient(url, opts...)
188 return client, true178 return client, true
189 }179 }
M internal/adapters/in/http/admin/middleware.go +1 -10
@@ -117,18 +117,9 @@ func AuthMiddleware(
117117 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
118118 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
119119
120- // Prevent intermediaries from storing admin API responses, especially
121- // when a rotated bearer token is returned in X-Gordon-Token.
120+ // Prevent intermediaries from caching admin API responses.
122121 setNoStoreHeaders(w)
123122
124- // Attempt to slide token expiry. Non-fatal if it fails.
125- // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
126- if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {
127- log.Warn().Err(extErr).Str("subject", claims.Subject).Msg("token extension failed")
128- } else if newToken != token {
129- w.Header().Set("X-Gordon-Token", newToken)
130- }
131-
132123 next.ServeHTTP(w, r.WithContext(ctx))
133124 })
134125 }
@@ -117,18 +117,9 @@ func AuthMiddleware(
117 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)117 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
118 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)118 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
119 119
120- // Prevent intermediaries from storing admin API responses, especially120+ // Prevent intermediaries from caching admin API responses.
121- // when a rotated bearer token is returned in X-Gordon-Token.
122 setNoStoreHeaders(w)121 setNoStoreHeaders(w)
123 122
124- // Attempt to slide token expiry. Non-fatal if it fails.
125- // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
126- if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {
127- log.Warn().Err(extErr).Str("subject", claims.Subject).Msg("token extension failed")
128- } else if newToken != token {
129- w.Header().Set("X-Gordon-Token", newToken)
130- }
131-
132 next.ServeHTTP(w, r.WithContext(ctx))123 next.ServeHTTP(w, r.WithContext(ctx))
133 })124 })
134 }125 }
M internal/adapters/in/http/admin/middleware_test.go +0 -68
@@ -2,7 +2,6 @@ package admin
22
33 import (
44 "context"
5- "errors"
65 "net/http"
76 "net/http/httptest"
87 "testing"
@@ -89,8 +88,6 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
8988 Scopes: []string{"admin:*:*"},
9089 IsEphemeral: true,
9190 }, nil)
92- // ExtendToken is called after successful validation; non-fatal if it fails
93- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
9491
9592 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
9693 w.WriteHeader(http.StatusOK)
@@ -119,8 +116,6 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
119116 Scopes: []string{"admin:*:*"},
120117 IsEphemeral: true,
121118 }, nil)
122- // ExtendToken is called after successful validation; non-fatal if it fails
123- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
124119
125120 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
126121 w.WriteHeader(http.StatusOK)
@@ -256,7 +251,6 @@ func TestAuthMiddleware_AcceptsEphemeralToken(t *testing.T) {
256251 Scopes: []string{"admin:*:*"},
257252 IsEphemeral: true, // short-lived ephemeral token
258253 }, nil)
259- authSvc.EXPECT().ExtendToken(mock.Anything, "ephemeral-token").Return("ephemeral-token", nil)
260254
261255 handlerCalled := false
262256 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -320,8 +314,6 @@ func TestAuthMiddleware_Success(t *testing.T) {
320314 Scopes: []string{"admin:*:*"},
321315 IsEphemeral: true,
322316 }, nil)
323- // ExtendToken is called after successful validation; non-fatal if it fails
324- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
325317
326318 var capturedCtx context.Context
327319 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -433,66 +425,6 @@ func TestRequireScope(t *testing.T) {
433425 }
434426 }
435427
436-func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
437- authSvc := inmocks.NewMockAuthService(t)
438-
439- authSvc.EXPECT().IsEnabled().Return(true)
440- authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
441- Subject: "admin",
442- Scopes: []string{"admin:*:*"},
443- IsEphemeral: true,
444- }, nil)
445- // ExtendToken fails — response must still be 200 (non-fatal)
446- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable"))
447-
448- handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
449- w.WriteHeader(http.StatusOK)
450- })
451-
452- mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
453- wrappedHandler := mw(handler)
454-
455- req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
456- req.Header.Set("Authorization", "Bearer valid-admin-token")
457- rec := httptest.NewRecorder()
458- wrappedHandler.ServeHTTP(rec, req)
459-
460- assert.Equal(t, http.StatusOK, rec.Code)
461- assert.Empty(t, rec.Header().Get("X-Gordon-Token"))
462- assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
463- assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
464-}
465-
466-func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
467- authSvc := inmocks.NewMockAuthService(t)
468-
469- authSvc.EXPECT().IsEnabled().Return(true)
470- authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{
471- Subject: "admin",
472- Scopes: []string{"admin:*:*"},
473- IsEphemeral: true,
474- }, nil)
475- // ExtendToken returns a rotated token — must appear in response header
476- authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil)
477-
478- handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
479- w.WriteHeader(http.StatusOK)
480- })
481-
482- mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
483- wrappedHandler := mw(handler)
484-
485- req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
486- req.Header.Set("Authorization", "Bearer old-token")
487- rec := httptest.NewRecorder()
488- wrappedHandler.ServeHTTP(rec, req)
489-
490- assert.Equal(t, http.StatusOK, rec.Code)
491- assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))
492- assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
493- assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
494-}
495-
496428 func TestHasAccess(t *testing.T) {
497429 ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
498430
@@ -2,7 +2,6 @@ package admin
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "net/http"5 "net/http"
7 "net/http/httptest"6 "net/http/httptest"
8 "testing"7 "testing"
@@ -89,8 +88,6 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
89 Scopes: []string{"admin:*:*"},88 Scopes: []string{"admin:*:*"},
90 IsEphemeral: true,89 IsEphemeral: true,
91 }, nil)90 }, nil)
92- // ExtendToken is called after successful validation; non-fatal if it fails
93- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
94 91
95 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {92 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
96 w.WriteHeader(http.StatusOK)93 w.WriteHeader(http.StatusOK)
@@ -119,8 +116,6 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
119 Scopes: []string{"admin:*:*"},116 Scopes: []string{"admin:*:*"},
120 IsEphemeral: true,117 IsEphemeral: true,
121 }, nil)118 }, nil)
122- // ExtendToken is called after successful validation; non-fatal if it fails
123- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
124 119
125 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {120 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
126 w.WriteHeader(http.StatusOK)121 w.WriteHeader(http.StatusOK)
@@ -256,7 +251,6 @@ func TestAuthMiddleware_AcceptsEphemeralToken(t *testing.T) {
256 Scopes: []string{"admin:*:*"},251 Scopes: []string{"admin:*:*"},
257 IsEphemeral: true, // short-lived ephemeral token252 IsEphemeral: true, // short-lived ephemeral token
258 }, nil)253 }, nil)
259- authSvc.EXPECT().ExtendToken(mock.Anything, "ephemeral-token").Return("ephemeral-token", nil)
260 254
261 handlerCalled := false255 handlerCalled := false
262 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {256 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -320,8 +314,6 @@ func TestAuthMiddleware_Success(t *testing.T) {
320 Scopes: []string{"admin:*:*"},314 Scopes: []string{"admin:*:*"},
321 IsEphemeral: true,315 IsEphemeral: true,
322 }, nil)316 }, nil)
323- // ExtendToken is called after successful validation; non-fatal if it fails
324- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
325 317
326 var capturedCtx context.Context318 var capturedCtx context.Context
327 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {319 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -433,66 +425,6 @@ func TestRequireScope(t *testing.T) {
433 }425 }
434 }426 }
435 427
436-func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
437- authSvc := inmocks.NewMockAuthService(t)
438-
439- authSvc.EXPECT().IsEnabled().Return(true)
440- authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
441- Subject: "admin",
442- Scopes: []string{"admin:*:*"},
443- IsEphemeral: true,
444- }, nil)
445- // ExtendToken fails — response must still be 200 (non-fatal)
446- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable"))
447-
448- handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
449- w.WriteHeader(http.StatusOK)
450- })
451-
452- mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
453- wrappedHandler := mw(handler)
454-
455- req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
456- req.Header.Set("Authorization", "Bearer valid-admin-token")
457- rec := httptest.NewRecorder()
458- wrappedHandler.ServeHTTP(rec, req)
459-
460- assert.Equal(t, http.StatusOK, rec.Code)
461- assert.Empty(t, rec.Header().Get("X-Gordon-Token"))
462- assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
463- assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
464-}
465-
466-func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
467- authSvc := inmocks.NewMockAuthService(t)
468-
469- authSvc.EXPECT().IsEnabled().Return(true)
470- authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{
471- Subject: "admin",
472- Scopes: []string{"admin:*:*"},
473- IsEphemeral: true,
474- }, nil)
475- // ExtendToken returns a rotated token — must appear in response header
476- authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil)
477-
478- handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
479- w.WriteHeader(http.StatusOK)
480- })
481-
482- mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
483- wrappedHandler := mw(handler)
484-
485- req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
486- req.Header.Set("Authorization", "Bearer old-token")
487- rec := httptest.NewRecorder()
488- wrappedHandler.ServeHTTP(rec, req)
489-
490- assert.Equal(t, http.StatusOK, rec.Code)
491- assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))
492- assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
493- assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
494-}
495-
496 func TestHasAccess(t *testing.T) {428 func TestHasAccess(t *testing.T) {
497 ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})429 ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
498 430

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