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

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