v2.8.0 6aced72

auth.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"fmt"
7	"os"
8	"strings"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/spf13/cobra"
13	"github.com/spf13/viper"
14	"golang.org/x/term"
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(newAuthPasswordCmd())
37	cmd.AddCommand(newAuthInternalCmd())
38	cmd.AddCommand(newAuthLoginCmd())
39	cmd.AddCommand(newAuthStatusCmd())
40
41	return cmd
42}
43
44// newAuthTokenCmd creates the token subcommand group.
45func newAuthTokenCmd() *cobra.Command {
46	cmd := &cobra.Command{
47		Use:   "token",
48		Short: "Manage authentication tokens",
49		Long:  `Commands for generating, listing, and revoking authentication tokens.`,
50	}
51
52	cmd.AddCommand(newTokenGenerateCmd())
53	cmd.AddCommand(newTokenListCmd())
54	cmd.AddCommand(newTokenRevokeCmd())
55
56	return cmd
57}
58
59// newTokenGenerateCmd creates the token generate command.
60func newTokenGenerateCmd() *cobra.Command {
61	var (
62		subject    string
63		scopes     string
64		expiry     string
65		configPath string
66	)
67
68	cmd := &cobra.Command{
69		Use:   "generate",
70		Short: "Generate a new authentication token",
71		Long: `Generate a new JWT authentication token for registry and/or admin access.
72
73The token will be stored in the configured secrets backend and can be used
74for docker login as: docker login -u <subject> -p <token>
75
76SCOPES:
77
78Registry scopes (for Docker push/pull):
79  push              Push images to registry
80  pull              Pull images from registry
81
82Admin scopes (for remote CLI access):
83  Format: admin:<resource>:<actions>
84
85  Resources: routes, secrets, config, status, * (all)
86  Actions:   read, write, * (all)
87
88  Examples:
89    admin:*:*              Full admin access (recommended for CLI)
90    admin:routes:read      Read-only routes access
91    admin:status:read      Read-only status access
92
93Combined examples:
94  --scopes "push,pull"                Registry only (default)
95  --scopes "admin:*:*"                Admin CLI only
96  --scopes "push,pull,admin:*:*"      Registry + full admin
97
98EXPIRY:
99
100Supports human-friendly durations:
101  d (days):   1d = 24 hours      w (weeks):  1w = 7 days
102  M (months): 1M = 30 days       y (years):  1y = 365 days
103
104Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
105Use --expiry=0 for a token that never expires (useful for CI).`,
106		RunE: func(cmd *cobra.Command, args []string) error {
107			return runTokenGenerate(subject, scopes, expiry, configPath)
108		},
109	}
110
111	cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
112	cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
113	cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
114	cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
115
116	_ = cmd.MarkFlagRequired("subject")
117
118	return cmd
119}
120
121// newTokenListCmd creates the token list command.
122func newTokenListCmd() *cobra.Command {
123	var configPath string
124
125	cmd := &cobra.Command{
126		Use:   "list",
127		Short: "List all authentication tokens",
128		Long:  `List all stored authentication tokens with their subjects and expiry information.`,
129		RunE: func(cmd *cobra.Command, args []string) error {
130			return runTokenList(configPath)
131		},
132	}
133
134	cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
135
136	return cmd
137}
138
139// newTokenRevokeCmd creates the token revoke command.
140func newTokenRevokeCmd() *cobra.Command {
141	var (
142		configPath string
143		all        bool
144	)
145
146	cmd := &cobra.Command{
147		Use:   "revoke [token-id]",
148		Short: "Revoke authentication token(s)",
149		Long: `Revoke a token by its ID, or all tokens with --all.
150
151Examples:
152  gordon auth token revoke abc123-def456    Revoke specific token
153  gordon auth token revoke --all            Revoke all tokens`,
154		Args: cobra.MaximumNArgs(1),
155		RunE: func(cmd *cobra.Command, args []string) error {
156			if all {
157				return runTokenRevokeAll(configPath)
158			}
159			if len(args) == 0 {
160				return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
161			}
162			return runTokenRevoke(args[0], configPath)
163		},
164	}
165
166	cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
167	cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
168
169	return cmd
170}
171
172// newAuthPasswordCmd creates the password subcommand group.
173func newAuthPasswordCmd() *cobra.Command {
174	cmd := &cobra.Command{
175		Use:   "password",
176		Short: "Password utilities",
177		Long:  `Utilities for managing password authentication.`,
178	}
179
180	cmd.AddCommand(newPasswordHashCmd())
181
182	return cmd
183}
184
185// newAuthInternalCmd creates the internal credentials command.
186func newAuthInternalCmd() *cobra.Command {
187	return &cobra.Command{
188		Use:   "internal",
189		Short: "Show internal registry credentials for manual recovery",
190		Long: `Display the auto-generated internal registry credentials.
191
192These credentials are used by Gordon for pulling images from the local registry
193(localhost:5000) during internal deploys. You can use them for manual recovery:
194
195  docker login localhost:5000 -u gordon-internal -p <password>
196
197Note: Credentials are regenerated each time Gordon starts, and are only
198available while Gordon is running.`,
199		RunE: func(cmd *cobra.Command, args []string) error {
200			return runShowInternalAuth()
201		},
202	}
203}
204
205// newAuthLoginCmd creates the login command for remote authentication.
206func newAuthLoginCmd() *cobra.Command {
207	var (
208		remoteName string
209		username   string
210	)
211
212	cmd := &cobra.Command{
213		Use:   "login",
214		Short: "Authenticate with a Gordon server",
215		Long: `Authenticate with a Gordon server using password authentication.
216
217This command prompts for your username and password, authenticates with the
218remote server's /auth/password endpoint, and stores the returned token.
219
220The token is stored in your remote configuration and used for subsequent
221CLI operations.
222
223Note: This command only works with servers configured for password authentication.
224For servers using token-based auth, use 'gordon remote set-token' instead.
225
226Examples:
227  gordon auth login                    Login using active remote
228  gordon auth login --remote prod      Login to specific remote
229  gordon auth login --username admin   Pre-fill username`,
230		RunE: func(cmd *cobra.Command, args []string) error {
231			return runAuthLogin(remoteName, username)
232		},
233	}
234
235	cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
236	cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
237
238	return cmd
239}
240
241// runAuthLogin authenticates with a remote Gordon server.
242func runAuthLogin(remoteName, username string) error {
243	// Load remotes config
244	config, err := remote.LoadRemotes("")
245	if err != nil {
246		return fmt.Errorf("failed to load remotes: %w", err)
247	}
248
249	// Determine which remote to use
250	if remoteName == "" {
251		remoteName = config.Active
252	}
253	if remoteName == "" {
254		return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
255	}
256
257	remoteConfig, ok := config.Remotes[remoteName]
258	if !ok {
259		return fmt.Errorf("remote '%s' not found", remoteName)
260	}
261
262	// Prompt for username if not provided
263	if username == "" {
264		fmt.Print("Username: ")
265		reader := bufio.NewReader(os.Stdin)
266		input, err := reader.ReadString('\n')
267		if err != nil {
268			return fmt.Errorf("failed to read username: %w", err)
269		}
270		username = strings.TrimSpace(input)
271	}
272
273	if username == "" {
274		return fmt.Errorf("username cannot be empty")
275	}
276
277	// Prompt for password (hidden input)
278	fmt.Print("Password: ")
279	passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
280	if err != nil {
281		// Fallback for non-terminal input
282		reader := bufio.NewReader(os.Stdin)
283		password, readErr := reader.ReadString('\n')
284		if readErr != nil {
285			return fmt.Errorf("failed to read password: %w", readErr)
286		}
287		passwordBytes = []byte(strings.TrimSpace(password))
288	}
289	fmt.Println() // newline after hidden password input
290
291	if len(passwordBytes) == 0 {
292		return fmt.Errorf("password cannot be empty")
293	}
294
295	// Create remote client
296	client := remote.NewClient(remoteConfig.URL)
297
298	// Authenticate
299	ctx := context.Background()
300	fmt.Printf("Authenticating with %s...\n", remoteName)
301
302	resp, err := client.Authenticate(ctx, username, string(passwordBytes))
303	if err != nil {
304		return fmt.Errorf("authentication failed: %w", err)
305	}
306
307	// Update remote config with new token
308	if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil {
309		return fmt.Errorf("failed to save token: %w", err)
310	}
311
312	fmt.Println()
313	fmt.Println(styles.RenderSuccess("Authentication successful!"))
314	fmt.Printf("Token stored for remote '%s'\n", remoteName)
315	fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
316
317	return nil
318}
319
320// runShowInternalAuth displays the internal registry credentials.
321func runShowInternalAuth() error {
322	creds, err := app.GetInternalCredentials()
323	if err != nil {
324		return err
325	}
326
327	fmt.Println("Internal Registry Credentials")
328	fmt.Println("==============================")
329	fmt.Printf("Username: %s\n", creds.Username)
330	fmt.Printf("Password: %s\n", creds.Password)
331	fmt.Println()
332	fmt.Println("Usage:")
333	fmt.Printf("  docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
334
335	return nil
336}
337
338// newPasswordHashCmd creates the password hash command.
339func newPasswordHashCmd() *cobra.Command {
340	return &cobra.Command{
341		Use:   "hash",
342		Short: "Generate a bcrypt hash for a password",
343		Long: `Interactively prompt for a password and output its bcrypt hash.
344
345The hash can be stored in your secrets backend and referenced in the config:
346
347  [auth]
348  type = "password"
349  password_hash = "github.com/bnema/gordon/auth/password_hash"`,
350		RunE: func(cmd *cobra.Command, args []string) error {
351			return runPasswordHash()
352		},
353	}
354}
355
356// runTokenGenerate generates a new authentication token.
357// parseAndConvertScopes parses a comma-separated scope string and converts
358// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
359func parseAndConvertScopes(scopesStr string) []string {
360	rawScopes := strings.Split(scopesStr, ",")
361	for i := range rawScopes {
362		rawScopes[i] = strings.TrimSpace(rawScopes[i])
363	}
364
365	// Check if scopes are already in v2 format (contain colons)
366	hasSimpleScope := false
367	for _, s := range rawScopes {
368		if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
369			hasSimpleScope = true
370			break
371		}
372	}
373
374	if !hasSimpleScope {
375		return rawScopes
376	}
377
378	// Convert simple scopes to v2 format: repository:*:push,pull
379	var scopes []string
380	var actions []string
381	for _, s := range rawScopes {
382		if s == "push" || s == "pull" || s == "*" {
383			actions = append(actions, s)
384		} else {
385			// Keep non-simple scopes as-is (e.g., admin scopes)
386			scopes = append(scopes, s)
387		}
388	}
389	if len(actions) > 0 {
390		scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
391	}
392	return scopes
393}
394
395func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
396	cfg, err := loadAuthConfig(configPath)
397	if err != nil {
398		return err
399	}
400
401	// Parse expiry (supports human-friendly units: d, w, M, y)
402	var expiry time.Duration
403	if expiryStr != "0" {
404		expiry, err = duration.Parse(expiryStr)
405		if err != nil {
406			return fmt.Errorf("invalid expiry duration: %w", err)
407		}
408	}
409
410	scopes := parseAndConvertScopes(scopesStr)
411
412	// Create auth service
413	log := zerowrap.New(zerowrap.Config{Level: "warn"})
414	authSvc, err := createAuthServiceForCLI(cfg, log)
415	if err != nil {
416		return err
417	}
418
419	// Generate token
420	ctx := context.Background()
421	token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
422	if err != nil {
423		return fmt.Errorf("failed to generate token: %w", err)
424	}
425
426	fmt.Println("Token generated successfully!")
427	fmt.Printf("Subject: %s\n", subject)
428	fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
429	if expiry == 0 {
430		fmt.Println("Expiry: never")
431	} else {
432		fmt.Printf("Expiry: %s\n", expiry)
433	}
434	fmt.Println()
435	fmt.Println("Token (use as password with docker login):")
436	fmt.Println(token)
437	fmt.Println()
438	fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
439
440	return nil
441}
442
443// runTokenList lists all stored tokens.
444func runTokenList(configPath string) error {
445	cfg, err := loadAuthConfig(configPath)
446	if err != nil {
447		return err
448	}
449
450	log := zerowrap.New(zerowrap.Config{Level: "warn"})
451	authSvc, err := createAuthServiceForCLI(cfg, log)
452	if err != nil {
453		return err
454	}
455
456	ctx := context.Background()
457	tokens, err := authSvc.ListTokens(ctx)
458	if err != nil {
459		return fmt.Errorf("failed to list tokens: %w", err)
460	}
461
462	if len(tokens) == 0 {
463		fmt.Println("No tokens found.")
464		return nil
465	}
466
467	fmt.Printf("%-36s  %-20s  %-20s  %-10s\n", "ID", "Subject", "Expires", "Revoked")
468	fmt.Println(strings.Repeat("-", 90))
469
470	for _, t := range tokens {
471		expiry := "never"
472		if !t.ExpiresAt.IsZero() {
473			expiry = t.ExpiresAt.Format("2006-01-02 15:04")
474		}
475		revoked := "no"
476		if t.Revoked {
477			revoked = "yes"
478		}
479		fmt.Printf("%-36s  %-20s  %-20s  %-10s\n", t.ID, t.Subject, expiry, revoked)
480	}
481
482	return nil
483}
484
485// runTokenRevoke revokes a token by ID.
486func runTokenRevoke(tokenID, configPath string) error {
487	cfg, err := loadAuthConfig(configPath)
488	if err != nil {
489		return err
490	}
491
492	log := zerowrap.New(zerowrap.Config{Level: "warn"})
493	authSvc, err := createAuthServiceForCLI(cfg, log)
494	if err != nil {
495		return err
496	}
497
498	ctx := context.Background()
499	if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
500		return fmt.Errorf("failed to revoke token: %w", err)
501	}
502
503	fmt.Printf("Token %s has been revoked.\n", tokenID)
504	return nil
505}
506
507// runTokenRevokeAll revokes all tokens.
508func runTokenRevokeAll(configPath string) error {
509	cfg, err := loadAuthConfig(configPath)
510	if err != nil {
511		return err
512	}
513
514	log := zerowrap.New(zerowrap.Config{Level: "warn"})
515	authSvc, err := createAuthServiceForCLI(cfg, log)
516	if err != nil {
517		return err
518	}
519
520	ctx := context.Background()
521	count, err := authSvc.RevokeAllTokens(ctx)
522	if err != nil {
523		return fmt.Errorf("failed to revoke tokens: %w", err)
524	}
525
526	if count == 0 {
527		fmt.Println("No tokens to revoke.")
528	} else {
529		fmt.Printf("Revoked %d token(s).\n", count)
530	}
531	return nil
532}
533
534// runPasswordHash generates a bcrypt hash for a password.
535func runPasswordHash() error {
536	fmt.Print("Enter password: ")
537
538	// Read password without echo (use os.Stdin.Fd() for better compatibility)
539	passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
540	if err != nil {
541		// Fallback for non-terminal input
542		reader := bufio.NewReader(os.Stdin)
543		password, err := reader.ReadString('\n')
544		if err != nil {
545			return fmt.Errorf("failed to read password: %w", err)
546		}
547		passwordBytes = []byte(strings.TrimSpace(password))
548	}
549	fmt.Println()
550
551	if len(passwordBytes) == 0 {
552		return fmt.Errorf("password cannot be empty")
553	}
554
555	// Create a temporary auth service to generate the hash
556	authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
557	hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
558	if err != nil {
559		return fmt.Errorf("failed to generate hash: %w", err)
560	}
561
562	fmt.Println()
563	fmt.Println("Bcrypt hash (store in your secrets backend):")
564	fmt.Println(hash)
565	fmt.Println()
566	fmt.Println("Then reference the path in your config:")
567	fmt.Println("  [auth]")
568	fmt.Println("  type = \"password\"")
569	fmt.Println("  password_hash = \"github.com/bnema/gordon/auth/password_hash\"")
570
571	return nil
572}
573
574// cliConfig holds the configuration needed for CLI commands.
575type cliConfig struct {
576	Backend     domain.SecretsBackend
577	DataDir     string
578	TokenSecret []byte
579}
580
581// loadAuthConfig loads the configuration needed for auth CLI commands.
582func loadAuthConfig(configPath string) (*cliConfig, error) {
583	v := viper.New()
584
585	// Set defaults
586	v.SetDefault("server.data_dir", app.DefaultDataDir())
587	v.SetDefault("auth.secrets_backend", "unsafe")
588
589	app.ConfigureViper(v, configPath)
590
591	if err := v.ReadInConfig(); err != nil {
592		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
593			return nil, fmt.Errorf("failed to read config file: %w", err)
594		}
595		// Config file not found is OK for CLI commands
596	}
597
598	cfg := &cliConfig{
599		DataDir: v.GetString("server.data_dir"),
600	}
601
602	// Determine backend
603	switch v.GetString("auth.secrets_backend") {
604	case "pass":
605		cfg.Backend = domain.SecretsBackendPass
606	case "sops":
607		cfg.Backend = domain.SecretsBackendSops
608	default:
609		cfg.Backend = domain.SecretsBackendUnsafe
610	}
611
612	// Load token secret from secrets backend
613	tokenSecretPath := v.GetString("auth.token_secret")
614	if tokenSecretPath != "" {
615		// Load actual secret from the configured backend
616		ctx := context.Background()
617		log := zerowrap.New(zerowrap.Config{Level: "warn"})
618
619		switch cfg.Backend {
620		case domain.SecretsBackendPass:
621			provider := secrets.NewPassProvider(log)
622			secret, err := provider.GetSecret(ctx, tokenSecretPath)
623			if err != nil {
624				return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
625			}
626			cfg.TokenSecret = []byte(secret)
627		default:
628			// For unsafe backend, use the path as the secret (backwards compatible)
629			cfg.TokenSecret = []byte(tokenSecretPath)
630		}
631	} else {
632		// Generate a default secret for CLI token generation
633		cfg.TokenSecret = []byte("gordon-cli-default-secret")
634	}
635
636	return cfg, nil
637}
638
639// createAuthServiceForCLI creates an auth service for CLI commands.
640func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
641	// Create token store
642	store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
643	if err != nil {
644		return nil, fmt.Errorf("failed to create token store: %w", err)
645	}
646
647	// Create auth service config
648	authConfig := auth.Config{
649		Enabled:     true,
650		AuthType:    domain.AuthTypeToken,
651		TokenSecret: cfg.TokenSecret,
652	}
653
654	return auth.NewService(authConfig, store, log), nil
655}
656
657// newAuthStatusCmd creates the auth status command.
658func newAuthStatusCmd() *cobra.Command {
659	var remoteNameFlag string
660	cmd := &cobra.Command{
661		Use:   "status",
662		Short: "Check authentication session status",
663		Long: `Verify if stored authentication session is still valid.
664
665Shows token validity, expiry, and connection status for the active remote.
666
667Examples:
668  gordon auth status              Check status of active remote
669  gordon auth status --remote prod  Check specific remote`,
670		RunE: func(cmd *cobra.Command, args []string) error {
671			return runAuthStatus(remoteNameFlag)
672		},
673	}
674	cmd.Flags().StringVarP(&remoteNameFlag, "remote", "r", "", "Check specific remote")
675	return cmd
676}
677
678// runAuthStatus checks authentication status for the active remote.
679func runAuthStatus(remoteNameArg string) error {
680	// Load remotes config
681	config, err := remote.LoadRemotes("")
682	if err != nil {
683		return fmt.Errorf("failed to load remotes: %w", err)
684	}
685
686	// Determine remote name
687	remoteName := remoteNameArg
688	if remoteName == "" {
689		remoteName = config.Active
690	}
691	if remoteName == "" {
692		return fmt.Errorf("no active remote configured. Use 'gordon remote use <name>' or --remote flag")
693	}
694
695	remoteConfig, ok := config.Remotes[remoteName]
696	if !ok {
697		if remoteNameArg != "" {
698			return fmt.Errorf("remote '%s' not found", remoteName)
699		}
700		return fmt.Errorf("active remote '%s' not found", remoteName)
701	}
702
703	// Create client
704	client := remote.NewClient(remoteConfig.URL, remote.WithToken(remoteConfig.Token))
705
706	// Verify auth
707	ctx := context.Background()
708	fmt.Printf("Checking authentication status for '%s'...\n", remoteName)
709
710	status, err := client.VerifyAuth(ctx)
711	if err != nil {
712		return handleAuthVerifyError(err, remoteName, remoteConfig.URL)
713	}
714
715	// Display status
716	displayAuthStatus(remoteName, remoteConfig.URL, status)
717	return nil
718}
719
720// handleAuthVerifyError handles errors from auth verification.
721func handleAuthVerifyError(err error, remoteName, url string) error {
722	errStrLower := strings.ToLower(err.Error())
723
724	// Connection errors
725	if strings.Contains(errStrLower, "connection refused") ||
726		strings.Contains(errStrLower, "no such host") ||
727		strings.Contains(errStrLower, "timeout") {
728		fmt.Println(styles.Theme.Bold.Render("Remote:  " + url))
729		fmt.Println()
730		fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
731		fmt.Printf("Error:  %s\n\n", err.Error())
732		fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
733		return nil
734	}
735
736	// Auth errors
737	if strings.Contains(errStrLower, "401") ||
738		strings.Contains(errStrLower, "unauthorized") ||
739		strings.Contains(errStrLower, "invalid token") {
740		fmt.Println(styles.Theme.Bold.Render("Remote:  " + url))
741		fmt.Println()
742		fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
743		fmt.Println()
744		fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
745		fmt.Println()
746		fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
747		fmt.Printf("  gordon auth login --remote %s\n", remoteName)
748		return nil
749	}
750
751	return fmt.Errorf("failed to verify auth: %w", err)
752}
753
754// displayAuthStatus displays authentication status information.
755func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
756	fmt.Println(styles.Theme.Bold.Render("Remote:  " + remoteName + " | URL: " + url))
757	fmt.Println()
758
759	if status.Valid {
760		// Valid authentication
761		fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
762		fmt.Println()
763
764		if status.Subject != "" {
765			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
766		}
767
768		if status.IssuedAt > 0 {
769			issuedAt := time.Unix(status.IssuedAt, 0)
770			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
771		}
772
773		if status.ExpiresAt > 0 {
774			expiresAt := time.Unix(status.ExpiresAt, 0)
775			if expiresAt.Before(time.Now()) {
776				fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
777					expiresAt.Format("2006-01-02 15:04:05"),
778					styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
779			} else {
780				remaining := time.Until(expiresAt)
781				fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
782					expiresAt.Format("2006-01-02 15:04:05"),
783					styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
784			}
785		}
786
787		if len(status.Scopes) > 0 {
788			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
789		}
790	} else {
791		// Invalid or no authentication
792		fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
793		fmt.Println()
794		fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
795		fmt.Printf("  gordon auth login --remote %s\n", remoteName)
796	}
797}

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