v2.31.0-dev17 bd898f0

auth.go RawHistoryFolder

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

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