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, err := remote.ResolveStrict(remoteFlag, flagToken, insecureTLSFlag)
240 if err != nil {
241 return nil, fmt.Errorf("failed to resolve remote %q: %w", remoteFlag, err)
242 }
243 if !isRemote {
244 return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
245 }
246
247 return resolved, nil
248}
249
250func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {
251 token = strings.TrimSpace(token)
252 if token == "" {
253 return fmt.Errorf("token cannot be empty")
254 }
255
256 // Resolve remote — but use the new token (not the stored one) for verification.
257 resolved, err := resolveRequiredRemote("")
258 if err != nil {
259 return err
260 }
261
262 client := remote.NewClient(resolved.URL, remoteClientOptions(token, resolved.InsecureTLS)...)
263 verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
264 defer cancel()
265 verified := true
266 if _, err := client.GetStatus(verifyCtx); err != nil {
267 _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
268 verified = false
269 }
270
271 // Store token — requires a named remote.
272 if resolved.Name == "" {
273 _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
274 return nil
275 }
276 if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {
277 return fmt.Errorf("failed to save token: %w", err)
278 }
279
280 _ = cliWriteLine(out, "")
281 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))
282 if verified {
283 _ = cliWriteLine(out, styles.RenderSuccess("Token verified with remote status endpoint"))
284 }
285 return nil
286}
287
288func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
289 resolved, err := resolveRequiredRemote(tokenFlag)
290 if err != nil {
291 return err
292 }
293 if resolved.Token == "" {
294 return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())
295 }
296
297 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
298 verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
299 defer cancel()
300
301 if _, err := client.GetStatus(verifyCtx); err != nil {
302 _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed for '%s': %v", resolved.DisplayName(), err)))
303 return fmt.Errorf("authentication failed")
304 }
305
306 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
307 return nil
308}
309
310// newAuthShowTokenCmd creates the show-token command.
311func newAuthShowTokenCmd() *cobra.Command {
312 cmd := &cobra.Command{
313 Use: "show-token",
314 Short: "Print the stored token for a remote",
315 Long: `Print the raw authentication token for a remote to stdout.
316
317The token is resolved from pass, TOML config, or environment variable
318using the same precedence as other commands.
319
320Output is suitable for piping (no decoration, single line).
321
322Examples:
323 gordon auth show-token Show token for active remote
324 gordon auth show-token --remote prod Show token for specific remote
325 gordon auth show-token | pbcopy Copy token to clipboard`,
326 RunE: func(cmd *cobra.Command, args []string) error {
327 return runAuthShowToken(cmd.OutOrStdout())
328 },
329 }
330
331 return cmd
332}
333
334func runAuthShowToken(out io.Writer) error {
335 resolved, err := resolveRequiredRemote(tokenFlag)
336 if err != nil {
337 return err
338 }
339 if resolved.Token == "" {
340 return fmt.Errorf("no token found for remote '%s'", resolved.DisplayName())
341 }
342
343 if f, ok := out.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
344 _ = cliWriteLine(os.Stderr, styles.RenderWarning("Warning: token will be visible in terminal scrollback"))
345 }
346
347 _ = cliWriteLine(out, resolved.Token)
348 return nil
349}
350
351// newAuthLogoutCmd creates the logout command.
352func newAuthLogoutCmd() *cobra.Command {
353 cmd := &cobra.Command{
354 Use: "logout",
355 Short: "Remove stored authentication for a remote",
356 Long: `Remove the stored token for a remote from pass and TOML config.
357
358This clears the token from all local storage but does not revoke the
359token on the server. To revoke server-side, use: gordon auth token revoke
360
361Examples:
362 gordon auth logout Logout from active remote
363 gordon auth logout --remote prod Logout from specific remote`,
364 RunE: func(cmd *cobra.Command, args []string) error {
365 return runAuthLogout(cmd.OutOrStdout())
366 },
367 }
368
369 return cmd
370}
371
372func runAuthLogout(out io.Writer) error {
373 resolved, err := resolveRequiredRemote(tokenFlag)
374 if err != nil {
375 return err
376 }
377 if resolved.Name == "" {
378 return fmt.Errorf("cannot logout from ad-hoc URL. Use 'gordon remotes add' to save this remote first")
379 }
380
381 if err := remote.ClearRemoteToken(resolved.Name); err != nil {
382 return fmt.Errorf("failed to clear token: %w", err)
383 }
384
385 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolved.Name)))
386 return nil
387}
388
389// runShowInternalAuth displays the internal registry credentials.
390func runShowInternalAuth() error {
391 creds, err := app.GetInternalCredentials()
392 if err != nil {
393 return err
394 }
395
396 fmt.Println("Internal Registry Credentials")
397 fmt.Println("==============================")
398 fmt.Printf("Username: %s\n", creds.Username)
399 fmt.Printf("Password: %s\n", creds.Password)
400 fmt.Println()
401 fmt.Println("Usage:")
402 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
403
404 return nil
405}
406
407// runTokenGenerate generates a new authentication token.
408// parseAndConvertScopes parses a comma-separated scope string and converts
409// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
410func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
411 repo = strings.TrimSpace(repo)
412 if repo == "" {
413 return nil, fmt.Errorf("repository name cannot be empty")
414 }
415
416 rawScopes := strings.Split(scopesStr, ",")
417 for i := range rawScopes {
418 rawScopes[i] = strings.TrimSpace(rawScopes[i])
419 }
420
421 // Check if scopes are already in v2 format (contain colons)
422 hasSimpleScope := false
423 for _, s := range rawScopes {
424 if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
425 hasSimpleScope = true
426 break
427 }
428 }
429
430 if !hasSimpleScope {
431 return rawScopes, nil
432 }
433
434 // Convert simple scopes to v2 format: repository:<repo>:push,pull
435 var scopes []string
436 var actions []string
437 for _, s := range rawScopes {
438 if s == "push" || s == "pull" || s == "*" {
439 actions = append(actions, s)
440 } else {
441 // Keep non-simple scopes as-is (e.g., admin scopes)
442 scopes = append(scopes, s)
443 }
444 }
445 if len(actions) > 0 {
446 scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
447 }
448 return scopes, nil
449}
450
451func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
452 cfg, err := loadAuthConfig(configPath)
453 if err != nil {
454 return err
455 }
456
457 // Parse expiry (supports human-friendly units: d, w, M, y)
458 var expiry time.Duration
459 if expiryStr != "0" {
460 expiry, err = duration.Parse(expiryStr)
461 if err != nil {
462 return fmt.Errorf("invalid expiry duration: %w", err)
463 }
464 }
465
466 scopes, err := parseAndConvertScopes(scopesStr, repo)
467 if err != nil {
468 return fmt.Errorf("invalid scopes: %w", err)
469 }
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, out io.Writer, jsonOut bool) 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 if jsonOut {
523 return writeJSON(out, []map[string]any{})
524 }
525 fmt.Println("No tokens found.")
526 return nil
527 }
528
529 if jsonOut {
530 payload := make([]map[string]any, 0, len(tokens))
531 for _, t := range tokens {
532 var expiresAt *time.Time
533 if !t.ExpiresAt.IsZero() {
534 expiresAt = &t.ExpiresAt
535 }
536 payload = append(payload, map[string]any{
537 "id": t.ID,
538 "description": t.Subject,
539 "scopes": t.Scopes,
540 "created_at": t.IssuedAt,
541 "expires_at": expiresAt,
542 "revoked": t.Revoked,
543 })
544 }
545 return writeJSON(out, payload)
546 }
547
548 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
549 fmt.Println(strings.Repeat("-", 90))
550
551 for _, t := range tokens {
552 expiry := "never"
553 if !t.ExpiresAt.IsZero() {
554 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
555 }
556 revoked := "no"
557 if t.Revoked {
558 revoked = "yes"
559 }
560 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
561 }
562
563 return nil
564}
565
566// runTokenRevoke revokes a token by ID.
567func runTokenRevoke(tokenID, 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 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
581 return fmt.Errorf("failed to revoke token: %w", err)
582 }
583
584 fmt.Printf("Token %s has been revoked.\n", tokenID)
585 return nil
586}
587
588// runTokenRevokeAll revokes all tokens.
589func runTokenRevokeAll(configPath string) error {
590 cfg, err := loadAuthConfig(configPath)
591 if err != nil {
592 return err
593 }
594
595 log := zerowrap.New(zerowrap.Config{Level: "warn"})
596 authSvc, err := createAuthServiceForCLI(cfg, log)
597 if err != nil {
598 return err
599 }
600
601 ctx := context.Background()
602 count, err := authSvc.RevokeAllTokens(ctx)
603 if err != nil {
604 return fmt.Errorf("failed to revoke tokens: %w", err)
605 }
606
607 if count == 0 {
608 fmt.Println("No tokens to revoke.")
609 } else {
610 fmt.Printf("Revoked %d token(s).\n", count)
611 }
612 return nil
613}
614
615// cliConfig holds the configuration needed for CLI commands.
616type cliConfig struct {
617 Backend domain.SecretsBackend
618 DataDir string
619 TokenSecret []byte
620}
621
622// loadAuthConfig loads the configuration needed for auth CLI commands.
623func loadAuthConfig(configPath string) (*cliConfig, error) {
624 v := viper.New()
625
626 // Set defaults
627 v.SetDefault("server.data_dir", app.DefaultDataDir())
628 v.SetDefault("auth.secrets_backend", "unsafe")
629
630 app.ConfigureViper(v, configPath)
631
632 if err := v.ReadInConfig(); err != nil {
633 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
634 return nil, fmt.Errorf("failed to read config file: %w", err)
635 }
636 // Config file not found is OK for CLI commands
637 }
638
639 cfg := &cliConfig{
640 DataDir: v.GetString("server.data_dir"),
641 }
642
643 // Determine backend
644 switch v.GetString("auth.secrets_backend") {
645 case "pass":
646 cfg.Backend = domain.SecretsBackendPass
647 case "sops":
648 cfg.Backend = domain.SecretsBackendSops
649 default:
650 cfg.Backend = domain.SecretsBackendUnsafe
651 }
652
653 // Load token secret from secrets backend
654 tokenSecretPath := v.GetString("auth.token_secret")
655 if tokenSecretPath != "" {
656 // Load actual secret from the configured backend
657 ctx := context.Background()
658 log := zerowrap.New(zerowrap.Config{Level: "warn"})
659
660 switch cfg.Backend {
661 case domain.SecretsBackendPass:
662 provider := secrets.NewPassProvider(log)
663 secret, err := provider.GetSecret(ctx, tokenSecretPath)
664 if err != nil {
665 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
666 }
667 cfg.TokenSecret = []byte(secret)
668 default:
669 // For unsafe backend, use the path as the secret (backwards compatible)
670 cfg.TokenSecret = []byte(tokenSecretPath)
671 }
672 } else {
673 return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret")
674 }
675
676 return cfg, nil
677}
678
679// createAuthServiceForCLI creates an auth service for CLI commands.
680func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
681 // Create token store
682 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
683 if err != nil {
684 return nil, fmt.Errorf("failed to create token store: %w", err)
685 }
686
687 // Create auth service config
688 authConfig := auth.Config{
689 Enabled: true,
690 AuthType: domain.AuthTypeToken,
691 TokenSecret: cfg.TokenSecret,
692 }
693
694 return auth.NewService(authConfig, store, log), nil
695}
696
697// newAuthStatusCmd creates the auth status command.
698func newAuthStatusCmd() *cobra.Command {
699 cmd := &cobra.Command{
700 Use: "status",
701 Short: "Check authentication session status",
702 Long: `Verify if stored authentication session is still valid.
703
704Shows token validity, expiry, and connection status for the active remote.
705
706Examples:
707 gordon auth status Check status of active remote
708 gordon auth status --remote prod Check specific remote`,
709 RunE: func(cmd *cobra.Command, args []string) error {
710 return runAuthStatus(cmd.Context())
711 },
712 }
713 return cmd
714}
715
716// runAuthStatus checks authentication status for the active remote.
717func runAuthStatus(ctx context.Context) error {
718 resolved, err := resolveRequiredRemote(tokenFlag)
719 if err != nil {
720 return err
721 }
722
723 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
724
725 name := resolved.DisplayName()
726 fmt.Printf("Checking authentication status for '%s'...\n", name)
727
728 status, err := client.VerifyAuth(ctx)
729 if err != nil {
730 return handleAuthVerifyError(err, name, resolved.URL)
731 }
732
733 displayAuthStatus(name, resolved.URL, status)
734 return nil
735}
736
737// handleAuthVerifyError handles errors from auth verification.
738func handleAuthVerifyError(err error, remoteName, url string) error {
739 errStrLower := strings.ToLower(err.Error())
740
741 // Connection errors
742 if strings.Contains(errStrLower, "connection refused") ||
743 strings.Contains(errStrLower, "no such host") ||
744 strings.Contains(errStrLower, "timeout") {
745 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
746 fmt.Println()
747 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
748 fmt.Printf("Error: %s\n\n", err.Error())
749 fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
750 return nil
751 }
752
753 // Auth errors
754 if strings.Contains(errStrLower, "401") ||
755 strings.Contains(errStrLower, "unauthorized") ||
756 strings.Contains(errStrLower, "invalid token") {
757 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
758 fmt.Println()
759 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
760 fmt.Println()
761 fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
762 fmt.Println()
763 fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
764 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
765 return nil
766 }
767
768 return fmt.Errorf("failed to verify auth: %w", err)
769}
770
771// displayAuthStatus displays authentication status information.
772func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
773 fmt.Println(styles.Theme.Bold.Render("Remote: " + remoteName + " | URL: " + url))
774 fmt.Println()
775
776 if status.Valid {
777 // Valid authentication
778 fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
779 fmt.Println()
780
781 if status.Subject != "" {
782 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
783 }
784
785 if status.IssuedAt > 0 {
786 issuedAt := time.Unix(status.IssuedAt, 0)
787 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
788 }
789
790 if status.ExpiresAt > 0 {
791 expiresAt := time.Unix(status.ExpiresAt, 0)
792 if expiresAt.Before(time.Now()) {
793 fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
794 expiresAt.Format("2006-01-02 15:04:05"),
795 styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
796 } else {
797 remaining := time.Until(expiresAt)
798 fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
799 expiresAt.Format("2006-01-02 15:04:05"),
800 styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
801 }
802 }
803
804 if len(status.Scopes) > 0 {
805 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
806 }
807 } else {
808 // Invalid or no authentication
809 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
810 fmt.Println()
811 fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
812 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
813 }
814}