v2.30.0-pre7 3ee801c

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close