1package cli
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "strings"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/spf13/cobra"
13 "github.com/spf13/viper"
14 "golang.org/x/term"
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(newAuthPasswordCmd())
37 cmd.AddCommand(newAuthInternalCmd())
38 cmd.AddCommand(newAuthLoginCmd())
39 cmd.AddCommand(newAuthStatusCmd())
40
41 return cmd
42}
43
44// newAuthTokenCmd creates the token subcommand group.
45func newAuthTokenCmd() *cobra.Command {
46 cmd := &cobra.Command{
47 Use: "token",
48 Short: "Manage authentication tokens",
49 Long: `Commands for generating, listing, and revoking authentication tokens.`,
50 }
51
52 cmd.AddCommand(newTokenGenerateCmd())
53 cmd.AddCommand(newTokenListCmd())
54 cmd.AddCommand(newTokenRevokeCmd())
55
56 return cmd
57}
58
59// newTokenGenerateCmd creates the token generate command.
60func newTokenGenerateCmd() *cobra.Command {
61 var (
62 subject string
63 scopes string
64 expiry string
65 configPath string
66 )
67
68 cmd := &cobra.Command{
69 Use: "generate",
70 Short: "Generate a new authentication token",
71 Long: `Generate a new JWT authentication token for registry and/or admin access.
72
73The token will be stored in the configured secrets backend and can be used
74for docker login as: docker login -u <subject> -p <token>
75
76SCOPES:
77
78Registry scopes (for Docker push/pull):
79 push Push images to registry
80 pull Pull images from registry
81
82Admin scopes (for remote CLI access):
83 Format: admin:<resource>:<actions>
84
85 Resources: routes, secrets, config, status, * (all)
86 Actions: read, write, * (all)
87
88 Examples:
89 admin:*:* Full admin access (recommended for CLI)
90 admin:routes:read Read-only routes access
91 admin:status:read Read-only status access
92
93Combined examples:
94 --scopes "push,pull" Registry only (default)
95 --scopes "admin:*:*" Admin CLI only
96 --scopes "push,pull,admin:*:*" Registry + full admin
97
98EXPIRY:
99
100Supports human-friendly durations:
101 d (days): 1d = 24 hours w (weeks): 1w = 7 days
102 M (months): 1M = 30 days y (years): 1y = 365 days
103
104Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
105Use --expiry=0 for a token that never expires (useful for CI).`,
106 RunE: func(cmd *cobra.Command, args []string) error {
107 return runTokenGenerate(subject, scopes, expiry, configPath)
108 },
109 }
110
111 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
112 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
113 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
114 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
115
116 _ = cmd.MarkFlagRequired("subject")
117
118 return cmd
119}
120
121// newTokenListCmd creates the token list command.
122func newTokenListCmd() *cobra.Command {
123 var configPath string
124
125 cmd := &cobra.Command{
126 Use: "list",
127 Short: "List all authentication tokens",
128 Long: `List all stored authentication tokens with their subjects and expiry information.`,
129 RunE: func(cmd *cobra.Command, args []string) error {
130 return runTokenList(configPath)
131 },
132 }
133
134 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
135
136 return cmd
137}
138
139// newTokenRevokeCmd creates the token revoke command.
140func newTokenRevokeCmd() *cobra.Command {
141 var (
142 configPath string
143 all bool
144 )
145
146 cmd := &cobra.Command{
147 Use: "revoke [token-id]",
148 Short: "Revoke authentication token(s)",
149 Long: `Revoke a token by its ID, or all tokens with --all.
150
151Examples:
152 gordon auth token revoke abc123-def456 Revoke specific token
153 gordon auth token revoke --all Revoke all tokens`,
154 Args: cobra.MaximumNArgs(1),
155 RunE: func(cmd *cobra.Command, args []string) error {
156 if all {
157 return runTokenRevokeAll(configPath)
158 }
159 if len(args) == 0 {
160 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
161 }
162 return runTokenRevoke(args[0], configPath)
163 },
164 }
165
166 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
167 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
168
169 return cmd
170}
171
172// newAuthPasswordCmd creates the password subcommand group.
173func newAuthPasswordCmd() *cobra.Command {
174 cmd := &cobra.Command{
175 Use: "password",
176 Short: "Password utilities",
177 Long: `Utilities for managing password authentication.`,
178 }
179
180 cmd.AddCommand(newPasswordHashCmd())
181
182 return cmd
183}
184
185// newAuthInternalCmd creates the internal credentials command.
186func newAuthInternalCmd() *cobra.Command {
187 return &cobra.Command{
188 Use: "internal",
189 Short: "Show internal registry credentials for manual recovery",
190 Long: `Display the auto-generated internal registry credentials.
191
192These credentials are used by Gordon for pulling images from the local registry
193(localhost:5000) during internal deploys. You can use them for manual recovery:
194
195 docker login localhost:5000 -u gordon-internal -p <password>
196
197Note: Credentials are regenerated each time Gordon starts, and are only
198available while Gordon is running.`,
199 RunE: func(cmd *cobra.Command, args []string) error {
200 return runShowInternalAuth()
201 },
202 }
203}
204
205// newAuthLoginCmd creates the login command for remote authentication.
206func newAuthLoginCmd() *cobra.Command {
207 var (
208 remoteName string
209 username string
210 )
211
212 cmd := &cobra.Command{
213 Use: "login",
214 Short: "Authenticate with a Gordon server",
215 Long: `Authenticate with a Gordon server using password authentication.
216
217This command prompts for your username and password, authenticates with the
218remote server's /auth/password endpoint, and stores the returned token.
219
220The token is stored in your remote configuration and used for subsequent
221CLI operations.
222
223Note: This command only works with servers configured for password authentication.
224For servers using token-based auth, use 'gordon remote set-token' instead.
225
226Examples:
227 gordon auth login Login using active remote
228 gordon auth login --remote prod Login to specific remote
229 gordon auth login --username admin Pre-fill username`,
230 RunE: func(cmd *cobra.Command, args []string) error {
231 return runAuthLogin(remoteName, username)
232 },
233 }
234
235 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
236 cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
237
238 return cmd
239}
240
241// runAuthLogin authenticates with a remote Gordon server.
242func runAuthLogin(remoteName, username string) error {
243 // Load remotes config
244 config, err := remote.LoadRemotes("")
245 if err != nil {
246 return fmt.Errorf("failed to load remotes: %w", err)
247 }
248
249 // Determine which remote to use
250 if remoteName == "" {
251 remoteName = config.Active
252 }
253 if remoteName == "" {
254 return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
255 }
256
257 remoteConfig, ok := config.Remotes[remoteName]
258 if !ok {
259 return fmt.Errorf("remote '%s' not found", remoteName)
260 }
261
262 // Prompt for username if not provided
263 if username == "" {
264 fmt.Print("Username: ")
265 reader := bufio.NewReader(os.Stdin)
266 input, err := reader.ReadString('\n')
267 if err != nil {
268 return fmt.Errorf("failed to read username: %w", err)
269 }
270 username = strings.TrimSpace(input)
271 }
272
273 if username == "" {
274 return fmt.Errorf("username cannot be empty")
275 }
276
277 // Prompt for password (hidden input)
278 fmt.Print("Password: ")
279 passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
280 if err != nil {
281 // Fallback for non-terminal input
282 reader := bufio.NewReader(os.Stdin)
283 password, readErr := reader.ReadString('\n')
284 if readErr != nil {
285 return fmt.Errorf("failed to read password: %w", readErr)
286 }
287 passwordBytes = []byte(strings.TrimSpace(password))
288 }
289 fmt.Println() // newline after hidden password input
290
291 if len(passwordBytes) == 0 {
292 return fmt.Errorf("password cannot be empty")
293 }
294
295 // Create remote client
296 client := remote.NewClient(remoteConfig.URL)
297
298 // Authenticate
299 ctx := context.Background()
300 fmt.Printf("Authenticating with %s...\n", remoteName)
301
302 resp, err := client.Authenticate(ctx, username, string(passwordBytes))
303 if err != nil {
304 return fmt.Errorf("authentication failed: %w", err)
305 }
306
307 // Update remote config with new token
308 if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil {
309 return fmt.Errorf("failed to save token: %w", err)
310 }
311
312 fmt.Println()
313 fmt.Println(styles.RenderSuccess("Authentication successful!"))
314 fmt.Printf("Token stored for remote '%s'\n", remoteName)
315 fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
316
317 return nil
318}
319
320// runShowInternalAuth displays the internal registry credentials.
321func runShowInternalAuth() error {
322 creds, err := app.GetInternalCredentials()
323 if err != nil {
324 return err
325 }
326
327 fmt.Println("Internal Registry Credentials")
328 fmt.Println("==============================")
329 fmt.Printf("Username: %s\n", creds.Username)
330 fmt.Printf("Password: %s\n", creds.Password)
331 fmt.Println()
332 fmt.Println("Usage:")
333 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
334
335 return nil
336}
337
338// newPasswordHashCmd creates the password hash command.
339func newPasswordHashCmd() *cobra.Command {
340 return &cobra.Command{
341 Use: "hash",
342 Short: "Generate a bcrypt hash for a password",
343 Long: `Interactively prompt for a password and output its bcrypt hash.
344
345The hash can be stored in your secrets backend and referenced in the config:
346
347 [auth]
348 type = "password"
349 password_hash = "github.com/bnema/gordon/auth/password_hash"`,
350 RunE: func(cmd *cobra.Command, args []string) error {
351 return runPasswordHash()
352 },
353 }
354}
355
356// runTokenGenerate generates a new authentication token.
357// parseAndConvertScopes parses a comma-separated scope string and converts
358// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
359func parseAndConvertScopes(scopesStr string) []string {
360 rawScopes := strings.Split(scopesStr, ",")
361 for i := range rawScopes {
362 rawScopes[i] = strings.TrimSpace(rawScopes[i])
363 }
364
365 // Check if scopes are already in v2 format (contain colons)
366 hasSimpleScope := false
367 for _, s := range rawScopes {
368 if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
369 hasSimpleScope = true
370 break
371 }
372 }
373
374 if !hasSimpleScope {
375 return rawScopes
376 }
377
378 // Convert simple scopes to v2 format: repository:*:push,pull
379 var scopes []string
380 var actions []string
381 for _, s := range rawScopes {
382 if s == "push" || s == "pull" || s == "*" {
383 actions = append(actions, s)
384 } else {
385 // Keep non-simple scopes as-is (e.g., admin scopes)
386 scopes = append(scopes, s)
387 }
388 }
389 if len(actions) > 0 {
390 scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
391 }
392 return scopes
393}
394
395func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
396 cfg, err := loadAuthConfig(configPath)
397 if err != nil {
398 return err
399 }
400
401 // Parse expiry (supports human-friendly units: d, w, M, y)
402 var expiry time.Duration
403 if expiryStr != "0" {
404 expiry, err = duration.Parse(expiryStr)
405 if err != nil {
406 return fmt.Errorf("invalid expiry duration: %w", err)
407 }
408 }
409
410 scopes := parseAndConvertScopes(scopesStr)
411
412 // Create auth service
413 log := zerowrap.New(zerowrap.Config{Level: "warn"})
414 authSvc, err := createAuthServiceForCLI(cfg, log)
415 if err != nil {
416 return err
417 }
418
419 // Generate token
420 ctx := context.Background()
421 token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
422 if err != nil {
423 return fmt.Errorf("failed to generate token: %w", err)
424 }
425
426 fmt.Println("Token generated successfully!")
427 fmt.Printf("Subject: %s\n", subject)
428 fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
429 if expiry == 0 {
430 fmt.Println("Expiry: never")
431 } else {
432 fmt.Printf("Expiry: %s\n", expiry)
433 }
434 fmt.Println()
435 fmt.Println("Token (use as password with docker login):")
436 fmt.Println(token)
437 fmt.Println()
438 fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
439
440 return nil
441}
442
443// runTokenList lists all stored tokens.
444func runTokenList(configPath string) error {
445 cfg, err := loadAuthConfig(configPath)
446 if err != nil {
447 return err
448 }
449
450 log := zerowrap.New(zerowrap.Config{Level: "warn"})
451 authSvc, err := createAuthServiceForCLI(cfg, log)
452 if err != nil {
453 return err
454 }
455
456 ctx := context.Background()
457 tokens, err := authSvc.ListTokens(ctx)
458 if err != nil {
459 return fmt.Errorf("failed to list tokens: %w", err)
460 }
461
462 if len(tokens) == 0 {
463 fmt.Println("No tokens found.")
464 return nil
465 }
466
467 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
468 fmt.Println(strings.Repeat("-", 90))
469
470 for _, t := range tokens {
471 expiry := "never"
472 if !t.ExpiresAt.IsZero() {
473 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
474 }
475 revoked := "no"
476 if t.Revoked {
477 revoked = "yes"
478 }
479 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
480 }
481
482 return nil
483}
484
485// runTokenRevoke revokes a token by ID.
486func runTokenRevoke(tokenID, configPath string) error {
487 cfg, err := loadAuthConfig(configPath)
488 if err != nil {
489 return err
490 }
491
492 log := zerowrap.New(zerowrap.Config{Level: "warn"})
493 authSvc, err := createAuthServiceForCLI(cfg, log)
494 if err != nil {
495 return err
496 }
497
498 ctx := context.Background()
499 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
500 return fmt.Errorf("failed to revoke token: %w", err)
501 }
502
503 fmt.Printf("Token %s has been revoked.\n", tokenID)
504 return nil
505}
506
507// runTokenRevokeAll revokes all tokens.
508func runTokenRevokeAll(configPath string) error {
509 cfg, err := loadAuthConfig(configPath)
510 if err != nil {
511 return err
512 }
513
514 log := zerowrap.New(zerowrap.Config{Level: "warn"})
515 authSvc, err := createAuthServiceForCLI(cfg, log)
516 if err != nil {
517 return err
518 }
519
520 ctx := context.Background()
521 count, err := authSvc.RevokeAllTokens(ctx)
522 if err != nil {
523 return fmt.Errorf("failed to revoke tokens: %w", err)
524 }
525
526 if count == 0 {
527 fmt.Println("No tokens to revoke.")
528 } else {
529 fmt.Printf("Revoked %d token(s).\n", count)
530 }
531 return nil
532}
533
534// runPasswordHash generates a bcrypt hash for a password.
535func runPasswordHash() error {
536 fmt.Print("Enter password: ")
537
538 // Read password without echo (use os.Stdin.Fd() for better compatibility)
539 passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
540 if err != nil {
541 // Fallback for non-terminal input
542 reader := bufio.NewReader(os.Stdin)
543 password, err := reader.ReadString('\n')
544 if err != nil {
545 return fmt.Errorf("failed to read password: %w", err)
546 }
547 passwordBytes = []byte(strings.TrimSpace(password))
548 }
549 fmt.Println()
550
551 if len(passwordBytes) == 0 {
552 return fmt.Errorf("password cannot be empty")
553 }
554
555 // Create a temporary auth service to generate the hash
556 authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
557 hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
558 if err != nil {
559 return fmt.Errorf("failed to generate hash: %w", err)
560 }
561
562 fmt.Println()
563 fmt.Println("Bcrypt hash (store in your secrets backend):")
564 fmt.Println(hash)
565 fmt.Println()
566 fmt.Println("Then reference the path in your config:")
567 fmt.Println(" [auth]")
568 fmt.Println(" type = \"password\"")
569 fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"")
570
571 return nil
572}
573
574// cliConfig holds the configuration needed for CLI commands.
575type cliConfig struct {
576 Backend domain.SecretsBackend
577 DataDir string
578 TokenSecret []byte
579}
580
581// loadAuthConfig loads the configuration needed for auth CLI commands.
582func loadAuthConfig(configPath string) (*cliConfig, error) {
583 v := viper.New()
584
585 // Set defaults
586 v.SetDefault("server.data_dir", app.DefaultDataDir())
587 v.SetDefault("auth.secrets_backend", "unsafe")
588
589 app.ConfigureViper(v, configPath)
590
591 if err := v.ReadInConfig(); err != nil {
592 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
593 return nil, fmt.Errorf("failed to read config file: %w", err)
594 }
595 // Config file not found is OK for CLI commands
596 }
597
598 cfg := &cliConfig{
599 DataDir: v.GetString("server.data_dir"),
600 }
601
602 // Determine backend
603 switch v.GetString("auth.secrets_backend") {
604 case "pass":
605 cfg.Backend = domain.SecretsBackendPass
606 case "sops":
607 cfg.Backend = domain.SecretsBackendSops
608 default:
609 cfg.Backend = domain.SecretsBackendUnsafe
610 }
611
612 // Load token secret from secrets backend
613 tokenSecretPath := v.GetString("auth.token_secret")
614 if tokenSecretPath != "" {
615 // Load actual secret from the configured backend
616 ctx := context.Background()
617 log := zerowrap.New(zerowrap.Config{Level: "warn"})
618
619 switch cfg.Backend {
620 case domain.SecretsBackendPass:
621 provider := secrets.NewPassProvider(log)
622 secret, err := provider.GetSecret(ctx, tokenSecretPath)
623 if err != nil {
624 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
625 }
626 cfg.TokenSecret = []byte(secret)
627 default:
628 // For unsafe backend, use the path as the secret (backwards compatible)
629 cfg.TokenSecret = []byte(tokenSecretPath)
630 }
631 } else {
632 // Generate a default secret for CLI token generation
633 cfg.TokenSecret = []byte("gordon-cli-default-secret")
634 }
635
636 return cfg, nil
637}
638
639// createAuthServiceForCLI creates an auth service for CLI commands.
640func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
641 // Create token store
642 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
643 if err != nil {
644 return nil, fmt.Errorf("failed to create token store: %w", err)
645 }
646
647 // Create auth service config
648 authConfig := auth.Config{
649 Enabled: true,
650 AuthType: domain.AuthTypeToken,
651 TokenSecret: cfg.TokenSecret,
652 }
653
654 return auth.NewService(authConfig, store, log), nil
655}
656
657// newAuthStatusCmd creates the auth status command.
658func newAuthStatusCmd() *cobra.Command {
659 var remoteNameFlag string
660 cmd := &cobra.Command{
661 Use: "status",
662 Short: "Check authentication session status",
663 Long: `Verify if stored authentication session is still valid.
664
665Shows token validity, expiry, and connection status for the active remote.
666
667Examples:
668 gordon auth status Check status of active remote
669 gordon auth status --remote prod Check specific remote`,
670 RunE: func(cmd *cobra.Command, args []string) error {
671 return runAuthStatus(remoteNameFlag)
672 },
673 }
674 cmd.Flags().StringVarP(&remoteNameFlag, "remote", "r", "", "Check specific remote")
675 return cmd
676}
677
678// runAuthStatus checks authentication status for the active remote.
679func runAuthStatus(remoteNameArg string) error {
680 // Load remotes config
681 config, err := remote.LoadRemotes("")
682 if err != nil {
683 return fmt.Errorf("failed to load remotes: %w", err)
684 }
685
686 // Determine remote name
687 remoteName := remoteNameArg
688 if remoteName == "" {
689 remoteName = config.Active
690 }
691 if remoteName == "" {
692 return fmt.Errorf("no active remote configured. Use 'gordon remote use <name>' or --remote flag")
693 }
694
695 remoteConfig, ok := config.Remotes[remoteName]
696 if !ok {
697 if remoteNameArg != "" {
698 return fmt.Errorf("remote '%s' not found", remoteName)
699 }
700 return fmt.Errorf("active remote '%s' not found", remoteName)
701 }
702
703 // Create client
704 client := remote.NewClient(remoteConfig.URL, remote.WithToken(remoteConfig.Token))
705
706 // Verify auth
707 ctx := context.Background()
708 fmt.Printf("Checking authentication status for '%s'...\n", remoteName)
709
710 status, err := client.VerifyAuth(ctx)
711 if err != nil {
712 return handleAuthVerifyError(err, remoteName, remoteConfig.URL)
713 }
714
715 // Display status
716 displayAuthStatus(remoteName, remoteConfig.URL, status)
717 return nil
718}
719
720// handleAuthVerifyError handles errors from auth verification.
721func handleAuthVerifyError(err error, remoteName, url string) error {
722 errStrLower := strings.ToLower(err.Error())
723
724 // Connection errors
725 if strings.Contains(errStrLower, "connection refused") ||
726 strings.Contains(errStrLower, "no such host") ||
727 strings.Contains(errStrLower, "timeout") {
728 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
729 fmt.Println()
730 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
731 fmt.Printf("Error: %s\n\n", err.Error())
732 fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
733 return nil
734 }
735
736 // Auth errors
737 if strings.Contains(errStrLower, "401") ||
738 strings.Contains(errStrLower, "unauthorized") ||
739 strings.Contains(errStrLower, "invalid token") {
740 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
741 fmt.Println()
742 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
743 fmt.Println()
744 fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
745 fmt.Println()
746 fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
747 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
748 return nil
749 }
750
751 return fmt.Errorf("failed to verify auth: %w", err)
752}
753
754// displayAuthStatus displays authentication status information.
755func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
756 fmt.Println(styles.Theme.Bold.Render("Remote: " + remoteName + " | URL: " + url))
757 fmt.Println()
758
759 if status.Valid {
760 // Valid authentication
761 fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
762 fmt.Println()
763
764 if status.Subject != "" {
765 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
766 }
767
768 if status.IssuedAt > 0 {
769 issuedAt := time.Unix(status.IssuedAt, 0)
770 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
771 }
772
773 if status.ExpiresAt > 0 {
774 expiresAt := time.Unix(status.ExpiresAt, 0)
775 if expiresAt.Before(time.Now()) {
776 fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
777 expiresAt.Format("2006-01-02 15:04:05"),
778 styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
779 } else {
780 remaining := time.Until(expiresAt)
781 fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
782 expiresAt.Format("2006-01-02 15:04:05"),
783 styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
784 }
785 }
786
787 if len(status.Scopes) > 0 {
788 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
789 }
790 } else {
791 // Invalid or no authentication
792 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
793 fmt.Println()
794 fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
795 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
796 }
797}