v2.14.0 5e11a00

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

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