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