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