v2.31.0-dev8 ec8c5b9

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

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