v2.30.0-pre 7341e0e

auth.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close