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, logs, volumes, * (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 admin:logs:read Read-only log access
95 admin:volumes:read Read-only volume access
96 admin:volumes:write Volume management access
97
98Repository scoping:
99 --repo myapp Scope to specific repository
100 --repo "*" All repositories (default)
101
102Combined examples:
103 --scopes "push,pull" All repos, registry only (default)
104 --scopes "push" --repo myapp Push to myapp only
105 --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)
106 --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)
107
108EXPIRY:
109
110Supports human-friendly durations:
111 d (days): 1d = 24 hours w (weeks): 1w = 7 days
112 M (months): 1M = 30 days y (years): 1y = 365 days
113
114Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
115Use --expiry=0 for a token that never expires (useful for CI).`,
116 RunE: func(cmd *cobra.Command, args []string) error {
117 return runTokenGenerate(subject, scopes, expiry, configPath, repo)
118 },
119 }
120
121 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
122 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
123 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
124 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
125 cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
126
127 _ = cmd.MarkFlagRequired("subject")
128
129 return cmd
130}
131
132// newTokenListCmd creates the token list command.
133func newTokenListCmd() *cobra.Command {
134 var (
135 configPath string
136 jsonOut bool
137 )
138
139 cmd := &cobra.Command{
140 Use: "list",
141 Short: "List all authentication tokens",
142 Long: `List all stored authentication tokens with their subjects and expiry information.`,
143 RunE: func(cmd *cobra.Command, args []string) error {
144 return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)
145 },
146 }
147
148 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
149 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
150
151 return cmd
152}
153
154// newTokenRevokeCmd creates the token revoke command.
155func newTokenRevokeCmd() *cobra.Command {
156 var (
157 configPath string
158 all bool
159 )
160
161 cmd := &cobra.Command{
162 Use: "revoke [token-id]",
163 Short: "Revoke authentication token(s)",
164 Long: `Revoke a token by its ID, or all tokens with --all.
165
166Examples:
167 gordon auth token revoke abc123-def456 Revoke specific token
168 gordon auth token revoke --all Revoke all tokens`,
169 Args: cobra.MaximumNArgs(1),
170 RunE: func(cmd *cobra.Command, args []string) error {
171 if all {
172 return runTokenRevokeAll(configPath)
173 }
174 if len(args) == 0 {
175 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
176 }
177 return runTokenRevoke(args[0], configPath)
178 },
179 }
180
181 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
182 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
183
184 return cmd
185}
186
187// newAuthInternalCmd creates the internal credentials command.
188func newAuthInternalCmd() *cobra.Command {
189 return &cobra.Command{
190 Use: "internal",
191 Short: "Show internal registry credentials for manual recovery",
192 Long: `Display the auto-generated internal registry credentials.
193
194These credentials are used by Gordon for pulling images from the local registry
195(localhost:5000) during internal deploys. You can use them for manual recovery:
196
197 docker login localhost:5000 -u gordon-internal -p <password>
198
199Note: Credentials are regenerated each time Gordon starts, and are only
200available while Gordon is running.`,
201 RunE: func(cmd *cobra.Command, args []string) error {
202 return runShowInternalAuth()
203 },
204 }
205}
206
207// newAuthLoginCmd creates the login command for remote authentication.
208func newAuthLoginCmd() *cobra.Command {
209 var token string
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(), token, cmd.OutOrStdout())
228 }
229 return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout())
230 },
231 }
232
233 cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
234
235 return cmd
236}
237
238func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
239 resolved, isRemote := remote.Resolve(remoteFlag, flagToken, insecureTLSFlag)
240 if !isRemote {
241 return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
242 }
243
244 return resolved, nil
245}
246
247func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {
248 token = strings.TrimSpace(token)
249 if token == "" {
250 return fmt.Errorf("token cannot be empty")
251 }
252
253 // Resolve remote — but use the new token (not the stored one) for verification.
254 resolved, err := resolveRequiredRemote("")
255 if err != nil {
256 return err
257 }
258
259 client := remote.NewClient(resolved.URL, remoteClientOptions(token, resolved.InsecureTLS)...)
260 verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
261 defer cancel()
262 verified := true
263 if _, err := client.GetStatus(verifyCtx); err != nil {
264 _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
265 verified = false
266 }
267
268 // Store token — requires a named remote.
269 if resolved.Name == "" {
270 _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
271 return nil
272 }
273 if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {
274 return fmt.Errorf("failed to save token: %w", err)
275 }
276
277 _ = cliWriteLine(out, "")
278 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))
279 if verified {
280 _ = cliWriteLine(out, styles.RenderSuccess("Token verified with remote status endpoint"))
281 }
282 return nil
283}
284
285func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
286 resolved, err := resolveRequiredRemote(tokenFlag)
287 if err != nil {
288 return err
289 }
290 if resolved.Token == "" {
291 return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())
292 }
293
294 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
295 verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
296 defer cancel()
297
298 if _, err := client.GetStatus(verifyCtx); err != nil {
299 _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed for '%s': %v", resolved.DisplayName(), err)))
300 return fmt.Errorf("authentication failed")
301 }
302
303 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
304 return nil
305}
306
307// newAuthShowTokenCmd creates the show-token command.
308func newAuthShowTokenCmd() *cobra.Command {
309 cmd := &cobra.Command{
310 Use: "show-token",
311 Short: "Print the stored token for a remote",
312 Long: `Print the raw authentication token for a remote to stdout.
313
314The token is resolved from pass, TOML config, or environment variable
315using the same precedence as other commands.
316
317Output is suitable for piping (no decoration, single line).
318
319Examples:
320 gordon auth show-token Show token for active remote
321 gordon auth show-token --remote prod Show token for specific remote
322 gordon auth show-token | pbcopy Copy token to clipboard`,
323 RunE: func(cmd *cobra.Command, args []string) error {
324 return runAuthShowToken(cmd.OutOrStdout())
325 },
326 }
327
328 return cmd
329}
330
331func runAuthShowToken(out io.Writer) error {
332 resolved, err := resolveRequiredRemote(tokenFlag)
333 if err != nil {
334 return err
335 }
336 if resolved.Token == "" {
337 return fmt.Errorf("no token found for remote '%s'", resolved.DisplayName())
338 }
339
340 if f, ok := out.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
341 _ = cliWriteLine(os.Stderr, styles.RenderWarning("Warning: token will be visible in terminal scrollback"))
342 }
343
344 _ = cliWriteLine(out, resolved.Token)
345 return nil
346}
347
348// newAuthLogoutCmd creates the logout command.
349func newAuthLogoutCmd() *cobra.Command {
350 cmd := &cobra.Command{
351 Use: "logout",
352 Short: "Remove stored authentication for a remote",
353 Long: `Remove the stored token for a remote from pass and TOML config.
354
355This clears the token from all local storage but does not revoke the
356token on the server. To revoke server-side, use: gordon auth token revoke
357
358Examples:
359 gordon auth logout Logout from active remote
360 gordon auth logout --remote prod Logout from specific remote`,
361 RunE: func(cmd *cobra.Command, args []string) error {
362 return runAuthLogout(cmd.OutOrStdout())
363 },
364 }
365
366 return cmd
367}
368
369func runAuthLogout(out io.Writer) error {
370 resolved, err := resolveRequiredRemote(tokenFlag)
371 if err != nil {
372 return err
373 }
374 if resolved.Name == "" {
375 return fmt.Errorf("cannot logout from ad-hoc URL. Use 'gordon remotes add' to save this remote first")
376 }
377
378 if err := remote.ClearRemoteToken(resolved.Name); err != nil {
379 return fmt.Errorf("failed to clear token: %w", err)
380 }
381
382 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolved.Name)))
383 return nil
384}
385
386// runShowInternalAuth displays the internal registry credentials.
387func runShowInternalAuth() error {
388 creds, err := app.GetInternalCredentials()
389 if err != nil {
390 return err
391 }
392
393 fmt.Println("Internal Registry Credentials")
394 fmt.Println("==============================")
395 fmt.Printf("Username: %s\n", creds.Username)
396 fmt.Printf("Password: %s\n", creds.Password)
397 fmt.Println()
398 fmt.Println("Usage:")
399 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
400
401 return nil
402}
403
404// runTokenGenerate generates a new authentication token.
405// parseAndConvertScopes parses a comma-separated scope string and converts
406// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
407func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
408 repo = strings.TrimSpace(repo)
409 if repo == "" {
410 return nil, fmt.Errorf("repository name cannot be empty")
411 }
412
413 rawScopes := strings.Split(scopesStr, ",")
414 for i := range rawScopes {
415 rawScopes[i] = strings.TrimSpace(rawScopes[i])
416 }
417
418 // Check if scopes are already in v2 format (contain colons)
419 hasSimpleScope := false
420 for _, s := range rawScopes {
421 if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
422 hasSimpleScope = true
423 break
424 }
425 }
426
427 if !hasSimpleScope {
428 return rawScopes, nil
429 }
430
431 // Convert simple scopes to v2 format: repository:<repo>:push,pull
432 var scopes []string
433 var actions []string
434 for _, s := range rawScopes {
435 if s == "push" || s == "pull" || s == "*" {
436 actions = append(actions, s)
437 } else {
438 // Keep non-simple scopes as-is (e.g., admin scopes)
439 scopes = append(scopes, s)
440 }
441 }
442 if len(actions) > 0 {
443 scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
444 }
445 return scopes, nil
446}
447
448func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
449 cfg, err := loadAuthConfig(configPath)
450 if err != nil {
451 return err
452 }
453
454 // Parse expiry (supports human-friendly units: d, w, M, y)
455 var expiry time.Duration
456 if expiryStr != "0" {
457 expiry, err = duration.Parse(expiryStr)
458 if err != nil {
459 return fmt.Errorf("invalid expiry duration: %w", err)
460 }
461 }
462
463 scopes, err := parseAndConvertScopes(scopesStr, repo)
464 if err != nil {
465 return fmt.Errorf("invalid scopes: %w", err)
466 }
467
468 // Create auth service
469 log := zerowrap.New(zerowrap.Config{Level: "warn"})
470 authSvc, err := createAuthServiceForCLI(cfg, log)
471 if err != nil {
472 return err
473 }
474
475 // Generate token
476 ctx := context.Background()
477 token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
478 if err != nil {
479 return fmt.Errorf("failed to generate token: %w", err)
480 }
481
482 fmt.Println("Token generated successfully!")
483 fmt.Printf("Subject: %s\n", subject)
484 fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
485 if expiry == 0 {
486 fmt.Println("Expiry: never")
487 } else {
488 fmt.Printf("Expiry: %s\n", expiry)
489 }
490 fmt.Println()
491 fmt.Println("Token (use as password with docker login):")
492 fmt.Println(token)
493 fmt.Println()
494 fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
495
496 return nil
497}
498
499// runTokenList lists all stored tokens.
500func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
501 cfg, err := loadAuthConfig(configPath)
502 if err != nil {
503 return err
504 }
505
506 log := zerowrap.New(zerowrap.Config{Level: "warn"})
507 authSvc, err := createAuthServiceForCLI(cfg, log)
508 if err != nil {
509 return err
510 }
511
512 ctx := context.Background()
513 tokens, err := authSvc.ListTokens(ctx)
514 if err != nil {
515 return fmt.Errorf("failed to list tokens: %w", err)
516 }
517
518 if len(tokens) == 0 {
519 if jsonOut {
520 return writeJSON(out, []map[string]any{})
521 }
522 fmt.Println("No tokens found.")
523 return nil
524 }
525
526 if jsonOut {
527 payload := make([]map[string]any, 0, len(tokens))
528 for _, t := range tokens {
529 var expiresAt *time.Time
530 if !t.ExpiresAt.IsZero() {
531 expiresAt = &t.ExpiresAt
532 }
533 payload = append(payload, map[string]any{
534 "id": t.ID,
535 "description": t.Subject,
536 "scopes": t.Scopes,
537 "created_at": t.IssuedAt,
538 "expires_at": expiresAt,
539 "revoked": t.Revoked,
540 })
541 }
542 return writeJSON(out, payload)
543 }
544
545 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
546 fmt.Println(strings.Repeat("-", 90))
547
548 for _, t := range tokens {
549 expiry := "never"
550 if !t.ExpiresAt.IsZero() {
551 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
552 }
553 revoked := "no"
554 if t.Revoked {
555 revoked = "yes"
556 }
557 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
558 }
559
560 return nil
561}
562
563// runTokenRevoke revokes a token by ID.
564func runTokenRevoke(tokenID, configPath string) error {
565 cfg, err := loadAuthConfig(configPath)
566 if err != nil {
567 return err
568 }
569
570 log := zerowrap.New(zerowrap.Config{Level: "warn"})
571 authSvc, err := createAuthServiceForCLI(cfg, log)
572 if err != nil {
573 return err
574 }
575
576 ctx := context.Background()
577 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
578 return fmt.Errorf("failed to revoke token: %w", err)
579 }
580
581 fmt.Printf("Token %s has been revoked.\n", tokenID)
582 return nil
583}
584
585// runTokenRevokeAll revokes all tokens.
586func runTokenRevokeAll(configPath string) error {
587 cfg, err := loadAuthConfig(configPath)
588 if err != nil {
589 return err
590 }
591
592 log := zerowrap.New(zerowrap.Config{Level: "warn"})
593 authSvc, err := createAuthServiceForCLI(cfg, log)
594 if err != nil {
595 return err
596 }
597
598 ctx := context.Background()
599 count, err := authSvc.RevokeAllTokens(ctx)
600 if err != nil {
601 return fmt.Errorf("failed to revoke tokens: %w", err)
602 }
603
604 if count == 0 {
605 fmt.Println("No tokens to revoke.")
606 } else {
607 fmt.Printf("Revoked %d token(s).\n", count)
608 }
609 return nil
610}
611
612// cliConfig holds the configuration needed for CLI commands.
613type cliConfig struct {
614 Backend domain.SecretsBackend
615 DataDir string
616 TokenSecret []byte
617}
618
619// loadAuthConfig loads the configuration needed for auth CLI commands.
620func loadAuthConfig(configPath string) (*cliConfig, error) {
621 v := viper.New()
622
623 // Set defaults
624 v.SetDefault("server.data_dir", app.DefaultDataDir())
625 v.SetDefault("auth.secrets_backend", "unsafe")
626
627 app.ConfigureViper(v, configPath)
628
629 if err := v.ReadInConfig(); err != nil {
630 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
631 return nil, fmt.Errorf("failed to read config file: %w", err)
632 }
633 // Config file not found is OK for CLI commands
634 }
635
636 cfg := &cliConfig{
637 DataDir: v.GetString("server.data_dir"),
638 }
639
640 // Determine backend
641 switch v.GetString("auth.secrets_backend") {
642 case "pass":
643 cfg.Backend = domain.SecretsBackendPass
644 case "sops":
645 cfg.Backend = domain.SecretsBackendSops
646 default:
647 cfg.Backend = domain.SecretsBackendUnsafe
648 }
649
650 // Load token secret from secrets backend
651 tokenSecretPath := v.GetString("auth.token_secret")
652 if tokenSecretPath != "" {
653 // Load actual secret from the configured backend
654 ctx := context.Background()
655 log := zerowrap.New(zerowrap.Config{Level: "warn"})
656
657 switch cfg.Backend {
658 case domain.SecretsBackendPass:
659 provider := secrets.NewPassProvider(log)
660 secret, err := provider.GetSecret(ctx, tokenSecretPath)
661 if err != nil {
662 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
663 }
664 cfg.TokenSecret = []byte(secret)
665 default:
666 // For unsafe backend, use the path as the secret (backwards compatible)
667 cfg.TokenSecret = []byte(tokenSecretPath)
668 }
669 } else {
670 return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret")
671 }
672
673 return cfg, nil
674}
675
676// createAuthServiceForCLI creates an auth service for CLI commands.
677func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
678 // Create token store
679 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
680 if err != nil {
681 return nil, fmt.Errorf("failed to create token store: %w", err)
682 }
683
684 // Create auth service config
685 authConfig := auth.Config{
686 Enabled: true,
687 AuthType: domain.AuthTypeToken,
688 TokenSecret: cfg.TokenSecret,
689 }
690
691 return auth.NewService(authConfig, store, log), nil
692}
693
694// newAuthStatusCmd creates the auth status command.
695func newAuthStatusCmd() *cobra.Command {
696 cmd := &cobra.Command{
697 Use: "status",
698 Short: "Check authentication session status",
699 Long: `Verify if stored authentication session is still valid.
700
701Shows token validity, expiry, and connection status for the active remote.
702
703Examples:
704 gordon auth status Check status of active remote
705 gordon auth status --remote prod Check specific remote`,
706 RunE: func(cmd *cobra.Command, args []string) error {
707 return runAuthStatus(cmd.Context())
708 },
709 }
710 return cmd
711}
712
713// runAuthStatus checks authentication status for the active remote.
714func runAuthStatus(ctx context.Context) error {
715 resolved, err := resolveRequiredRemote(tokenFlag)
716 if err != nil {
717 return err
718 }
719
720 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
721
722 name := resolved.DisplayName()
723 fmt.Printf("Checking authentication status for '%s'...\n", name)
724
725 status, err := client.VerifyAuth(ctx)
726 if err != nil {
727 return handleAuthVerifyError(err, name, resolved.URL)
728 }
729
730 displayAuthStatus(name, resolved.URL, status)
731 return nil
732}
733
734// handleAuthVerifyError handles errors from auth verification.
735func handleAuthVerifyError(err error, remoteName, url string) error {
736 errStrLower := strings.ToLower(err.Error())
737
738 // Connection errors
739 if strings.Contains(errStrLower, "connection refused") ||
740 strings.Contains(errStrLower, "no such host") ||
741 strings.Contains(errStrLower, "timeout") {
742 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
743 fmt.Println()
744 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
745 fmt.Printf("Error: %s\n\n", err.Error())
746 fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
747 return nil
748 }
749
750 // Auth errors
751 if strings.Contains(errStrLower, "401") ||
752 strings.Contains(errStrLower, "unauthorized") ||
753 strings.Contains(errStrLower, "invalid token") {
754 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
755 fmt.Println()
756 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
757 fmt.Println()
758 fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
759 fmt.Println()
760 fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
761 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
762 return nil
763 }
764
765 return fmt.Errorf("failed to verify auth: %w", err)
766}
767
768// displayAuthStatus displays authentication status information.
769func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
770 fmt.Println(styles.Theme.Bold.Render("Remote: " + remoteName + " | URL: " + url))
771 fmt.Println()
772
773 if status.Valid {
774 // Valid authentication
775 fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
776 fmt.Println()
777
778 if status.Subject != "" {
779 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
780 }
781
782 if status.IssuedAt > 0 {
783 issuedAt := time.Unix(status.IssuedAt, 0)
784 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
785 }
786
787 if status.ExpiresAt > 0 {
788 expiresAt := time.Unix(status.ExpiresAt, 0)
789 if expiresAt.Before(time.Now()) {
790 fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
791 expiresAt.Format("2006-01-02 15:04:05"),
792 styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
793 } else {
794 remaining := time.Until(expiresAt)
795 fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
796 expiresAt.Format("2006-01-02 15:04:05"),
797 styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
798 }
799 }
800
801 if len(status.Scopes) > 0 {
802 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
803 }
804 } else {
805 // Invalid or no authentication
806 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
807 fmt.Println()
808 fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
809 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
810 }
811}