1package cli
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "io"
8 "os"
9 "strings"
10 "time"
11
12 "github.com/bnema/zerowrap"
13 "github.com/spf13/cobra"
14 "github.com/spf13/viper"
15 "golang.org/x/term"
16
17 "github.com/bnema/gordon/internal/adapters/dto"
18 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
19 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
20 "github.com/bnema/gordon/internal/adapters/out/secrets"
21 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
22 "github.com/bnema/gordon/internal/app"
23 "github.com/bnema/gordon/internal/domain"
24 "github.com/bnema/gordon/internal/usecase/auth"
25 "github.com/bnema/gordon/pkg/duration"
26)
27
28// stdinFD returns os.Stdin's file descriptor as an int, guarded against
29// uintptr overflow on platforms where uintptr > int.
30func stdinFD() (int, error) {
31 fd := os.Stdin.Fd()
32 if fd > uintptr(^uint(0)>>1) {
33 return 0, fmt.Errorf("stdin fd value %d overflows int", fd)
34 }
35 return int(fd), nil
36}
37
38// newAuthCmd creates the auth command group.
39func newAuthCmd() *cobra.Command {
40 cmd := &cobra.Command{
41 Use: "auth",
42 Short: "Manage Gordon server authentication",
43 Long: `Commands for managing Gordon server authentication tokens and passwords.`,
44 }
45
46 cmd.AddCommand(newAuthTokenCmd())
47 cmd.AddCommand(newAuthPasswordCmd())
48 cmd.AddCommand(newAuthInternalCmd())
49 cmd.AddCommand(newAuthLoginCmd())
50 cmd.AddCommand(newAuthStatusCmd())
51
52 return cmd
53}
54
55// newAuthTokenCmd creates the token subcommand group.
56func newAuthTokenCmd() *cobra.Command {
57 cmd := &cobra.Command{
58 Use: "token",
59 Short: "Manage authentication tokens",
60 Long: `Commands for generating, listing, and revoking authentication tokens.`,
61 }
62
63 cmd.AddCommand(newTokenGenerateCmd())
64 cmd.AddCommand(newTokenListCmd())
65 cmd.AddCommand(newTokenRevokeCmd())
66
67 return cmd
68}
69
70// newTokenGenerateCmd creates the token generate command.
71func newTokenGenerateCmd() *cobra.Command {
72 var (
73 subject string
74 scopes string
75 expiry string
76 configPath string
77 repo string
78 )
79
80 cmd := &cobra.Command{
81 Use: "generate",
82 Short: "Generate a new authentication token",
83 Long: `Generate a new JWT authentication token for registry and/or admin access.
84
85The token will be stored in the configured secrets backend and can be used
86for docker login as: docker login -u <subject> -p <token>
87
88SCOPES:
89
90Registry scopes (for Docker push/pull):
91 push Push images to registry
92 pull Pull images from registry
93
94Admin scopes (for remote CLI access):
95 Format: admin:<resource>:<actions>
96
97 Resources: routes, secrets, config, status, * (all)
98 Actions: read, write, * (all)
99
100 Examples:
101 admin:*:* Full admin access (recommended for CLI)
102 admin:routes:read Read-only routes access
103 admin:status:read Read-only status access
104
105Repository scoping:
106 --repo myapp Scope to specific repository
107 --repo "*" All repositories (default)
108
109Combined examples:
110 --scopes "push,pull" All repos, registry only (default)
111 --scopes "push" --repo myapp Push to myapp only
112 --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)
113 --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)
114
115EXPIRY:
116
117Supports human-friendly durations:
118 d (days): 1d = 24 hours w (weeks): 1w = 7 days
119 M (months): 1M = 30 days y (years): 1y = 365 days
120
121Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
122Use --expiry=0 for a token that never expires (useful for CI).`,
123 RunE: func(cmd *cobra.Command, args []string) error {
124 return runTokenGenerate(subject, scopes, expiry, configPath, repo)
125 },
126 }
127
128 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
129 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
130 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
131 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
132 cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
133
134 _ = cmd.MarkFlagRequired("subject")
135
136 return cmd
137}
138
139// newTokenListCmd creates the token list command.
140func newTokenListCmd() *cobra.Command {
141 var (
142 configPath string
143 jsonOut bool
144 )
145
146 cmd := &cobra.Command{
147 Use: "list",
148 Short: "List all authentication tokens",
149 Long: `List all stored authentication tokens with their subjects and expiry information.`,
150 RunE: func(cmd *cobra.Command, args []string) error {
151 return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)
152 },
153 }
154
155 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
156 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
157
158 return cmd
159}
160
161// newTokenRevokeCmd creates the token revoke command.
162func newTokenRevokeCmd() *cobra.Command {
163 var (
164 configPath string
165 all bool
166 )
167
168 cmd := &cobra.Command{
169 Use: "revoke [token-id]",
170 Short: "Revoke authentication token(s)",
171 Long: `Revoke a token by its ID, or all tokens with --all.
172
173Examples:
174 gordon auth token revoke abc123-def456 Revoke specific token
175 gordon auth token revoke --all Revoke all tokens`,
176 Args: cobra.MaximumNArgs(1),
177 RunE: func(cmd *cobra.Command, args []string) error {
178 if all {
179 return runTokenRevokeAll(configPath)
180 }
181 if len(args) == 0 {
182 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
183 }
184 return runTokenRevoke(args[0], configPath)
185 },
186 }
187
188 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
189 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
190
191 return cmd
192}
193
194// newAuthPasswordCmd creates the password subcommand group.
195func newAuthPasswordCmd() *cobra.Command {
196 cmd := &cobra.Command{
197 Use: "password",
198 Short: "Password utilities",
199 Long: `Utilities for managing password authentication.`,
200 }
201
202 cmd.AddCommand(newPasswordHashCmd())
203
204 return cmd
205}
206
207// newAuthInternalCmd creates the internal credentials command.
208func newAuthInternalCmd() *cobra.Command {
209 return &cobra.Command{
210 Use: "internal",
211 Short: "Show internal registry credentials for manual recovery",
212 Long: `Display the auto-generated internal registry credentials.
213
214These credentials are used by Gordon for pulling images from the local registry
215(localhost:5000) during internal deploys. You can use them for manual recovery:
216
217 docker login localhost:5000 -u gordon-internal -p <password>
218
219Note: Credentials are regenerated each time Gordon starts, and are only
220available while Gordon is running.`,
221 RunE: func(cmd *cobra.Command, args []string) error {
222 return runShowInternalAuth()
223 },
224 }
225}
226
227// newAuthLoginCmd creates the login command for remote authentication.
228func newAuthLoginCmd() *cobra.Command {
229 var (
230 remoteName string
231 username string
232 token string
233 password string
234 )
235
236 cmd := &cobra.Command{
237 Use: "login",
238 Short: "Authenticate with a Gordon server",
239 Long: `Authenticate with a Gordon server using password authentication or a pre-generated token.
240
241This command prompts for your username and password, authenticates with the
242remote server's /auth/password endpoint, and stores the returned token.
243
244The token is stored in your remote configuration and used for subsequent
245CLI operations.
246
247If --token is provided, the token is stored for the remote and verified
248against /admin/status on a best-effort basis.
249
250Examples:
251 gordon auth login Login using active remote
252 gordon auth login --remote prod Login to specific remote
253 gordon auth login --username admin Pre-fill username
254 gordon auth login --token <token> Store a token for token-only servers`,
255 RunE: func(cmd *cobra.Command, args []string) error {
256 if token != "" {
257 return runAuthLoginWithToken(remoteName, token)
258 }
259 return runAuthLogin(remoteName, username, password)
260 },
261 }
262
263 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
264 cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
265 cmd.Flags().StringVarP(&password, "password", "p", "", "Password (visible in process listings; prefer interactive prompt)")
266 cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
267
268 cmd.MarkFlagsMutuallyExclusive("token", "password")
269 cmd.MarkFlagsMutuallyExclusive("token", "username")
270
271 return cmd
272}
273
274// runAuthLogin authenticates with a remote Gordon server.
275func runAuthLogin(remoteName, username, password string) error {
276 resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
277 if err != nil {
278 return err
279 }
280
281 // Prompt for username if not provided
282 if username == "" {
283 fmt.Print("Username: ")
284 reader := bufio.NewReader(os.Stdin)
285 input, err := reader.ReadString('\n')
286 if err != nil {
287 return fmt.Errorf("failed to read username: %w", err)
288 }
289 username = strings.TrimSpace(input)
290 }
291
292 if username == "" {
293 return fmt.Errorf("username cannot be empty")
294 }
295
296 if password == "" {
297 // Prompt for password (hidden input)
298 fmt.Print("Password: ")
299 fd, err := stdinFD()
300 if err != nil {
301 return fmt.Errorf("failed to get stdin fd: %w", err)
302 }
303 passwordBytes, err := term.ReadPassword(fd)
304 if err != nil {
305 // Fallback for non-terminal input
306 reader := bufio.NewReader(os.Stdin)
307 passwordInput, readErr := reader.ReadString('\n')
308 if readErr != nil {
309 return fmt.Errorf("failed to read password: %w", readErr)
310 }
311 // Only trim trailing newline from fallback input
312 passwordBytes = []byte(strings.TrimRight(passwordInput, "\r\n"))
313 }
314 fmt.Println()
315 password = string(passwordBytes)
316 } else {
317 // Only trim trailing newline from flag-provided password
318 password = strings.TrimRight(password, "\r\n")
319 }
320
321 if password == "" {
322 return fmt.Errorf("password cannot be empty")
323 }
324
325 // Create remote client
326 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
327 client := remote.NewClient(remoteConfig.URL, remoteClientOptions("", insecureTLS)...)
328
329 // Authenticate
330 ctx := context.Background()
331 fmt.Printf("Authenticating with %s...\n", resolvedName)
332
333 resp, err := client.Authenticate(ctx, username, password)
334 if err != nil {
335 return fmt.Errorf("authentication failed: %w", err)
336 }
337
338 // Update remote config with new token
339 if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil {
340 return fmt.Errorf("failed to save token: %w", err)
341 }
342
343 fmt.Println()
344 fmt.Println(styles.RenderSuccess("Authentication successful!"))
345 fmt.Printf("Token stored for remote '%s'\n", resolvedName)
346 fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
347
348 return nil
349}
350
351func runAuthLoginWithToken(remoteName, token string) error {
352 token = strings.TrimSpace(token)
353 if token == "" {
354 return fmt.Errorf("token cannot be empty")
355 }
356
357 resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
358 if err != nil {
359 return err
360 }
361
362 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
363 client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
364 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
365 defer cancel()
366 verified := true
367 if _, err := client.GetStatus(ctx); err != nil {
368 fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
369 verified = false
370 }
371
372 if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
373 return fmt.Errorf("failed to save token: %w", err)
374 }
375
376 fmt.Println()
377 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
378 if verified {
379 fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
380 }
381 return nil
382}
383
384func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) {
385 config, err := remote.LoadRemotes("")
386 if err != nil {
387 return "", remote.RemoteEntry{}, fmt.Errorf("failed to load remotes: %w", err)
388 }
389
390 if remoteName == "" {
391 remoteName = config.Active
392 }
393 if remoteName == "" {
394 return "", remote.RemoteEntry{}, fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
395 }
396
397 remoteConfig, ok := config.Remotes[remoteName]
398 if !ok {
399 return "", remote.RemoteEntry{}, fmt.Errorf("remote '%s' not found", remoteName)
400 }
401
402 return remoteName, remoteConfig, nil
403}
404
405// runShowInternalAuth displays the internal registry credentials.
406func runShowInternalAuth() error {
407 creds, err := app.GetInternalCredentials()
408 if err != nil {
409 return err
410 }
411
412 fmt.Println("Internal Registry Credentials")
413 fmt.Println("==============================")
414 fmt.Printf("Username: %s\n", creds.Username)
415 fmt.Printf("Password: %s\n", creds.Password)
416 fmt.Println()
417 fmt.Println("Usage:")
418 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
419
420 return nil
421}
422
423// newPasswordHashCmd creates the password hash command.
424func newPasswordHashCmd() *cobra.Command {
425 return &cobra.Command{
426 Use: "hash",
427 Short: "Generate a bcrypt hash for a password",
428 Long: `Interactively prompt for a password and output its bcrypt hash.
429
430The hash can be stored in your secrets backend and referenced in the config:
431
432 [auth]
433 type = "password"
434 password_hash = "github.com/bnema/gordon/auth/password_hash"`,
435 RunE: func(cmd *cobra.Command, args []string) error {
436 return runPasswordHash()
437 },
438 }
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, configPath, repo string) error {
486 cfg, err := loadAuthConfig(configPath)
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 fmt.Println("Token generated successfully!")
520 fmt.Printf("Subject: %s\n", subject)
521 fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
522 if expiry == 0 {
523 fmt.Println("Expiry: never")
524 } else {
525 fmt.Printf("Expiry: %s\n", expiry)
526 }
527 fmt.Println()
528 fmt.Println("Token (use as password with docker login):")
529 fmt.Println(token)
530 fmt.Println()
531 fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
532
533 return nil
534}
535
536// runTokenList lists all stored tokens.
537func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
538 cfg, err := loadAuthConfig(configPath)
539 if err != nil {
540 return err
541 }
542
543 log := zerowrap.New(zerowrap.Config{Level: "warn"})
544 authSvc, err := createAuthServiceForCLI(cfg, log)
545 if err != nil {
546 return err
547 }
548
549 ctx := context.Background()
550 tokens, err := authSvc.ListTokens(ctx)
551 if err != nil {
552 return fmt.Errorf("failed to list tokens: %w", err)
553 }
554
555 if len(tokens) == 0 {
556 if jsonOut {
557 return writeJSON(out, []map[string]any{})
558 }
559 fmt.Println("No tokens found.")
560 return nil
561 }
562
563 if jsonOut {
564 payload := make([]map[string]any, 0, len(tokens))
565 for _, t := range tokens {
566 var expiresAt *time.Time
567 if !t.ExpiresAt.IsZero() {
568 expiresAt = &t.ExpiresAt
569 }
570 payload = append(payload, map[string]any{
571 "id": t.ID,
572 "description": t.Subject,
573 "scopes": t.Scopes,
574 "created_at": t.IssuedAt,
575 "expires_at": expiresAt,
576 "revoked": t.Revoked,
577 })
578 }
579 return writeJSON(out, payload)
580 }
581
582 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
583 fmt.Println(strings.Repeat("-", 90))
584
585 for _, t := range tokens {
586 expiry := "never"
587 if !t.ExpiresAt.IsZero() {
588 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
589 }
590 revoked := "no"
591 if t.Revoked {
592 revoked = "yes"
593 }
594 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
595 }
596
597 return nil
598}
599
600// runTokenRevoke revokes a token by ID.
601func runTokenRevoke(tokenID, configPath string) error {
602 cfg, err := loadAuthConfig(configPath)
603 if err != nil {
604 return err
605 }
606
607 log := zerowrap.New(zerowrap.Config{Level: "warn"})
608 authSvc, err := createAuthServiceForCLI(cfg, log)
609 if err != nil {
610 return err
611 }
612
613 ctx := context.Background()
614 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
615 return fmt.Errorf("failed to revoke token: %w", err)
616 }
617
618 fmt.Printf("Token %s has been revoked.\n", tokenID)
619 return nil
620}
621
622// runTokenRevokeAll revokes all tokens.
623func runTokenRevokeAll(configPath string) error {
624 cfg, err := loadAuthConfig(configPath)
625 if err != nil {
626 return err
627 }
628
629 log := zerowrap.New(zerowrap.Config{Level: "warn"})
630 authSvc, err := createAuthServiceForCLI(cfg, log)
631 if err != nil {
632 return err
633 }
634
635 ctx := context.Background()
636 count, err := authSvc.RevokeAllTokens(ctx)
637 if err != nil {
638 return fmt.Errorf("failed to revoke tokens: %w", err)
639 }
640
641 if count == 0 {
642 fmt.Println("No tokens to revoke.")
643 } else {
644 fmt.Printf("Revoked %d token(s).\n", count)
645 }
646 return nil
647}
648
649// runPasswordHash generates a bcrypt hash for a password.
650func runPasswordHash() error {
651 fmt.Print("Enter password: ")
652
653 // Read password without echo (use os.Stdin.Fd() for better compatibility)
654 fd, err := stdinFD()
655 if err != nil {
656 return fmt.Errorf("failed to get stdin fd: %w", err)
657 }
658 passwordBytes, err := term.ReadPassword(fd)
659 if err != nil {
660 // Fallback for non-terminal input
661 reader := bufio.NewReader(os.Stdin)
662 password, err := reader.ReadString('\n')
663 if err != nil {
664 return fmt.Errorf("failed to read password: %w", err)
665 }
666 passwordBytes = []byte(strings.TrimSpace(password))
667 }
668 fmt.Println()
669
670 if len(passwordBytes) == 0 {
671 return fmt.Errorf("password cannot be empty")
672 }
673
674 // Create a temporary auth service to generate the hash
675 authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
676 hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
677 if err != nil {
678 return fmt.Errorf("failed to generate hash: %w", err)
679 }
680
681 fmt.Println()
682 fmt.Println("Bcrypt hash (store in your secrets backend):")
683 fmt.Println(hash)
684 fmt.Println()
685 fmt.Println("Then reference the path in your config:")
686 fmt.Println(" [auth]")
687 fmt.Println(" type = \"password\"")
688 fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"")
689
690 return nil
691}
692
693// cliConfig holds the configuration needed for CLI commands.
694type cliConfig struct {
695 Backend domain.SecretsBackend
696 DataDir string
697 TokenSecret []byte
698}
699
700// loadAuthConfig loads the configuration needed for auth CLI commands.
701func loadAuthConfig(configPath string) (*cliConfig, error) {
702 v := viper.New()
703
704 // Set defaults
705 v.SetDefault("server.data_dir", app.DefaultDataDir())
706 v.SetDefault("auth.secrets_backend", "unsafe")
707
708 app.ConfigureViper(v, configPath)
709
710 if err := v.ReadInConfig(); err != nil {
711 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
712 return nil, fmt.Errorf("failed to read config file: %w", err)
713 }
714 // Config file not found is OK for CLI commands
715 }
716
717 cfg := &cliConfig{
718 DataDir: v.GetString("server.data_dir"),
719 }
720
721 // Determine backend
722 switch v.GetString("auth.secrets_backend") {
723 case "pass":
724 cfg.Backend = domain.SecretsBackendPass
725 case "sops":
726 cfg.Backend = domain.SecretsBackendSops
727 default:
728 cfg.Backend = domain.SecretsBackendUnsafe
729 }
730
731 // Load token secret from secrets backend
732 tokenSecretPath := v.GetString("auth.token_secret")
733 if tokenSecretPath != "" {
734 // Load actual secret from the configured backend
735 ctx := context.Background()
736 log := zerowrap.New(zerowrap.Config{Level: "warn"})
737
738 switch cfg.Backend {
739 case domain.SecretsBackendPass:
740 provider := secrets.NewPassProvider(log)
741 secret, err := provider.GetSecret(ctx, tokenSecretPath)
742 if err != nil {
743 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
744 }
745 cfg.TokenSecret = []byte(secret)
746 default:
747 // For unsafe backend, use the path as the secret (backwards compatible)
748 cfg.TokenSecret = []byte(tokenSecretPath)
749 }
750 } else {
751 // Generate a default secret for CLI token generation
752 cfg.TokenSecret = []byte("gordon-cli-default-secret")
753 }
754
755 return cfg, nil
756}
757
758// createAuthServiceForCLI creates an auth service for CLI commands.
759func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
760 // Create token store
761 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
762 if err != nil {
763 return nil, fmt.Errorf("failed to create token store: %w", err)
764 }
765
766 // Create auth service config
767 authConfig := auth.Config{
768 Enabled: true,
769 AuthType: domain.AuthTypeToken,
770 TokenSecret: cfg.TokenSecret,
771 }
772
773 return auth.NewService(authConfig, store, log), nil
774}
775
776// newAuthStatusCmd creates the auth status command.
777func newAuthStatusCmd() *cobra.Command {
778 var remoteNameFlag string
779 cmd := &cobra.Command{
780 Use: "status",
781 Short: "Check authentication session status",
782 Long: `Verify if stored authentication session is still valid.
783
784Shows token validity, expiry, and connection status for the active remote.
785
786Examples:
787 gordon auth status Check status of active remote
788 gordon auth status --remote prod Check specific remote`,
789 RunE: func(cmd *cobra.Command, args []string) error {
790 return runAuthStatus(remoteNameFlag)
791 },
792 }
793 cmd.Flags().StringVarP(&remoteNameFlag, "remote", "r", "", "Check specific remote")
794 return cmd
795}
796
797// runAuthStatus checks authentication status for the active remote.
798func runAuthStatus(remoteNameArg string) error {
799 // Load remotes config
800 config, err := remote.LoadRemotes("")
801 if err != nil {
802 return fmt.Errorf("failed to load remotes: %w", err)
803 }
804
805 // Determine remote name
806 remoteName := remoteNameArg
807 if remoteName == "" {
808 remoteName = config.Active
809 }
810 if remoteName == "" {
811 return fmt.Errorf("no active remote configured. Use 'gordon remote use <name>' or --remote flag")
812 }
813
814 remoteConfig, ok := config.Remotes[remoteName]
815 if !ok {
816 if remoteNameArg != "" {
817 return fmt.Errorf("remote '%s' not found", remoteName)
818 }
819 return fmt.Errorf("active remote '%s' not found", remoteName)
820 }
821
822 // Create client
823 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, remoteName)
824 client := remote.NewClient(remoteConfig.URL, remoteClientOptions(remoteConfig.Token, insecureTLS)...)
825
826 // Verify auth
827 ctx := context.Background()
828 fmt.Printf("Checking authentication status for '%s'...\n", remoteName)
829
830 status, err := client.VerifyAuth(ctx)
831 if err != nil {
832 return handleAuthVerifyError(err, remoteName, remoteConfig.URL)
833 }
834
835 // Display status
836 displayAuthStatus(remoteName, remoteConfig.URL, status)
837 return nil
838}
839
840// handleAuthVerifyError handles errors from auth verification.
841func handleAuthVerifyError(err error, remoteName, url string) error {
842 errStrLower := strings.ToLower(err.Error())
843
844 // Connection errors
845 if strings.Contains(errStrLower, "connection refused") ||
846 strings.Contains(errStrLower, "no such host") ||
847 strings.Contains(errStrLower, "timeout") {
848 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
849 fmt.Println()
850 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
851 fmt.Printf("Error: %s\n\n", err.Error())
852 fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
853 return nil
854 }
855
856 // Auth errors
857 if strings.Contains(errStrLower, "401") ||
858 strings.Contains(errStrLower, "unauthorized") ||
859 strings.Contains(errStrLower, "invalid token") {
860 fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
861 fmt.Println()
862 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
863 fmt.Println()
864 fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
865 fmt.Println()
866 fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
867 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
868 return nil
869 }
870
871 return fmt.Errorf("failed to verify auth: %w", err)
872}
873
874// displayAuthStatus displays authentication status information.
875func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
876 fmt.Println(styles.Theme.Bold.Render("Remote: " + remoteName + " | URL: " + url))
877 fmt.Println()
878
879 if status.Valid {
880 // Valid authentication
881 fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
882 fmt.Println()
883
884 if status.Subject != "" {
885 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
886 }
887
888 if status.IssuedAt > 0 {
889 issuedAt := time.Unix(status.IssuedAt, 0)
890 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
891 }
892
893 if status.ExpiresAt > 0 {
894 expiresAt := time.Unix(status.ExpiresAt, 0)
895 if expiresAt.Before(time.Now()) {
896 fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
897 expiresAt.Format("2006-01-02 15:04:05"),
898 styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
899 } else {
900 remaining := time.Until(expiresAt)
901 fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
902 expiresAt.Format("2006-01-02 15:04:05"),
903 styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
904 }
905 }
906
907 if len(status.Scopes) > 0 {
908 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
909 }
910 } else {
911 // Invalid or no authentication
912 fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
913 fmt.Println()
914 fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
915 fmt.Printf(" gordon auth login --remote %s\n", remoteName)
916 }
917}