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 token string
211 password string
212 )
213
214 cmd := &cobra.Command{
215 Use: "login",
216 Short: "Authenticate with a Gordon server",
217 Long: `Authenticate with a Gordon server using password authentication or a pre-generated token.
218
219This command prompts for your username and password, authenticates with the
220remote server's /auth/password endpoint, and stores the returned token.
221
222The token is stored in your remote configuration and used for subsequent
223CLI operations.
224
225If --token is provided, the token is stored for the remote and verified
226against /admin/status on a best-effort basis.
227
228Examples:
229 gordon auth login Login using active remote
230 gordon auth login --remote prod Login to specific remote
231 gordon auth login --username admin Pre-fill username
232 gordon auth login --token <token> Store a token for token-only servers`,
233 RunE: func(cmd *cobra.Command, args []string) error {
234 if token != "" {
235 return runAuthLoginWithToken(remoteName, token)
236 }
237 return runAuthLogin(remoteName, username, password)
238 },
239 }
240
241 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
242 cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
243 cmd.Flags().StringVarP(&password, "password", "p", "", "Password (visible in process listings; prefer interactive prompt)")
244 cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
245
246 cmd.MarkFlagsMutuallyExclusive("token", "password")
247 cmd.MarkFlagsMutuallyExclusive("token", "username")
248
249 return cmd
250}
251
252// runAuthLogin authenticates with a remote Gordon server.
253func runAuthLogin(remoteName, username, password string) error {
254 resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
255 if err != nil {
256 return err
257 }
258
259 // Prompt for username if not provided
260 if username == "" {
261 fmt.Print("Username: ")
262 reader := bufio.NewReader(os.Stdin)
263 input, err := reader.ReadString('\n')
264 if err != nil {
265 return fmt.Errorf("failed to read username: %w", err)
266 }
267 username = strings.TrimSpace(input)
268 }
269
270 if username == "" {
271 return fmt.Errorf("username cannot be empty")
272 }
273
274 if password == "" {
275 // Prompt for password (hidden input)
276 fmt.Print("Password: ")
277 passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
278 if err != nil {
279 // Fallback for non-terminal input
280 reader := bufio.NewReader(os.Stdin)
281 passwordInput, readErr := reader.ReadString('\n')
282 if readErr != nil {
283 return fmt.Errorf("failed to read password: %w", readErr)
284 }
285 // Only trim trailing newline from fallback input
286 passwordBytes = []byte(strings.TrimRight(passwordInput, "\r\n"))
287 }
288 fmt.Println()
289 password = string(passwordBytes)
290 } else {
291 // Only trim trailing newline from flag-provided password
292 password = strings.TrimRight(password, "\r\n")
293 }
294
295 if password == "" {
296 return fmt.Errorf("password cannot be empty")
297 }
298
299 // Create remote client
300 client := remote.NewClient(remoteConfig.URL)
301
302 // Authenticate
303 ctx := context.Background()
304 fmt.Printf("Authenticating with %s...\n", resolvedName)
305
306 resp, err := client.Authenticate(ctx, username, password)
307 if err != nil {
308 return fmt.Errorf("authentication failed: %w", err)
309 }
310
311 // Update remote config with new token
312 if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil {
313 return fmt.Errorf("failed to save token: %w", err)
314 }
315
316 fmt.Println()
317 fmt.Println(styles.RenderSuccess("Authentication successful!"))
318 fmt.Printf("Token stored for remote '%s'\n", resolvedName)
319 fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
320
321 return nil
322}
323
324func runAuthLoginWithToken(remoteName, token string) error {
325 token = strings.TrimSpace(token)
326 if token == "" {
327 return fmt.Errorf("token cannot be empty")
328 }
329
330 resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
331 if err != nil {
332 return err
333 }
334
335 client := remote.NewClient(remoteConfig.URL, remote.WithToken(token))
336 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
337 defer cancel()
338 verified := true
339 if _, err := client.GetStatus(ctx); err != nil {
340 fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
341 verified = false
342 }
343
344 if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
345 return fmt.Errorf("failed to save token: %w", err)
346 }
347
348 fmt.Println()
349 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
350 if verified {
351 fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
352 }
353 return nil
354}
355
356func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) {
357 config, err := remote.LoadRemotes("")
358 if err != nil {
359 return "", remote.RemoteEntry{}, fmt.Errorf("failed to load remotes: %w", err)
360 }
361
362 if remoteName == "" {
363 remoteName = config.Active
364 }
365 if remoteName == "" {
366 return "", remote.RemoteEntry{}, fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
367 }
368
369 remoteConfig, ok := config.Remotes[remoteName]
370 if !ok {
371 return "", remote.RemoteEntry{}, fmt.Errorf("remote '%s' not found", remoteName)
372 }
373
374 return remoteName, remoteConfig, nil
375}
376
377// runShowInternalAuth displays the internal registry credentials.
378func runShowInternalAuth() error {
379 creds, err := app.GetInternalCredentials()
380 if err != nil {
381 return err
382 }
383
384 fmt.Println("Internal Registry Credentials")
385 fmt.Println("==============================")
386 fmt.Printf("Username: %s\n", creds.Username)
387 fmt.Printf("Password: %s\n", creds.Password)
388 fmt.Println()
389 fmt.Println("Usage:")
390 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
391
392 return nil
393}
394
395// newPasswordHashCmd creates the password hash command.
396func newPasswordHashCmd() *cobra.Command {
397 return &cobra.Command{
398 Use: "hash",
399 Short: "Generate a bcrypt hash for a password",
400 Long: `Interactively prompt for a password and output its bcrypt hash.
401
402The hash can be stored in your secrets backend and referenced in the config:
403
404 [auth]
405 type = "password"
406 password_hash = "github.com/bnema/gordon/auth/password_hash"`,
407 RunE: func(cmd *cobra.Command, args []string) error {
408 return runPasswordHash()
409 },
410 }
411}
412
413// runTokenGenerate generates a new authentication token.
414// parseAndConvertScopes parses a comma-separated scope string and converts
415// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
416func parseAndConvertScopes(scopesStr string) []string {
417 rawScopes := strings.Split(scopesStr, ",")
418 for i := range rawScopes {
419 rawScopes[i] = strings.TrimSpace(rawScopes[i])
420 }
421
422 // Check if scopes are already in v2 format (contain colons)
423 hasSimpleScope := false
424 for _, s := range rawScopes {
425 if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
426 hasSimpleScope = true
427 break
428 }
429 }
430
431 if !hasSimpleScope {
432 return rawScopes
433 }
434
435 // Convert simple scopes to v2 format: repository:*:push,pull
436 var scopes []string
437 var actions []string
438 for _, s := range rawScopes {
439 if s == "push" || s == "pull" || s == "*" {
440 actions = append(actions, s)
441 } else {
442 // Keep non-simple scopes as-is (e.g., admin scopes)
443 scopes = append(scopes, s)
444 }
445 }
446 if len(actions) > 0 {
447 scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
448 }
449 return scopes
450}
451
452func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
453 cfg, err := loadAuthConfig(configPath)
454 if err != nil {
455 return err
456 }
457
458 // Parse expiry (supports human-friendly units: d, w, M, y)
459 var expiry time.Duration
460 if expiryStr != "0" {
461 expiry, err = duration.Parse(expiryStr)
462 if err != nil {
463 return fmt.Errorf("invalid expiry duration: %w", err)
464 }
465 }
466
467 scopes := parseAndConvertScopes(scopesStr)
468
469 // Create auth service
470 log := zerowrap.New(zerowrap.Config{Level: "warn"})
471 authSvc, err := createAuthServiceForCLI(cfg, log)
472 if err != nil {
473 return err
474 }
475
476 // Generate token
477 ctx := context.Background()
478 token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
479 if err != nil {
480 return fmt.Errorf("failed to generate token: %w", err)
481 }
482
483 fmt.Println("Token generated successfully!")
484 fmt.Printf("Subject: %s\n", subject)
485 fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
486 if expiry == 0 {
487 fmt.Println("Expiry: never")
488 } else {
489 fmt.Printf("Expiry: %s\n", expiry)
490 }
491 fmt.Println()
492 fmt.Println("Token (use as password with docker login):")
493 fmt.Println(token)
494 fmt.Println()
495 fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
496
497 return nil
498}
499
500// runTokenList lists all stored tokens.
501func runTokenList(configPath string) error {
502 cfg, err := loadAuthConfig(configPath)
503 if err != nil {
504 return err
505 }
506
507 log := zerowrap.New(zerowrap.Config{Level: "warn"})
508 authSvc, err := createAuthServiceForCLI(cfg, log)
509 if err != nil {
510 return err
511 }
512
513 ctx := context.Background()
514 tokens, err := authSvc.ListTokens(ctx)
515 if err != nil {
516 return fmt.Errorf("failed to list tokens: %w", err)
517 }
518
519 if len(tokens) == 0 {
520 fmt.Println("No tokens found.")
521 return nil
522 }
523
524 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
525 fmt.Println(strings.Repeat("-", 90))
526
527 for _, t := range tokens {
528 expiry := "never"
529 if !t.ExpiresAt.IsZero() {
530 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
531 }
532 revoked := "no"
533 if t.Revoked {
534 revoked = "yes"
535 }
536 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
537 }
538
539 return nil
540}
541
542// runTokenRevoke revokes a token by ID.
543func runTokenRevoke(tokenID, configPath string) error {
544 cfg, err := loadAuthConfig(configPath)
545 if err != nil {
546 return err
547 }
548
549 log := zerowrap.New(zerowrap.Config{Level: "warn"})
550 authSvc, err := createAuthServiceForCLI(cfg, log)
551 if err != nil {
552 return err
553 }
554
555 ctx := context.Background()
556 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
557 return fmt.Errorf("failed to revoke token: %w", err)
558 }
559
560 fmt.Printf("Token %s has been revoked.\n", tokenID)
561 return nil
562}
563
564// runTokenRevokeAll revokes all tokens.
565func runTokenRevokeAll(configPath string) error {
566 cfg, err := loadAuthConfig(configPath)
567 if err != nil {
568 return err
569 }
570
571 log := zerowrap.New(zerowrap.Config{Level: "warn"})
572 authSvc, err := createAuthServiceForCLI(cfg, log)
573 if err != nil {
574 return err
575 }
576
577 ctx := context.Background()
578 count, err := authSvc.RevokeAllTokens(ctx)
579 if err != nil {
580 return fmt.Errorf("failed to revoke tokens: %w", err)
581 }
582
583 if count == 0 {
584 fmt.Println("No tokens to revoke.")
585 } else {
586 fmt.Printf("Revoked %d token(s).\n", count)
587 }
588 return nil
589}
590
591// runPasswordHash generates a bcrypt hash for a password.
592func runPasswordHash() error {
593 fmt.Print("Enter password: ")
594
595 // Read password without echo (use os.Stdin.Fd() for better compatibility)
596 passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
597 if err != nil {
598 // Fallback for non-terminal input
599 reader := bufio.NewReader(os.Stdin)
600 password, err := reader.ReadString('\n')
601 if err != nil {
602 return fmt.Errorf("failed to read password: %w", err)
603 }
604 passwordBytes = []byte(strings.TrimSpace(password))
605 }
606 fmt.Println()
607
608 if len(passwordBytes) == 0 {
609 return fmt.Errorf("password cannot be empty")
610 }
611
612 // Create a temporary auth service to generate the hash
613 authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
614 hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
615 if err != nil {
616 return fmt.Errorf("failed to generate hash: %w", err)
617 }
618
619 fmt.Println()
620 fmt.Println("Bcrypt hash (store in your secrets backend):")
621 fmt.Println(hash)
622 fmt.Println()
623 fmt.Println("Then reference the path in your config:")
624 fmt.Println(" [auth]")
625 fmt.Println(" type = \"password\"")
626 fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"")
627
628 return nil
629}
630
631// cliConfig holds the configuration needed for CLI commands.
632type cliConfig struct {
633 Backend domain.SecretsBackend
634 DataDir string
635 TokenSecret []byte
636}
637
638// loadAuthConfig loads the configuration needed for auth CLI commands.
639func loadAuthConfig(configPath string) (*cliConfig, error) {
640 v := viper.New()
641
642 // Set defaults
643 v.SetDefault("server.data_dir", app.DefaultDataDir())
644 v.SetDefault("auth.secrets_backend", "unsafe")
645
646 app.ConfigureViper(v, configPath)
647
648 if err := v.ReadInConfig(); err != nil {
649 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
650 return nil, fmt.Errorf("failed to read config file: %w", err)
651 }
652 // Config file not found is OK for CLI commands
653 }
654
655 cfg := &cliConfig{
656 DataDir: v.GetString("server.data_dir"),
657 }
658
659 // Determine backend
660 switch v.GetString("auth.secrets_backend") {
661 case "pass":
662 cfg.Backend = domain.SecretsBackendPass
663 case "sops":
664 cfg.Backend = domain.SecretsBackendSops
665 default:
666 cfg.Backend = domain.SecretsBackendUnsafe
667 }
668
669 // Load token secret from secrets backend
670 tokenSecretPath := v.GetString("auth.token_secret")
671 if tokenSecretPath != "" {
672 // Load actual secret from the configured backend
673 ctx := context.Background()
674 log := zerowrap.New(zerowrap.Config{Level: "warn"})
675
676 switch cfg.Backend {
677 case domain.SecretsBackendPass:
678 provider := secrets.NewPassProvider(log)
679 secret, err := provider.GetSecret(ctx, tokenSecretPath)
680 if err != nil {
681 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
682 }
683 cfg.TokenSecret = []byte(secret)
684 default:
685 // For unsafe backend, use the path as the secret (backwards compatible)
686 cfg.TokenSecret = []byte(tokenSecretPath)
687 }
688 } else {
689 // Generate a default secret for CLI token generation
690 cfg.TokenSecret = []byte("gordon-cli-default-secret")
691 }
692
693 return cfg, nil
694}
695
696// createAuthServiceForCLI creates an auth service for CLI commands.
697func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
698 // Create token store
699 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
700 if err != nil {
701 return nil, fmt.Errorf("failed to create token store: %w", err)
702 }
703
704 // Create auth service config
705 authConfig := auth.Config{
706 Enabled: true,
707 AuthType: domain.AuthTypeToken,
708 TokenSecret: cfg.TokenSecret,
709 }
710
711 return auth.NewService(authConfig, store, log), nil
712}
713
714// newAuthStatusCmd creates the auth status command.
715func newAuthStatusCmd() *cobra.Command {
716 var remoteNameFlag string
717 cmd := &cobra.Command{
718 Use: "status",
719 Short: "Check authentication session status",
720 Long: `Verify if stored authentication session is still valid.
721
722Shows token validity, expiry, and connection status for the active remote.
723
724Examples:
725 gordon auth status Check status of active remote
726 gordon auth status --remote prod Check specific remote`,
727 RunE: func(cmd *cobra.Command, args []string) error {
728 return runAuthStatus(remoteNameFlag)
729 },
730 }
731 cmd.Flags().StringVarP(&remoteNameFlag, "remote", "r", "", "Check specific remote")
732 return cmd
733}
734
735// runAuthStatus checks authentication status for the active remote.
736func runAuthStatus(remoteNameArg string) error {
737 // Load remotes config
738 config, err := remote.LoadRemotes("")
739 if err != nil {
740 return fmt.Errorf("failed to load remotes: %w", err)
741 }
742
743 // Determine remote name
744 remoteName := remoteNameArg
745 if remoteName == "" {
746 remoteName = config.Active
747 }
748 if remoteName == "" {
749 return fmt.Errorf("no active remote configured. Use 'gordon remote use <name>' or --remote flag")
750 }
751
752 remoteConfig, ok := config.Remotes[remoteName]
753 if !ok {
754 if remoteNameArg != "" {
755 return fmt.Errorf("remote '%s' not found", remoteName)
756 }
757 return fmt.Errorf("active remote '%s' not found", remoteName)
758 }
759
760 // Create client
761 client := remote.NewClient(remoteConfig.URL, remote.WithToken(remoteConfig.Token))
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}