feat(auth): add show-token and logout CLI commands
- commit
- 16515bfe0380e2b8a17075e39231aeffc5317c1c
- author
- brice · · 2026-03-22 19:59 UTC
- parent
- 8f94295
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +130 -0
M internal/adapters/in/cli/auth.go +94 -0
| @@ -34,6 +34,8 @@ func newAuthCmd() *cobra.Command { | |||
| 34 | 34 | cmd.AddCommand(newAuthInternalCmd()) | |
| 35 | 35 | cmd.AddCommand(newAuthLoginCmd()) | |
| 36 | 36 | cmd.AddCommand(newAuthStatusCmd()) | |
| 37 | + | cmd.AddCommand(newAuthShowTokenCmd()) | |
| 38 | + | cmd.AddCommand(newAuthLogoutCmd()) | |
| 37 | 39 | ||
| 38 | 40 | return cmd | |
| 39 | 41 | } | |
| @@ -264,6 +266,98 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||
| 264 | 266 | return nil | |
| 265 | 267 | } | |
| 266 | 268 | ||
| 269 | + | // newAuthShowTokenCmd creates the show-token command. | |
| 270 | + | func newAuthShowTokenCmd() *cobra.Command { | |
| 271 | + | var remoteName string | |
| 272 | + | ||
| 273 | + | cmd := &cobra.Command{ | |
| 274 | + | Use: "show-token", | |
| 275 | + | Short: "Print the stored token for a remote", | |
| 276 | + | Long: `Print the raw authentication token for a remote to stdout. | |
| 277 | + | ||
| 278 | + | The token is resolved from pass, TOML config, or environment variable | |
| 279 | + | using the same precedence as other commands. | |
| 280 | + | ||
| 281 | + | Output is suitable for piping (no decoration, single line). | |
| 282 | + | ||
| 283 | + | Examples: | |
| 284 | + | gordon auth show-token Show token for active remote | |
| 285 | + | gordon auth show-token --remote prod Show token for specific remote | |
| 286 | + | gordon auth show-token | pbcopy Copy token to clipboard`, | |
| 287 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 288 | + | return runAuthShowToken(remoteName) | |
| 289 | + | }, | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to show token for (defaults to active remote)") | |
| 293 | + | ||
| 294 | + | return cmd | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func runAuthShowToken(remoteName string) error { | |
| 298 | + | resolvedName, entry, err := resolveRemoteEntry(remoteName) | |
| 299 | + | if err != nil { | |
| 300 | + | return err | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | token := remote.ResolveTokenForRemote(resolvedName, entry) | |
| 304 | + | if token == "" { | |
| 305 | + | return fmt.Errorf("no token found for remote '%s'", resolvedName) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | fmt.Println(token) | |
| 309 | + | return nil | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // newAuthLogoutCmd creates the logout command. | |
| 313 | + | func newAuthLogoutCmd() *cobra.Command { | |
| 314 | + | var ( | |
| 315 | + | remoteName string | |
| 316 | + | revoke bool | |
| 317 | + | ) | |
| 318 | + | ||
| 319 | + | cmd := &cobra.Command{ | |
| 320 | + | Use: "logout", | |
| 321 | + | Short: "Remove stored authentication for a remote", | |
| 322 | + | Long: `Remove the stored token for a remote from pass and TOML config. | |
| 323 | + | ||
| 324 | + | This clears the token from all local storage but does not revoke the | |
| 325 | + | token on the server. Use --revoke to also invalidate the token server-side | |
| 326 | + | (not yet implemented). | |
| 327 | + | ||
| 328 | + | Examples: | |
| 329 | + | gordon auth logout Logout from active remote | |
| 330 | + | gordon auth logout --remote prod Logout from specific remote | |
| 331 | + | gordon auth logout --revoke Logout and revoke token on server`, | |
| 332 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 333 | + | return runAuthLogout(remoteName, revoke) | |
| 334 | + | }, | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to logout from (defaults to active remote)") | |
| 338 | + | cmd.Flags().BoolVar(&revoke, "revoke", false, "Also revoke the token on the server (not yet implemented)") | |
| 339 | + | ||
| 340 | + | return cmd | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func runAuthLogout(remoteName string, revoke bool) error { | |
| 344 | + | resolvedName, _, err := resolveRemoteEntry(remoteName) | |
| 345 | + | if err != nil { | |
| 346 | + | return err | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | if revoke { | |
| 350 | + | fmt.Println(styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only")) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | if err := remote.ClearRemoteToken(resolvedName); err != nil { | |
| 354 | + | return fmt.Errorf("failed to clear token: %w", err) | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName))) | |
| 358 | + | return nil | |
| 359 | + | } | |
| 360 | + | ||
| 267 | 361 | func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) { | |
| 268 | 362 | config, err := remote.LoadRemotes("") | |
| 269 | 363 | if err != nil { | |
| @@ -34,6 +34,8 @@ func newAuthCmd() *cobra.Command { | |||||
| 34 | cmd.AddCommand(newAuthInternalCmd()) | 34 | cmd.AddCommand(newAuthInternalCmd()) | ||
| 35 | cmd.AddCommand(newAuthLoginCmd()) | 35 | cmd.AddCommand(newAuthLoginCmd()) | ||
| 36 | cmd.AddCommand(newAuthStatusCmd()) | 36 | cmd.AddCommand(newAuthStatusCmd()) | ||
| 37 | + | cmd.AddCommand(newAuthShowTokenCmd()) | |||
| 38 | + | cmd.AddCommand(newAuthLogoutCmd()) | |||
| 37 | 39 | ||||
| 38 | return cmd | 40 | return cmd | ||
| 39 | } | 41 | } | ||
| @@ -264,6 +266,98 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||||
| 264 | return nil | 266 | return nil | ||
| 265 | } | 267 | } | ||
| 266 | 268 | ||||
| 269 | + | // newAuthShowTokenCmd creates the show-token command. | |||
| 270 | + | func newAuthShowTokenCmd() *cobra.Command { | |||
| 271 | + | var remoteName string | |||
| 272 | + | ||||
| 273 | + | cmd := &cobra.Command{ | |||
| 274 | + | Use: "show-token", | |||
| 275 | + | Short: "Print the stored token for a remote", | |||
| 276 | + | Long: `Print the raw authentication token for a remote to stdout. | |||
| 277 | + | ||||
| 278 | + | The token is resolved from pass, TOML config, or environment variable | |||
| 279 | + | using the same precedence as other commands. | |||
| 280 | + | ||||
| 281 | + | Output is suitable for piping (no decoration, single line). | |||
| 282 | + | ||||
| 283 | + | Examples: | |||
| 284 | + | gordon auth show-token Show token for active remote | |||
| 285 | + | gordon auth show-token --remote prod Show token for specific remote | |||
| 286 | + | gordon auth show-token | pbcopy Copy token to clipboard`, | |||
| 287 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 288 | + | return runAuthShowToken(remoteName) | |||
| 289 | + | }, | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to show token for (defaults to active remote)") | |||
| 293 | + | ||||
| 294 | + | return cmd | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func runAuthShowToken(remoteName string) error { | |||
| 298 | + | resolvedName, entry, err := resolveRemoteEntry(remoteName) | |||
| 299 | + | if err != nil { | |||
| 300 | + | return err | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | token := remote.ResolveTokenForRemote(resolvedName, entry) | |||
| 304 | + | if token == "" { | |||
| 305 | + | return fmt.Errorf("no token found for remote '%s'", resolvedName) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | fmt.Println(token) | |||
| 309 | + | return nil | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // newAuthLogoutCmd creates the logout command. | |||
| 313 | + | func newAuthLogoutCmd() *cobra.Command { | |||
| 314 | + | var ( | |||
| 315 | + | remoteName string | |||
| 316 | + | revoke bool | |||
| 317 | + | ) | |||
| 318 | + | ||||
| 319 | + | cmd := &cobra.Command{ | |||
| 320 | + | Use: "logout", | |||
| 321 | + | Short: "Remove stored authentication for a remote", | |||
| 322 | + | Long: `Remove the stored token for a remote from pass and TOML config. | |||
| 323 | + | ||||
| 324 | + | This clears the token from all local storage but does not revoke the | |||
| 325 | + | token on the server. Use --revoke to also invalidate the token server-side | |||
| 326 | + | (not yet implemented). | |||
| 327 | + | ||||
| 328 | + | Examples: | |||
| 329 | + | gordon auth logout Logout from active remote | |||
| 330 | + | gordon auth logout --remote prod Logout from specific remote | |||
| 331 | + | gordon auth logout --revoke Logout and revoke token on server`, | |||
| 332 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 333 | + | return runAuthLogout(remoteName, revoke) | |||
| 334 | + | }, | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to logout from (defaults to active remote)") | |||
| 338 | + | cmd.Flags().BoolVar(&revoke, "revoke", false, "Also revoke the token on the server (not yet implemented)") | |||
| 339 | + | ||||
| 340 | + | return cmd | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | func runAuthLogout(remoteName string, revoke bool) error { | |||
| 344 | + | resolvedName, _, err := resolveRemoteEntry(remoteName) | |||
| 345 | + | if err != nil { | |||
| 346 | + | return err | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | if revoke { | |||
| 350 | + | fmt.Println(styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only")) | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | if err := remote.ClearRemoteToken(resolvedName); err != nil { | |||
| 354 | + | return fmt.Errorf("failed to clear token: %w", err) | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName))) | |||
| 358 | + | return nil | |||
| 359 | + | } | |||
| 360 | + | ||||
| 267 | func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) { | 361 | func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) { | ||
| 268 | config, err := remote.LoadRemotes("") | 362 | config, err := remote.LoadRemotes("") | ||
| 269 | if err != nil { | 363 | if err != nil { | ||
M internal/adapters/in/cli/remote/config.go +24 -0
| @@ -353,6 +353,30 @@ func SetActiveRemote(name string) error { | |||
| 353 | 353 | return SaveRemotes("", config) | |
| 354 | 354 | } | |
| 355 | 355 | ||
| 356 | + | // ClearRemoteToken removes the token and token_env fields for a named remote | |
| 357 | + | // from the remotes TOML config, and deletes the pass store entry if present. | |
| 358 | + | func ClearRemoteToken(name string) error { | |
| 359 | + | // Delete from pass (best-effort, no error if pass unavailable) | |
| 360 | + | _ = passDeleteToken(name) | |
| 361 | + | ||
| 362 | + | // Clear TOML fields | |
| 363 | + | config, err := LoadRemotes("") | |
| 364 | + | if err != nil { | |
| 365 | + | return err | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | entry, ok := config.Remotes[name] | |
| 369 | + | if !ok { | |
| 370 | + | return fmt.Errorf("remote '%s' not found", name) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | entry.Token = "" | |
| 374 | + | entry.TokenEnv = "" | |
| 375 | + | config.Remotes[name] = entry | |
| 376 | + | ||
| 377 | + | return SaveRemotes("", config) | |
| 378 | + | } | |
| 379 | + | ||
| 356 | 380 | // ListRemotes returns all saved remotes. | |
| 357 | 381 | func ListRemotes() (map[string]RemoteEntry, string, error) { | |
| 358 | 382 | config, err := LoadRemotes("") | |
| @@ -353,6 +353,30 @@ func SetActiveRemote(name string) error { | |||||
| 353 | return SaveRemotes("", config) | 353 | return SaveRemotes("", config) | ||
| 354 | } | 354 | } | ||
| 355 | 355 | ||||
| 356 | + | // ClearRemoteToken removes the token and token_env fields for a named remote | |||
| 357 | + | // from the remotes TOML config, and deletes the pass store entry if present. | |||
| 358 | + | func ClearRemoteToken(name string) error { | |||
| 359 | + | // Delete from pass (best-effort, no error if pass unavailable) | |||
| 360 | + | _ = passDeleteToken(name) | |||
| 361 | + | ||||
| 362 | + | // Clear TOML fields | |||
| 363 | + | config, err := LoadRemotes("") | |||
| 364 | + | if err != nil { | |||
| 365 | + | return err | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | entry, ok := config.Remotes[name] | |||
| 369 | + | if !ok { | |||
| 370 | + | return fmt.Errorf("remote '%s' not found", name) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | entry.Token = "" | |||
| 374 | + | entry.TokenEnv = "" | |||
| 375 | + | config.Remotes[name] = entry | |||
| 376 | + | ||||
| 377 | + | return SaveRemotes("", config) | |||
| 378 | + | } | |||
| 379 | + | ||||
| 356 | // ListRemotes returns all saved remotes. | 380 | // ListRemotes returns all saved remotes. | ||
| 357 | func ListRemotes() (map[string]RemoteEntry, string, error) { | 381 | func ListRemotes() (map[string]RemoteEntry, string, error) { | ||
| 358 | config, err := LoadRemotes("") | 382 | config, err := LoadRemotes("") | ||
M internal/adapters/in/cli/remote/pass.go +12 -0
| @@ -95,6 +95,18 @@ 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 | + | return cmd.Run() | |
| 108 | + | } | |
| 109 | + | ||
| 98 | 110 | func warnPassUnavailable() { | |
| 99 | 111 | passWarnOnce.Do(func() { | |
| 100 | 112 | 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,18 @@ 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 | + | return cmd.Run() | |||
| 108 | + | } | |||
| 109 | + | ||||
| 98 | func warnPassUnavailable() { | 110 | func warnPassUnavailable() { | ||
| 99 | passWarnOnce.Do(func() { | 111 | 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") | 112 | 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") | ||