Merge pull request #142 from bnema/token-exchange-client
feat(auth): client-side token exchange and server hardening
21 files · +847 -394
M docs/cli/auth.md +45 -8
| @@ -8,7 +8,10 @@ Manage Gordon server authentication tokens. | |||
| 8 | 8 | ||
| 9 | 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 | 15 | | `token generate` | Generate a new JWT token | | |
| 13 | 16 | | `token list` | List all stored tokens | | |
| 14 | 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 | 22 | ## 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 | 26 | ```bash | |
| 24 | - | gordon auth login [options] | |
| 27 | + | gordon auth login --token <token> [options] | |
| 25 | 28 | ``` | |
| 26 | 29 | ||
| 27 | 30 | ### Options | |
| 28 | 31 | ||
| 29 | 32 | | Option | Description | | |
| 30 | 33 | |--------|-------------| | |
| 34 | + | | `-t, --token` | **Required.** Authentication token to store | | |
| 31 | 35 | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | |
| 32 | - | | `-t, --token` | Authentication token to store for the remote | | |
| 33 | 36 | ||
| 34 | 37 | ### 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 | 43 | The token is verified against `/admin/status` on a best-effort basis. | |
| 39 | 44 | ||
| 40 | 45 | ### Examples | |
| 41 | 46 | ||
| 42 | 47 | ```bash | |
| 43 | - | # Store a token for the active remote | |
| 44 | 48 | gordon auth login --token <token> | |
| 45 | - | ||
| 46 | - | # Store a token for a specific remote | |
| 47 | 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 | 104 | ## gordon auth token generate | |
| 68 | 105 | ||
| 69 | 106 | 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 login | 22 | ## 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 | ```bash | 26 | ```bash | ||
| 24 | - | gordon auth login [options] | 27 | + | gordon auth login --token <token> [options] |
| 25 | ``` | 28 | ``` | ||
| 26 | 29 | ||||
| 27 | ### Options | 30 | ### 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 | ### Description | 37 | ### 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 | ### Examples | 45 | ### Examples | ||
| 41 | 46 | ||||
| 42 | ```bash | 47 | ```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 generate | 104 | ## 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 | |||
| 13 | 13 | - `auth.type = "password"` is no longer accepted | |
| 14 | 14 | - `auth.password` and `auth.password_hash` config fields are removed | |
| 15 | 15 | - 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) | |
| 17 | 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 | 20 | **New features:** | |
| 20 | 21 | ||
| 21 | 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 | 26 | - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations | |
| 23 | 27 | ||
| 24 | 28 | **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 accepted | 13 | - `auth.type = "password"` is no longer accepted | ||
| 14 | - `auth.password` and `auth.password_hash` config fields are removed | 14 | - `auth.password` and `auth.password_hash` config fields are removed | ||
| 15 | - The `gordon auth password hash` CLI command is removed | 15 | - 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) |
| 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 operations | 26 | - 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 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bufio" | |
| 5 | 4 | "context" | |
| 6 | 5 | "fmt" | |
| 7 | 6 | "io" | |
| @@ -10,9 +9,9 @@ import ( | |||
| 10 | 9 | "time" | |
| 11 | 10 | ||
| 12 | 11 | "github.com/bnema/zerowrap" | |
| 12 | + | "github.com/mattn/go-isatty" | |
| 13 | 13 | "github.com/spf13/cobra" | |
| 14 | 14 | "github.com/spf13/viper" | |
| 15 | - | "golang.org/x/term" | |
| 16 | 15 | ||
| 17 | 16 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 18 | 17 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -25,16 +24,6 @@ import ( | |||
| 25 | 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 | 27 | // newAuthCmd creates the auth command group. | |
| 39 | 28 | func newAuthCmd() *cobra.Command { | |
| 40 | 29 | cmd := &cobra.Command{ | |
| @@ -47,6 +36,8 @@ func newAuthCmd() *cobra.Command { | |||
| 47 | 36 | cmd.AddCommand(newAuthInternalCmd()) | |
| 48 | 37 | cmd.AddCommand(newAuthLoginCmd()) | |
| 49 | 38 | cmd.AddCommand(newAuthStatusCmd()) | |
| 39 | + | cmd.AddCommand(newAuthShowTokenCmd()) | |
| 40 | + | cmd.AddCommand(newAuthLogoutCmd()) | |
| 50 | 41 | ||
| 51 | 42 | return cmd | |
| 52 | 43 | } | |
| @@ -214,156 +205,178 @@ available while Gordon is running.`, | |||
| 214 | 205 | func newAuthLoginCmd() *cobra.Command { | |
| 215 | 206 | var ( | |
| 216 | 207 | remoteName string | |
| 217 | - | username string | |
| 218 | 208 | token string | |
| 219 | - | password string | |
| 220 | 209 | ) | |
| 221 | 210 | ||
| 222 | 211 | cmd := &cobra.Command{ | |
| 223 | 212 | Use: "login", | |
| 224 | 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 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. | |
| 229 | 218 | ||
| 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 | |
| 235 | 220 | ||
| 236 | 221 | 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`, | |
| 241 | 225 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 242 | 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 | 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 | 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 | 236 | return cmd | |
| 258 | 237 | } | |
| 259 | 238 | ||
| 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 | + | ||
| 262 | 245 | resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName) | |
| 263 | 246 | if err != nil { | |
| 264 | 247 | return err | |
| 265 | 248 | } | |
| 266 | 249 | ||
| 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 | |
| 276 | 258 | } | |
| 277 | 259 | ||
| 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) | |
| 280 | 262 | } | |
| 281 | 263 | ||
| 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 | |
| 305 | 276 | } | |
| 306 | 277 | ||
| 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) | |
| 309 | 281 | } | |
| 310 | 282 | ||
| 311 | - | // Create remote client | |
| 312 | 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 | - | // 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. | |
| 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 | 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 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) | |
| 327 | 334 | } | |
| 328 | 335 | ||
| 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 | + | } | |
| 333 | 339 | ||
| 340 | + | _ = cliWriteLine(out, token) | |
| 334 | 341 | return nil | |
| 335 | 342 | } | |
| 336 | 343 | ||
| 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 | + | }, | |
| 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 | 371 | if err != nil { | |
| 345 | 372 | return err | |
| 346 | 373 | } | |
| 347 | 374 | ||
| 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) | |
| 360 | 377 | } | |
| 361 | 378 | ||
| 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))) | |
| 367 | 380 | return nil | |
| 368 | 381 | } | |
| 369 | 382 | ||
| @@ -743,8 +756,9 @@ func runAuthStatus(remoteNameArg string) error { | |||
| 743 | 756 | } | |
| 744 | 757 | ||
| 745 | 758 | // Create client | |
| 759 | + | token := remote.ResolveTokenForRemote(remoteName, remoteConfig) | |
| 746 | 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 | 763 | // Verify auth | |
| 750 | 764 | ctx := context.Background() | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package cli | 1 | 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 cmd | 42 | 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 string | 207 | remoteName string | ||
| 217 | - | username string | |||
| 218 | token string | 208 | 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 the | 216 | + | 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 subsequent | 219 | + | 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 remote | 222 | + | gordon auth login --token <token> Store token for active remote |
| 238 | - | gordon auth login --remote prod Login to specific remote | 223 | + | gordon auth login --remote prod --token <tok> Store token for specific remote |
| 239 | - | gordon auth login --username admin Pre-fill username | 224 | + | 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 cmd | 236 | 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 err | 247 | return err | ||
| 265 | } | 248 | } | ||
| 266 | 249 | ||||
| 267 | - | // Prompt for username if not provided | 250 | + | 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 input | 273 | + | 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 | - | // Authenticate | 288 | + | 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 token | 331 | + | 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 nil | 341 | 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 err | 372 | 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 nil | 380 | return nil | ||
| 368 | } | 381 | } | ||
| 369 | 382 | ||||
| @@ -743,8 +756,9 @@ func runAuthStatus(remoteNameArg string) error { | |||||
| 743 | } | 756 | } | ||
| 744 | 757 | ||||
| 745 | // Create client | 758 | // 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 auth | 763 | // Verify auth | ||
| 750 | ctx := context.Background() | 764 | ctx := context.Background() | ||
M internal/adapters/in/cli/auth_test.go +6 -40
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 4 | 6 | "net/http" | |
| 5 | 7 | "net/http/httptest" | |
| 6 | 8 | "path/filepath" | |
| @@ -11,49 +13,12 @@ import ( | |||
| 11 | 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 | 17 | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 16 | 18 | t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled")) | |
| 17 | 19 | ||
| 18 | - | var receivedAuth string | |
| 20 | + | // Mock server — verification may fail (token exchange not mocked), but token should still be stored. | |
| 19 | 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 | 22 | w.WriteHeader(http.StatusUnauthorized) | |
| 58 | 23 | _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) | |
| 59 | 24 | })) | |
| @@ -67,7 +32,8 @@ func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) { | |||
| 67 | 32 | } | |
| 68 | 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 | 37 | assert.NoError(t, err) | |
| 72 | 38 | ||
| 73 | 39 | loaded, err := remote.LoadRemotes("") | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package cli | 1 | 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 string | 20 | + | // 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 ( | |||
| 14 | 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 | 35 | // TestExchangeRegistryAuth_Success verifies the happy path: | |
| 18 | 36 | // VerifyAuth is called, then ExchangeRegistryToken is called with the | |
| 19 | 37 | // subject from VerifyAuth, and the result is "Bearer <short-lived-token>". | |
| @@ -24,7 +42,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) { | |||
| 24 | 42 | verifyHandled := false | |
| 25 | 43 | 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 | 46 | switch r.URL.Path { | |
| 29 | 47 | case "/admin/auth/verify": | |
| 30 | 48 | verifyHandled = true | |
| @@ -36,8 +54,8 @@ func TestExchangeRegistryAuth_Success(t *testing.T) { | |||
| 36 | 54 | } | |
| 37 | 55 | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |
| 38 | 56 | case "/auth/token": | |
| 57 | + | // Registry token exchange (scope != admin:*:*) | |
| 39 | 58 | tokenHandled = true | |
| 40 | - | // Verify basic auth was set with subject as username | |
| 41 | 59 | u, p, ok := r.BasicAuth() | |
| 42 | 60 | assert.True(t, ok, "expected basic auth") | |
| 43 | 61 | assert.Equal(t, subject, u, "expected subject as username") | |
| @@ -49,7 +67,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) { | |||
| 49 | 67 | default: | |
| 50 | 68 | http.NotFound(w, r) | |
| 51 | 69 | } | |
| 52 | - | })) | |
| 70 | + | }) | |
| 53 | 71 | defer srv.Close() | |
| 54 | 72 | ||
| 55 | 73 | client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token")) | |
| @@ -68,13 +86,13 @@ func TestExchangeRegistryAuth_Success(t *testing.T) { | |||
| 68 | 86 | // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails, | |
| 69 | 87 | // exchangeRegistryAuth returns an error. | |
| 70 | 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 | 90 | if r.URL.Path == "/admin/auth/verify" { | |
| 73 | 91 | http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) | |
| 74 | 92 | return | |
| 75 | 93 | } | |
| 76 | 94 | http.NotFound(w, r) | |
| 77 | - | })) | |
| 95 | + | }) | |
| 78 | 96 | defer srv.Close() | |
| 79 | 97 | ||
| 80 | 98 | client := remote.NewClient(srv.URL, remote.WithToken("bad-token")) | |
| @@ -91,7 +109,7 @@ func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) { | |||
| 91 | 109 | // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns | |
| 92 | 110 | // Valid=false, exchangeRegistryAuth returns an error. | |
| 93 | 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 | 113 | if r.URL.Path == "/admin/auth/verify" { | |
| 96 | 114 | w.Header().Set("Content-Type", "application/json") | |
| 97 | 115 | resp := dto.AuthVerifyResponse{Valid: false} | |
| @@ -99,7 +117,7 @@ func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) { | |||
| 99 | 117 | return | |
| 100 | 118 | } | |
| 101 | 119 | http.NotFound(w, r) | |
| 102 | - | })) | |
| 120 | + | }) | |
| 103 | 121 | defer srv.Close() | |
| 104 | 122 | ||
| 105 | 123 | 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 the | 36 | // 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 := false | 42 | verifyHandled := false | ||
| 25 | tokenHandled := false | 43 | 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 = true | 48 | verifyHandled = true | ||
| @@ -36,8 +54,8 @@ func TestExchangeRegistryAuth_Success(t *testing.T) { | |||||
| 36 | } | 54 | } | ||
| 37 | json.NewEncoder(w).Encode(resp) //nolint:errcheck | 55 | json.NewEncoder(w).Encode(resp) //nolint:errcheck | ||
| 38 | case "/auth/token": | 56 | case "/auth/token": | ||
| 57 | + | // Registry token exchange (scope != admin:*:*) | |||
| 39 | tokenHandled = true | 58 | 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 | return | 92 | 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 returns | 109 | // 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 | return | 117 | 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 @@ | |||
| 1 | 1 | package remote | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "context" | |
| 6 | 5 | "encoding/json" | |
| 7 | 6 | "fmt" | |
| @@ -12,67 +11,6 @@ import ( | |||
| 12 | 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 | 14 | // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a | |
| 77 | 15 | // short-lived registry access token via the Docker v2 token endpoint. | |
| 78 | 16 | // The subject must match the token's sub claim (use VerifyAuth to obtain it). | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package remote | 1 | 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 a | 14 | // 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 ( | |||
| 13 | 13 | "net/url" | |
| 14 | 14 | "os" | |
| 15 | 15 | "strings" | |
| 16 | + | "sync" | |
| 16 | 17 | "time" | |
| 17 | 18 | ||
| 19 | + | "github.com/golang-jwt/jwt/v5" | |
| 20 | + | ||
| 18 | 21 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 19 | 22 | "github.com/bnema/gordon/internal/domain" | |
| 20 | 23 | ) | |
| 21 | 24 | ||
| 22 | 25 | // Client is an HTTP client for the Gordon admin API. | |
| 23 | 26 | 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 | |
| 29 | 37 | } | |
| 30 | 38 | ||
| 31 | 39 | var ( | |
| @@ -60,10 +68,31 @@ func NewClient(baseURL string, opts ...ClientOption) *Client { | |||
| 60 | 68 | } | |
| 61 | 69 | ||
| 62 | 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 | 74 | func WithToken(token string) ClientOption { | |
| 64 | 75 | return func(c *Client) { | |
| 65 | 76 | 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 | 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 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) | |
| 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, 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 | |
| 107 | 187 | } | |
| 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 | + | } | |
| 118 | 197 | } | |
| 198 | + | return c.ephemeral, nil | |
| 119 | 199 | } | |
| 120 | 200 | ||
| 121 | 201 | func (c *Client) applyTLSConfig() { | |
| @@ -151,18 +231,51 @@ func (c *Client) applyTLSConfig() { | |||
| 151 | 231 | ||
| 152 | 232 | // request performs an HTTP request to the admin API. | |
| 153 | 233 | 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 | |
| 157 | 236 | if body != nil { | |
| 158 | - | jsonBody, err := json.Marshal(body) | |
| 237 | + | var err error | |
| 238 | + | jsonBody, err = json.Marshal(body) | |
| 159 | 239 | if err != nil { | |
| 160 | 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 | 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 | 279 | if err != nil { | |
| 167 | 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 | 283 | req.Header.Set("Content-Type", "application/json") | |
| 171 | 284 | req.Header.Set("Accept", "application/json") | |
| 172 | 285 | ||
| 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) | |
| 175 | 292 | } | |
| 176 | 293 | ||
| 177 | 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 | 296 | 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 | 299 | return resp, nil | |
| 186 | 300 | } | |
| 187 | 301 | ||
| @@ -999,18 +1113,22 @@ func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern strin | |||
| 999 | 1113 | return parseResponse(resp, nil) | |
| 1000 | 1114 | } | |
| 1001 | 1115 | ||
| 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 | |
| 1005 | 1119 | ||
| 1006 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| 1120 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil) | |
| 1007 | 1121 | if err != nil { | |
| 1008 | 1122 | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 1009 | 1123 | } | |
| 1010 | 1124 | ||
| 1011 | 1125 | 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) | |
| 1014 | 1132 | } | |
| 1015 | 1133 | ||
| 1016 | 1134 | // 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 | 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 | 1144 | if resp.StatusCode >= 400 { | |
| 1030 | 1145 | body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize)) | |
| 1031 | 1146 | resp.Body.Close() | |
| 1032 | 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 | 1160 | ch := make(chan string, 100) | |
| 1036 | 1161 | ||
| 1037 | 1162 | 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 string | 27 | + | baseURL string |
| 25 | - | token string | 28 | + | token string |
| 26 | - | httpClient *http.Client | 29 | + | httpClient *http.Client |
| 27 | - | insecureTLS bool | 30 | + | insecureTLS bool |
| 28 | - | onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token | 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 | |||
| 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 = token | 76 | 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 server | 119 | + | // 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 = fn | 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) | |||
| 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, if | 181 | + | // bearerToken returns the token to use in Authorization headers. |
| 100 | - | // the server issued a refreshed token, updates the client's in-memory token and | 182 | + | // It exchanges the long-lived token for an ephemeral one via /auth/token. |
| 101 | - | // invokes the optional persistence callback. Panics in the callback are caught | 183 | + | // 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 = newToken | 188 | + | 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" + path | 234 | + | // 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, err | 296 | 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, nil | 299 | 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" + path | 1118 | + | 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 and | 1134 | // 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 ( | |||
| 7 | 7 | "strconv" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/pelletier/go-toml/v2" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 12 | 14 | // ClientConfig represents the client-mode configuration. | |
| @@ -256,6 +258,20 @@ func resolveRemoteEntryToken(remote *RemoteEntry) string { | |||
| 256 | 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 | 275 | // ResolveInsecureTLSForRemote resolves insecure TLS behavior for a named remote. | |
| 260 | 276 | // Precedence: flag > env > client config > specific remote config. | |
| 261 | 277 | func ResolveInsecureTLSForRemote(flagInsecure bool, remoteName string) bool { | |
| @@ -331,7 +347,7 @@ func SetActiveRemote(name string) error { | |||
| 331 | 347 | ||
| 332 | 348 | // Verify remote exists | |
| 333 | 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 | 353 | config.Active = name | |
| @@ -339,6 +355,30 @@ func SetActiveRemote(name string) error { | |||
| 339 | 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 | 382 | // ListRemotes returns all saved remotes. | |
| 343 | 383 | func ListRemotes() (map[string]RemoteEntry, string, error) { | |
| 344 | 384 | 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 exists | 348 | // 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 = name | 353 | 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 { | |||
| 95 | 95 | 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 | 115 | func warnPassUnavailable() { | |
| 99 | 116 | passWarnOnce.Do(func() { | |
| 100 | 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") | |
| @@ -95,6 +95,23 @@ func passWriteToken(name, token string) error { | |||||
| 95 | return nil | 95 | 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: | |||
| 168 | 168 | // GetRemoteClient returns a remote client if targeting a remote instance, | |
| 169 | 169 | // or nil if running locally. | |
| 170 | 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 | 172 | if !isRemote { | |
| 173 | 173 | return nil, false | |
| 174 | 174 | } | |
| 175 | 175 | ||
| 176 | 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 | 177 | client := remote.NewClient(url, opts...) | |
| 188 | 178 | return client, true | |
| 189 | 179 | } | |
| @@ -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, false | 173 | 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, true | 178 | return client, true | ||
| 189 | } | 179 | } | ||
M internal/adapters/in/http/admin/middleware.go +12 -10
| @@ -86,6 +86,17 @@ func AuthMiddleware( | |||
| 86 | 86 | 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 | 100 | // Check if token has admin scopes | |
| 90 | 101 | hasAdminScope := false | |
| 91 | 102 | for _, scope := range claims.Scopes { | |
| @@ -106,18 +117,9 @@ func AuthMiddleware( | |||
| 106 | 117 | ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject) | |
| 107 | 118 | ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims) | |
| 108 | 119 | ||
| 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. | |
| 111 | 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 | 123 | next.ServeHTTP(w, r.WithContext(ctx)) | |
| 122 | 124 | }) | |
| 123 | 125 | } | |
| @@ -86,6 +86,17 @@ func AuthMiddleware( | |||||
| 86 | return | 86 | 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 scopes | 100 | // Check if token has admin scopes | ||
| 90 | hasAdminScope := false | 101 | 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, especially | 120 | + | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "net/http" | |
| 7 | 6 | "net/http/httptest" | |
| 8 | 7 | "testing" | |
| @@ -85,11 +84,10 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 85 | 84 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | |
| 86 | 85 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 87 | 86 | 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, | |
| 90 | 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 | 92 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 95 | 93 | w.WriteHeader(http.StatusOK) | |
| @@ -114,11 +112,10 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||
| 114 | 112 | // No rate limiters, auth still required | |
| 115 | 113 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 116 | 114 | 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, | |
| 119 | 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 | 120 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 124 | 121 | w.WriteHeader(http.StatusOK) | |
| @@ -216,6 +213,63 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) { | |||
| 216 | 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 | 273 | func TestAuthMiddleware_MissingAdminScope(t *testing.T) { | |
| 220 | 274 | authSvc := inmocks.NewMockAuthService(t) | |
| 221 | 275 | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| @@ -225,8 +279,9 @@ func TestAuthMiddleware_MissingAdminScope(t *testing.T) { | |||
| 225 | 279 | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 226 | 280 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 227 | 281 | 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, | |
| 230 | 285 | }, nil) | |
| 231 | 286 | ||
| 232 | 287 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| @@ -255,11 +310,10 @@ func TestAuthMiddleware_Success(t *testing.T) { | |||
| 255 | 310 | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 256 | 311 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 257 | 312 | 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, | |
| 260 | 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 | 318 | var capturedCtx context.Context | |
| 265 | 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 | 428 | func TestHasAccess(t *testing.T) { | |
| 433 | 429 | ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"}) | |
| 434 | 430 | ||
| @@ -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 required | 112 | // 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 scopes | 283 | + | 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.Context | 318 | 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 | |||
| 177 | 177 | // Validate JWT token sent via Basic Auth password field. | |
| 178 | 178 | claims, err := h.authSvc.ValidateToken(ctx, password) | |
| 179 | 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 | 184 | return true, claims | |
| 181 | 185 | } | |
| 182 | 186 | ||
| @@ -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, claims | 184 | 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) { | |||
| 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 | 502 | func TestHandler_Token_AdminScopeSuccess(t *testing.T) { | |
| 452 | 503 | authSvc := mocks.NewMockAuthService(t) | |
| 453 | 504 | ||
| @@ -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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/subtle" | |
| 6 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| @@ -91,12 +92,14 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 91 | 92 | // Authenticate based on auth type | |
| 92 | 93 | // When auth type is "password", accept BOTH password and token auth | |
| 93 | 94 | // (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) | |
| 97 | 96 | ||
| 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 | + | } | |
| 100 | 103 | return | |
| 101 | 104 | } | |
| 102 | 105 | ||
| @@ -119,8 +122,9 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 119 | 122 | ||
| 120 | 123 | // authenticateToken handles token-based authentication. | |
| 121 | 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. | |
| 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) { | |
| 124 | 128 | // First, check for Bearer token | |
| 125 | 129 | authHeader := r.Header.Get("Authorization") | |
| 126 | 130 | if strings.HasPrefix(authHeader, "Bearer ") { | |
| @@ -132,7 +136,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 132 | 136 | Str(zerowrap.FieldMethod, r.Method). | |
| 133 | 137 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 134 | 138 | 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 | |
| 136 | 149 | } | |
| 137 | 150 | ||
| 138 | 151 | log.Debug(). | |
| @@ -140,7 +153,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 140 | 153 | Str(zerowrap.FieldMethod, r.Method). | |
| 141 | 154 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 142 | 155 | Msg("bearer token authentication successful") | |
| 143 | - | return true, claims | |
| 156 | + | return claims, nil | |
| 144 | 157 | } | |
| 145 | 158 | ||
| 146 | 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 | 164 | Str(zerowrap.FieldMethod, r.Method). | |
| 152 | 165 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 153 | 166 | Msg("no auth credentials provided") | |
| 154 | - | return false, nil | |
| 167 | + | return nil, errors.New("no auth credentials provided") | |
| 155 | 168 | } | |
| 156 | 169 | ||
| 157 | 170 | // 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 | 176 | Str(zerowrap.FieldMethod, r.Method). | |
| 164 | 177 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 165 | 178 | Msg("token-as-password validation failed") | |
| 166 | - | return false, nil | |
| 179 | + | return nil, err | |
| 167 | 180 | } | |
| 168 | 181 | ||
| 169 | 182 | // Verify the username matches the token subject | |
| @@ -174,7 +187,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 174 | 187 | Str(zerowrap.FieldMethod, r.Method). | |
| 175 | 188 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 176 | 189 | 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 | |
| 178 | 200 | } | |
| 179 | 201 | ||
| 180 | 202 | log.Debug(). | |
| @@ -182,7 +204,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 182 | 204 | Str(zerowrap.FieldMethod, r.Method). | |
| 183 | 205 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 184 | 206 | Msg("token-as-password authentication successful") | |
| 185 | - | return true, claims | |
| 207 | + | return claims, nil | |
| 186 | 208 | } | |
| 187 | 209 | ||
| 188 | 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 | 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 | 339 | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | |
| 287 | 340 | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | |
| 288 | 341 | 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 type | 92 | // Authenticate based on auth type | ||
| 92 | // When auth type is "password", accept BOTH password and token auth | 93 | // 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 bool | 95 | + | 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 | return | 103 | 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 token | 128 | // 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, 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 | |||
| 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, claims | 156 | + | 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, nil | 167 | + | return nil, errors.New("no auth credentials provided") |
| 155 | } | 168 | } | ||
| 156 | 169 | ||||
| 157 | // Try to validate the password as a JWT token | 170 | // 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, nil | 179 | + | return nil, err |
| 167 | } | 180 | } | ||
| 168 | 181 | ||||
| 169 | // Verify the username matches the token subject | 182 | // 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, 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 | |||
| 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, claims | 207 | + | 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) { | |||
| 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 | 445 | func TestCheckScopeAccess_DenialCases(t *testing.T) { | |
| 345 | 446 | log := testLogger() | |
| 346 | 447 | ||
| @@ -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 { | |||
| 41 | 41 | ||
| 42 | 42 | // ExtendToken re-issues the token with expiry slid forward by 24h. | |
| 43 | 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 | 45 | ExtendToken(ctx context.Context, tokenString string) (string, error) | |
| 46 | 46 | ||
| 47 | 47 | // 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 { | |||
| 29 | 29 | ||
| 30 | 30 | // TokenClaims represents the JWT claims for a token. | |
| 31 | 31 | 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) | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 40 | 41 | // 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 expires | 36 | + | 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 ( | |||
| 69 | 69 | ErrInvalidScope = errors.New("invalid scope format") | |
| 70 | 70 | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | |
| 71 | 71 | ErrInvalidEnvKey = errors.New("invalid environment variable key") | |
| 72 | + | ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token") | |
| 72 | 73 | ||
| 73 | 74 | // Remote errors | |
| 74 | 75 | ErrEmptyRemoteName = errors.New("remote name must not be empty") | |
| 75 | 76 | ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters") | |
| 76 | 77 | ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots") | |
| 77 | 78 | ErrPassUnavailable = errors.New("pass is not available") | |
| 79 | + | ErrRemoteNotFound = errors.New("remote not found") | |
| 78 | 80 | ||
| 79 | 81 | // Attachment errors | |
| 80 | 82 | 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 errors | 74 | // 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 errors | 81 | // 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 ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "strings" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zerowrap" | |
| @@ -94,11 +95,12 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai | |||
| 94 | 95 | ||
| 95 | 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 | 99 | // CLI-generated tokens must exist in store to prevent use of externally-created tokens. | |
| 99 | 100 | // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent | |
| 100 | 101 | // attackers with stolen secrets from creating arbitrary short-lived tokens. | |
| 101 | 102 | isAccessToken := s.isEphemeralAccessToken(tokenClaims) | |
| 103 | + | tokenClaims.IsEphemeral = isAccessToken | |
| 102 | 104 | ||
| 103 | 105 | if !isAccessToken { | |
| 104 | 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 | 265 | // GenerateToken creates a new JWT token for the given subject. | |
| 264 | 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 | 271 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 266 | 272 | zerowrap.FieldLayer: "usecase", | |
| 267 | 273 | zerowrap.FieldUseCase: "GenerateToken", | |
| @@ -430,7 +436,7 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||
| 430 | 436 | ||
| 431 | 437 | // ExtendToken re-issues the token with expiry slid forward by 24h. | |
| 432 | 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 | 440 | func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) { | |
| 435 | 441 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 436 | 442 | zerowrap.FieldLayer: "usecase", | |
| @@ -446,7 +452,7 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||
| 446 | 452 | ||
| 447 | 453 | tokenClaims := buildTokenClaims(rawClaims) | |
| 448 | 454 | ||
| 449 | - | // Skip ephemeral access tokens (≤5min lifetime) | |
| 455 | + | // Skip ephemeral access tokens (≤1h lifetime) | |
| 450 | 456 | if s.isEphemeralAccessToken(tokenClaims) { | |
| 451 | 457 | log.Debug().Msg("skipping extension for ephemeral access token") | |
| 452 | 458 | 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 prevent | 100 | // 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, nil | 458 | return tokenString, nil | ||
M internal/usecase/auth/service_test.go +90 -2
| @@ -881,7 +881,7 @@ func TestExtendTokenSkipsEphemeral(t *testing.T) { | |||
| 881 | 881 | ||
| 882 | 882 | ctx := testContext() | |
| 883 | 883 | ||
| 884 | - | // Generate an ephemeral access token (≤5min) | |
| 884 | + | // Generate an ephemeral access token (≤MaxAccessTokenLifetime) | |
| 885 | 885 | tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute) | |
| 886 | 886 | require.NoError(t, err) | |
| 887 | 887 | ||
| @@ -907,7 +907,7 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |||
| 907 | 907 | // (otherwise it bypasses revocation checks) | |
| 908 | 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 | 911 | // nbf is set to current time (not future) so the JWT library's nbf check does not reject it — | |
| 912 | 912 | // we need to isolate the iat bypass specifically. | |
| 913 | 913 | now := time.Now().UTC() | |
| @@ -964,3 +964,91 @@ func TestExtendTokenSkipsServiceToken(t *testing.T) { | |||
| 964 | 964 | require.NoError(t, err) | |
| 965 | 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 | + | } | |
| @@ -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 | + | } | |||