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 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
301 client := remote.NewClient(remoteConfig.URL, remoteClientOptions("", insecureTLS)...)
302
303 // Authenticate
304 ctx := context.Background()
305 fmt.Printf("Authenticating with %s...\n", resolvedName)
306
307 resp, err := client.Authenticate(ctx, username, password)
308 if err != nil {
309 return fmt.Errorf("authentication failed: %w", err)
310 }
311
312 // Update remote config with new token
313 if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil {
314 return fmt.Errorf("failed to save token: %w", err)
315 }
316
317 fmt.Println()
318 fmt.Println(styles.RenderSuccess("Authentication successful!"))
319 fmt.Printf("Token stored for remote '%s'\n", resolvedName)
320 fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
321
322 return nil
323}
324
325func runAuthLoginWithToken(remoteName, token string) error {
326 token = strings.TrimSpace(token)
327 if token == "" {
328 return fmt.Errorf("token cannot be empty")
329 }
330
331 resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
332 if err != nil {
333 return err
334 }
335
336 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
337 client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
338 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
339 defer cancel()
340 verified := true
341 if _, err := client.GetStatus(ctx); err != nil {
342 fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
343 verified = false
344 }
345
346 if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
347 return fmt.Errorf("failed to save token: %w", err)
348 }
349
350 fmt.Println()
351 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
352 if verified {
353 fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
354 }
355 return nil
356}
357
358func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) {
359 config, err := remote.LoadRemotes("")
360 if err != nil {
361 return "", remote.RemoteEntry{}, fmt.Errorf("failed to load remotes: %w", err)
362 }
363
364 if remoteName == "" {
365 remoteName = config.Active
366 }
367 if remoteName == "" {
368 return "", remote.RemoteEntry{}, fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
369 }
370
371 remoteConfig, ok := config.Remotes[remoteName]
372 if !ok {
373 return "", remote.RemoteEntry{}, fmt.Errorf("remote '%s' not found", remoteName)
374 }
375
376 return remoteName, remoteConfig, nil
377}
378
379// runShowInternalAuth displays the internal registry credentials.
380func runShowInternalAuth() error {
381 creds, err := app.GetInternalCredentials()
382 if err != nil {
383 return err
384 }
385
386 fmt.Println("Internal Registry Credentials")
387 fmt.Println("==============================")
388 fmt.Printf("Username: %s\n", creds.Username)
389 fmt.Printf("Password: %s\n", creds.Password)
390 fmt.Println()
391 fmt.Println("Usage:")
392 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
393
394 return nil
395}
396
397// newPasswordHashCmd creates the password hash command.
398func newPasswordHashCmd() *cobra.Command {
399 return &cobra.Command{
400 Use: "hash",
401 Short: "Generate a bcrypt hash for a password",
402 Long: `Interactively prompt for a password and output its bcrypt hash.
403
404The hash can be stored in your secrets backend and referenced in the config:
405
406 [auth]
407 type = "password"
408 password_hash = "github.com/bnema/gordon/auth/password_hash"`,
409 RunE: func(cmd *cobra.Command, args []string) error {
410 return runPasswordHash()
411 },
412 }
413}
414
415// runTokenGenerate generates a new authentication token.
416// parseAndConvertScopes parses a comma-separated scope string and converts
417// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
418func parseAndConvertScopes(scopesStr string) []string {
419 rawScopes := strings.Split(scopesStr, ",")
420 for i := range rawScopes {
421 rawScopes[i] = strings.TrimSpace(rawScopes[i])
422 }
423
424 // Check if scopes are already in v2 format (contain colons)
425 hasSimpleScope := false
426 for _, s := range rawScopes {
427 if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
428 hasSimpleScope = true
429 break
430 }
431 }
432
433 if !hasSimpleScope {
434 return rawScopes
435 }
436
437 // Convert simple scopes to v2 format: repository:*:push,pull
438 var scopes []string
439 var actions []string
440 for _, s := range rawScopes {
441 if s == "push" || s == "pull" || s == "*" {
442 actions = append(actions, s)
443 } else {
444 // Keep non-simple scopes as-is (e.g., admin scopes)
445 scopes = append(scopes, s)
446 }
447 }
448 if len(actions) > 0 {
449 scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
450 }
451 return scopes
452}
453
454func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
455 cfg, err := loadAuthConfig(configPath)
456 if err != nil {
457 return err
458 }
459
460 // Parse expiry (supports human-friendly units: d, w, M, y)
461 var expiry time.Duration
462 if expiryStr != "0" {
463 expiry, err = duration.Parse(expiryStr)
464 if err != nil {
465 return fmt.Errorf("invalid expiry duration: %w", err)
466 }
467 }
468
469 scopes := parseAndConvertScopes(scopesStr)
470
471 // Create auth service
472 log := zerowrap.New(zerowrap.Config{Level: "warn"})
473 authSvc, err := createAuthServiceForCLI(cfg, log)
474 if err != nil {
475 return err
476 }
477
478 // Generate token
479 ctx := context.Background()
480 token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
481 if err != nil {
482 return fmt.Errorf("failed to generate token: %w", err)
483 }
484
485 fmt.Println("Token generated successfully!")
486 fmt.Printf("Subject: %s\n", subject)
487 fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
488 if expiry == 0 {
489 fmt.Println("Expiry: never")
490 } else {
491 fmt.Printf("Expiry: %s\n", expiry)
492 }
493 fmt.Println()
494 fmt.Println("Token (use as password with docker login):")
495 fmt.Println(token)
496 fmt.Println()
497 fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
498
499 return nil
500}
501
502// runTokenList lists all stored tokens.
503func runTokenList(configPath string) error {
504 cfg, err := loadAuthConfig(configPath)
505 if err != nil {
506 return err
507 }
508
509 log := zerowrap.New(zerowrap.Config{Level: "warn"})
510 authSvc, err := createAuthServiceForCLI(cfg, log)
511 if err != nil {
512 return err
513 }
514
515 ctx := context.Background()
516 tokens, err := authSvc.ListTokens(ctx)
517 if err != nil {
518 return fmt.Errorf("failed to list tokens: %w", err)
519 }
520
521 if len(tokens) == 0 {
522 fmt.Println("No tokens found.")
523 return nil
524 }
525
526 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
527 fmt.Println(strings.Repeat("-", 90))
528
529 for _, t := range tokens {
530 expiry := "never"
531 if !t.ExpiresAt.IsZero() {
532 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
533 }
534 revoked := "no"
535 if t.Revoked {
536 revoked = "yes"
537 }
538 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
539 }
540
541 return nil
542}
543
544// runTokenRevoke revokes a token by ID.
545func runTokenRevoke(tokenID, configPath string) error {
546 cfg, err := loadAuthConfig(configPath)
547 if err != nil {
548 return err
549 }
550
551 log := zerowrap.New(zerowrap.Config{Level: "warn"})
552 authSvc, err := createAuthServiceForCLI(cfg, log)
553 if err != nil {
554 return err
555 }
556
557 ctx := context.Background()
558 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
559 return fmt.Errorf("failed to revoke token: %w", err)
560 }
561
562 fmt.Printf("Token %s has been revoked.\n", tokenID)
563 return nil
564}
565
566// runTokenRevokeAll revokes all tokens.
567func runTokenRevokeAll(configPath string) error {
568 cfg, err := loadAuthConfig(configPath)
569 if err != nil {
570 return err
571 }
572
573 log := zerowrap.New(zerowrap.Config{Level: "warn"})
574 authSvc, err := createAuthServiceForCLI(cfg, log)
575 if err != nil {
576 return err
577 }
578
579 ctx := context.Background()
580 count, err := authSvc.RevokeAllTokens(ctx)
581 if err != nil {
582 return fmt.Errorf("failed to revoke tokens: %w", err)
583 }
584
585 if count == 0 {
586 fmt.Println("No tokens to revoke.")
587 } else {
588 fmt.Printf("Revoked %d token(s).\n", count)
589 }
590 return nil
591}
592
593// runPasswordHash generates a bcrypt hash for a password.
594func runPasswordHash() error {
595 fmt.Print("Enter password: ")
596
597 // Read password without echo (use os.Stdin.Fd() for better compatibility)
598 passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
599 if err != nil {
600 // Fallback for non-terminal input
601 reader := bufio.NewReader(os.Stdin)
602 password, err := reader.ReadString('\n')
603 if err != nil {
604 return fmt.Errorf("failed to read password: %w", err)
605 }
606 passwordBytes = []byte(strings.TrimSpace(password))
607 }
608 fmt.Println()
609
610 if len(passwordBytes) == 0 {
611 return fmt.Errorf("password cannot be empty")
612 }
613
614 // Create a temporary auth service to generate the hash
615 authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
616 hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
617 if err != nil {
618 return fmt.Errorf("failed to generate hash: %w", err)
619 }
620
621 fmt.Println()
622 fmt.Println("Bcrypt hash (store in your secrets backend):")
623 fmt.Println(hash)
624 fmt.Println()
625 fmt.Println("Then reference the path in your config:")
626 fmt.Println(" [auth]")
627 fmt.Println(" type = \"password\"")
628 fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"")
629
630 return nil
631}
632
633// cliConfig holds the configuration needed for CLI commands.
634type cliConfig struct {
635 Backend domain.SecretsBackend
636 DataDir string
637 TokenSecret []byte
638}
639
640// loadAuthConfig loads the configuration needed for auth CLI commands.
641func loadAuthConfig(configPath string) (*cliConfig, error) {
642 v := viper.New()
643
644 // Set defaults
645 v.SetDefault("server.data_dir", app.DefaultDataDir())
646 v.SetDefault("auth.secrets_backend", "unsafe")
647
648 app.ConfigureViper(v, configPath)
649
650 if err := v.ReadInConfig(); err != nil {
651 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
652 return nil, fmt.Errorf("failed to read config file: %w", err)
653 }
654 // Config file not found is OK for CLI commands
655 }
656
657 cfg := &cliConfig{
658 DataDir: v.GetString("server.data_dir"),
659 }
660
661 // Determine backend
662 switch v.GetString("auth.secrets_backend") {
663 case "pass":
664 cfg.Backend = domain.SecretsBackendPass
665 case "sops":
666 cfg.Backend = domain.SecretsBackendSops
667 default:
668 cfg.Backend = domain.SecretsBackendUnsafe
669 }
670
671 // Load token secret from secrets backend
672 tokenSecretPath := v.GetString("auth.token_secret")
673 if tokenSecretPath != "" {
674 // Load actual secret from the configured backend
675 ctx := context.Background()
676 log := zerowrap.New(zerowrap.Config{Level: "warn"})
677
678 switch cfg.Backend {
679 case domain.SecretsBackendPass:
680 provider := secrets.NewPassProvider(log)
681 secret, err := provider.GetSecret(ctx, tokenSecretPath)
682 if err != nil {
683 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
684 }
685 cfg.TokenSecret = []byte(secret)
686 default:
687 // For unsafe backend, use the path as the secret (backwards compatible)
688 cfg.TokenSecret = []byte(tokenSecretPath)
689 }
690 } else {
691 // Generate a default secret for CLI token generation
692 cfg.TokenSecret = []byte("gordon-cli-default-secret")
693 }
694
695 return cfg, nil
696}
697
698// createAuthServiceForCLI creates an auth service for CLI commands.
699func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
700 // Create token store
701 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
702 if err != nil {
703 return nil, fmt.Errorf("failed to create token store: %w", err)
704 }
705
706 // Create auth service config
707 authConfig := auth.Config{
708 Enabled: true,
709 AuthType: domain.AuthTypeToken,
710 TokenSecret: cfg.TokenSecret,
711 }
712
713 return auth.NewService(authConfig, store, log), nil
714}
715
716// newAuthStatusCmd creates the auth status command.
717func newAuthStatusCmd() *cobra.Command {
718 var remoteNameFlag string
719 cmd := &cobra.Command{
720 Use: "status",
721 Short: "Check authentication session status",
722 Long: `Verify if stored authentication session is still valid.
723
724Shows token validity, expiry, and connection status for the active remote.
725
726Examples:
727 gordon auth status Check status of active remote
728 gordon auth status --remote prod Check specific remote`,
729 RunE: func(cmd *cobra.Command, args []string) error {
730 return runAuthStatus(remoteNameFlag)
731 },
732 }
733 cmd.Flags().StringVarP(&remoteNameFlag, "remote", "r", "", "Check specific remote")
734 return cmd
735}
736
737// runAuthStatus checks authentication status for the active remote.
738func runAuthStatus(remoteNameArg string) error {
739 // Load remotes config
740 config, err := remote.LoadRemotes("")
741 if err != nil {
742 return fmt.Errorf("failed to load remotes: %w", err)
743 }
744
745 // Determine remote name
746 remoteName := remoteNameArg
747 if remoteName == "" {
748 remoteName = config.Active
749 }
750 if remoteName == "" {
751 return fmt.Errorf("no active remote configured. Use 'gordon remote use <name>' or --remote flag")
752 }
753
754 remoteConfig, ok := config.Remotes[remoteName]
755 if !ok {
756 if remoteNameArg != "" {
757 return fmt.Errorf("remote '%s' not found", remoteName)
758 }
759 return fmt.Errorf("active remote '%s' not found", remoteName)
760 }
761
762 // Create client
763 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, remoteName)
764 client := remote.NewClient(remoteConfig.URL, remoteClientOptions(remoteConfig.Token, insecureTLS)...)
765
766 // Verify auth
767 ctx := context.Background()
768 fmt.Printf("Checking authentication status for '%s'...\n", remoteName)
769
770 status, err := client.VerifyAuth(ctx)
771 if err != nil {
772 return handleAuthVerifyError(err, remoteName, remoteConfig.URL)
773 }
774
775 // Display status
776 displayAuthStatus(remoteName, remoteConfig.URL, status)
777 return nil
778}
779
780// handleAuthVerifyError handles errors from auth verification.
781func handleAuthVerifyError(err error, remoteName, url string) error {
782 errStrLower := strings.ToLower(err.Error())
783
784 // Connection errors
785 if strings.Contains(errStrLower, "connection refused") ||
786 strings.Contains(errStrLower, "no such host") ||
787 strings.Contains(errStrLower, "timeout") {
788 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
789 fmt.Println()
790 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
791 fmt.Printf("Error: %s\n\n", err.Error())
792 fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
793 return nil
794 }
795
796 // Auth errors
797 if strings.Contains(errStrLower, "401") ||
798 strings.Contains(errStrLower, "unauthorized") ||
799 strings.Contains(errStrLower, "invalid token") {
800 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
801 fmt.Println()
802 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
803 fmt.Println()
804 fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
805 fmt.Println()
806 fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
807 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
808 return nil
809 }
810
811 return fmt.Errorf("failed to verify auth: %w", err)
812}
813
814// displayAuthStatus displays authentication status information.
815func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
816 fmt.Println(styles.Theme.Bold.Render("Remote: " + remoteName + " | URL: " + url))
817 fmt.Println()
818
819 if status.Valid {
820 // Valid authentication
821 fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
822 fmt.Println()
823
824 if status.Subject != "" {
825 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
826 }
827
828 if status.IssuedAt > 0 {
829 issuedAt := time.Unix(status.IssuedAt, 0)
830 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
831 }
832
833 if status.ExpiresAt > 0 {
834 expiresAt := time.Unix(status.ExpiresAt, 0)
835 if expiresAt.Before(time.Now()) {
836 fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
837 expiresAt.Format("2006-01-02 15:04:05"),
838 styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
839 } else {
840 remaining := time.Until(expiresAt)
841 fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
842 expiresAt.Format("2006-01-02 15:04:05"),
843 styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
844 }
845 }
846
847 if len(status.Scopes) > 0 {
848 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
849 }
850 } else {
851 // Invalid or no authentication
852 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
853 fmt.Println()
854 fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
855 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
856 }
857}