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