1package cli
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "strings"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/mattn/go-isatty"
13 "github.com/spf13/cobra"
14 "github.com/spf13/viper"
15
16 "github.com/bnema/gordon/internal/adapters/dto"
17 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
18 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
19 "github.com/bnema/gordon/internal/adapters/out/secrets"
20 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
21 "github.com/bnema/gordon/internal/app"
22 "github.com/bnema/gordon/internal/domain"
23 "github.com/bnema/gordon/internal/usecase/auth"
24 "github.com/bnema/gordon/pkg/duration"
25)
26
27// newAuthCmd creates the auth command group.
28func newAuthCmd() *cobra.Command {
29 cmd := &cobra.Command{
30 Use: "auth",
31 Short: "Manage Gordon server authentication",
32 Long: `Commands for managing Gordon server authentication tokens and passwords.`,
33 }
34
35 cmd.AddCommand(newAuthTokenCmd())
36 cmd.AddCommand(newAuthInternalCmd())
37 cmd.AddCommand(newAuthLoginCmd())
38 cmd.AddCommand(newAuthStatusCmd())
39 cmd.AddCommand(newAuthShowTokenCmd())
40 cmd.AddCommand(newAuthLogoutCmd())
41
42 return cmd
43}
44
45// newAuthTokenCmd creates the token subcommand group.
46func newAuthTokenCmd() *cobra.Command {
47 cmd := &cobra.Command{
48 Use: "token",
49 Short: "Manage authentication tokens",
50 Long: `Commands for generating, listing, and revoking authentication tokens.`,
51 }
52
53 cmd.AddCommand(newTokenGenerateCmd())
54 cmd.AddCommand(newTokenListCmd())
55 cmd.AddCommand(newTokenRevokeCmd())
56
57 return cmd
58}
59
60// newTokenGenerateCmd creates the token generate command.
61func newTokenGenerateCmd() *cobra.Command {
62 var (
63 subject string
64 scopes string
65 expiry string
66 configPath string
67 repo string
68 )
69
70 cmd := &cobra.Command{
71 Use: "generate",
72 Short: "Generate a new authentication token",
73 Long: `Generate a new JWT authentication token for registry and/or admin access.
74
75The token will be stored in the configured secrets backend and can be used
76for docker login as: docker login -u <subject> -p <token>
77
78SCOPES:
79
80Registry scopes (for Docker push/pull):
81 push Push images to registry
82 pull Pull images from registry
83
84Admin scopes (for remote CLI access):
85 Format: admin:<resource>:<actions>
86
87 Resources: routes, secrets, config, status, * (all)
88 Actions: read, write, * (all)
89
90 Examples:
91 admin:*:* Full admin access (recommended for CLI)
92 admin:routes:read Read-only routes access
93 admin:status:read Read-only status access
94
95Repository scoping:
96 --repo myapp Scope to specific repository
97 --repo "*" All repositories (default)
98
99Combined examples:
100 --scopes "push,pull" All repos, registry only (default)
101 --scopes "push" --repo myapp Push to myapp only
102 --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)
103 --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)
104
105EXPIRY:
106
107Supports human-friendly durations:
108 d (days): 1d = 24 hours w (weeks): 1w = 7 days
109 M (months): 1M = 30 days y (years): 1y = 365 days
110
111Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
112Use --expiry=0 for a token that never expires (useful for CI).`,
113 RunE: func(cmd *cobra.Command, args []string) error {
114 return runTokenGenerate(subject, scopes, expiry, configPath, repo)
115 },
116 }
117
118 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
119 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
120 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
121 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
122 cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
123
124 _ = cmd.MarkFlagRequired("subject")
125
126 return cmd
127}
128
129// newTokenListCmd creates the token list command.
130func newTokenListCmd() *cobra.Command {
131 var (
132 configPath string
133 jsonOut bool
134 )
135
136 cmd := &cobra.Command{
137 Use: "list",
138 Short: "List all authentication tokens",
139 Long: `List all stored authentication tokens with their subjects and expiry information.`,
140 RunE: func(cmd *cobra.Command, args []string) error {
141 return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)
142 },
143 }
144
145 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
146 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
147
148 return cmd
149}
150
151// newTokenRevokeCmd creates the token revoke command.
152func newTokenRevokeCmd() *cobra.Command {
153 var (
154 configPath string
155 all bool
156 )
157
158 cmd := &cobra.Command{
159 Use: "revoke [token-id]",
160 Short: "Revoke authentication token(s)",
161 Long: `Revoke a token by its ID, or all tokens with --all.
162
163Examples:
164 gordon auth token revoke abc123-def456 Revoke specific token
165 gordon auth token revoke --all Revoke all tokens`,
166 Args: cobra.MaximumNArgs(1),
167 RunE: func(cmd *cobra.Command, args []string) error {
168 if all {
169 return runTokenRevokeAll(configPath)
170 }
171 if len(args) == 0 {
172 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
173 }
174 return runTokenRevoke(args[0], configPath)
175 },
176 }
177
178 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
179 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
180
181 return cmd
182}
183
184// newAuthInternalCmd creates the internal credentials command.
185func newAuthInternalCmd() *cobra.Command {
186 return &cobra.Command{
187 Use: "internal",
188 Short: "Show internal registry credentials for manual recovery",
189 Long: `Display the auto-generated internal registry credentials.
190
191These credentials are used by Gordon for pulling images from the local registry
192(localhost:5000) during internal deploys. You can use them for manual recovery:
193
194 docker login localhost:5000 -u gordon-internal -p <password>
195
196Note: Credentials are regenerated each time Gordon starts, and are only
197available while Gordon is running.`,
198 RunE: func(cmd *cobra.Command, args []string) error {
199 return runShowInternalAuth()
200 },
201 }
202}
203
204// newAuthLoginCmd creates the login command for remote authentication.
205func newAuthLoginCmd() *cobra.Command {
206 var (
207 remoteName string
208 token string
209 )
210
211 cmd := &cobra.Command{
212 Use: "login",
213 Short: "Authenticate with a Gordon server",
214 Long: `Store or verify a token for a Gordon server remote.
215
216With --token: stores the token and verifies it.
217Without --token: verifies the existing stored token still works.
218
219Generate a token on the server with: gordon auth token generate
220
221Examples:
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`,
225 RunE: func(cmd *cobra.Command, args []string) error {
226 if token != "" {
227 return runAuthLoginWithToken(cmd.Context(), remoteName, token, cmd.OutOrStdout())
228 }
229 return runAuthLoginVerify(cmd.Context(), remoteName, cmd.OutOrStdout())
230 },
231 }
232
233 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
234 cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
235
236 return cmd
237}
238
239func 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
245 resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
246 if err != nil {
247 return err
248 }
249
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
258 }
259
260 if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
261 return fmt.Errorf("failed to save token: %w", err)
262 }
263
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
272func runAuthLoginVerify(ctx context.Context, remoteName string, out io.Writer) error {
273 resolvedName, entry, err := resolveRemoteEntry(remoteName)
274 if err != nil {
275 return err
276 }
277
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)
281 }
282
283 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
284 client := remote.NewClient(entry.URL, remoteClientOptions(token, insecureTLS)...)
285 verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
286 defer cancel()
287
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.
298func 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
306The token is resolved from pass, TOML config, or environment variable
307using the same precedence as other commands.
308
309Output is suitable for piping (no decoration, single line).
310
311Examples:
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
325func runAuthShowToken(remoteName string, out io.Writer) error {
326 resolvedName, entry, err := resolveRemoteEntry(remoteName)
327 if err != nil {
328 return err
329 }
330
331 token := remote.ResolveTokenForRemote(resolvedName, entry)
332 if token == "" {
333 return fmt.Errorf("no token found for remote '%s'", resolvedName)
334 }
335
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 }
339
340 _ = cliWriteLine(out, token)
341 return nil
342}
343
344// newAuthLogoutCmd creates the logout command.
345func 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
353This clears the token from all local storage but does not revoke the
354token on the server. To revoke server-side, use: gordon auth token revoke
355
356Examples:
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 },
362 }
363
364 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to logout from (defaults to active remote)")
365
366 return cmd
367}
368
369func runAuthLogout(remoteName string, out io.Writer) error {
370 resolvedName, _, err := resolveRemoteEntry(remoteName)
371 if err != nil {
372 return err
373 }
374
375 if err := remote.ClearRemoteToken(resolvedName); err != nil {
376 return fmt.Errorf("failed to clear token: %w", err)
377 }
378
379 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName)))
380 return nil
381}
382
383func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) {
384 config, err := remote.LoadRemotes("")
385 if err != nil {
386 return "", remote.RemoteEntry{}, fmt.Errorf("failed to load remotes: %w", err)
387 }
388
389 if remoteName == "" {
390 remoteName = config.Active
391 }
392 if remoteName == "" {
393 return "", remote.RemoteEntry{}, fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
394 }
395
396 remoteConfig, ok := config.Remotes[remoteName]
397 if !ok {
398 return "", remote.RemoteEntry{}, fmt.Errorf("remote '%s' not found", remoteName)
399 }
400
401 return remoteName, remoteConfig, nil
402}
403
404// runShowInternalAuth displays the internal registry credentials.
405func runShowInternalAuth() error {
406 creds, err := app.GetInternalCredentials()
407 if err != nil {
408 return err
409 }
410
411 fmt.Println("Internal Registry Credentials")
412 fmt.Println("==============================")
413 fmt.Printf("Username: %s\n", creds.Username)
414 fmt.Printf("Password: %s\n", creds.Password)
415 fmt.Println()
416 fmt.Println("Usage:")
417 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
418
419 return nil
420}
421
422// runTokenGenerate generates a new authentication token.
423// parseAndConvertScopes parses a comma-separated scope string and converts
424// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
425func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
426 repo = strings.TrimSpace(repo)
427 if repo == "" {
428 return nil, fmt.Errorf("repository name cannot be empty")
429 }
430
431 rawScopes := strings.Split(scopesStr, ",")
432 for i := range rawScopes {
433 rawScopes[i] = strings.TrimSpace(rawScopes[i])
434 }
435
436 // Check if scopes are already in v2 format (contain colons)
437 hasSimpleScope := false
438 for _, s := range rawScopes {
439 if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
440 hasSimpleScope = true
441 break
442 }
443 }
444
445 if !hasSimpleScope {
446 return rawScopes, nil
447 }
448
449 // Convert simple scopes to v2 format: repository:<repo>:push,pull
450 var scopes []string
451 var actions []string
452 for _, s := range rawScopes {
453 if s == "push" || s == "pull" || s == "*" {
454 actions = append(actions, s)
455 } else {
456 // Keep non-simple scopes as-is (e.g., admin scopes)
457 scopes = append(scopes, s)
458 }
459 }
460 if len(actions) > 0 {
461 scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
462 }
463 return scopes, nil
464}
465
466func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
467 cfg, err := loadAuthConfig(configPath)
468 if err != nil {
469 return err
470 }
471
472 // Parse expiry (supports human-friendly units: d, w, M, y)
473 var expiry time.Duration
474 if expiryStr != "0" {
475 expiry, err = duration.Parse(expiryStr)
476 if err != nil {
477 return fmt.Errorf("invalid expiry duration: %w", err)
478 }
479 }
480
481 scopes, err := parseAndConvertScopes(scopesStr, repo)
482 if err != nil {
483 return fmt.Errorf("invalid scopes: %w", err)
484 }
485
486 // Create auth service
487 log := zerowrap.New(zerowrap.Config{Level: "warn"})
488 authSvc, err := createAuthServiceForCLI(cfg, log)
489 if err != nil {
490 return err
491 }
492
493 // Generate token
494 ctx := context.Background()
495 token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
496 if err != nil {
497 return fmt.Errorf("failed to generate token: %w", err)
498 }
499
500 fmt.Println("Token generated successfully!")
501 fmt.Printf("Subject: %s\n", subject)
502 fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
503 if expiry == 0 {
504 fmt.Println("Expiry: never")
505 } else {
506 fmt.Printf("Expiry: %s\n", expiry)
507 }
508 fmt.Println()
509 fmt.Println("Token (use as password with docker login):")
510 fmt.Println(token)
511 fmt.Println()
512 fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
513
514 return nil
515}
516
517// runTokenList lists all stored tokens.
518func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
519 cfg, err := loadAuthConfig(configPath)
520 if err != nil {
521 return err
522 }
523
524 log := zerowrap.New(zerowrap.Config{Level: "warn"})
525 authSvc, err := createAuthServiceForCLI(cfg, log)
526 if err != nil {
527 return err
528 }
529
530 ctx := context.Background()
531 tokens, err := authSvc.ListTokens(ctx)
532 if err != nil {
533 return fmt.Errorf("failed to list tokens: %w", err)
534 }
535
536 if len(tokens) == 0 {
537 if jsonOut {
538 return writeJSON(out, []map[string]any{})
539 }
540 fmt.Println("No tokens found.")
541 return nil
542 }
543
544 if jsonOut {
545 payload := make([]map[string]any, 0, len(tokens))
546 for _, t := range tokens {
547 var expiresAt *time.Time
548 if !t.ExpiresAt.IsZero() {
549 expiresAt = &t.ExpiresAt
550 }
551 payload = append(payload, map[string]any{
552 "id": t.ID,
553 "description": t.Subject,
554 "scopes": t.Scopes,
555 "created_at": t.IssuedAt,
556 "expires_at": expiresAt,
557 "revoked": t.Revoked,
558 })
559 }
560 return writeJSON(out, payload)
561 }
562
563 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
564 fmt.Println(strings.Repeat("-", 90))
565
566 for _, t := range tokens {
567 expiry := "never"
568 if !t.ExpiresAt.IsZero() {
569 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
570 }
571 revoked := "no"
572 if t.Revoked {
573 revoked = "yes"
574 }
575 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
576 }
577
578 return nil
579}
580
581// runTokenRevoke revokes a token by ID.
582func runTokenRevoke(tokenID, configPath string) error {
583 cfg, err := loadAuthConfig(configPath)
584 if err != nil {
585 return err
586 }
587
588 log := zerowrap.New(zerowrap.Config{Level: "warn"})
589 authSvc, err := createAuthServiceForCLI(cfg, log)
590 if err != nil {
591 return err
592 }
593
594 ctx := context.Background()
595 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
596 return fmt.Errorf("failed to revoke token: %w", err)
597 }
598
599 fmt.Printf("Token %s has been revoked.\n", tokenID)
600 return nil
601}
602
603// runTokenRevokeAll revokes all tokens.
604func runTokenRevokeAll(configPath string) error {
605 cfg, err := loadAuthConfig(configPath)
606 if err != nil {
607 return err
608 }
609
610 log := zerowrap.New(zerowrap.Config{Level: "warn"})
611 authSvc, err := createAuthServiceForCLI(cfg, log)
612 if err != nil {
613 return err
614 }
615
616 ctx := context.Background()
617 count, err := authSvc.RevokeAllTokens(ctx)
618 if err != nil {
619 return fmt.Errorf("failed to revoke tokens: %w", err)
620 }
621
622 if count == 0 {
623 fmt.Println("No tokens to revoke.")
624 } else {
625 fmt.Printf("Revoked %d token(s).\n", count)
626 }
627 return nil
628}
629
630// cliConfig holds the configuration needed for CLI commands.
631type cliConfig struct {
632 Backend domain.SecretsBackend
633 DataDir string
634 TokenSecret []byte
635}
636
637// loadAuthConfig loads the configuration needed for auth CLI commands.
638func loadAuthConfig(configPath string) (*cliConfig, error) {
639 v := viper.New()
640
641 // Set defaults
642 v.SetDefault("server.data_dir", app.DefaultDataDir())
643 v.SetDefault("auth.secrets_backend", "unsafe")
644
645 app.ConfigureViper(v, configPath)
646
647 if err := v.ReadInConfig(); err != nil {
648 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
649 return nil, fmt.Errorf("failed to read config file: %w", err)
650 }
651 // Config file not found is OK for CLI commands
652 }
653
654 cfg := &cliConfig{
655 DataDir: v.GetString("server.data_dir"),
656 }
657
658 // Determine backend
659 switch v.GetString("auth.secrets_backend") {
660 case "pass":
661 cfg.Backend = domain.SecretsBackendPass
662 case "sops":
663 cfg.Backend = domain.SecretsBackendSops
664 default:
665 cfg.Backend = domain.SecretsBackendUnsafe
666 }
667
668 // Load token secret from secrets backend
669 tokenSecretPath := v.GetString("auth.token_secret")
670 if tokenSecretPath != "" {
671 // Load actual secret from the configured backend
672 ctx := context.Background()
673 log := zerowrap.New(zerowrap.Config{Level: "warn"})
674
675 switch cfg.Backend {
676 case domain.SecretsBackendPass:
677 provider := secrets.NewPassProvider(log)
678 secret, err := provider.GetSecret(ctx, tokenSecretPath)
679 if err != nil {
680 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
681 }
682 cfg.TokenSecret = []byte(secret)
683 default:
684 // For unsafe backend, use the path as the secret (backwards compatible)
685 cfg.TokenSecret = []byte(tokenSecretPath)
686 }
687 } else {
688 return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret")
689 }
690
691 return cfg, nil
692}
693
694// createAuthServiceForCLI creates an auth service for CLI commands.
695func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
696 // Create token store
697 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
698 if err != nil {
699 return nil, fmt.Errorf("failed to create token store: %w", err)
700 }
701
702 // Create auth service config
703 authConfig := auth.Config{
704 Enabled: true,
705 AuthType: domain.AuthTypeToken,
706 TokenSecret: cfg.TokenSecret,
707 }
708
709 return auth.NewService(authConfig, store, log), nil
710}
711
712// newAuthStatusCmd creates the auth status command.
713func newAuthStatusCmd() *cobra.Command {
714 var remoteNameFlag string
715 cmd := &cobra.Command{
716 Use: "status",
717 Short: "Check authentication session status",
718 Long: `Verify if stored authentication session is still valid.
719
720Shows token validity, expiry, and connection status for the active remote.
721
722Examples:
723 gordon auth status Check status of active remote
724 gordon auth status --remote prod Check specific remote`,
725 RunE: func(cmd *cobra.Command, args []string) error {
726 return runAuthStatus(remoteNameFlag)
727 },
728 }
729 cmd.Flags().StringVarP(&remoteNameFlag, "remote", "r", "", "Check specific remote")
730 return cmd
731}
732
733// runAuthStatus checks authentication status for the active remote.
734func runAuthStatus(remoteNameArg string) error {
735 // Load remotes config
736 config, err := remote.LoadRemotes("")
737 if err != nil {
738 return fmt.Errorf("failed to load remotes: %w", err)
739 }
740
741 // Determine remote name
742 remoteName := remoteNameArg
743 if remoteName == "" {
744 remoteName = config.Active
745 }
746 if remoteName == "" {
747 return fmt.Errorf("no active remote configured. Use 'gordon remote use <name>' or --remote flag")
748 }
749
750 remoteConfig, ok := config.Remotes[remoteName]
751 if !ok {
752 if remoteNameArg != "" {
753 return fmt.Errorf("remote '%s' not found", remoteName)
754 }
755 return fmt.Errorf("active remote '%s' not found", remoteName)
756 }
757
758 // Create client
759 token := remote.ResolveTokenForRemote(remoteName, remoteConfig)
760 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, remoteName)
761 client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
762
763 // Verify auth
764 ctx := context.Background()
765 fmt.Printf("Checking authentication status for '%s'...\n", remoteName)
766
767 status, err := client.VerifyAuth(ctx)
768 if err != nil {
769 return handleAuthVerifyError(err, remoteName, remoteConfig.URL)
770 }
771
772 // Display status
773 displayAuthStatus(remoteName, remoteConfig.URL, status)
774 return nil
775}
776
777// handleAuthVerifyError handles errors from auth verification.
778func handleAuthVerifyError(err error, remoteName, url string) error {
779 errStrLower := strings.ToLower(err.Error())
780
781 // Connection errors
782 if strings.Contains(errStrLower, "connection refused") ||
783 strings.Contains(errStrLower, "no such host") ||
784 strings.Contains(errStrLower, "timeout") {
785 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
786 fmt.Println()
787 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
788 fmt.Printf("Error: %s\n\n", err.Error())
789 fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
790 return nil
791 }
792
793 // Auth errors
794 if strings.Contains(errStrLower, "401") ||
795 strings.Contains(errStrLower, "unauthorized") ||
796 strings.Contains(errStrLower, "invalid token") {
797 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
798 fmt.Println()
799 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
800 fmt.Println()
801 fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
802 fmt.Println()
803 fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
804 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
805 return nil
806 }
807
808 return fmt.Errorf("failed to verify auth: %w", err)
809}
810
811// displayAuthStatus displays authentication status information.
812func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
813 fmt.Println(styles.Theme.Bold.Render("Remote: " + remoteName + " | URL: " + url))
814 fmt.Println()
815
816 if status.Valid {
817 // Valid authentication
818 fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
819 fmt.Println()
820
821 if status.Subject != "" {
822 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
823 }
824
825 if status.IssuedAt > 0 {
826 issuedAt := time.Unix(status.IssuedAt, 0)
827 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
828 }
829
830 if status.ExpiresAt > 0 {
831 expiresAt := time.Unix(status.ExpiresAt, 0)
832 if expiresAt.Before(time.Now()) {
833 fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
834 expiresAt.Format("2006-01-02 15:04:05"),
835 styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
836 } else {
837 remaining := time.Until(expiresAt)
838 fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
839 expiresAt.Format("2006-01-02 15:04:05"),
840 styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
841 }
842 }
843
844 if len(status.Scopes) > 0 {
845 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
846 }
847 } else {
848 // Invalid or no authentication
849 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
850 fmt.Println()
851 fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
852 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
853 }
854}