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