v2.31.1 f37fe67

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 tokenFile string
210	var jsonOut bool
211
212	cmd := &cobra.Command{
213		Use:   "login",
214		Short: "Authenticate with a Gordon server",
215		Long: `Store or verify a token for a Gordon server remote.
216
217With --token-file: reads, stores, and verifies a token without exposing it in argv.
218Without --token-file: verifies the existing stored token still works.
219
220Generate a token on the server with: gordon auth token generate
221
222Examples:
223	gordon auth login --token-file ./token         Store token for active remote
224	gordon auth login --remote prod --token-file ./token
225	gordon auth login                              Verify existing token`,
226		RunE: func(cmd *cobra.Command, args []string) error {
227			if tokenFile != "" {
228				token, err := readProtectedSecretFile(tokenFile)
229				if err != nil {
230					return fmt.Errorf("read token: %w", err)
231				}
232				return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout(), jsonOut)
233			}
234			return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout(), jsonOut)
235		},
236	}
237
238	cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
239	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
240
241	return cmd
242}
243
244func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
245	resolved, isRemote, err := remote.ResolveStrict(remoteFlag, flagToken, insecureTLSFlag)
246	if err != nil {
247		return nil, fmt.Errorf("failed to resolve remote %q: %w", remoteFlag, err)
248	}
249	if !isRemote {
250		return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
251	}
252
253	return resolved, nil
254}
255
256func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer, jsonOut bool) error {
257	token = strings.TrimSpace(token)
258	if token == "" {
259		return fmt.Errorf("token cannot be empty")
260	}
261
262	// Resolve remote — but use the new token (not the stored one) for verification.
263	resolved, err := resolveRequiredRemote("")
264	if err != nil {
265		return err
266	}
267
268	client := remote.NewClient(resolved.URL, remoteClientOptions(token, resolved.InsecureTLS)...)
269	verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
270	defer cancel()
271	verified := true
272	if _, err := client.GetStatus(verifyCtx); err != nil {
273		if !jsonOut {
274			_ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
275		}
276		verified = false
277	}
278
279	// Store token — requires a named remote.
280	if resolved.Name == "" {
281		if jsonOut {
282			return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "stored": false, "verified": verified})
283		}
284		return cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
285	}
286	if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {
287		return fmt.Errorf("failed to save token: %w", err)
288	}
289
290	if jsonOut {
291		return writeJSON(out, map[string]any{"remote": resolved.Name, "stored": true, "verified": verified})
292	}
293	_ = cliWriteLine(out, "")
294	_ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))
295	if verified {
296		_ = cliWriteLine(out, styles.RenderSuccess("Token verified with remote status endpoint"))
297	}
298	return nil
299}
300
301func runAuthLoginVerify(ctx context.Context, out io.Writer, jsonOut bool) error {
302	resolved, err := resolveRequiredRemote(tokenFlag)
303	if err != nil {
304		return err
305	}
306	if resolved.Token == "" {
307		return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token-file <path>", resolved.DisplayName())
308	}
309
310	client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
311	verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
312	defer cancel()
313
314	if _, err := client.GetStatus(verifyCtx); err != nil {
315		_ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed for '%s': %v", resolved.DisplayName(), err)))
316		return fmt.Errorf("authentication failed")
317	}
318
319	if jsonOut {
320		return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "authenticated": true})
321	}
322	return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
323}
324
325// newAuthShowTokenCmd creates the show-token command.
326func newAuthShowTokenCmd() *cobra.Command {
327	cmd := &cobra.Command{
328		Use:   "show-token",
329		Short: "Print the stored token for a remote",
330		Long: `Print the raw authentication token for a remote to stdout.
331
332The token is resolved from pass, TOML config, or environment variable
333using the same precedence as other commands.
334
335Output is suitable for piping (no decoration, single line).
336
337Examples:
338  gordon auth show-token                     Show token for active remote
339  gordon auth show-token --remote prod       Show token for specific remote
340  gordon auth show-token | pbcopy            Copy token to clipboard`,
341		RunE: func(cmd *cobra.Command, args []string) error {
342			return runAuthShowToken(cmd.OutOrStdout())
343		},
344	}
345
346	return cmd
347}
348
349func runAuthShowToken(out io.Writer) error {
350	resolved, err := resolveRequiredRemote(tokenFlag)
351	if err != nil {
352		return err
353	}
354	if resolved.Token == "" {
355		return fmt.Errorf("no token found for remote '%s'", resolved.DisplayName())
356	}
357
358	if f, ok := out.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
359		_ = cliWriteLine(os.Stderr, styles.RenderWarning("Warning: token will be visible in terminal scrollback"))
360	}
361
362	_ = cliWriteLine(out, resolved.Token)
363	return nil
364}
365
366// newAuthLogoutCmd creates the logout command.
367func newAuthLogoutCmd() *cobra.Command {
368	cmd := &cobra.Command{
369		Use:   "logout",
370		Short: "Remove stored authentication for a remote",
371		Long: `Remove the stored token for a remote from pass and TOML config.
372
373This clears the token from all local storage but does not revoke the
374token on the server. To revoke server-side, use: gordon auth token revoke
375
376Examples:
377  gordon auth logout                   Logout from active remote
378  gordon auth logout --remote prod     Logout from specific remote`,
379		RunE: func(cmd *cobra.Command, args []string) error {
380			return runAuthLogout(cmd.OutOrStdout())
381		},
382	}
383
384	return cmd
385}
386
387func runAuthLogout(out io.Writer) error {
388	resolved, err := resolveRequiredRemote(tokenFlag)
389	if err != nil {
390		return err
391	}
392	if resolved.Name == "" {
393		return fmt.Errorf("cannot logout from ad-hoc URL. Use 'gordon remotes add' to save this remote first")
394	}
395
396	if err := remote.ClearRemoteToken(resolved.Name); err != nil {
397		return fmt.Errorf("failed to clear token: %w", err)
398	}
399
400	_ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolved.Name)))
401	return 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	cmd := &cobra.Command{
715		Use:   "status",
716		Short: "Check authentication session status",
717		Long: `Verify if stored authentication session is still valid.
718
719Shows token validity, expiry, and connection status for the active remote.
720
721Examples:
722  gordon auth status              Check status of active remote
723  gordon auth status --remote prod  Check specific remote`,
724		RunE: func(cmd *cobra.Command, args []string) error {
725			return runAuthStatus(cmd.Context())
726		},
727	}
728	return cmd
729}
730
731// runAuthStatus checks authentication status for the active remote.
732func runAuthStatus(ctx context.Context) error {
733	resolved, err := resolveRequiredRemote(tokenFlag)
734	if err != nil {
735		return err
736	}
737
738	client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
739
740	name := resolved.DisplayName()
741	fmt.Printf("Checking authentication status for '%s'...\n", name)
742
743	status, err := client.VerifyAuth(ctx)
744	if err != nil {
745		return handleAuthVerifyError(err, name, resolved.URL)
746	}
747
748	displayAuthStatus(name, resolved.URL, status)
749	return nil
750}
751
752// handleAuthVerifyError handles errors from auth verification.
753func handleAuthVerifyError(err error, remoteName, url string) error {
754	errStrLower := strings.ToLower(err.Error())
755
756	// Connection errors
757	if strings.Contains(errStrLower, "connection refused") ||
758		strings.Contains(errStrLower, "no such host") ||
759		strings.Contains(errStrLower, "timeout") {
760		fmt.Println(styles.Theme.Bold.Render("Remote:  " + url))
761		fmt.Println()
762		fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
763		fmt.Printf("Error:  %s\n\n", err.Error())
764		fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
765		return nil
766	}
767
768	// Auth errors
769	if strings.Contains(errStrLower, "401") ||
770		strings.Contains(errStrLower, "unauthorized") ||
771		strings.Contains(errStrLower, "invalid token") {
772		fmt.Println(styles.Theme.Bold.Render("Remote:  " + url))
773		fmt.Println()
774		fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
775		fmt.Println()
776		fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
777		fmt.Println()
778		fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
779		fmt.Printf("  gordon auth login --remote %s\n", remoteName)
780		return nil
781	}
782
783	return fmt.Errorf("failed to verify auth: %w", err)
784}
785
786// displayAuthStatus displays authentication status information.
787func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
788	fmt.Println(styles.Theme.Bold.Render("Remote:  " + remoteName + " | URL: " + url))
789	fmt.Println()
790
791	if status.Valid {
792		// Valid authentication
793		fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
794		fmt.Println()
795
796		if status.Subject != "" {
797			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
798		}
799
800		if status.IssuedAt > 0 {
801			issuedAt := time.Unix(status.IssuedAt, 0)
802			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
803		}
804
805		if status.ExpiresAt > 0 {
806			expiresAt := time.Unix(status.ExpiresAt, 0)
807			if expiresAt.Before(time.Now()) {
808				fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
809					expiresAt.Format("2006-01-02 15:04:05"),
810					styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
811			} else {
812				remaining := time.Until(expiresAt)
813				fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
814					expiresAt.Format("2006-01-02 15:04:05"),
815					styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
816			}
817		}
818
819		if len(status.Scopes) > 0 {
820			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
821		}
822	} else {
823		// Invalid or no authentication
824		fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
825		fmt.Println()
826		fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
827		fmt.Printf("  gordon auth login --remote %s\n", remoteName)
828	}
829}

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