v2.31.0-dev4 f8fb41e

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

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