v2.31.0-dev23 58a92f3

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, err := remote.ResolveStrict(remoteFlag, flagToken, insecureTLSFlag)
240	if err != nil {
241		return nil, fmt.Errorf("failed to resolve remote %q: %w", remoteFlag, err)
242	}
243	if !isRemote {
244		return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
245	}
246
247	return resolved, nil
248}
249
250func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {
251	token = strings.TrimSpace(token)
252	if token == "" {
253		return fmt.Errorf("token cannot be empty")
254	}
255
256	// Resolve remote — but use the new token (not the stored one) for verification.
257	resolved, err := resolveRequiredRemote("")
258	if err != nil {
259		return err
260	}
261
262	client := remote.NewClient(resolved.URL, remoteClientOptions(token, resolved.InsecureTLS)...)
263	verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
264	defer cancel()
265	verified := true
266	if _, err := client.GetStatus(verifyCtx); err != nil {
267		_ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
268		verified = false
269	}
270
271	// Store token — requires a named remote.
272	if resolved.Name == "" {
273		_ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
274		return nil
275	}
276	if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {
277		return fmt.Errorf("failed to save token: %w", err)
278	}
279
280	_ = cliWriteLine(out, "")
281	_ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))
282	if verified {
283		_ = cliWriteLine(out, styles.RenderSuccess("Token verified with remote status endpoint"))
284	}
285	return nil
286}
287
288func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
289	resolved, err := resolveRequiredRemote(tokenFlag)
290	if err != nil {
291		return err
292	}
293	if resolved.Token == "" {
294		return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())
295	}
296
297	client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
298	verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
299	defer cancel()
300
301	if _, err := client.GetStatus(verifyCtx); err != nil {
302		_ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed for '%s': %v", resolved.DisplayName(), err)))
303		return fmt.Errorf("authentication failed")
304	}
305
306	_ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
307	return nil
308}
309
310// newAuthShowTokenCmd creates the show-token command.
311func newAuthShowTokenCmd() *cobra.Command {
312	cmd := &cobra.Command{
313		Use:   "show-token",
314		Short: "Print the stored token for a remote",
315		Long: `Print the raw authentication token for a remote to stdout.
316
317The token is resolved from pass, TOML config, or environment variable
318using the same precedence as other commands.
319
320Output is suitable for piping (no decoration, single line).
321
322Examples:
323  gordon auth show-token                     Show token for active remote
324  gordon auth show-token --remote prod       Show token for specific remote
325  gordon auth show-token | pbcopy            Copy token to clipboard`,
326		RunE: func(cmd *cobra.Command, args []string) error {
327			return runAuthShowToken(cmd.OutOrStdout())
328		},
329	}
330
331	return cmd
332}
333
334func runAuthShowToken(out io.Writer) error {
335	resolved, err := resolveRequiredRemote(tokenFlag)
336	if err != nil {
337		return err
338	}
339	if resolved.Token == "" {
340		return fmt.Errorf("no token found for remote '%s'", resolved.DisplayName())
341	}
342
343	if f, ok := out.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
344		_ = cliWriteLine(os.Stderr, styles.RenderWarning("Warning: token will be visible in terminal scrollback"))
345	}
346
347	_ = cliWriteLine(out, resolved.Token)
348	return nil
349}
350
351// newAuthLogoutCmd creates the logout command.
352func newAuthLogoutCmd() *cobra.Command {
353	cmd := &cobra.Command{
354		Use:   "logout",
355		Short: "Remove stored authentication for a remote",
356		Long: `Remove the stored token for a remote from pass and TOML config.
357
358This clears the token from all local storage but does not revoke the
359token on the server. To revoke server-side, use: gordon auth token revoke
360
361Examples:
362  gordon auth logout                   Logout from active remote
363  gordon auth logout --remote prod     Logout from specific remote`,
364		RunE: func(cmd *cobra.Command, args []string) error {
365			return runAuthLogout(cmd.OutOrStdout())
366		},
367	}
368
369	return cmd
370}
371
372func runAuthLogout(out io.Writer) error {
373	resolved, err := resolveRequiredRemote(tokenFlag)
374	if err != nil {
375		return err
376	}
377	if resolved.Name == "" {
378		return fmt.Errorf("cannot logout from ad-hoc URL. Use 'gordon remotes add' to save this remote first")
379	}
380
381	if err := remote.ClearRemoteToken(resolved.Name); err != nil {
382		return fmt.Errorf("failed to clear token: %w", err)
383	}
384
385	_ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolved.Name)))
386	return nil
387}
388
389// runShowInternalAuth displays the internal registry credentials.
390func runShowInternalAuth() error {
391	creds, err := app.GetInternalCredentials()
392	if err != nil {
393		return err
394	}
395
396	fmt.Println("Internal Registry Credentials")
397	fmt.Println("==============================")
398	fmt.Printf("Username: %s\n", creds.Username)
399	fmt.Printf("Password: %s\n", creds.Password)
400	fmt.Println()
401	fmt.Println("Usage:")
402	fmt.Printf("  docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
403
404	return nil
405}
406
407// runTokenGenerate generates a new authentication token.
408// parseAndConvertScopes parses a comma-separated scope string and converts
409// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
410func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
411	repo = strings.TrimSpace(repo)
412	if repo == "" {
413		return nil, fmt.Errorf("repository name cannot be empty")
414	}
415
416	rawScopes := strings.Split(scopesStr, ",")
417	for i := range rawScopes {
418		rawScopes[i] = strings.TrimSpace(rawScopes[i])
419	}
420
421	// Check if scopes are already in v2 format (contain colons)
422	hasSimpleScope := false
423	for _, s := range rawScopes {
424		if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
425			hasSimpleScope = true
426			break
427		}
428	}
429
430	if !hasSimpleScope {
431		return rawScopes, nil
432	}
433
434	// Convert simple scopes to v2 format: repository:<repo>:push,pull
435	var scopes []string
436	var actions []string
437	for _, s := range rawScopes {
438		if s == "push" || s == "pull" || s == "*" {
439			actions = append(actions, s)
440		} else {
441			// Keep non-simple scopes as-is (e.g., admin scopes)
442			scopes = append(scopes, s)
443		}
444	}
445	if len(actions) > 0 {
446		scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
447	}
448	return scopes, nil
449}
450
451func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
452	cfg, err := loadAuthConfig(configPath)
453	if err != nil {
454		return err
455	}
456
457	// Parse expiry (supports human-friendly units: d, w, M, y)
458	var expiry time.Duration
459	if expiryStr != "0" {
460		expiry, err = duration.Parse(expiryStr)
461		if err != nil {
462			return fmt.Errorf("invalid expiry duration: %w", err)
463		}
464	}
465
466	scopes, err := parseAndConvertScopes(scopesStr, repo)
467	if err != nil {
468		return fmt.Errorf("invalid scopes: %w", err)
469	}
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, out io.Writer, jsonOut bool) 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		if jsonOut {
523			return writeJSON(out, []map[string]any{})
524		}
525		fmt.Println("No tokens found.")
526		return nil
527	}
528
529	if jsonOut {
530		payload := make([]map[string]any, 0, len(tokens))
531		for _, t := range tokens {
532			var expiresAt *time.Time
533			if !t.ExpiresAt.IsZero() {
534				expiresAt = &t.ExpiresAt
535			}
536			payload = append(payload, map[string]any{
537				"id":          t.ID,
538				"description": t.Subject,
539				"scopes":      t.Scopes,
540				"created_at":  t.IssuedAt,
541				"expires_at":  expiresAt,
542				"revoked":     t.Revoked,
543			})
544		}
545		return writeJSON(out, payload)
546	}
547
548	fmt.Printf("%-36s  %-20s  %-20s  %-10s\n", "ID", "Subject", "Expires", "Revoked")
549	fmt.Println(strings.Repeat("-", 90))
550
551	for _, t := range tokens {
552		expiry := "never"
553		if !t.ExpiresAt.IsZero() {
554			expiry = t.ExpiresAt.Format("2006-01-02 15:04")
555		}
556		revoked := "no"
557		if t.Revoked {
558			revoked = "yes"
559		}
560		fmt.Printf("%-36s  %-20s  %-20s  %-10s\n", t.ID, t.Subject, expiry, revoked)
561	}
562
563	return nil
564}
565
566// runTokenRevoke revokes a token by ID.
567func runTokenRevoke(tokenID, 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	if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
581		return fmt.Errorf("failed to revoke token: %w", err)
582	}
583
584	fmt.Printf("Token %s has been revoked.\n", tokenID)
585	return nil
586}
587
588// runTokenRevokeAll revokes all tokens.
589func runTokenRevokeAll(configPath string) error {
590	cfg, err := loadAuthConfig(configPath)
591	if err != nil {
592		return err
593	}
594
595	log := zerowrap.New(zerowrap.Config{Level: "warn"})
596	authSvc, err := createAuthServiceForCLI(cfg, log)
597	if err != nil {
598		return err
599	}
600
601	ctx := context.Background()
602	count, err := authSvc.RevokeAllTokens(ctx)
603	if err != nil {
604		return fmt.Errorf("failed to revoke tokens: %w", err)
605	}
606
607	if count == 0 {
608		fmt.Println("No tokens to revoke.")
609	} else {
610		fmt.Printf("Revoked %d token(s).\n", count)
611	}
612	return nil
613}
614
615// cliConfig holds the configuration needed for CLI commands.
616type cliConfig struct {
617	Backend     domain.SecretsBackend
618	DataDir     string
619	TokenSecret []byte
620}
621
622// loadAuthConfig loads the configuration needed for auth CLI commands.
623func loadAuthConfig(configPath string) (*cliConfig, error) {
624	v := viper.New()
625
626	// Set defaults
627	v.SetDefault("server.data_dir", app.DefaultDataDir())
628	v.SetDefault("auth.secrets_backend", "unsafe")
629
630	app.ConfigureViper(v, configPath)
631
632	if err := v.ReadInConfig(); err != nil {
633		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
634			return nil, fmt.Errorf("failed to read config file: %w", err)
635		}
636		// Config file not found is OK for CLI commands
637	}
638
639	cfg := &cliConfig{
640		DataDir: v.GetString("server.data_dir"),
641	}
642
643	// Determine backend
644	switch v.GetString("auth.secrets_backend") {
645	case "pass":
646		cfg.Backend = domain.SecretsBackendPass
647	case "sops":
648		cfg.Backend = domain.SecretsBackendSops
649	default:
650		cfg.Backend = domain.SecretsBackendUnsafe
651	}
652
653	// Load token secret from secrets backend
654	tokenSecretPath := v.GetString("auth.token_secret")
655	if tokenSecretPath != "" {
656		// Load actual secret from the configured backend
657		ctx := context.Background()
658		log := zerowrap.New(zerowrap.Config{Level: "warn"})
659
660		switch cfg.Backend {
661		case domain.SecretsBackendPass:
662			provider := secrets.NewPassProvider(log)
663			secret, err := provider.GetSecret(ctx, tokenSecretPath)
664			if err != nil {
665				return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
666			}
667			cfg.TokenSecret = []byte(secret)
668		default:
669			// For unsafe backend, use the path as the secret (backwards compatible)
670			cfg.TokenSecret = []byte(tokenSecretPath)
671		}
672	} else {
673		return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret")
674	}
675
676	return cfg, nil
677}
678
679// createAuthServiceForCLI creates an auth service for CLI commands.
680func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
681	// Create token store
682	store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
683	if err != nil {
684		return nil, fmt.Errorf("failed to create token store: %w", err)
685	}
686
687	// Create auth service config
688	authConfig := auth.Config{
689		Enabled:     true,
690		AuthType:    domain.AuthTypeToken,
691		TokenSecret: cfg.TokenSecret,
692	}
693
694	return auth.NewService(authConfig, store, log), nil
695}
696
697// newAuthStatusCmd creates the auth status command.
698func newAuthStatusCmd() *cobra.Command {
699	cmd := &cobra.Command{
700		Use:   "status",
701		Short: "Check authentication session status",
702		Long: `Verify if stored authentication session is still valid.
703
704Shows token validity, expiry, and connection status for the active remote.
705
706Examples:
707  gordon auth status              Check status of active remote
708  gordon auth status --remote prod  Check specific remote`,
709		RunE: func(cmd *cobra.Command, args []string) error {
710			return runAuthStatus(cmd.Context())
711		},
712	}
713	return cmd
714}
715
716// runAuthStatus checks authentication status for the active remote.
717func runAuthStatus(ctx context.Context) error {
718	resolved, err := resolveRequiredRemote(tokenFlag)
719	if err != nil {
720		return err
721	}
722
723	client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
724
725	name := resolved.DisplayName()
726	fmt.Printf("Checking authentication status for '%s'...\n", name)
727
728	status, err := client.VerifyAuth(ctx)
729	if err != nil {
730		return handleAuthVerifyError(err, name, resolved.URL)
731	}
732
733	displayAuthStatus(name, resolved.URL, status)
734	return nil
735}
736
737// handleAuthVerifyError handles errors from auth verification.
738func handleAuthVerifyError(err error, remoteName, url string) error {
739	errStrLower := strings.ToLower(err.Error())
740
741	// Connection errors
742	if strings.Contains(errStrLower, "connection refused") ||
743		strings.Contains(errStrLower, "no such host") ||
744		strings.Contains(errStrLower, "timeout") {
745		fmt.Println(styles.Theme.Bold.Render("Remote:  " + url))
746		fmt.Println()
747		fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
748		fmt.Printf("Error:  %s\n\n", err.Error())
749		fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
750		return nil
751	}
752
753	// Auth errors
754	if strings.Contains(errStrLower, "401") ||
755		strings.Contains(errStrLower, "unauthorized") ||
756		strings.Contains(errStrLower, "invalid token") {
757		fmt.Println(styles.Theme.Bold.Render("Remote:  " + url))
758		fmt.Println()
759		fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
760		fmt.Println()
761		fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
762		fmt.Println()
763		fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
764		fmt.Printf("  gordon auth login --remote %s\n", remoteName)
765		return nil
766	}
767
768	return fmt.Errorf("failed to verify auth: %w", err)
769}
770
771// displayAuthStatus displays authentication status information.
772func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
773	fmt.Println(styles.Theme.Bold.Render("Remote:  " + remoteName + " | URL: " + url))
774	fmt.Println()
775
776	if status.Valid {
777		// Valid authentication
778		fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
779		fmt.Println()
780
781		if status.Subject != "" {
782			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
783		}
784
785		if status.IssuedAt > 0 {
786			issuedAt := time.Unix(status.IssuedAt, 0)
787			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
788		}
789
790		if status.ExpiresAt > 0 {
791			expiresAt := time.Unix(status.ExpiresAt, 0)
792			if expiresAt.Before(time.Now()) {
793				fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
794					expiresAt.Format("2006-01-02 15:04:05"),
795					styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
796			} else {
797				remaining := time.Until(expiresAt)
798				fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
799					expiresAt.Format("2006-01-02 15:04:05"),
800					styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
801			}
802		}
803
804		if len(status.Scopes) > 0 {
805			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
806		}
807	} else {
808		// Invalid or no authentication
809		fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
810		fmt.Println()
811		fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
812		fmt.Printf("  gordon auth login --remote %s\n", remoteName)
813	}
814}

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