v2.10.1 43db1bb

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

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