detached 63e7f3a

Merge pull request #142 from bnema/token-exchange-client

feat(auth): client-side token exchange and server hardening
commit
63e7f3acc1315af92e87757b6858a7277076bdb6
author
bnema · · 2026-03-23 05:38 UTC
committer
GitHub · · 2026-03-23 05:38 UTC
merge of
f03ab25 + 37ca27d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.30.0-pre3
21 files · +847 -394
M docs/cli/auth.md +45 -8
@@ -8,7 +8,10 @@ Manage Gordon server authentication tokens.
88
99 | Subcommand | Description |
1010 |------------|-------------|
11-| `login` | Authenticate with a remote Gordon server |
11+| `login` | Store a token for a remote Gordon server |
12+| `show-token` | Print the stored token for a remote |
13+| `logout` | Remove stored token for a remote |
14+| `status` | Check authentication session status |
1215 | `token generate` | Generate a new JWT token |
1316 | `token list` | List all stored tokens |
1417 | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |
@@ -18,32 +21,31 @@ Manage Gordon server authentication tokens.
1821
1922 ## gordon auth login
2023
21-Authenticate with a remote Gordon server using a pre-generated token.
24+Store a pre-generated token for a remote Gordon server.
2225
2326 ```bash
24-gordon auth login [options]
27+gordon auth login --token <token> [options]
2528 ```
2629
2730 ### Options
2831
2932 | Option | Description |
3033 |--------|-------------|
34+| `-t, --token` | **Required.** Authentication token to store |
3135 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |
32-| `-t, --token` | Authentication token to store for the remote |
3336
3437 ### Description
3538
36-This command stores a pre-generated token for use with a remote Gordon server. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning.
39+Stores a token for use with a remote Gordon server. The `--token` flag is required — generate a token on the server with `gordon auth token generate`.
40+
41+When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and plaintext fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext with a warning.
3742
3843 The token is verified against `/admin/status` on a best-effort basis.
3944
4045 ### Examples
4146
4247 ```bash
43-# Store a token for the active remote
4448 gordon auth login --token <token>
45-
46-# Store a token for a specific remote
4749 gordon auth login --remote prod --token <token>
4850 ```
4951
@@ -64,6 +66,41 @@ Token stored for remote 'prod'
6466
6567 ---
6668
69+## gordon auth show-token
70+
71+Print the stored token for a remote.
72+
73+```bash
74+gordon auth show-token [--remote <name>]
75+```
76+
77+Output is the raw token string, suitable for piping. Returns an error if no token is stored.
78+
79+```bash
80+gordon auth show-token
81+gordon auth show-token --remote prod
82+gordon auth show-token | pbcopy # copy to clipboard
83+```
84+
85+---
86+
87+## gordon auth logout
88+
89+Remove the stored token for a remote.
90+
91+```bash
92+gordon auth logout [--remote <name>] [--revoke]
93+```
94+
95+| Option | Description |
96+|--------|-------------|
97+| `-r, --remote` | Remote to log out from (defaults to active remote) |
98+| `--revoke` | Also revoke the token server-side (not yet implemented) |
99+
100+Deletes the token from pass and clears token fields in `remotes.toml`.
101+
102+---
103+
67104 ## gordon auth token generate
68105
69106 Generate a new JWT authentication token for registry access.
@@ -8,7 +8,10 @@ Manage Gordon server authentication tokens.
8 8
9 | Subcommand | Description |9 | Subcommand | Description |
10 |------------|-------------|10 |------------|-------------|
11-| `login` | Authenticate with a remote Gordon server |11+| `login` | Store a token for a remote Gordon server |
12+| `show-token` | Print the stored token for a remote |
13+| `logout` | Remove stored token for a remote |
14+| `status` | Check authentication session status |
12 | `token generate` | Generate a new JWT token |15 | `token generate` | Generate a new JWT token |
13 | `token list` | List all stored tokens |16 | `token list` | List all stored tokens |
14 | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |17 | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |
@@ -18,32 +21,31 @@ Manage Gordon server authentication tokens.
18 21
19 ## gordon auth login22 ## gordon auth login
20 23
21-Authenticate with a remote Gordon server using a pre-generated token.24+Store a pre-generated token for a remote Gordon server.
22 25
23 ```bash26 ```bash
24-gordon auth login [options]27+gordon auth login --token <token> [options]
25 ```28 ```
26 29
27 ### Options30 ### Options
28 31
29 | Option | Description |32 | Option | Description |
30 |--------|-------------|33 |--------|-------------|
34+| `-t, --token` | **Required.** Authentication token to store |
31 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |35 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |
32-| `-t, --token` | Authentication token to store for the remote |
33 36
34 ### Description37 ### Description
35 38
36-This command stores a pre-generated token for use with a remote Gordon server. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning.39+Stores a token for use with a remote Gordon server. The `--token` flag is required — generate a token on the server with `gordon auth token generate`.
40+
41+When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and plaintext fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext with a warning.
37 42
38 The token is verified against `/admin/status` on a best-effort basis.43 The token is verified against `/admin/status` on a best-effort basis.
39 44
40 ### Examples45 ### Examples
41 46
42 ```bash47 ```bash
43-# Store a token for the active remote
44 gordon auth login --token <token>48 gordon auth login --token <token>
45-
46-# Store a token for a specific remote
47 gordon auth login --remote prod --token <token>49 gordon auth login --remote prod --token <token>
48 ```50 ```
49 51
@@ -64,6 +66,41 @@ Token stored for remote 'prod'
64 66
65 ---67 ---
66 68
69+## gordon auth show-token
70+
71+Print the stored token for a remote.
72+
73+```bash
74+gordon auth show-token [--remote <name>]
75+```
76+
77+Output is the raw token string, suitable for piping. Returns an error if no token is stored.
78+
79+```bash
80+gordon auth show-token
81+gordon auth show-token --remote prod
82+gordon auth show-token | pbcopy # copy to clipboard
83+```
84+
85+---
86+
87+## gordon auth logout
88+
89+Remove the stored token for a remote.
90+
91+```bash
92+gordon auth logout [--remote <name>] [--revoke]
93+```
94+
95+| Option | Description |
96+|--------|-------------|
97+| `-r, --remote` | Remote to log out from (defaults to active remote) |
98+| `--revoke` | Also revoke the token server-side (not yet implemented) |
99+
100+Deletes the token from pass and clears token fields in `remotes.toml`.
101+
102+---
103+
67 ## gordon auth token generate104 ## gordon auth token generate
68 105
69 Generate a new JWT authentication token for registry access.106 Generate a new JWT authentication token for registry access.
M docs/upgrading.md +5 -1
@@ -13,12 +13,16 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
1313 - `auth.type = "password"` is no longer accepted
1414 - `auth.password` and `auth.password_hash` config fields are removed
1515 - The `gordon auth password hash` CLI command is removed
16-- The `gordon auth login` command no longer accepts `--username` or `--password` flags
16+- The `gordon auth login` command now requires `--token` (no more interactive password prompt)
1717 - The `/auth/password` endpoint now returns `410 Gone`
18+- Long-lived tokens are no longer accepted on admin/registry endpoints — they must be exchanged for ephemeral tokens via `/auth/token`
1819
1920 **New features:**
2021
2122 - `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`)
23+- `gordon auth show-token` prints the stored token for a remote
24+- `gordon auth logout` removes the stored token (with optional `--revoke`)
25+- Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls
2226 - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations
2327
2428 **Migration steps:**
@@ -13,12 +13,16 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
13 - `auth.type = "password"` is no longer accepted13 - `auth.type = "password"` is no longer accepted
14 - `auth.password` and `auth.password_hash` config fields are removed14 - `auth.password` and `auth.password_hash` config fields are removed
15 - The `gordon auth password hash` CLI command is removed15 - The `gordon auth password hash` CLI command is removed
16-- The `gordon auth login` command no longer accepts `--username` or `--password` flags16+- The `gordon auth login` command now requires `--token` (no more interactive password prompt)
17 - The `/auth/password` endpoint now returns `410 Gone`17 - The `/auth/password` endpoint now returns `410 Gone`
18+- Long-lived tokens are no longer accepted on admin/registry endpoints — they must be exchanged for ephemeral tokens via `/auth/token`
18 19
19 **New features:**20 **New features:**
20 21
21 - `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`)22 - `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`)
23+- `gordon auth show-token` prints the stored token for a remote
24+- `gordon auth logout` removes the stored token (with optional `--revoke`)
25+- Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls
22 - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations26 - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations
23 27
24 **Migration steps:**28 **Migration steps:**
M internal/adapters/in/cli/auth.go +122 -108
@@ -1,7 +1,6 @@
11 package cli
22
33 import (
4- "bufio"
54 "context"
65 "fmt"
76 "io"
@@ -10,9 +9,9 @@ import (
109 "time"
1110
1211 "github.com/bnema/zerowrap"
12+ "github.com/mattn/go-isatty"
1313 "github.com/spf13/cobra"
1414 "github.com/spf13/viper"
15- "golang.org/x/term"
1615
1716 "github.com/bnema/gordon/internal/adapters/dto"
1817 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -25,16 +24,6 @@ import (
2524 "github.com/bnema/gordon/pkg/duration"
2625 )
2726
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-
3827 // newAuthCmd creates the auth command group.
3928 func newAuthCmd() *cobra.Command {
4029 cmd := &cobra.Command{
@@ -47,6 +36,8 @@ func newAuthCmd() *cobra.Command {
4736 cmd.AddCommand(newAuthInternalCmd())
4837 cmd.AddCommand(newAuthLoginCmd())
4938 cmd.AddCommand(newAuthStatusCmd())
39+ cmd.AddCommand(newAuthShowTokenCmd())
40+ cmd.AddCommand(newAuthLogoutCmd())
5041
5142 return cmd
5243 }
@@ -214,156 +205,178 @@ available while Gordon is running.`,
214205 func newAuthLoginCmd() *cobra.Command {
215206 var (
216207 remoteName string
217- username string
218208 token string
219- password string
220209 )
221210
222211 cmd := &cobra.Command{
223212 Use: "login",
224213 Short: "Authenticate with a Gordon server",
225- Long: `Authenticate with a Gordon server using password authentication or a pre-generated token.
214+ Long: `Store or verify a token for a Gordon server remote.
226215
227-This command prompts for your username and password, authenticates with the
228-remote server's /auth/password endpoint, and stores the returned token.
216+With --token: stores the token and verifies it.
217+Without --token: verifies the existing stored token still works.
229218
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.
219+Generate a token on the server with: gordon auth token generate
235220
236221 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`,
222+ gordon auth login --token <token> Store token for active remote
223+ gordon auth login --remote prod --token <tok> Store token for specific remote
224+ gordon auth login Verify existing token`,
241225 RunE: func(cmd *cobra.Command, args []string) error {
242226 if token != "" {
243- return runAuthLoginWithToken(remoteName, token)
227+ return runAuthLoginWithToken(cmd.Context(), remoteName, token, cmd.OutOrStdout())
244228 }
245- return runAuthLogin(remoteName, username, password)
229+ return runAuthLoginVerify(cmd.Context(), remoteName, cmd.OutOrStdout())
246230 },
247231 }
248232
249233 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)")
252234 cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
253235
254- cmd.MarkFlagsMutuallyExclusive("token", "password")
255- cmd.MarkFlagsMutuallyExclusive("token", "username")
256-
257236 return cmd
258237 }
259238
260-// runAuthLogin authenticates with a remote Gordon server.
261-func runAuthLogin(remoteName, username, password string) error {
239+func runAuthLoginWithToken(ctx context.Context, remoteName, token string, out io.Writer) error {
240+ token = strings.TrimSpace(token)
241+ if token == "" {
242+ return fmt.Errorf("token cannot be empty")
243+ }
244+
262245 resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
263246 if err != nil {
264247 return err
265248 }
266249
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)
250+ insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
251+ client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
252+ verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
253+ defer cancel()
254+ verified := true
255+ if _, err := client.GetStatus(verifyCtx); err != nil {
256+ _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
257+ verified = false
276258 }
277259
278- if username == "" {
279- return fmt.Errorf("username cannot be empty")
260+ if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
261+ return fmt.Errorf("failed to save token: %w", err)
280262 }
281263
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")
264+ _ = cliWriteLine(out, "")
265+ _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
266+ if verified {
267+ _ = cliWriteLine(out, styles.RenderSuccess("Token verified with remote status endpoint"))
268+ }
269+ return nil
270+}
271+
272+func runAuthLoginVerify(ctx context.Context, remoteName string, out io.Writer) error {
273+ resolvedName, entry, err := resolveRemoteEntry(remoteName)
274+ if err != nil {
275+ return err
305276 }
306277
307- if password == "" {
308- return fmt.Errorf("password cannot be empty")
278+ token := remote.ResolveTokenForRemote(resolvedName, entry)
279+ if token == "" {
280+ return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolvedName)
309281 }
310282
311- // Create remote client
312283 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
313- client := remote.NewClient(remoteConfig.URL, remoteClientOptions("", insecureTLS)...)
284+ client := remote.NewClient(entry.URL, remoteClientOptions(token, insecureTLS)...)
285+ verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
286+ defer cancel()
314287
315- // Authenticate
316- ctx := context.Background()
317- fmt.Printf("Authenticating with %s...\n", resolvedName)
288+ if _, err := client.GetStatus(verifyCtx); err != nil {
289+ _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed for '%s': %v", resolvedName, err)))
290+ return fmt.Errorf("authentication failed")
291+ }
292+
293+ _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolvedName)))
294+ return nil
295+}
296+
297+// newAuthShowTokenCmd creates the show-token command.
298+func newAuthShowTokenCmd() *cobra.Command {
299+ var remoteName string
300+
301+ cmd := &cobra.Command{
302+ Use: "show-token",
303+ Short: "Print the stored token for a remote",
304+ Long: `Print the raw authentication token for a remote to stdout.
305+
306+The token is resolved from pass, TOML config, or environment variable
307+using the same precedence as other commands.
318308
319- resp, err := client.Authenticate(ctx, username, password)
309+Output is suitable for piping (no decoration, single line).
310+
311+Examples:
312+ gordon auth show-token Show token for active remote
313+ gordon auth show-token --remote prod Show token for specific remote
314+ gordon auth show-token | pbcopy Copy token to clipboard`,
315+ RunE: func(cmd *cobra.Command, args []string) error {
316+ return runAuthShowToken(remoteName, cmd.OutOrStdout())
317+ },
318+ }
319+
320+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to show token for (defaults to active remote)")
321+
322+ return cmd
323+}
324+
325+func runAuthShowToken(remoteName string, out io.Writer) error {
326+ resolvedName, entry, err := resolveRemoteEntry(remoteName)
320327 if err != nil {
321- return fmt.Errorf("authentication failed: %w", err)
328+ return err
322329 }
323330
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)
331+ token := remote.ResolveTokenForRemote(resolvedName, entry)
332+ if token == "" {
333+ return fmt.Errorf("no token found for remote '%s'", resolvedName)
327334 }
328335
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)
336+ if f, ok := out.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
337+ _ = cliWriteLine(os.Stderr, styles.RenderWarning("Warning: token will be visible in terminal scrollback"))
338+ }
333339
340+ _ = cliWriteLine(out, token)
334341 return nil
335342 }
336343
337-func runAuthLoginWithToken(remoteName, token string) error {
338- token = strings.TrimSpace(token)
339- if token == "" {
340- return fmt.Errorf("token cannot be empty")
344+// newAuthLogoutCmd creates the logout command.
345+func newAuthLogoutCmd() *cobra.Command {
346+ var remoteName string
347+
348+ cmd := &cobra.Command{
349+ Use: "logout",
350+ Short: "Remove stored authentication for a remote",
351+ Long: `Remove the stored token for a remote from pass and TOML config.
352+
353+This clears the token from all local storage but does not revoke the
354+token on the server. To revoke server-side, use: gordon auth token revoke
355+
356+Examples:
357+ gordon auth logout Logout from active remote
358+ gordon auth logout --remote prod Logout from specific remote`,
359+ RunE: func(cmd *cobra.Command, args []string) error {
360+ return runAuthLogout(remoteName, cmd.OutOrStdout())
361+ },
341362 }
342363
343- resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
364+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to logout from (defaults to active remote)")
365+
366+ return cmd
367+}
368+
369+func runAuthLogout(remoteName string, out io.Writer) error {
370+ resolvedName, _, err := resolveRemoteEntry(remoteName)
344371 if err != nil {
345372 return err
346373 }
347374
348- insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
349- client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
350- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
351- defer cancel()
352- verified := true
353- if _, err := client.GetStatus(ctx); err != nil {
354- fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
355- verified = false
356- }
357-
358- if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
359- return fmt.Errorf("failed to save token: %w", err)
375+ if err := remote.ClearRemoteToken(resolvedName); err != nil {
376+ return fmt.Errorf("failed to clear token: %w", err)
360377 }
361378
362- fmt.Println()
363- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
364- if verified {
365- fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
366- }
379+ _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName)))
367380 return nil
368381 }
369382
@@ -743,8 +756,9 @@ func runAuthStatus(remoteNameArg string) error {
743756 }
744757
745758 // Create client
759+ token := remote.ResolveTokenForRemote(remoteName, remoteConfig)
746760 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, remoteName)
747- client := remote.NewClient(remoteConfig.URL, remoteClientOptions(remoteConfig.Token, insecureTLS)...)
761+ client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
748762
749763 // Verify auth
750764 ctx := context.Background()
@@ -1,7 +1,6 @@
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"
@@ -10,9 +9,9 @@ import (
10 "time"9 "time"
11 10
12 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12+ "github.com/mattn/go-isatty"
13 "github.com/spf13/cobra"13 "github.com/spf13/cobra"
14 "github.com/spf13/viper"14 "github.com/spf13/viper"
15- "golang.org/x/term"
16 15
17 "github.com/bnema/gordon/internal/adapters/dto"16 "github.com/bnema/gordon/internal/adapters/dto"
18 "github.com/bnema/gordon/internal/adapters/in/cli/remote"17 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -25,16 +24,6 @@ import (
25 "github.com/bnema/gordon/pkg/duration"24 "github.com/bnema/gordon/pkg/duration"
26 )25 )
27 26
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.27 // newAuthCmd creates the auth command group.
39 func newAuthCmd() *cobra.Command {28 func newAuthCmd() *cobra.Command {
40 cmd := &cobra.Command{29 cmd := &cobra.Command{
@@ -47,6 +36,8 @@ func newAuthCmd() *cobra.Command {
47 cmd.AddCommand(newAuthInternalCmd())36 cmd.AddCommand(newAuthInternalCmd())
48 cmd.AddCommand(newAuthLoginCmd())37 cmd.AddCommand(newAuthLoginCmd())
49 cmd.AddCommand(newAuthStatusCmd())38 cmd.AddCommand(newAuthStatusCmd())
39+ cmd.AddCommand(newAuthShowTokenCmd())
40+ cmd.AddCommand(newAuthLogoutCmd())
50 41
51 return cmd42 return cmd
52 }43 }
@@ -214,156 +205,178 @@ available while Gordon is running.`,
214 func newAuthLoginCmd() *cobra.Command {205 func newAuthLoginCmd() *cobra.Command {
215 var (206 var (
216 remoteName string207 remoteName string
217- username string
218 token string208 token string
219- password string
220 )209 )
221 210
222 cmd := &cobra.Command{211 cmd := &cobra.Command{
223 Use: "login",212 Use: "login",
224 Short: "Authenticate with a Gordon server",213 Short: "Authenticate with a Gordon server",
225- Long: `Authenticate with a Gordon server using password authentication or a pre-generated token.214+ Long: `Store or verify a token for a Gordon server remote.
226 215
227-This command prompts for your username and password, authenticates with the216+With --token: stores the token and verifies it.
228-remote server's /auth/password endpoint, and stores the returned token.217+Without --token: verifies the existing stored token still works.
229 218
230-The token is stored in your remote configuration and used for subsequent219+Generate a token on the server with: gordon auth token generate
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.
235 220
236 Examples:221 Examples:
237- gordon auth login Login using active remote222+ gordon auth login --token <token> Store token for active remote
238- gordon auth login --remote prod Login to specific remote223+ gordon auth login --remote prod --token <tok> Store token for specific remote
239- gordon auth login --username admin Pre-fill username224+ gordon auth login Verify existing token`,
240- gordon auth login --token <token> Store a token for token-only servers`,
241 RunE: func(cmd *cobra.Command, args []string) error {225 RunE: func(cmd *cobra.Command, args []string) error {
242 if token != "" {226 if token != "" {
243- return runAuthLoginWithToken(remoteName, token)227+ return runAuthLoginWithToken(cmd.Context(), remoteName, token, cmd.OutOrStdout())
244 }228 }
245- return runAuthLogin(remoteName, username, password)229+ return runAuthLoginVerify(cmd.Context(), remoteName, cmd.OutOrStdout())
246 },230 },
247 }231 }
248 232
249 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")233 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")234 cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
253 235
254- cmd.MarkFlagsMutuallyExclusive("token", "password")
255- cmd.MarkFlagsMutuallyExclusive("token", "username")
256-
257 return cmd236 return cmd
258 }237 }
259 238
260-// runAuthLogin authenticates with a remote Gordon server.239+func runAuthLoginWithToken(ctx context.Context, remoteName, token string, out io.Writer) error {
261-func runAuthLogin(remoteName, username, password string) error {240+ token = strings.TrimSpace(token)
241+ if token == "" {
242+ return fmt.Errorf("token cannot be empty")
243+ }
244+
262 resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)245 resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
263 if err != nil {246 if err != nil {
264 return err247 return err
265 }248 }
266 249
267- // Prompt for username if not provided250+ insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
268- if username == "" {251+ client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
269- fmt.Print("Username: ")252+ verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
270- reader := bufio.NewReader(os.Stdin)253+ defer cancel()
271- input, err := reader.ReadString('\n')254+ verified := true
272- if err != nil {255+ if _, err := client.GetStatus(verifyCtx); err != nil {
273- return fmt.Errorf("failed to read username: %w", err)256+ _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
274- }257+ verified = false
275- username = strings.TrimSpace(input)
276 }258 }
277 259
278- if username == "" {260+ if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
279- return fmt.Errorf("username cannot be empty")261+ return fmt.Errorf("failed to save token: %w", err)
280 }262 }
281 263
282- if password == "" {264+ _ = cliWriteLine(out, "")
283- // Prompt for password (hidden input)265+ _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
284- fmt.Print("Password: ")266+ if verified {
285- fd, err := stdinFD()267+ _ = cliWriteLine(out, styles.RenderSuccess("Token verified with remote status endpoint"))
286- if err != nil {268+ }
287- return fmt.Errorf("failed to get stdin fd: %w", err)269+ return nil
288- }270+}
289- passwordBytes, err := term.ReadPassword(fd)271+
290- if err != nil {272+func runAuthLoginVerify(ctx context.Context, remoteName string, out io.Writer) error {
291- // Fallback for non-terminal input273+ resolvedName, entry, err := resolveRemoteEntry(remoteName)
292- reader := bufio.NewReader(os.Stdin)274+ if err != nil {
293- passwordInput, readErr := reader.ReadString('\n')275+ return err
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 }276 }
306 277
307- if password == "" {278+ token := remote.ResolveTokenForRemote(resolvedName, entry)
308- return fmt.Errorf("password cannot be empty")279+ if token == "" {
280+ return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolvedName)
309 }281 }
310 282
311- // Create remote client
312 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)283 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
313- client := remote.NewClient(remoteConfig.URL, remoteClientOptions("", insecureTLS)...)284+ client := remote.NewClient(entry.URL, remoteClientOptions(token, insecureTLS)...)
285+ verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
286+ defer cancel()
314 287
315- // Authenticate288+ if _, err := client.GetStatus(verifyCtx); err != nil {
316- ctx := context.Background()289+ _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed for '%s': %v", resolvedName, err)))
317- fmt.Printf("Authenticating with %s...\n", resolvedName)290+ return fmt.Errorf("authentication failed")
291+ }
292+
293+ _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolvedName)))
294+ return nil
295+}
296+
297+// newAuthShowTokenCmd creates the show-token command.
298+func newAuthShowTokenCmd() *cobra.Command {
299+ var remoteName string
300+
301+ cmd := &cobra.Command{
302+ Use: "show-token",
303+ Short: "Print the stored token for a remote",
304+ Long: `Print the raw authentication token for a remote to stdout.
305+
306+The token is resolved from pass, TOML config, or environment variable
307+using the same precedence as other commands.
318 308
319- resp, err := client.Authenticate(ctx, username, password)309+Output is suitable for piping (no decoration, single line).
310+
311+Examples:
312+ gordon auth show-token Show token for active remote
313+ gordon auth show-token --remote prod Show token for specific remote
314+ gordon auth show-token | pbcopy Copy token to clipboard`,
315+ RunE: func(cmd *cobra.Command, args []string) error {
316+ return runAuthShowToken(remoteName, cmd.OutOrStdout())
317+ },
318+ }
319+
320+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to show token for (defaults to active remote)")
321+
322+ return cmd
323+}
324+
325+func runAuthShowToken(remoteName string, out io.Writer) error {
326+ resolvedName, entry, err := resolveRemoteEntry(remoteName)
320 if err != nil {327 if err != nil {
321- return fmt.Errorf("authentication failed: %w", err)328+ return err
322 }329 }
323 330
324- // Update remote config with new token331+ token := remote.ResolveTokenForRemote(resolvedName, entry)
325- if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil {332+ if token == "" {
326- return fmt.Errorf("failed to save token: %w", err)333+ return fmt.Errorf("no token found for remote '%s'", resolvedName)
327 }334 }
328 335
329- fmt.Println()336+ if f, ok := out.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
330- fmt.Println(styles.RenderSuccess("Authentication successful!"))337+ _ = cliWriteLine(os.Stderr, styles.RenderWarning("Warning: token will be visible in terminal scrollback"))
331- fmt.Printf("Token stored for remote '%s'\n", resolvedName)338+ }
332- fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
333 339
340+ _ = cliWriteLine(out, token)
334 return nil341 return nil
335 }342 }
336 343
337-func runAuthLoginWithToken(remoteName, token string) error {344+// newAuthLogoutCmd creates the logout command.
338- token = strings.TrimSpace(token)345+func newAuthLogoutCmd() *cobra.Command {
339- if token == "" {346+ var remoteName string
340- return fmt.Errorf("token cannot be empty")347+
348+ cmd := &cobra.Command{
349+ Use: "logout",
350+ Short: "Remove stored authentication for a remote",
351+ Long: `Remove the stored token for a remote from pass and TOML config.
352+
353+This clears the token from all local storage but does not revoke the
354+token on the server. To revoke server-side, use: gordon auth token revoke
355+
356+Examples:
357+ gordon auth logout Logout from active remote
358+ gordon auth logout --remote prod Logout from specific remote`,
359+ RunE: func(cmd *cobra.Command, args []string) error {
360+ return runAuthLogout(remoteName, cmd.OutOrStdout())
361+ },
341 }362 }
342 363
343- resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)364+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to logout from (defaults to active remote)")
365+
366+ return cmd
367+}
368+
369+func runAuthLogout(remoteName string, out io.Writer) error {
370+ resolvedName, _, err := resolveRemoteEntry(remoteName)
344 if err != nil {371 if err != nil {
345 return err372 return err
346 }373 }
347 374
348- insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)375+ if err := remote.ClearRemoteToken(resolvedName); err != nil {
349- client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)376+ return fmt.Errorf("failed to clear token: %w", err)
350- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
351- defer cancel()
352- verified := true
353- if _, err := client.GetStatus(ctx); err != nil {
354- fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
355- verified = false
356- }
357-
358- if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
359- return fmt.Errorf("failed to save token: %w", err)
360 }377 }
361 378
362- fmt.Println()379+ _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName)))
363- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
364- if verified {
365- fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
366- }
367 return nil380 return nil
368 }381 }
369 382
@@ -743,8 +756,9 @@ func runAuthStatus(remoteNameArg string) error {
743 }756 }
744 757
745 // Create client758 // Create client
759+ token := remote.ResolveTokenForRemote(remoteName, remoteConfig)
746 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, remoteName)760 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, remoteName)
747- client := remote.NewClient(remoteConfig.URL, remoteClientOptions(remoteConfig.Token, insecureTLS)...)761+ client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
748 762
749 // Verify auth763 // Verify auth
750 ctx := context.Background()764 ctx := context.Background()
M internal/adapters/in/cli/auth_test.go +6 -40
@@ -1,6 +1,8 @@
11 package cli
22
33 import (
4+ "bytes"
5+ "context"
46 "net/http"
57 "net/http/httptest"
68 "path/filepath"
@@ -11,49 +13,12 @@ import (
1113 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1214 )
1315
14-func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
16+func TestRunAuthLoginWithToken_StoresToken(t *testing.T) {
1517 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
1618 t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
1719
18- var receivedAuth string
20+ // Mock server — verification may fail (token exchange not mocked), but token should still be stored.
1921 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20- if r.URL.Path != "/admin/status" {
21- w.WriteHeader(http.StatusNotFound)
22- return
23- }
24- receivedAuth = r.Header.Get("Authorization")
25- w.Header().Set("Content-Type", "application/json")
26- w.WriteHeader(http.StatusOK)
27- _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
28- }))
29- defer server.Close()
30-
31- config := &remote.ClientConfig{
32- Active: "prod",
33- Remotes: map[string]remote.RemoteEntry{
34- "prod": {URL: server.URL},
35- },
36- }
37- assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
38-
39- err := runAuthLoginWithToken("", "token123")
40- assert.NoError(t, err)
41-
42- loaded, err := remote.LoadRemotes("")
43- assert.NoError(t, err)
44- assert.Equal(t, "token123", loaded.Remotes["prod"].Token)
45- assert.Equal(t, "Bearer token123", receivedAuth)
46-}
47-
48-func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
49- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
50- t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
51-
52- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53- if r.URL.Path != "/admin/status" {
54- w.WriteHeader(http.StatusNotFound)
55- return
56- }
5722 w.WriteHeader(http.StatusUnauthorized)
5823 _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
5924 }))
@@ -67,7 +32,8 @@ func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
6732 }
6833 assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
6934
70- err := runAuthLoginWithToken("", "token123")
35+ var out bytes.Buffer
36+ err := runAuthLoginWithToken(context.Background(), "", "token123", &out)
7137 assert.NoError(t, err)
7238
7339 loaded, err := remote.LoadRemotes("")
@@ -1,6 +1,8 @@
1 package cli1 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"
@@ -11,49 +13,12 @@ import (
11 "github.com/bnema/gordon/internal/adapters/in/cli/remote"13 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
12 )14 )
13 15
14-func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {16+func TestRunAuthLoginWithToken_StoresToken(t *testing.T) {
15 t.Setenv("XDG_CONFIG_HOME", t.TempDir())17 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
16 t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))18 t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
17 19
18- var receivedAuth string20+ // Mock server — verification may fail (token exchange not mocked), but token should still be stored.
19 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {21 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20- if r.URL.Path != "/admin/status" {
21- w.WriteHeader(http.StatusNotFound)
22- return
23- }
24- receivedAuth = r.Header.Get("Authorization")
25- w.Header().Set("Content-Type", "application/json")
26- w.WriteHeader(http.StatusOK)
27- _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
28- }))
29- defer server.Close()
30-
31- config := &remote.ClientConfig{
32- Active: "prod",
33- Remotes: map[string]remote.RemoteEntry{
34- "prod": {URL: server.URL},
35- },
36- }
37- assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
38-
39- err := runAuthLoginWithToken("", "token123")
40- assert.NoError(t, err)
41-
42- loaded, err := remote.LoadRemotes("")
43- assert.NoError(t, err)
44- assert.Equal(t, "token123", loaded.Remotes["prod"].Token)
45- assert.Equal(t, "Bearer token123", receivedAuth)
46-}
47-
48-func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
49- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
50- t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
51-
52- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53- if r.URL.Path != "/admin/status" {
54- w.WriteHeader(http.StatusNotFound)
55- return
56- }
57 w.WriteHeader(http.StatusUnauthorized)22 w.WriteHeader(http.StatusUnauthorized)
58 _, _ = w.Write([]byte(`{"error":"unauthorized"}`))23 _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
59 }))24 }))
@@ -67,7 +32,8 @@ func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
67 }32 }
68 assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))33 assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
69 34
70- err := runAuthLoginWithToken("", "token123")35+ var out bytes.Buffer
36+ err := runAuthLoginWithToken(context.Background(), "", "token123", &out)
71 assert.NoError(t, err)37 assert.NoError(t, err)
72 38
73 loaded, err := remote.LoadRemotes("")39 loaded, err := remote.LoadRemotes("")
M internal/adapters/in/cli/push_image_ops_test.go +25 -7
@@ -14,6 +14,24 @@ import (
1414 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1515 )
1616
17+// newTestServer creates a mock server that handles admin token exchange
18+// and custom handlers for specific paths.
19+func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
20+ t.Helper()
21+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+ // Handle admin token exchange for all tests — the client does this automatically.
23+ if r.URL.Path == "/auth/token" && r.URL.Query().Get("scope") == "admin:*:*" {
24+ w.Header().Set("Content-Type", "application/json")
25+ json.NewEncoder(w).Encode(dto.TokenResponse{
26+ Token: "ephemeral-admin-token",
27+ ExpiresIn: 900,
28+ }) //nolint:errcheck
29+ return
30+ }
31+ handler(w, r)
32+ }))
33+}
34+
1735 // TestExchangeRegistryAuth_Success verifies the happy path:
1836 // VerifyAuth is called, then ExchangeRegistryToken is called with the
1937 // subject from VerifyAuth, and the result is "Bearer <short-lived-token>".
@@ -24,7 +42,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
2442 verifyHandled := false
2543 tokenHandled := false
2644
27- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
2846 switch r.URL.Path {
2947 case "/admin/auth/verify":
3048 verifyHandled = true
@@ -36,8 +54,8 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
3654 }
3755 json.NewEncoder(w).Encode(resp) //nolint:errcheck
3856 case "/auth/token":
57+ // Registry token exchange (scope != admin:*:*)
3958 tokenHandled = true
40- // Verify basic auth was set with subject as username
4159 u, p, ok := r.BasicAuth()
4260 assert.True(t, ok, "expected basic auth")
4361 assert.Equal(t, subject, u, "expected subject as username")
@@ -49,7 +67,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
4967 default:
5068 http.NotFound(w, r)
5169 }
52- }))
70+ })
5371 defer srv.Close()
5472
5573 client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))
@@ -68,13 +86,13 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
6886 // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,
6987 // exchangeRegistryAuth returns an error.
7088 func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
71- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
7290 if r.URL.Path == "/admin/auth/verify" {
7391 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
7492 return
7593 }
7694 http.NotFound(w, r)
77- }))
95+ })
7896 defer srv.Close()
7997
8098 client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))
@@ -91,7 +109,7 @@ func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
91109 // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns
92110 // Valid=false, exchangeRegistryAuth returns an error.
93111 func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
94- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
95113 if r.URL.Path == "/admin/auth/verify" {
96114 w.Header().Set("Content-Type", "application/json")
97115 resp := dto.AuthVerifyResponse{Valid: false}
@@ -99,7 +117,7 @@ func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
99117 return
100118 }
101119 http.NotFound(w, r)
102- }))
120+ })
103121 defer srv.Close()
104122
105123 client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))
@@ -14,6 +14,24 @@ import (
14 "github.com/bnema/gordon/internal/adapters/in/cli/remote"14 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15 )15 )
16 16
17+// newTestServer creates a mock server that handles admin token exchange
18+// and custom handlers for specific paths.
19+func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
20+ t.Helper()
21+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+ // Handle admin token exchange for all tests — the client does this automatically.
23+ if r.URL.Path == "/auth/token" && r.URL.Query().Get("scope") == "admin:*:*" {
24+ w.Header().Set("Content-Type", "application/json")
25+ json.NewEncoder(w).Encode(dto.TokenResponse{
26+ Token: "ephemeral-admin-token",
27+ ExpiresIn: 900,
28+ }) //nolint:errcheck
29+ return
30+ }
31+ handler(w, r)
32+ }))
33+}
34+
17 // TestExchangeRegistryAuth_Success verifies the happy path:35 // TestExchangeRegistryAuth_Success verifies the happy path:
18 // VerifyAuth is called, then ExchangeRegistryToken is called with the36 // VerifyAuth is called, then ExchangeRegistryToken is called with the
19 // subject from VerifyAuth, and the result is "Bearer <short-lived-token>".37 // subject from VerifyAuth, and the result is "Bearer <short-lived-token>".
@@ -24,7 +42,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
24 verifyHandled := false42 verifyHandled := false
25 tokenHandled := false43 tokenHandled := false
26 44
27- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {45+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
28 switch r.URL.Path {46 switch r.URL.Path {
29 case "/admin/auth/verify":47 case "/admin/auth/verify":
30 verifyHandled = true48 verifyHandled = true
@@ -36,8 +54,8 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
36 }54 }
37 json.NewEncoder(w).Encode(resp) //nolint:errcheck55 json.NewEncoder(w).Encode(resp) //nolint:errcheck
38 case "/auth/token":56 case "/auth/token":
57+ // Registry token exchange (scope != admin:*:*)
39 tokenHandled = true58 tokenHandled = true
40- // Verify basic auth was set with subject as username
41 u, p, ok := r.BasicAuth()59 u, p, ok := r.BasicAuth()
42 assert.True(t, ok, "expected basic auth")60 assert.True(t, ok, "expected basic auth")
43 assert.Equal(t, subject, u, "expected subject as username")61 assert.Equal(t, subject, u, "expected subject as username")
@@ -49,7 +67,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
49 default:67 default:
50 http.NotFound(w, r)68 http.NotFound(w, r)
51 }69 }
52- }))70+ })
53 defer srv.Close()71 defer srv.Close()
54 72
55 client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))73 client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))
@@ -68,13 +86,13 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
68 // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,86 // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,
69 // exchangeRegistryAuth returns an error.87 // exchangeRegistryAuth returns an error.
70 func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {88 func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
71- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {89+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
72 if r.URL.Path == "/admin/auth/verify" {90 if r.URL.Path == "/admin/auth/verify" {
73 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)91 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
74 return92 return
75 }93 }
76 http.NotFound(w, r)94 http.NotFound(w, r)
77- }))95+ })
78 defer srv.Close()96 defer srv.Close()
79 97
80 client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))98 client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))
@@ -91,7 +109,7 @@ func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
91 // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns109 // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns
92 // Valid=false, exchangeRegistryAuth returns an error.110 // Valid=false, exchangeRegistryAuth returns an error.
93 func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {111 func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
94- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {112+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
95 if r.URL.Path == "/admin/auth/verify" {113 if r.URL.Path == "/admin/auth/verify" {
96 w.Header().Set("Content-Type", "application/json")114 w.Header().Set("Content-Type", "application/json")
97 resp := dto.AuthVerifyResponse{Valid: false}115 resp := dto.AuthVerifyResponse{Valid: false}
@@ -99,7 +117,7 @@ func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
99 return117 return
100 }118 }
101 http.NotFound(w, r)119 http.NotFound(w, r)
102- }))120+ })
103 defer srv.Close()121 defer srv.Close()
104 122
105 client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))123 client := remote.NewClient(srv.URL, remote.WithToken("expired-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 +173 -48
@@ -13,19 +13,27 @@ import (
1313 "net/url"
1414 "os"
1515 "strings"
16+ "sync"
1617 "time"
1718
19+ "github.com/golang-jwt/jwt/v5"
20+
1821 "github.com/bnema/gordon/internal/adapters/dto"
1922 "github.com/bnema/gordon/internal/domain"
2023 )
2124
2225 // Client is an HTTP client for the Gordon admin API.
2326 type Client struct {
24- baseURL string
25- token string
26- httpClient *http.Client
27- insecureTLS bool
28- onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token
27+ baseURL string
28+ token string
29+ httpClient *http.Client
30+ insecureTLS bool
31+
32+ // Ephemeral admin token exchange fields.
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
2937 }
3038
3139 var (
@@ -60,10 +68,31 @@ func NewClient(baseURL string, opts ...ClientOption) *Client {
6068 }
6169
6270 // WithToken sets the authentication token.
71+// The token must be a valid JWT — the subject claim is extracted for
72+// ephemeral token exchange. If parsing fails, requests will return
73+// an error rather than sending the long-lived token directly.
6374 func WithToken(token string) ClientOption {
6475 return func(c *Client) {
6576 c.token = token
77+ c.subject = extractJWTSubject(token)
78+ }
79+}
80+
81+// extractJWTSubject parses a JWT without verification and returns the
82+// "sub" claim. Returns "" if the token is not a valid JWT or has no sub.
83+func extractJWTSubject(tokenStr string) string {
84+ if tokenStr == "" {
85+ return ""
6686 }
87+ parser := jwt.NewParser()
88+ claims := jwt.MapClaims{}
89+ // We only need the claims; signature verification happens server-side.
90+ _, _, err := parser.ParseUnverified(tokenStr, claims)
91+ if err != nil {
92+ return ""
93+ }
94+ sub, _ := claims.GetSubject()
95+ return sub
6796 }
6897
6998 // WithHTTPClient sets a custom HTTP client.
@@ -87,35 +116,86 @@ func WithInsecureTLS(insecure bool) ClientOption {
87116 }
88117 }
89118
90-// WithTokenRefreshCallback sets a callback that is invoked when the server
91-// returns a refreshed token in the X-Gordon-Token response header.
92-// The CLI uses this to persist the new token to the remotes config atomically.
93-func WithTokenRefreshCallback(fn func(newToken string)) ClientOption {
94- return func(c *Client) {
95- c.onTokenRefreshed = fn
119+// ephemeralMargin is the safety margin before expiry at which
120+// the cached ephemeral token is considered stale.
121+const ephemeralMargin = 30 * time.Second
122+
123+// ephemeralValid reports whether the cached ephemeral token is still usable.
124+func (c *Client) ephemeralValid() bool {
125+ return c.ephemeral != "" && time.Now().Before(c.ephemeralExp.Add(-ephemeralMargin))
126+}
127+
128+// exchangeToken exchanges the long-lived token for a short-lived ephemeral
129+// admin token via /auth/token. Follows the same pattern as ExchangeRegistryToken.
130+// The caller must hold c.mu.
131+func (c *Client) exchangeToken(ctx context.Context) error {
132+ url := c.baseURL + "/auth/token?scope=admin:*:*&service=gordon-registry"
133+
134+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
135+ if err != nil {
136+ return fmt.Errorf("ephemeral token exchange: %w", err)
137+ }
138+
139+ req.SetBasicAuth(c.subject, c.token)
140+ req.Header.Set("Accept", "application/json")
141+
142+ resp, err := c.httpClient.Do(req)
143+ if err != nil {
144+ return fmt.Errorf("ephemeral token exchange: %w", err)
96145 }
146+ defer resp.Body.Close()
147+
148+ if resp.StatusCode >= 400 {
149+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
150+ var errResp struct {
151+ Error string `json:"error"`
152+ }
153+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
154+ return fmt.Errorf("ephemeral token exchange: %s: %s", resp.Status, errResp.Error)
155+ }
156+ return fmt.Errorf("ephemeral token exchange: %s: %s", resp.Status, string(body))
157+ }
158+
159+ var result dto.TokenResponse
160+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
161+ return fmt.Errorf("ephemeral token exchange: decode: %w", err)
162+ }
163+
164+ token := result.Token
165+ if token == "" {
166+ token = result.AccessToken
167+ }
168+ if token == "" {
169+ return fmt.Errorf("ephemeral token exchange returned empty token")
170+ }
171+
172+ if result.ExpiresIn <= 0 {
173+ return fmt.Errorf("ephemeral token exchange: invalid expires_in value: %d", result.ExpiresIn)
174+ }
175+
176+ c.ephemeral = token
177+ c.ephemeralExp = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
178+ return nil
97179 }
98180
99-// observeTokenRotation checks the X-Gordon-Token header on a response and, if
100-// the server issued a refreshed token, updates the client's in-memory token and
101-// invokes the optional persistence callback. Panics in the callback are caught
102-// so they never propagate to the caller.
103-func (c *Client) observeTokenRotation(resp *http.Response) {
104- newToken := resp.Header.Get("X-Gordon-Token")
105- if newToken == "" || newToken == c.token {
106- return
181+// bearerToken returns the token to use in Authorization headers.
182+// It exchanges the long-lived token for an ephemeral one via /auth/token.
183+// If subject extraction failed, uses "unknown" — the server validates everything.
184+func (c *Client) bearerToken(ctx context.Context) (string, error) {
185+ if c.token == "" {
186+ return "", nil
107187 }
108- c.token = newToken
109- if c.onTokenRefreshed != nil {
110- func() {
111- defer func() {
112- if r := recover(); r != nil {
113- fmt.Fprintf(os.Stderr, "warning: token refresh callback panicked: %v\n", r)
114- }
115- }()
116- c.onTokenRefreshed(newToken)
117- }()
188+ if c.subject == "" {
189+ c.subject = "unknown"
190+ }
191+ c.mu.Lock()
192+ defer c.mu.Unlock()
193+ if !c.ephemeralValid() {
194+ if err := c.exchangeToken(ctx); err != nil {
195+ return "", err
196+ }
118197 }
198+ return c.ephemeral, nil
119199 }
120200
121201 func (c *Client) applyTLSConfig() {
@@ -151,18 +231,51 @@ func (c *Client) applyTLSConfig() {
151231
152232 // request performs an HTTP request to the admin API.
153233 func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
154- url := c.baseURL + "/admin" + path
155-
156- var bodyReader io.Reader
234+ // Marshal body once so it can be replayed on 401 retry.
235+ var jsonBody []byte
157236 if body != nil {
158- jsonBody, err := json.Marshal(body)
237+ var err error
238+ jsonBody, err = json.Marshal(body)
159239 if err != nil {
160240 return nil, fmt.Errorf("failed to marshal request body: %w", err)
161241 }
242+ }
243+
244+ resp, err := c.doRequest(ctx, method, path, jsonBody)
245+ if err != nil {
246+ return nil, err
247+ }
248+
249+ // On 401: invalidate ephemeral, re-exchange, retry once.
250+ if resp.StatusCode == http.StatusUnauthorized && c.subject != "" && c.ephemeral != "" {
251+ // Drain and close the first response.
252+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBodySize))
253+ resp.Body.Close()
254+
255+ c.mu.Lock()
256+ c.ephemeral = ""
257+ c.ephemeralExp = time.Time{}
258+ if exErr := c.exchangeToken(ctx); exErr != nil {
259+ c.mu.Unlock()
260+ return nil, fmt.Errorf("token re-exchange after 401: %w", exErr)
261+ }
262+ c.mu.Unlock()
263+ return c.doRequest(ctx, method, path, jsonBody)
264+ }
265+
266+ return resp, nil
267+}
268+
269+// doRequest builds and executes a single HTTP request to the admin API.
270+func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody []byte) (*http.Response, error) {
271+ reqURL := c.baseURL + "/admin" + path
272+
273+ var bodyReader io.Reader
274+ if jsonBody != nil {
162275 bodyReader = bytes.NewReader(jsonBody)
163276 }
164277
165- req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
278+ req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
166279 if err != nil {
167280 return nil, fmt.Errorf("failed to create request: %w", err)
168281 }
@@ -170,8 +283,12 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
170283 req.Header.Set("Content-Type", "application/json")
171284 req.Header.Set("Accept", "application/json")
172285
173- if c.token != "" {
174- req.Header.Set("Authorization", "Bearer "+c.token)
286+ bearer, err := c.bearerToken(ctx)
287+ if err != nil {
288+ return nil, err
289+ }
290+ if bearer != "" {
291+ req.Header.Set("Authorization", "Bearer "+bearer)
175292 }
176293
177294 resp, err := c.httpClient.Do(req)
@@ -179,9 +296,6 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
179296 return nil, err
180297 }
181298
182- // Observe token rotation: the server re-signs and returns the token via X-Gordon-Token.
183- c.observeTokenRotation(resp)
184-
185299 return resp, nil
186300 }
187301
@@ -999,18 +1113,22 @@ func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern strin
9991113 return parseResponse(resp, nil)
10001114 }
10011115
1002-// streamLogs handles SSE streaming for log endpoints.
1003-func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
1004- url := c.baseURL + "/admin" + path
1116+// openSSEStream opens an SSE connection to the given admin path and returns the response.
1117+func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) {
1118+ streamURL := c.baseURL + "/admin" + path
10051119
1006- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
1120+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
10071121 if err != nil {
10081122 return nil, fmt.Errorf("failed to create request: %w", err)
10091123 }
10101124
10111125 req.Header.Set("Accept", "text/event-stream")
1012- if c.token != "" {
1013- req.Header.Set("Authorization", "Bearer "+c.token)
1126+ bearer, err := c.bearerToken(ctx)
1127+ if err != nil {
1128+ return nil, err
1129+ }
1130+ if bearer != "" {
1131+ req.Header.Set("Authorization", "Bearer "+bearer)
10141132 }
10151133
10161134 // Use the same transport as the main client (honoring TLS config and
@@ -1023,15 +1141,22 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
10231141 return nil, fmt.Errorf("failed to connect: %w", err)
10241142 }
10251143
1026- // Observe token rotation on streaming responses, same as request().
1027- c.observeTokenRotation(resp)
1028-
10291144 if resp.StatusCode >= 400 {
10301145 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
10311146 resp.Body.Close()
10321147 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
10331148 }
10341149
1150+ return resp, nil
1151+}
1152+
1153+// streamLogs handles SSE streaming for log endpoints.
1154+func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
1155+ resp, err := c.openSSEStream(ctx, path)
1156+ if err != nil {
1157+ return nil, err
1158+ }
1159+
10351160 ch := make(chan string, 100)
10361161
10371162 go func() {
@@ -13,19 +13,27 @@ import (
13 "net/url"13 "net/url"
14 "os"14 "os"
15 "strings"15 "strings"
16+ "sync"
16 "time"17 "time"
17 18
19+ "github.com/golang-jwt/jwt/v5"
20+
18 "github.com/bnema/gordon/internal/adapters/dto"21 "github.com/bnema/gordon/internal/adapters/dto"
19 "github.com/bnema/gordon/internal/domain"22 "github.com/bnema/gordon/internal/domain"
20 )23 )
21 24
22 // Client is an HTTP client for the Gordon admin API.25 // Client is an HTTP client for the Gordon admin API.
23 type Client struct {26 type Client struct {
24- baseURL string27+ baseURL string
25- token string28+ token string
26- httpClient *http.Client29+ httpClient *http.Client
27- insecureTLS bool30+ insecureTLS bool
28- onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token31+
32+ // Ephemeral admin token exchange fields.
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
29 }37 }
30 38
31 var (39 var (
@@ -60,10 +68,31 @@ func NewClient(baseURL string, opts ...ClientOption) *Client {
60 }68 }
61 69
62 // WithToken sets the authentication token.70 // WithToken sets the authentication token.
71+// The token must be a valid JWT — the subject claim is extracted for
72+// ephemeral token exchange. If parsing fails, requests will return
73+// an error rather than sending the long-lived token directly.
63 func WithToken(token string) ClientOption {74 func WithToken(token string) ClientOption {
64 return func(c *Client) {75 return func(c *Client) {
65 c.token = token76 c.token = token
77+ c.subject = extractJWTSubject(token)
78+ }
79+}
80+
81+// extractJWTSubject parses a JWT without verification and returns the
82+// "sub" claim. Returns "" if the token is not a valid JWT or has no sub.
83+func extractJWTSubject(tokenStr string) string {
84+ if tokenStr == "" {
85+ return ""
66 }86 }
87+ parser := jwt.NewParser()
88+ claims := jwt.MapClaims{}
89+ // We only need the claims; signature verification happens server-side.
90+ _, _, err := parser.ParseUnverified(tokenStr, claims)
91+ if err != nil {
92+ return ""
93+ }
94+ sub, _ := claims.GetSubject()
95+ return sub
67 }96 }
68 97
69 // WithHTTPClient sets a custom HTTP client.98 // WithHTTPClient sets a custom HTTP client.
@@ -87,35 +116,86 @@ func WithInsecureTLS(insecure bool) ClientOption {
87 }116 }
88 }117 }
89 118
90-// WithTokenRefreshCallback sets a callback that is invoked when the server119+// ephemeralMargin is the safety margin before expiry at which
91-// returns a refreshed token in the X-Gordon-Token response header.120+// the cached ephemeral token is considered stale.
92-// The CLI uses this to persist the new token to the remotes config atomically.121+const ephemeralMargin = 30 * time.Second
93-func WithTokenRefreshCallback(fn func(newToken string)) ClientOption {122+
94- return func(c *Client) {123+// ephemeralValid reports whether the cached ephemeral token is still usable.
95- c.onTokenRefreshed = fn124+func (c *Client) ephemeralValid() bool {
125+ return c.ephemeral != "" && time.Now().Before(c.ephemeralExp.Add(-ephemeralMargin))
126+}
127+
128+// exchangeToken exchanges the long-lived token for a short-lived ephemeral
129+// admin token via /auth/token. Follows the same pattern as ExchangeRegistryToken.
130+// The caller must hold c.mu.
131+func (c *Client) exchangeToken(ctx context.Context) error {
132+ url := c.baseURL + "/auth/token?scope=admin:*:*&service=gordon-registry"
133+
134+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
135+ if err != nil {
136+ return fmt.Errorf("ephemeral token exchange: %w", err)
137+ }
138+
139+ req.SetBasicAuth(c.subject, c.token)
140+ req.Header.Set("Accept", "application/json")
141+
142+ resp, err := c.httpClient.Do(req)
143+ if err != nil {
144+ return fmt.Errorf("ephemeral token exchange: %w", err)
96 }145 }
146+ defer resp.Body.Close()
147+
148+ if resp.StatusCode >= 400 {
149+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
150+ var errResp struct {
151+ Error string `json:"error"`
152+ }
153+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
154+ return fmt.Errorf("ephemeral token exchange: %s: %s", resp.Status, errResp.Error)
155+ }
156+ return fmt.Errorf("ephemeral token exchange: %s: %s", resp.Status, string(body))
157+ }
158+
159+ var result dto.TokenResponse
160+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
161+ return fmt.Errorf("ephemeral token exchange: decode: %w", err)
162+ }
163+
164+ token := result.Token
165+ if token == "" {
166+ token = result.AccessToken
167+ }
168+ if token == "" {
169+ return fmt.Errorf("ephemeral token exchange returned empty token")
170+ }
171+
172+ if result.ExpiresIn <= 0 {
173+ return fmt.Errorf("ephemeral token exchange: invalid expires_in value: %d", result.ExpiresIn)
174+ }
175+
176+ c.ephemeral = token
177+ c.ephemeralExp = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
178+ return nil
97 }179 }
98 180
99-// observeTokenRotation checks the X-Gordon-Token header on a response and, if181+// bearerToken returns the token to use in Authorization headers.
100-// the server issued a refreshed token, updates the client's in-memory token and182+// It exchanges the long-lived token for an ephemeral one via /auth/token.
101-// invokes the optional persistence callback. Panics in the callback are caught183+// If subject extraction failed, uses "unknown" — the server validates everything.
102-// so they never propagate to the caller.184+func (c *Client) bearerToken(ctx context.Context) (string, error) {
103-func (c *Client) observeTokenRotation(resp *http.Response) {185+ if c.token == "" {
104- newToken := resp.Header.Get("X-Gordon-Token")186+ return "", nil
105- if newToken == "" || newToken == c.token {
106- return
107 }187 }
108- c.token = newToken188+ if c.subject == "" {
109- if c.onTokenRefreshed != nil {189+ c.subject = "unknown"
110- func() {190+ }
111- defer func() {191+ c.mu.Lock()
112- if r := recover(); r != nil {192+ defer c.mu.Unlock()
113- fmt.Fprintf(os.Stderr, "warning: token refresh callback panicked: %v\n", r)193+ if !c.ephemeralValid() {
114- }194+ if err := c.exchangeToken(ctx); err != nil {
115- }()195+ return "", err
116- c.onTokenRefreshed(newToken)196+ }
117- }()
118 }197 }
198+ return c.ephemeral, nil
119 }199 }
120 200
121 func (c *Client) applyTLSConfig() {201 func (c *Client) applyTLSConfig() {
@@ -151,18 +231,51 @@ func (c *Client) applyTLSConfig() {
151 231
152 // request performs an HTTP request to the admin API.232 // request performs an HTTP request to the admin API.
153 func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {233 func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
154- url := c.baseURL + "/admin" + path234+ // Marshal body once so it can be replayed on 401 retry.
155-235+ var jsonBody []byte
156- var bodyReader io.Reader
157 if body != nil {236 if body != nil {
158- jsonBody, err := json.Marshal(body)237+ var err error
238+ jsonBody, err = json.Marshal(body)
159 if err != nil {239 if err != nil {
160 return nil, fmt.Errorf("failed to marshal request body: %w", err)240 return nil, fmt.Errorf("failed to marshal request body: %w", err)
161 }241 }
242+ }
243+
244+ resp, err := c.doRequest(ctx, method, path, jsonBody)
245+ if err != nil {
246+ return nil, err
247+ }
248+
249+ // On 401: invalidate ephemeral, re-exchange, retry once.
250+ if resp.StatusCode == http.StatusUnauthorized && c.subject != "" && c.ephemeral != "" {
251+ // Drain and close the first response.
252+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBodySize))
253+ resp.Body.Close()
254+
255+ c.mu.Lock()
256+ c.ephemeral = ""
257+ c.ephemeralExp = time.Time{}
258+ if exErr := c.exchangeToken(ctx); exErr != nil {
259+ c.mu.Unlock()
260+ return nil, fmt.Errorf("token re-exchange after 401: %w", exErr)
261+ }
262+ c.mu.Unlock()
263+ return c.doRequest(ctx, method, path, jsonBody)
264+ }
265+
266+ return resp, nil
267+}
268+
269+// doRequest builds and executes a single HTTP request to the admin API.
270+func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody []byte) (*http.Response, error) {
271+ reqURL := c.baseURL + "/admin" + path
272+
273+ var bodyReader io.Reader
274+ if jsonBody != nil {
162 bodyReader = bytes.NewReader(jsonBody)275 bodyReader = bytes.NewReader(jsonBody)
163 }276 }
164 277
165- req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)278+ req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
166 if err != nil {279 if err != nil {
167 return nil, fmt.Errorf("failed to create request: %w", err)280 return nil, fmt.Errorf("failed to create request: %w", err)
168 }281 }
@@ -170,8 +283,12 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
170 req.Header.Set("Content-Type", "application/json")283 req.Header.Set("Content-Type", "application/json")
171 req.Header.Set("Accept", "application/json")284 req.Header.Set("Accept", "application/json")
172 285
173- if c.token != "" {286+ bearer, err := c.bearerToken(ctx)
174- req.Header.Set("Authorization", "Bearer "+c.token)287+ if err != nil {
288+ return nil, err
289+ }
290+ if bearer != "" {
291+ req.Header.Set("Authorization", "Bearer "+bearer)
175 }292 }
176 293
177 resp, err := c.httpClient.Do(req)294 resp, err := c.httpClient.Do(req)
@@ -179,9 +296,6 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
179 return nil, err296 return nil, err
180 }297 }
181 298
182- // Observe token rotation: the server re-signs and returns the token via X-Gordon-Token.
183- c.observeTokenRotation(resp)
184-
185 return resp, nil299 return resp, nil
186 }300 }
187 301
@@ -999,18 +1113,22 @@ func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern strin
999 return parseResponse(resp, nil)1113 return parseResponse(resp, nil)
1000 }1114 }
1001 1115
1002-// streamLogs handles SSE streaming for log endpoints.1116+// openSSEStream opens an SSE connection to the given admin path and returns the response.
1003-func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {1117+func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) {
1004- url := c.baseURL + "/admin" + path1118+ streamURL := c.baseURL + "/admin" + path
1005 1119
1006- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)1120+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
1007 if err != nil {1121 if err != nil {
1008 return nil, fmt.Errorf("failed to create request: %w", err)1122 return nil, fmt.Errorf("failed to create request: %w", err)
1009 }1123 }
1010 1124
1011 req.Header.Set("Accept", "text/event-stream")1125 req.Header.Set("Accept", "text/event-stream")
1012- if c.token != "" {1126+ bearer, err := c.bearerToken(ctx)
1013- req.Header.Set("Authorization", "Bearer "+c.token)1127+ if err != nil {
1128+ return nil, err
1129+ }
1130+ if bearer != "" {
1131+ req.Header.Set("Authorization", "Bearer "+bearer)
1014 }1132 }
1015 1133
1016 // Use the same transport as the main client (honoring TLS config and1134 // Use the same transport as the main client (honoring TLS config and
@@ -1023,15 +1141,22 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
1023 return nil, fmt.Errorf("failed to connect: %w", err)1141 return nil, fmt.Errorf("failed to connect: %w", err)
1024 }1142 }
1025 1143
1026- // Observe token rotation on streaming responses, same as request().
1027- c.observeTokenRotation(resp)
1028-
1029 if resp.StatusCode >= 400 {1144 if resp.StatusCode >= 400 {
1030 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))1145 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
1031 resp.Body.Close()1146 resp.Body.Close()
1032 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))1147 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
1033 }1148 }
1034 1149
1150+ return resp, nil
1151+}
1152+
1153+// streamLogs handles SSE streaming for log endpoints.
1154+func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
1155+ resp, err := c.openSSEStream(ctx, path)
1156+ if err != nil {
1157+ return nil, err
1158+ }
1159+
1035 ch := make(chan string, 100)1160 ch := make(chan string, 100)
1036 1161
1037 go func() {1162 go func() {
M internal/adapters/in/cli/remote/config.go +41 -1
@@ -7,6 +7,8 @@ import (
77 "strconv"
88
99 "github.com/pelletier/go-toml/v2"
10+
11+ "github.com/bnema/gordon/internal/domain"
1012 )
1113
1214 // ClientConfig represents the client-mode configuration.
@@ -256,6 +258,20 @@ func resolveRemoteEntryToken(remote *RemoteEntry) string {
256258 return ""
257259 }
258260
261+// ResolveTokenForRemote resolves the token for a named remote.
262+// Precedence: TOML token field > token_env > pass store.
263+func ResolveTokenForRemote(name string, entry RemoteEntry) string {
264+ if token := resolveRemoteEntryToken(&entry); token != "" {
265+ return token
266+ }
267+ if passAvailable() {
268+ if token, err := passReadToken(name); err == nil && token != "" {
269+ return token
270+ }
271+ }
272+ return ""
273+}
274+
259275 // ResolveInsecureTLSForRemote resolves insecure TLS behavior for a named remote.
260276 // Precedence: flag > env > client config > specific remote config.
261277 func ResolveInsecureTLSForRemote(flagInsecure bool, remoteName string) bool {
@@ -331,7 +347,7 @@ func SetActiveRemote(name string) error {
331347
332348 // Verify remote exists
333349 if _, ok := config.Remotes[name]; !ok {
334- return fmt.Errorf("remote '%s' not found", name)
350+ return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
335351 }
336352
337353 config.Active = name
@@ -339,6 +355,30 @@ func SetActiveRemote(name string) error {
339355 return SaveRemotes("", config)
340356 }
341357
358+// ClearRemoteToken removes the token and token_env fields for a named remote
359+// from the remotes TOML config, and deletes the pass store entry if present.
360+func ClearRemoteToken(name string) error {
361+ // Delete from pass (best-effort, no error if pass unavailable)
362+ _ = passDeleteToken(name)
363+
364+ // Clear TOML fields
365+ config, err := LoadRemotes("")
366+ if err != nil {
367+ return err
368+ }
369+
370+ entry, ok := config.Remotes[name]
371+ if !ok {
372+ return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
373+ }
374+
375+ entry.Token = ""
376+ entry.TokenEnv = ""
377+ config.Remotes[name] = entry
378+
379+ return SaveRemotes("", config)
380+}
381+
342382 // ListRemotes returns all saved remotes.
343383 func ListRemotes() (map[string]RemoteEntry, string, error) {
344384 config, err := LoadRemotes("")
@@ -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.
@@ -256,6 +258,20 @@ func resolveRemoteEntryToken(remote *RemoteEntry) string {
256 return ""258 return ""
257 }259 }
258 260
261+// ResolveTokenForRemote resolves the token for a named remote.
262+// Precedence: TOML token field > token_env > pass store.
263+func ResolveTokenForRemote(name string, entry RemoteEntry) string {
264+ if token := resolveRemoteEntryToken(&entry); token != "" {
265+ return token
266+ }
267+ if passAvailable() {
268+ if token, err := passReadToken(name); err == nil && token != "" {
269+ return token
270+ }
271+ }
272+ return ""
273+}
274+
259 // ResolveInsecureTLSForRemote resolves insecure TLS behavior for a named remote.275 // ResolveInsecureTLSForRemote resolves insecure TLS behavior for a named remote.
260 // Precedence: flag > env > client config > specific remote config.276 // Precedence: flag > env > client config > specific remote config.
261 func ResolveInsecureTLSForRemote(flagInsecure bool, remoteName string) bool {277 func ResolveInsecureTLSForRemote(flagInsecure bool, remoteName string) bool {
@@ -331,7 +347,7 @@ func SetActiveRemote(name string) error {
331 347
332 // Verify remote exists348 // Verify remote exists
333 if _, ok := config.Remotes[name]; !ok {349 if _, ok := config.Remotes[name]; !ok {
334- return fmt.Errorf("remote '%s' not found", name)350+ return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
335 }351 }
336 352
337 config.Active = name353 config.Active = name
@@ -339,6 +355,30 @@ func SetActiveRemote(name string) error {
339 return SaveRemotes("", config)355 return SaveRemotes("", config)
340 }356 }
341 357
358+// ClearRemoteToken removes the token and token_env fields for a named remote
359+// from the remotes TOML config, and deletes the pass store entry if present.
360+func ClearRemoteToken(name string) error {
361+ // Delete from pass (best-effort, no error if pass unavailable)
362+ _ = passDeleteToken(name)
363+
364+ // Clear TOML fields
365+ config, err := LoadRemotes("")
366+ if err != nil {
367+ return err
368+ }
369+
370+ entry, ok := config.Remotes[name]
371+ if !ok {
372+ return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
373+ }
374+
375+ entry.Token = ""
376+ entry.TokenEnv = ""
377+ config.Remotes[name] = entry
378+
379+ return SaveRemotes("", config)
380+}
381+
342 // ListRemotes returns all saved remotes.382 // ListRemotes returns all saved remotes.
343 func ListRemotes() (map[string]RemoteEntry, string, error) {383 func ListRemotes() (map[string]RemoteEntry, string, error) {
344 config, err := LoadRemotes("")384 config, err := LoadRemotes("")
M internal/adapters/in/cli/remote/pass.go +17 -0
@@ -95,6 +95,23 @@ func passWriteToken(name, token string) error {
9595 return nil
9696 }
9797
98+func passDeleteToken(name string) error {
99+ if err := validateRemoteName(name); err != nil {
100+ return err
101+ }
102+ if !passAvailable() {
103+ return nil // nothing to delete
104+ }
105+
106+ cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
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
113+}
114+
98115 func warnPassUnavailable() {
99116 passWarnOnce.Do(func() {
100117 fmt.Fprintf(os.Stderr, "Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.\n")
@@ -95,6 +95,23 @@ func passWriteToken(name, token string) error {
95 return nil95 return nil
96 }96 }
97 97
98+func passDeleteToken(name string) error {
99+ if err := validateRemoteName(name); err != nil {
100+ return err
101+ }
102+ if !passAvailable() {
103+ return nil // nothing to delete
104+ }
105+
106+ cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
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
113+}
114+
98 func warnPassUnavailable() {115 func warnPassUnavailable() {
99 passWarnOnce.Do(func() {116 passWarnOnce.Do(func() {
100 fmt.Fprintf(os.Stderr, "Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.\n")117 fmt.Fprintf(os.Stderr, "Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.\n")
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 +12 -10
@@ -86,6 +86,17 @@ func AuthMiddleware(
8686 return
8787 }
8888
89+ // SECURITY: Reject long-lived (stored) tokens — they must be exchanged
90+ // for ephemeral access tokens via /auth/token first.
91+ if !claims.IsEphemeral {
92+ log.Warn().
93+ Str("subject", claims.Subject).
94+ Str("path", r.URL.Path).
95+ Msg("long-lived token rejected on admin endpoint")
96+ sendUnauthorized(w, domain.ErrLongLivedToken.Error())
97+ return
98+ }
99+
89100 // Check if token has admin scopes
90101 hasAdminScope := false
91102 for _, scope := range claims.Scopes {
@@ -106,18 +117,9 @@ func AuthMiddleware(
106117 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
107118 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
108119
109- // Prevent intermediaries from storing admin API responses, especially
110- // when a rotated bearer token is returned in X-Gordon-Token.
120+ // Prevent intermediaries from caching admin API responses.
111121 setNoStoreHeaders(w)
112122
113- // Attempt to slide token expiry. Non-fatal if it fails.
114- // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
115- if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {
116- log.Warn().Err(extErr).Str("subject", claims.Subject).Msg("token extension failed")
117- } else if newToken != token {
118- w.Header().Set("X-Gordon-Token", newToken)
119- }
120-
121123 next.ServeHTTP(w, r.WithContext(ctx))
122124 })
123125 }
@@ -86,6 +86,17 @@ func AuthMiddleware(
86 return86 return
87 }87 }
88 88
89+ // SECURITY: Reject long-lived (stored) tokens — they must be exchanged
90+ // for ephemeral access tokens via /auth/token first.
91+ if !claims.IsEphemeral {
92+ log.Warn().
93+ Str("subject", claims.Subject).
94+ Str("path", r.URL.Path).
95+ Msg("long-lived token rejected on admin endpoint")
96+ sendUnauthorized(w, domain.ErrLongLivedToken.Error())
97+ return
98+ }
99+
89 // Check if token has admin scopes100 // Check if token has admin scopes
90 hasAdminScope := false101 hasAdminScope := false
91 for _, scope := range claims.Scopes {102 for _, scope := range claims.Scopes {
@@ -106,18 +117,9 @@ func AuthMiddleware(
106 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)117 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
107 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)118 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
108 119
109- // Prevent intermediaries from storing admin API responses, especially120+ // Prevent intermediaries from caching admin API responses.
110- // when a rotated bearer token is returned in X-Gordon-Token.
111 setNoStoreHeaders(w)121 setNoStoreHeaders(w)
112 122
113- // Attempt to slide token expiry. Non-fatal if it fails.
114- // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
115- if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {
116- log.Warn().Err(extErr).Str("subject", claims.Subject).Msg("token extension failed")
117- } else if newToken != token {
118- w.Header().Set("X-Gordon-Token", newToken)
119- }
120-
121 next.ServeHTTP(w, r.WithContext(ctx))123 next.ServeHTTP(w, r.WithContext(ctx))
122 })124 })
123 }125 }
M internal/adapters/in/http/admin/middleware_test.go +69 -73
@@ -2,7 +2,6 @@ package admin
22
33 import (
44 "context"
5- "errors"
65 "net/http"
76 "net/http/httptest"
87 "testing"
@@ -85,11 +84,10 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
8584 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
8685 authSvc.EXPECT().IsEnabled().Return(true)
8786 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
88- Subject: "admin",
89- Scopes: []string{"admin:*:*"},
87+ Subject: "admin",
88+ Scopes: []string{"admin:*:*"},
89+ IsEphemeral: true,
9090 }, nil)
91- // ExtendToken is called after successful validation; non-fatal if it fails
92- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
9391
9492 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
9593 w.WriteHeader(http.StatusOK)
@@ -114,11 +112,10 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
114112 // No rate limiters, auth still required
115113 authSvc.EXPECT().IsEnabled().Return(true)
116114 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
117- Subject: "admin",
118- Scopes: []string{"admin:*:*"},
115+ Subject: "admin",
116+ Scopes: []string{"admin:*:*"},
117+ IsEphemeral: true,
119118 }, nil)
120- // ExtendToken is called after successful validation; non-fatal if it fails
121- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
122119
123120 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
124121 w.WriteHeader(http.StatusOK)
@@ -216,6 +213,63 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) {
216213 assert.Contains(t, rec.Body.String(), "invalid token")
217214 }
218215
216+func TestAuthMiddleware_RejectsLongLivedToken(t *testing.T) {
217+ authSvc := inmocks.NewMockAuthService(t)
218+
219+ authSvc.EXPECT().IsEnabled().Return(true)
220+ authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-token").Return(&domain.TokenClaims{
221+ Subject: "admin",
222+ Scopes: []string{"admin:*:*"},
223+ IsEphemeral: false, // long-lived stored token
224+ }, nil)
225+
226+ handlerCalled := false
227+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
228+ handlerCalled = true
229+ w.WriteHeader(http.StatusOK)
230+ })
231+
232+ mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
233+ wrappedHandler := mw(handler)
234+
235+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
236+ req.Header.Set("Authorization", "Bearer long-lived-token")
237+ rec := httptest.NewRecorder()
238+ wrappedHandler.ServeHTTP(rec, req)
239+
240+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
241+ assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
242+ assert.False(t, handlerCalled, "handler should not be called for long-lived tokens")
243+}
244+
245+func TestAuthMiddleware_AcceptsEphemeralToken(t *testing.T) {
246+ authSvc := inmocks.NewMockAuthService(t)
247+
248+ authSvc.EXPECT().IsEnabled().Return(true)
249+ authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-token").Return(&domain.TokenClaims{
250+ Subject: "admin",
251+ Scopes: []string{"admin:*:*"},
252+ IsEphemeral: true, // short-lived ephemeral token
253+ }, nil)
254+
255+ handlerCalled := false
256+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
257+ handlerCalled = true
258+ w.WriteHeader(http.StatusOK)
259+ })
260+
261+ mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
262+ wrappedHandler := mw(handler)
263+
264+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
265+ req.Header.Set("Authorization", "Bearer ephemeral-token")
266+ rec := httptest.NewRecorder()
267+ wrappedHandler.ServeHTTP(rec, req)
268+
269+ assert.Equal(t, http.StatusOK, rec.Code)
270+ assert.True(t, handlerCalled, "handler should be called for ephemeral tokens")
271+}
272+
219273 func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
220274 authSvc := inmocks.NewMockAuthService(t)
221275 globalLimiter := outmocks.NewMockRateLimiter(t)
@@ -225,8 +279,9 @@ func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
225279 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
226280 authSvc.EXPECT().IsEnabled().Return(true)
227281 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{
228- Subject: "user1",
229- Scopes: []string{"push", "pull"}, // No admin scopes
282+ Subject: "user1",
283+ Scopes: []string{"push", "pull"}, // No admin scopes
284+ IsEphemeral: true,
230285 }, nil)
231286
232287 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -255,11 +310,10 @@ func TestAuthMiddleware_Success(t *testing.T) {
255310 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
256311 authSvc.EXPECT().IsEnabled().Return(true)
257312 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
258- Subject: "admin",
259- Scopes: []string{"admin:*:*"},
313+ Subject: "admin",
314+ Scopes: []string{"admin:*:*"},
315+ IsEphemeral: true,
260316 }, nil)
261- // ExtendToken is called after successful validation; non-fatal if it fails
262- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
263317
264318 var capturedCtx context.Context
265319 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -371,64 +425,6 @@ func TestRequireScope(t *testing.T) {
371425 }
372426 }
373427
374-func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
375- authSvc := inmocks.NewMockAuthService(t)
376-
377- authSvc.EXPECT().IsEnabled().Return(true)
378- authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
379- Subject: "admin",
380- Scopes: []string{"admin:*:*"},
381- }, nil)
382- // ExtendToken fails — response must still be 200 (non-fatal)
383- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable"))
384-
385- handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
386- w.WriteHeader(http.StatusOK)
387- })
388-
389- mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
390- wrappedHandler := mw(handler)
391-
392- req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
393- req.Header.Set("Authorization", "Bearer valid-admin-token")
394- rec := httptest.NewRecorder()
395- wrappedHandler.ServeHTTP(rec, req)
396-
397- assert.Equal(t, http.StatusOK, rec.Code)
398- assert.Empty(t, rec.Header().Get("X-Gordon-Token"))
399- assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
400- assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
401-}
402-
403-func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
404- authSvc := inmocks.NewMockAuthService(t)
405-
406- authSvc.EXPECT().IsEnabled().Return(true)
407- authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{
408- Subject: "admin",
409- Scopes: []string{"admin:*:*"},
410- }, nil)
411- // ExtendToken returns a rotated token — must appear in response header
412- authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil)
413-
414- handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
415- w.WriteHeader(http.StatusOK)
416- })
417-
418- mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
419- wrappedHandler := mw(handler)
420-
421- req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
422- req.Header.Set("Authorization", "Bearer old-token")
423- rec := httptest.NewRecorder()
424- wrappedHandler.ServeHTTP(rec, req)
425-
426- assert.Equal(t, http.StatusOK, rec.Code)
427- assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))
428- assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
429- assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
430-}
431-
432428 func TestHasAccess(t *testing.T) {
433429 ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
434430
@@ -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"
@@ -85,11 +84,10 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
85 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)84 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
86 authSvc.EXPECT().IsEnabled().Return(true)85 authSvc.EXPECT().IsEnabled().Return(true)
87 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{86 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
88- Subject: "admin",87+ Subject: "admin",
89- Scopes: []string{"admin:*:*"},88+ Scopes: []string{"admin:*:*"},
89+ IsEphemeral: true,
90 }, nil)90 }, nil)
91- // ExtendToken is called after successful validation; non-fatal if it fails
92- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
93 91
94 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {92 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
95 w.WriteHeader(http.StatusOK)93 w.WriteHeader(http.StatusOK)
@@ -114,11 +112,10 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
114 // No rate limiters, auth still required112 // No rate limiters, auth still required
115 authSvc.EXPECT().IsEnabled().Return(true)113 authSvc.EXPECT().IsEnabled().Return(true)
116 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{114 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
117- Subject: "admin",115+ Subject: "admin",
118- Scopes: []string{"admin:*:*"},116+ Scopes: []string{"admin:*:*"},
117+ IsEphemeral: true,
119 }, nil)118 }, nil)
120- // ExtendToken is called after successful validation; non-fatal if it fails
121- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
122 119
123 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {120 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
124 w.WriteHeader(http.StatusOK)121 w.WriteHeader(http.StatusOK)
@@ -216,6 +213,63 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) {
216 assert.Contains(t, rec.Body.String(), "invalid token")213 assert.Contains(t, rec.Body.String(), "invalid token")
217 }214 }
218 215
216+func TestAuthMiddleware_RejectsLongLivedToken(t *testing.T) {
217+ authSvc := inmocks.NewMockAuthService(t)
218+
219+ authSvc.EXPECT().IsEnabled().Return(true)
220+ authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-token").Return(&domain.TokenClaims{
221+ Subject: "admin",
222+ Scopes: []string{"admin:*:*"},
223+ IsEphemeral: false, // long-lived stored token
224+ }, nil)
225+
226+ handlerCalled := false
227+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
228+ handlerCalled = true
229+ w.WriteHeader(http.StatusOK)
230+ })
231+
232+ mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
233+ wrappedHandler := mw(handler)
234+
235+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
236+ req.Header.Set("Authorization", "Bearer long-lived-token")
237+ rec := httptest.NewRecorder()
238+ wrappedHandler.ServeHTTP(rec, req)
239+
240+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
241+ assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
242+ assert.False(t, handlerCalled, "handler should not be called for long-lived tokens")
243+}
244+
245+func TestAuthMiddleware_AcceptsEphemeralToken(t *testing.T) {
246+ authSvc := inmocks.NewMockAuthService(t)
247+
248+ authSvc.EXPECT().IsEnabled().Return(true)
249+ authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-token").Return(&domain.TokenClaims{
250+ Subject: "admin",
251+ Scopes: []string{"admin:*:*"},
252+ IsEphemeral: true, // short-lived ephemeral token
253+ }, nil)
254+
255+ handlerCalled := false
256+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
257+ handlerCalled = true
258+ w.WriteHeader(http.StatusOK)
259+ })
260+
261+ mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
262+ wrappedHandler := mw(handler)
263+
264+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
265+ req.Header.Set("Authorization", "Bearer ephemeral-token")
266+ rec := httptest.NewRecorder()
267+ wrappedHandler.ServeHTTP(rec, req)
268+
269+ assert.Equal(t, http.StatusOK, rec.Code)
270+ assert.True(t, handlerCalled, "handler should be called for ephemeral tokens")
271+}
272+
219 func TestAuthMiddleware_MissingAdminScope(t *testing.T) {273 func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
220 authSvc := inmocks.NewMockAuthService(t)274 authSvc := inmocks.NewMockAuthService(t)
221 globalLimiter := outmocks.NewMockRateLimiter(t)275 globalLimiter := outmocks.NewMockRateLimiter(t)
@@ -225,8 +279,9 @@ func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
225 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)279 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
226 authSvc.EXPECT().IsEnabled().Return(true)280 authSvc.EXPECT().IsEnabled().Return(true)
227 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{281 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{
228- Subject: "user1",282+ Subject: "user1",
229- Scopes: []string{"push", "pull"}, // No admin scopes283+ Scopes: []string{"push", "pull"}, // No admin scopes
284+ IsEphemeral: true,
230 }, nil)285 }, nil)
231 286
232 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {287 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -255,11 +310,10 @@ func TestAuthMiddleware_Success(t *testing.T) {
255 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)310 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
256 authSvc.EXPECT().IsEnabled().Return(true)311 authSvc.EXPECT().IsEnabled().Return(true)
257 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{312 authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
258- Subject: "admin",313+ Subject: "admin",
259- Scopes: []string{"admin:*:*"},314+ Scopes: []string{"admin:*:*"},
315+ IsEphemeral: true,
260 }, nil)316 }, nil)
261- // ExtendToken is called after successful validation; non-fatal if it fails
262- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
263 317
264 var capturedCtx context.Context318 var capturedCtx context.Context
265 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {319 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -371,64 +425,6 @@ func TestRequireScope(t *testing.T) {
371 }425 }
372 }426 }
373 427
374-func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
375- authSvc := inmocks.NewMockAuthService(t)
376-
377- authSvc.EXPECT().IsEnabled().Return(true)
378- authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
379- Subject: "admin",
380- Scopes: []string{"admin:*:*"},
381- }, nil)
382- // ExtendToken fails — response must still be 200 (non-fatal)
383- authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable"))
384-
385- handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
386- w.WriteHeader(http.StatusOK)
387- })
388-
389- mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
390- wrappedHandler := mw(handler)
391-
392- req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
393- req.Header.Set("Authorization", "Bearer valid-admin-token")
394- rec := httptest.NewRecorder()
395- wrappedHandler.ServeHTTP(rec, req)
396-
397- assert.Equal(t, http.StatusOK, rec.Code)
398- assert.Empty(t, rec.Header().Get("X-Gordon-Token"))
399- assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
400- assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
401-}
402-
403-func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
404- authSvc := inmocks.NewMockAuthService(t)
405-
406- authSvc.EXPECT().IsEnabled().Return(true)
407- authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{
408- Subject: "admin",
409- Scopes: []string{"admin:*:*"},
410- }, nil)
411- // ExtendToken returns a rotated token — must appear in response header
412- authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil)
413-
414- handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
415- w.WriteHeader(http.StatusOK)
416- })
417-
418- mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
419- wrappedHandler := mw(handler)
420-
421- req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
422- req.Header.Set("Authorization", "Bearer old-token")
423- rec := httptest.NewRecorder()
424- wrappedHandler.ServeHTTP(rec, req)
425-
426- assert.Equal(t, http.StatusOK, rec.Code)
427- assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))
428- assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
429- assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
430-}
431-
432 func TestHasAccess(t *testing.T) {428 func TestHasAccess(t *testing.T) {
433 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:*"})
434 430
M internal/adapters/in/http/auth/handler.go +4 -0
@@ -177,6 +177,10 @@ func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Requ
177177 // Validate JWT token sent via Basic Auth password field.
178178 claims, err := h.authSvc.ValidateToken(ctx, password)
179179 if err == nil && claims.Subject == username {
180+ if claims.IsEphemeral {
181+ log.Debug().Msg("rejected ephemeral token as exchange credential")
182+ return false, nil
183+ }
180184 return true, claims
181185 }
182186
@@ -177,6 +177,10 @@ func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Requ
177 // Validate JWT token sent via Basic Auth password field.177 // Validate JWT token sent via Basic Auth password field.
178 claims, err := h.authSvc.ValidateToken(ctx, password)178 claims, err := h.authSvc.ValidateToken(ctx, password)
179 if err == nil && claims.Subject == username {179 if err == nil && claims.Subject == username {
180+ if claims.IsEphemeral {
181+ log.Debug().Msg("rejected ephemeral token as exchange credential")
182+ return false, nil
183+ }
180 return true, claims184 return true, claims
181 }185 }
182 186
M internal/adapters/in/http/auth/handler_test.go +51 -0
@@ -448,6 +448,57 @@ func TestHandler_isInternalAuth(t *testing.T) {
448448 }
449449 }
450450
451+func TestHandler_Token_EphemeralTokenRejected(t *testing.T) {
452+ authSvc := mocks.NewMockAuthService(t)
453+
454+ authSvc.EXPECT().IsEnabled().Return(true)
455+ authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-jwt-token").Return(&domain.TokenClaims{
456+ Subject: "myuser",
457+ Scopes: []string{"repository:*:pull"},
458+ IsEphemeral: true,
459+ }, nil)
460+
461+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
462+
463+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
464+ req.SetBasicAuth("myuser", "ephemeral-jwt-token")
465+ rec := httptest.NewRecorder()
466+
467+ handler.ServeHTTP(rec, req)
468+
469+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
470+ assert.Contains(t, rec.Body.String(), "unauthorized")
471+}
472+
473+func TestHandler_Token_LongLivedTokenAccepted(t *testing.T) {
474+ authSvc := mocks.NewMockAuthService(t)
475+
476+ authSvc.EXPECT().IsEnabled().Return(true)
477+ authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-jwt-token").Return(&domain.TokenClaims{
478+ Subject: "myuser",
479+ Scopes: []string{"repository:*:pull"},
480+ IsEphemeral: false,
481+ }, nil)
482+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
483+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
484+ Return("new-access-token", nil)
485+
486+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
487+
488+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
489+ req.SetBasicAuth("myuser", "long-lived-jwt-token")
490+ rec := httptest.NewRecorder()
491+
492+ handler.ServeHTTP(rec, req)
493+
494+ assert.Equal(t, http.StatusOK, rec.Code)
495+
496+ var resp map[string]any
497+ err := json.NewDecoder(rec.Body).Decode(&resp)
498+ assert.NoError(t, err)
499+ assert.Equal(t, "new-access-token", resp["token"])
500+}
501+
451502 func TestHandler_Token_AdminScopeSuccess(t *testing.T) {
452503 authSvc := mocks.NewMockAuthService(t)
453504
@@ -448,6 +448,57 @@ func TestHandler_isInternalAuth(t *testing.T) {
448 }448 }
449 }449 }
450 450
451+func TestHandler_Token_EphemeralTokenRejected(t *testing.T) {
452+ authSvc := mocks.NewMockAuthService(t)
453+
454+ authSvc.EXPECT().IsEnabled().Return(true)
455+ authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-jwt-token").Return(&domain.TokenClaims{
456+ Subject: "myuser",
457+ Scopes: []string{"repository:*:pull"},
458+ IsEphemeral: true,
459+ }, nil)
460+
461+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
462+
463+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
464+ req.SetBasicAuth("myuser", "ephemeral-jwt-token")
465+ rec := httptest.NewRecorder()
466+
467+ handler.ServeHTTP(rec, req)
468+
469+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
470+ assert.Contains(t, rec.Body.String(), "unauthorized")
471+}
472+
473+func TestHandler_Token_LongLivedTokenAccepted(t *testing.T) {
474+ authSvc := mocks.NewMockAuthService(t)
475+
476+ authSvc.EXPECT().IsEnabled().Return(true)
477+ authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-jwt-token").Return(&domain.TokenClaims{
478+ Subject: "myuser",
479+ Scopes: []string{"repository:*:pull"},
480+ IsEphemeral: false,
481+ }, nil)
482+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
483+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
484+ Return("new-access-token", nil)
485+
486+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
487+
488+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
489+ req.SetBasicAuth("myuser", "long-lived-jwt-token")
490+ rec := httptest.NewRecorder()
491+
492+ handler.ServeHTTP(rec, req)
493+
494+ assert.Equal(t, http.StatusOK, rec.Code)
495+
496+ var resp map[string]any
497+ err := json.NewDecoder(rec.Body).Decode(&resp)
498+ assert.NoError(t, err)
499+ assert.Equal(t, "new-access-token", resp["token"])
500+}
501+
451 func TestHandler_Token_AdminScopeSuccess(t *testing.T) {502 func TestHandler_Token_AdminScopeSuccess(t *testing.T) {
452 authSvc := mocks.NewMockAuthService(t)503 authSvc := mocks.NewMockAuthService(t)
453 504
M internal/adapters/in/http/middleware/auth.go +66 -13
@@ -4,6 +4,7 @@ import (
44 "context"
55 "crypto/subtle"
66 "encoding/json"
7+ "errors"
78 "net/http"
89 "strings"
910
@@ -91,12 +92,14 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
9192 // Authenticate based on auth type
9293 // When auth type is "password", accept BOTH password and token auth
9394 // (allows CI/CD tokens while still supporting interactive password login)
94- var authenticated bool
95- var tokenClaims *domain.TokenClaims
96- authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
95+ tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log)
9796
98- if !authenticated {
99- sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
97+ if authErr != nil {
98+ if errors.Is(authErr, domain.ErrLongLivedToken) {
99+ sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, domain.ErrLongLivedToken.Error())
100+ } else {
101+ sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
102+ }
100103 return
101104 }
102105
@@ -119,8 +122,9 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
119122
120123 // authenticateToken handles token-based authentication.
121124 // It supports both Bearer token in Authorization header and token-as-password for CI.
122-// Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure.
123-func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (bool, *domain.TokenClaims) {
125+// Returns (tokenClaims, error). A nil error means authentication succeeded.
126+// Returns domain.ErrLongLivedToken when a stored (non-ephemeral) token is used.
127+func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) {
124128 // First, check for Bearer token
125129 authHeader := r.Header.Get("Authorization")
126130 if strings.HasPrefix(authHeader, "Bearer ") {
@@ -132,7 +136,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
132136 Str(zerowrap.FieldMethod, r.Method).
133137 Str(zerowrap.FieldPath, r.URL.Path).
134138 Msg("bearer token validation failed")
135- return false, nil
139+ return nil, err
140+ }
141+
142+ if !claims.IsEphemeral {
143+ log.Warn().
144+ Str("subject", claims.Subject).
145+ Str(zerowrap.FieldMethod, r.Method).
146+ Str(zerowrap.FieldPath, r.URL.Path).
147+ Msg("long-lived token rejected on registry endpoint")
148+ return nil, domain.ErrLongLivedToken
136149 }
137150
138151 log.Debug().
@@ -140,7 +153,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
140153 Str(zerowrap.FieldMethod, r.Method).
141154 Str(zerowrap.FieldPath, r.URL.Path).
142155 Msg("bearer token authentication successful")
143- return true, claims
156+ return claims, nil
144157 }
145158
146159 // Fall back to token-as-password (for CI/automation)
@@ -151,7 +164,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
151164 Str(zerowrap.FieldMethod, r.Method).
152165 Str(zerowrap.FieldPath, r.URL.Path).
153166 Msg("no auth credentials provided")
154- return false, nil
167+ return nil, errors.New("no auth credentials provided")
155168 }
156169
157170 // Try to validate the password as a JWT token
@@ -163,7 +176,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
163176 Str(zerowrap.FieldMethod, r.Method).
164177 Str(zerowrap.FieldPath, r.URL.Path).
165178 Msg("token-as-password validation failed")
166- return false, nil
179+ return nil, err
167180 }
168181
169182 // Verify the username matches the token subject
@@ -174,7 +187,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
174187 Str(zerowrap.FieldMethod, r.Method).
175188 Str(zerowrap.FieldPath, r.URL.Path).
176189 Msg("username does not match token subject")
177- return false, nil
190+ return nil, errors.New("username does not match token subject")
191+ }
192+
193+ if !claims.IsEphemeral {
194+ log.Warn().
195+ Str("subject", claims.Subject).
196+ Str(zerowrap.FieldMethod, r.Method).
197+ Str(zerowrap.FieldPath, r.URL.Path).
198+ Msg("long-lived token rejected on registry endpoint")
199+ return nil, domain.ErrLongLivedToken
178200 }
179201
180202 log.Debug().
@@ -182,7 +204,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
182204 Str(zerowrap.FieldMethod, r.Method).
183205 Str(zerowrap.FieldPath, r.URL.Path).
184206 Msg("token-as-password authentication successful")
185- return true, claims
207+ return claims, nil
186208 }
187209
188210 func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
@@ -283,6 +305,37 @@ func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request)
283305 Msg("forbidden: insufficient scope for operation")
284306 }
285307
308+// sendUnauthorizedMsg sends an HTTP 401 response with a custom error message.
309+func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request, msg string) {
310+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
311+
312+ switch authType {
313+ case domain.AuthTypeToken:
314+ realmHost := host
315+ scheme := "http"
316+ if r.TLS != nil {
317+ scheme = "https"
318+ }
319+ realmHost = sanitizeHeaderValue(realmHost)
320+ realm := scheme + "://" + realmHost + "/auth/token"
321+ w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
322+ default:
323+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
324+ }
325+
326+ w.Header().Set("Content-Type", "application/json")
327+ w.WriteHeader(http.StatusUnauthorized)
328+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: msg})
329+
330+ log.Warn().
331+ Str(zerowrap.FieldLayer, "adapter").
332+ Str(zerowrap.FieldAdapter, "http").
333+ Str(zerowrap.FieldMethod, r.Method).
334+ Str(zerowrap.FieldPath, r.URL.Path).
335+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
336+ Msg("unauthorized registry access attempt")
337+}
338+
286339 // sendUnauthorized sends an HTTP 401 response with appropriate headers.
287340 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
288341 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "crypto/subtle"5 "crypto/subtle"
6 "encoding/json"6 "encoding/json"
7+ "errors"
7 "net/http"8 "net/http"
8 "strings"9 "strings"
9 10
@@ -91,12 +92,14 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
91 // Authenticate based on auth type92 // Authenticate based on auth type
92 // When auth type is "password", accept BOTH password and token auth93 // When auth type is "password", accept BOTH password and token auth
93 // (allows CI/CD tokens while still supporting interactive password login)94 // (allows CI/CD tokens while still supporting interactive password login)
94- var authenticated bool95+ tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log)
95- var tokenClaims *domain.TokenClaims
96- authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
97 96
98- if !authenticated {97+ if authErr != nil {
99- sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)98+ if errors.Is(authErr, domain.ErrLongLivedToken) {
99+ sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, domain.ErrLongLivedToken.Error())
100+ } else {
101+ sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
102+ }
100 return103 return
101 }104 }
102 105
@@ -119,8 +122,9 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
119 122
120 // authenticateToken handles token-based authentication.123 // authenticateToken handles token-based authentication.
121 // 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.
122-// Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure.125+// Returns (tokenClaims, error). A nil error means authentication succeeded.
123-func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (bool, *domain.TokenClaims) {126+// Returns domain.ErrLongLivedToken when a stored (non-ephemeral) token is used.
127+func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) {
124 // First, check for Bearer token128 // First, check for Bearer token
125 authHeader := r.Header.Get("Authorization")129 authHeader := r.Header.Get("Authorization")
126 if strings.HasPrefix(authHeader, "Bearer ") {130 if strings.HasPrefix(authHeader, "Bearer ") {
@@ -132,7 +136,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
132 Str(zerowrap.FieldMethod, r.Method).136 Str(zerowrap.FieldMethod, r.Method).
133 Str(zerowrap.FieldPath, r.URL.Path).137 Str(zerowrap.FieldPath, r.URL.Path).
134 Msg("bearer token validation failed")138 Msg("bearer token validation failed")
135- return false, nil139+ return nil, err
140+ }
141+
142+ if !claims.IsEphemeral {
143+ log.Warn().
144+ Str("subject", claims.Subject).
145+ Str(zerowrap.FieldMethod, r.Method).
146+ Str(zerowrap.FieldPath, r.URL.Path).
147+ Msg("long-lived token rejected on registry endpoint")
148+ return nil, domain.ErrLongLivedToken
136 }149 }
137 150
138 log.Debug().151 log.Debug().
@@ -140,7 +153,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
140 Str(zerowrap.FieldMethod, r.Method).153 Str(zerowrap.FieldMethod, r.Method).
141 Str(zerowrap.FieldPath, r.URL.Path).154 Str(zerowrap.FieldPath, r.URL.Path).
142 Msg("bearer token authentication successful")155 Msg("bearer token authentication successful")
143- return true, claims156+ return claims, nil
144 }157 }
145 158
146 // Fall back to token-as-password (for CI/automation)159 // Fall back to token-as-password (for CI/automation)
@@ -151,7 +164,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
151 Str(zerowrap.FieldMethod, r.Method).164 Str(zerowrap.FieldMethod, r.Method).
152 Str(zerowrap.FieldPath, r.URL.Path).165 Str(zerowrap.FieldPath, r.URL.Path).
153 Msg("no auth credentials provided")166 Msg("no auth credentials provided")
154- return false, nil167+ return nil, errors.New("no auth credentials provided")
155 }168 }
156 169
157 // Try to validate the password as a JWT token170 // Try to validate the password as a JWT token
@@ -163,7 +176,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
163 Str(zerowrap.FieldMethod, r.Method).176 Str(zerowrap.FieldMethod, r.Method).
164 Str(zerowrap.FieldPath, r.URL.Path).177 Str(zerowrap.FieldPath, r.URL.Path).
165 Msg("token-as-password validation failed")178 Msg("token-as-password validation failed")
166- return false, nil179+ return nil, err
167 }180 }
168 181
169 // Verify the username matches the token subject182 // Verify the username matches the token subject
@@ -174,7 +187,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
174 Str(zerowrap.FieldMethod, r.Method).187 Str(zerowrap.FieldMethod, r.Method).
175 Str(zerowrap.FieldPath, r.URL.Path).188 Str(zerowrap.FieldPath, r.URL.Path).
176 Msg("username does not match token subject")189 Msg("username does not match token subject")
177- return false, nil190+ return nil, errors.New("username does not match token subject")
191+ }
192+
193+ if !claims.IsEphemeral {
194+ log.Warn().
195+ Str("subject", claims.Subject).
196+ Str(zerowrap.FieldMethod, r.Method).
197+ Str(zerowrap.FieldPath, r.URL.Path).
198+ Msg("long-lived token rejected on registry endpoint")
199+ return nil, domain.ErrLongLivedToken
178 }200 }
179 201
180 log.Debug().202 log.Debug().
@@ -182,7 +204,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
182 Str(zerowrap.FieldMethod, r.Method).204 Str(zerowrap.FieldMethod, r.Method).
183 Str(zerowrap.FieldPath, r.URL.Path).205 Str(zerowrap.FieldPath, r.URL.Path).
184 Msg("token-as-password authentication successful")206 Msg("token-as-password authentication successful")
185- return true, claims207+ return claims, nil
186 }208 }
187 209
188 func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {210 func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
@@ -283,6 +305,37 @@ func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request)
283 Msg("forbidden: insufficient scope for operation")305 Msg("forbidden: insufficient scope for operation")
284 }306 }
285 307
308+// sendUnauthorizedMsg sends an HTTP 401 response with a custom error message.
309+func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request, msg string) {
310+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
311+
312+ switch authType {
313+ case domain.AuthTypeToken:
314+ realmHost := host
315+ scheme := "http"
316+ if r.TLS != nil {
317+ scheme = "https"
318+ }
319+ realmHost = sanitizeHeaderValue(realmHost)
320+ realm := scheme + "://" + realmHost + "/auth/token"
321+ w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
322+ default:
323+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
324+ }
325+
326+ w.Header().Set("Content-Type", "application/json")
327+ w.WriteHeader(http.StatusUnauthorized)
328+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: msg})
329+
330+ log.Warn().
331+ Str(zerowrap.FieldLayer, "adapter").
332+ Str(zerowrap.FieldAdapter, "http").
333+ Str(zerowrap.FieldMethod, r.Method).
334+ Str(zerowrap.FieldPath, r.URL.Path).
335+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
336+ Msg("unauthorized registry access attempt")
337+}
338+
286 // sendUnauthorized sends an HTTP 401 response with appropriate headers.339 // sendUnauthorized sends an HTTP 401 response with appropriate headers.
287 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {340 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
288 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")341 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
M internal/adapters/in/http/middleware/auth_test.go +101 -0
@@ -341,6 +341,107 @@ func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
341341 }
342342 }
343343
344+func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) {
345+ log := testLogger()
346+ authSvc := stubAuthService{
347+ enabled: true,
348+ authType: domain.AuthTypeToken,
349+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
350+ return &domain.TokenClaims{
351+ Subject: "admin",
352+ Scopes: []string{"repository:*:pull,push"},
353+ IsEphemeral: false, // long-lived stored token
354+ }, nil
355+ },
356+ }
357+ internalAuth := InternalRegistryAuth{
358+ Username: "internal",
359+ Password: "secret",
360+ }
361+
362+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
363+ w.WriteHeader(http.StatusOK)
364+ }))
365+
366+ req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
367+ req.RemoteAddr = "10.0.0.1:12345"
368+ req.Header.Set("Authorization", "Bearer some-long-lived-jwt")
369+ rec := httptest.NewRecorder()
370+
371+ handler.ServeHTTP(rec, req)
372+
373+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
374+ assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
375+}
376+
377+func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) {
378+ log := testLogger()
379+ authSvc := stubAuthService{
380+ enabled: true,
381+ authType: domain.AuthTypeToken,
382+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
383+ return &domain.TokenClaims{
384+ Subject: "admin",
385+ Scopes: []string{"repository:*:pull,push"},
386+ IsEphemeral: true, // short-lived ephemeral token
387+ }, nil
388+ },
389+ }
390+ internalAuth := InternalRegistryAuth{
391+ Username: "internal",
392+ Password: "secret",
393+ }
394+
395+ handlerCalled := false
396+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
397+ handlerCalled = true
398+ w.WriteHeader(http.StatusOK)
399+ }))
400+
401+ req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
402+ req.RemoteAddr = "10.0.0.1:12345"
403+ req.Header.Set("Authorization", "Bearer some-ephemeral-jwt")
404+ rec := httptest.NewRecorder()
405+
406+ handler.ServeHTTP(rec, req)
407+
408+ assert.Equal(t, http.StatusOK, rec.Code)
409+ assert.True(t, handlerCalled, "handler should be called for ephemeral tokens")
410+}
411+
412+func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) {
413+ log := testLogger()
414+ authSvc := stubAuthService{
415+ enabled: true,
416+ authType: domain.AuthTypeToken,
417+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
418+ return &domain.TokenClaims{
419+ Subject: "ci-user",
420+ Scopes: []string{"repository:*:pull,push"},
421+ IsEphemeral: false, // long-lived stored token
422+ }, nil
423+ },
424+ }
425+ internalAuth := InternalRegistryAuth{
426+ Username: "internal",
427+ Password: "secret",
428+ }
429+
430+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
431+ w.WriteHeader(http.StatusOK)
432+ }))
433+
434+ req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
435+ req.RemoteAddr = "10.0.0.1:12345"
436+ req.SetBasicAuth("ci-user", "some-long-lived-jwt")
437+ rec := httptest.NewRecorder()
438+
439+ handler.ServeHTTP(rec, req)
440+
441+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
442+ assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
443+}
444+
344445 func TestCheckScopeAccess_DenialCases(t *testing.T) {
345446 log := testLogger()
346447
@@ -341,6 +341,107 @@ func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
341 }341 }
342 }342 }
343 343
344+func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) {
345+ log := testLogger()
346+ authSvc := stubAuthService{
347+ enabled: true,
348+ authType: domain.AuthTypeToken,
349+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
350+ return &domain.TokenClaims{
351+ Subject: "admin",
352+ Scopes: []string{"repository:*:pull,push"},
353+ IsEphemeral: false, // long-lived stored token
354+ }, nil
355+ },
356+ }
357+ internalAuth := InternalRegistryAuth{
358+ Username: "internal",
359+ Password: "secret",
360+ }
361+
362+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
363+ w.WriteHeader(http.StatusOK)
364+ }))
365+
366+ req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
367+ req.RemoteAddr = "10.0.0.1:12345"
368+ req.Header.Set("Authorization", "Bearer some-long-lived-jwt")
369+ rec := httptest.NewRecorder()
370+
371+ handler.ServeHTTP(rec, req)
372+
373+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
374+ assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
375+}
376+
377+func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) {
378+ log := testLogger()
379+ authSvc := stubAuthService{
380+ enabled: true,
381+ authType: domain.AuthTypeToken,
382+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
383+ return &domain.TokenClaims{
384+ Subject: "admin",
385+ Scopes: []string{"repository:*:pull,push"},
386+ IsEphemeral: true, // short-lived ephemeral token
387+ }, nil
388+ },
389+ }
390+ internalAuth := InternalRegistryAuth{
391+ Username: "internal",
392+ Password: "secret",
393+ }
394+
395+ handlerCalled := false
396+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
397+ handlerCalled = true
398+ w.WriteHeader(http.StatusOK)
399+ }))
400+
401+ req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
402+ req.RemoteAddr = "10.0.0.1:12345"
403+ req.Header.Set("Authorization", "Bearer some-ephemeral-jwt")
404+ rec := httptest.NewRecorder()
405+
406+ handler.ServeHTTP(rec, req)
407+
408+ assert.Equal(t, http.StatusOK, rec.Code)
409+ assert.True(t, handlerCalled, "handler should be called for ephemeral tokens")
410+}
411+
412+func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) {
413+ log := testLogger()
414+ authSvc := stubAuthService{
415+ enabled: true,
416+ authType: domain.AuthTypeToken,
417+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
418+ return &domain.TokenClaims{
419+ Subject: "ci-user",
420+ Scopes: []string{"repository:*:pull,push"},
421+ IsEphemeral: false, // long-lived stored token
422+ }, nil
423+ },
424+ }
425+ internalAuth := InternalRegistryAuth{
426+ Username: "internal",
427+ Password: "secret",
428+ }
429+
430+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
431+ w.WriteHeader(http.StatusOK)
432+ }))
433+
434+ req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
435+ req.RemoteAddr = "10.0.0.1:12345"
436+ req.SetBasicAuth("ci-user", "some-long-lived-jwt")
437+ rec := httptest.NewRecorder()
438+
439+ handler.ServeHTTP(rec, req)
440+
441+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
442+ assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
443+}
444+
344 func TestCheckScopeAccess_DenialCases(t *testing.T) {445 func TestCheckScopeAccess_DenialCases(t *testing.T) {
345 log := testLogger()446 log := testLogger()
346 447
M internal/boundaries/in/auth.go +1 -1
@@ -41,7 +41,7 @@ type AuthService interface {
4141
4242 // ExtendToken re-issues the token with expiry slid forward by 24h.
4343 // Returns the same token string if it was already extended within the debounce window (1h).
44- // Skips extension for ephemeral access tokens (≤5min) and service tokens.
44+ // Skips extension for ephemeral access tokens (≤1h) and service tokens.
4545 ExtendToken(ctx context.Context, tokenString string) (string, error)
4646
4747 // GetAccessTokenTTL returns the configured ephemeral access token lifetime.
@@ -41,7 +41,7 @@ type AuthService interface {
41 41
42 // ExtendToken re-issues the token with expiry slid forward by 24h.42 // ExtendToken re-issues the token with expiry slid forward by 24h.
43 // Returns the same token string if it was already extended within the debounce window (1h).43 // Returns the same token string if it was already extended within the debounce window (1h).
44- // Skips extension for ephemeral access tokens (≤5min) and service tokens.44+ // Skips extension for ephemeral access tokens (≤1h) and service tokens.
45 ExtendToken(ctx context.Context, tokenString string) (string, error)45 ExtendToken(ctx context.Context, tokenString string) (string, error)
46 46
47 // GetAccessTokenTTL returns the configured ephemeral access token lifetime.47 // GetAccessTokenTTL returns the configured ephemeral access token lifetime.
M internal/domain/auth.go +7 -6
@@ -29,12 +29,13 @@ type Token struct {
2929
3030 // TokenClaims represents the JWT claims for a token.
3131 type TokenClaims struct {
32- ID string `json:"jti"`
33- Subject string `json:"sub"`
34- Scopes []string `json:"scopes"`
35- IssuedAt int64 `json:"iat"`
36- ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires
37- Issuer string `json:"iss"`
32+ ID string `json:"jti"`
33+ Subject string `json:"sub"`
34+ Scopes []string `json:"scopes"`
35+ IssuedAt int64 `json:"iat"`
36+ ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires
37+ Issuer string `json:"iss"`
38+ IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored)
3839 }
3940
4041 // IsExpired checks if the token has expired.
@@ -29,12 +29,13 @@ type Token struct {
29 29
30 // TokenClaims represents the JWT claims for a token.30 // TokenClaims represents the JWT claims for a token.
31 type TokenClaims struct {31 type TokenClaims struct {
32- ID string `json:"jti"`32+ ID string `json:"jti"`
33- Subject string `json:"sub"`33+ Subject string `json:"sub"`
34- Scopes []string `json:"scopes"`34+ Scopes []string `json:"scopes"`
35- IssuedAt int64 `json:"iat"`35+ IssuedAt int64 `json:"iat"`
36- ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires36+ ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires
37- Issuer string `json:"iss"`37+ Issuer string `json:"iss"`
38+ IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored)
38 }39 }
39 40
40 // IsExpired checks if the token has expired.41 // IsExpired checks if the token has expired.
M internal/domain/errors.go +2 -0
@@ -69,12 +69,14 @@ var (
6969 ErrInvalidScope = errors.New("invalid scope format")
7070 ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
7171 ErrInvalidEnvKey = errors.New("invalid environment variable key")
72+ ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token")
7273
7374 // Remote errors
7475 ErrEmptyRemoteName = errors.New("remote name must not be empty")
7576 ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters")
7677 ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots")
7778 ErrPassUnavailable = errors.New("pass is not available")
79+ ErrRemoteNotFound = errors.New("remote not found")
7880
7981 // Attachment errors
8082 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 errors74 // 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 errors81 // Attachment errors
80 ErrAttachmentNotFound = errors.New("attachment not found")82 ErrAttachmentNotFound = errors.New("attachment not found")
M internal/usecase/auth/service.go +9 -3
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "fmt"
8+ "strings"
89 "time"
910
1011 "github.com/bnema/zerowrap"
@@ -94,11 +95,12 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai
9495
9596 tokenClaims := buildTokenClaims(claims)
9697
97- // Access tokens (short-lived, ≤5min, recently issued) skip store validation.
98+ // Access tokens (short-lived, ≤1h, recently issued) skip store validation.
9899 // CLI-generated tokens must exist in store to prevent use of externally-created tokens.
99100 // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent
100101 // attackers with stolen secrets from creating arbitrary short-lived tokens.
101102 isAccessToken := s.isEphemeralAccessToken(tokenClaims)
103+ tokenClaims.IsEphemeral = isAccessToken
102104
103105 if !isAccessToken {
104106 if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
@@ -262,6 +264,10 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
262264
263265 // GenerateToken creates a new JWT token for the given subject.
264266 func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
267+ if strings.Contains(subject, ":") {
268+ return "", fmt.Errorf("subject %q must not contain ':' (used as Basic Auth username)", subject)
269+ }
270+
265271 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
266272 zerowrap.FieldLayer: "usecase",
267273 zerowrap.FieldUseCase: "GenerateToken",
@@ -430,7 +436,7 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
430436
431437 // ExtendToken re-issues the token with expiry slid forward by 24h.
432438 // Returns the same token string if it was already extended within the debounce window (1h).
433-// Skips extension for ephemeral access tokens (≤5min) and service tokens.
439+// Skips extension for ephemeral access tokens (≤1h) and service tokens.
434440 func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) {
435441 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
436442 zerowrap.FieldLayer: "usecase",
@@ -446,7 +452,7 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
446452
447453 tokenClaims := buildTokenClaims(rawClaims)
448454
449- // Skip ephemeral access tokens (≤5min lifetime)
455+ // Skip ephemeral access tokens (≤1h lifetime)
450456 if s.isEphemeralAccessToken(tokenClaims) {
451457 log.Debug().Msg("skipping extension for ephemeral access token")
452458 return tokenString, nil
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "strings"
8 "time"9 "time"
9 10
10 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
@@ -94,11 +95,12 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai
94 95
95 tokenClaims := buildTokenClaims(claims)96 tokenClaims := buildTokenClaims(claims)
96 97
97- // Access tokens (short-lived, ≤5min, recently issued) skip store validation.98+ // Access tokens (short-lived, ≤1h, recently issued) skip store validation.
98 // CLI-generated tokens must exist in store to prevent use of externally-created tokens.99 // CLI-generated tokens must exist in store to prevent use of externally-created tokens.
99 // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent100 // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent
100 // attackers with stolen secrets from creating arbitrary short-lived tokens.101 // attackers with stolen secrets from creating arbitrary short-lived tokens.
101 isAccessToken := s.isEphemeralAccessToken(tokenClaims)102 isAccessToken := s.isEphemeralAccessToken(tokenClaims)
103+ tokenClaims.IsEphemeral = isAccessToken
102 104
103 if !isAccessToken {105 if !isAccessToken {
104 if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {106 if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
@@ -262,6 +264,10 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
262 264
263 // GenerateToken creates a new JWT token for the given subject.265 // GenerateToken creates a new JWT token for the given subject.
264 func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {266 func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
267+ if strings.Contains(subject, ":") {
268+ return "", fmt.Errorf("subject %q must not contain ':' (used as Basic Auth username)", subject)
269+ }
270+
265 ctx = zerowrap.CtxWithFields(ctx, map[string]any{271 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
266 zerowrap.FieldLayer: "usecase",272 zerowrap.FieldLayer: "usecase",
267 zerowrap.FieldUseCase: "GenerateToken",273 zerowrap.FieldUseCase: "GenerateToken",
@@ -430,7 +436,7 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
430 436
431 // ExtendToken re-issues the token with expiry slid forward by 24h.437 // ExtendToken re-issues the token with expiry slid forward by 24h.
432 // Returns the same token string if it was already extended within the debounce window (1h).438 // Returns the same token string if it was already extended within the debounce window (1h).
433-// Skips extension for ephemeral access tokens (≤5min) and service tokens.439+// Skips extension for ephemeral access tokens (≤1h) and service tokens.
434 func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) {440 func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) {
435 ctx = zerowrap.CtxWithFields(ctx, map[string]any{441 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
436 zerowrap.FieldLayer: "usecase",442 zerowrap.FieldLayer: "usecase",
@@ -446,7 +452,7 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
446 452
447 tokenClaims := buildTokenClaims(rawClaims)453 tokenClaims := buildTokenClaims(rawClaims)
448 454
449- // Skip ephemeral access tokens (≤5min lifetime)455+ // Skip ephemeral access tokens (≤1h lifetime)
450 if s.isEphemeralAccessToken(tokenClaims) {456 if s.isEphemeralAccessToken(tokenClaims) {
451 log.Debug().Msg("skipping extension for ephemeral access token")457 log.Debug().Msg("skipping extension for ephemeral access token")
452 return tokenString, nil458 return tokenString, nil
M internal/usecase/auth/service_test.go +90 -2
@@ -881,7 +881,7 @@ func TestExtendTokenSkipsEphemeral(t *testing.T) {
881881
882882 ctx := testContext()
883883
884- // Generate an ephemeral access token (≤5min)
884+ // Generate an ephemeral access token (≤MaxAccessTokenLifetime)
885885 tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute)
886886 require.NoError(t, err)
887887
@@ -907,7 +907,7 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
907907 // (otherwise it bypasses revocation checks)
908908 svc, _ := newTestAuthService(t)
909909
910- // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min).
910+ // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < MaxAccessTokenLifetime).
911911 // nbf is set to current time (not future) so the JWT library's nbf check does not reject it —
912912 // we need to isolate the iat bypass specifically.
913913 now := time.Now().UTC()
@@ -964,3 +964,91 @@ func TestExtendTokenSkipsServiceToken(t *testing.T) {
964964 require.NoError(t, err)
965965 assert.Equal(t, tokenStr, result, "service tokens should not be extended")
966966 }
967+
968+func TestValidateToken_IsEphemeral_EphemeralToken(t *testing.T) {
969+ // Ephemeral access tokens (generated by GenerateAccessToken) should have IsEphemeral = true
970+ svc := NewService(Config{
971+ Enabled: true,
972+ AuthType: domain.AuthTypeToken,
973+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
974+ }, nil, zerowrap.Default())
975+
976+ ctx := testContext()
977+
978+ tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 5*time.Minute)
979+ require.NoError(t, err)
980+
981+ claims, err := svc.ValidateToken(ctx, tokenStr)
982+ require.NoError(t, err)
983+ assert.True(t, claims.IsEphemeral, "ephemeral access token should have IsEphemeral = true")
984+}
985+
986+func TestValidateToken_IsEphemeral_StoredToken(t *testing.T) {
987+ // Long-lived stored tokens should have IsEphemeral = false
988+ tokenStore := mocks.NewMockTokenStore(t)
989+
990+ svc := NewService(Config{
991+ Enabled: true,
992+ AuthType: domain.AuthTypeToken,
993+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
994+ }, tokenStore, zerowrap.Default())
995+
996+ ctx := testContext()
997+
998+ var capturedToken *domain.Token
999+ tokenStore.EXPECT().
1000+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
1001+ Run(func(_ context.Context, t *domain.Token, _ string) {
1002+ capturedToken = t
1003+ }).
1004+ Return(nil)
1005+
1006+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute)
1007+ require.NoError(t, err)
1008+
1009+ tokenStore.EXPECT().
1010+ GetToken(mock.Anything, "testuser").
1011+ Return(tokenStr, capturedToken, nil)
1012+ tokenStore.EXPECT().
1013+ IsRevoked(mock.Anything, capturedToken.ID).
1014+ Return(false, nil)
1015+
1016+ claims, err := svc.ValidateToken(ctx, tokenStr)
1017+ require.NoError(t, err)
1018+ assert.False(t, claims.IsEphemeral, "stored token should have IsEphemeral = false")
1019+}
1020+
1021+func TestValidateToken_IsEphemeral_NeverExpiringToken(t *testing.T) {
1022+ // Never-expiring tokens must not be ephemeral
1023+ tokenStore := mocks.NewMockTokenStore(t)
1024+
1025+ svc := NewService(Config{
1026+ Enabled: true,
1027+ AuthType: domain.AuthTypeToken,
1028+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
1029+ }, tokenStore, zerowrap.Default())
1030+
1031+ ctx := testContext()
1032+
1033+ var capturedToken *domain.Token
1034+ tokenStore.EXPECT().
1035+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
1036+ Run(func(_ context.Context, t *domain.Token, _ string) {
1037+ capturedToken = t
1038+ }).
1039+ Return(nil)
1040+
1041+ tokenStr, err := svc.GenerateToken(ctx, "ci-bot", []string{"push"}, 0)
1042+ require.NoError(t, err)
1043+
1044+ tokenStore.EXPECT().
1045+ GetToken(mock.Anything, "ci-bot").
1046+ Return(tokenStr, capturedToken, nil)
1047+ tokenStore.EXPECT().
1048+ IsRevoked(mock.Anything, capturedToken.ID).
1049+ Return(false, nil)
1050+
1051+ claims, err := svc.ValidateToken(ctx, tokenStr)
1052+ require.NoError(t, err)
1053+ assert.False(t, claims.IsEphemeral, "never-expiring token should have IsEphemeral = false")
1054+}
@@ -881,7 +881,7 @@ func TestExtendTokenSkipsEphemeral(t *testing.T) {
881 881
882 ctx := testContext()882 ctx := testContext()
883 883
884- // Generate an ephemeral access token (≤5min)884+ // Generate an ephemeral access token (≤MaxAccessTokenLifetime)
885 tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute)885 tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute)
886 require.NoError(t, err)886 require.NoError(t, err)
887 887
@@ -907,7 +907,7 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
907 // (otherwise it bypasses revocation checks)907 // (otherwise it bypasses revocation checks)
908 svc, _ := newTestAuthService(t)908 svc, _ := newTestAuthService(t)
909 909
910- // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min).910+ // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < MaxAccessTokenLifetime).
911 // nbf is set to current time (not future) so the JWT library's nbf check does not reject it —911 // nbf is set to current time (not future) so the JWT library's nbf check does not reject it —
912 // we need to isolate the iat bypass specifically.912 // we need to isolate the iat bypass specifically.
913 now := time.Now().UTC()913 now := time.Now().UTC()
@@ -964,3 +964,91 @@ func TestExtendTokenSkipsServiceToken(t *testing.T) {
964 require.NoError(t, err)964 require.NoError(t, err)
965 assert.Equal(t, tokenStr, result, "service tokens should not be extended")965 assert.Equal(t, tokenStr, result, "service tokens should not be extended")
966 }966 }
967+
968+func TestValidateToken_IsEphemeral_EphemeralToken(t *testing.T) {
969+ // Ephemeral access tokens (generated by GenerateAccessToken) should have IsEphemeral = true
970+ svc := NewService(Config{
971+ Enabled: true,
972+ AuthType: domain.AuthTypeToken,
973+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
974+ }, nil, zerowrap.Default())
975+
976+ ctx := testContext()
977+
978+ tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 5*time.Minute)
979+ require.NoError(t, err)
980+
981+ claims, err := svc.ValidateToken(ctx, tokenStr)
982+ require.NoError(t, err)
983+ assert.True(t, claims.IsEphemeral, "ephemeral access token should have IsEphemeral = true")
984+}
985+
986+func TestValidateToken_IsEphemeral_StoredToken(t *testing.T) {
987+ // Long-lived stored tokens should have IsEphemeral = false
988+ tokenStore := mocks.NewMockTokenStore(t)
989+
990+ svc := NewService(Config{
991+ Enabled: true,
992+ AuthType: domain.AuthTypeToken,
993+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
994+ }, tokenStore, zerowrap.Default())
995+
996+ ctx := testContext()
997+
998+ var capturedToken *domain.Token
999+ tokenStore.EXPECT().
1000+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
1001+ Run(func(_ context.Context, t *domain.Token, _ string) {
1002+ capturedToken = t
1003+ }).
1004+ Return(nil)
1005+
1006+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute)
1007+ require.NoError(t, err)
1008+
1009+ tokenStore.EXPECT().
1010+ GetToken(mock.Anything, "testuser").
1011+ Return(tokenStr, capturedToken, nil)
1012+ tokenStore.EXPECT().
1013+ IsRevoked(mock.Anything, capturedToken.ID).
1014+ Return(false, nil)
1015+
1016+ claims, err := svc.ValidateToken(ctx, tokenStr)
1017+ require.NoError(t, err)
1018+ assert.False(t, claims.IsEphemeral, "stored token should have IsEphemeral = false")
1019+}
1020+
1021+func TestValidateToken_IsEphemeral_NeverExpiringToken(t *testing.T) {
1022+ // Never-expiring tokens must not be ephemeral
1023+ tokenStore := mocks.NewMockTokenStore(t)
1024+
1025+ svc := NewService(Config{
1026+ Enabled: true,
1027+ AuthType: domain.AuthTypeToken,
1028+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
1029+ }, tokenStore, zerowrap.Default())
1030+
1031+ ctx := testContext()
1032+
1033+ var capturedToken *domain.Token
1034+ tokenStore.EXPECT().
1035+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
1036+ Run(func(_ context.Context, t *domain.Token, _ string) {
1037+ capturedToken = t
1038+ }).
1039+ Return(nil)
1040+
1041+ tokenStr, err := svc.GenerateToken(ctx, "ci-bot", []string{"push"}, 0)
1042+ require.NoError(t, err)
1043+
1044+ tokenStore.EXPECT().
1045+ GetToken(mock.Anything, "ci-bot").
1046+ Return(tokenStr, capturedToken, nil)
1047+ tokenStore.EXPECT().
1048+ IsRevoked(mock.Anything, capturedToken.ID).
1049+ Return(false, nil)
1050+
1051+ claims, err := svc.ValidateToken(ctx, tokenStr)
1052+ require.NoError(t, err)
1053+ assert.False(t, claims.IsEphemeral, "never-expiring token should have IsEphemeral = false")
1054+}

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