1package cli
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "strings"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/spf13/cobra"
13 "github.com/spf13/viper"
14 "golang.org/x/term"
15
16 "gordon/internal/adapters/in/cli/remote"
17 "gordon/internal/adapters/in/cli/ui/styles"
18 "gordon/internal/adapters/out/secrets"
19 "gordon/internal/adapters/out/tokenstore"
20 "gordon/internal/app"
21 "gordon/internal/domain"
22 "gordon/internal/usecase/auth"
23 "gordon/pkg/duration"
24)
25
26// newAuthCmd creates the auth command group.
27func newAuthCmd() *cobra.Command {
28 cmd := &cobra.Command{
29 Use: "auth",
30 Short: "Manage Gordon server authentication",
31 Long: `Commands for managing Gordon server authentication tokens and passwords.`,
32 }
33
34 cmd.AddCommand(newAuthTokenCmd())
35 cmd.AddCommand(newAuthPasswordCmd())
36 cmd.AddCommand(newAuthInternalCmd())
37 cmd.AddCommand(newAuthLoginCmd())
38
39 return cmd
40}
41
42// newAuthTokenCmd creates the token subcommand group.
43func newAuthTokenCmd() *cobra.Command {
44 cmd := &cobra.Command{
45 Use: "token",
46 Short: "Manage authentication tokens",
47 Long: `Commands for generating, listing, and revoking authentication tokens.`,
48 }
49
50 cmd.AddCommand(newTokenGenerateCmd())
51 cmd.AddCommand(newTokenListCmd())
52 cmd.AddCommand(newTokenRevokeCmd())
53
54 return cmd
55}
56
57// newTokenGenerateCmd creates the token generate command.
58func newTokenGenerateCmd() *cobra.Command {
59 var (
60 subject string
61 scopes string
62 expiry string
63 configPath string
64 )
65
66 cmd := &cobra.Command{
67 Use: "generate",
68 Short: "Generate a new authentication token",
69 Long: `Generate a new JWT authentication token for registry and/or admin access.
70
71The token will be stored in the configured secrets backend and can be used
72for docker login as: docker login -u <subject> -p <token>
73
74SCOPES:
75
76Registry scopes (for Docker push/pull):
77 push Push images to registry
78 pull Pull images from registry
79
80Admin scopes (for remote CLI access):
81 Format: admin:<resource>:<actions>
82
83 Resources: routes, secrets, config, status, * (all)
84 Actions: read, write, * (all)
85
86 Examples:
87 admin:*:* Full admin access (recommended for CLI)
88 admin:routes:read Read-only routes access
89 admin:status:read Read-only status access
90
91Combined examples:
92 --scopes "push,pull" Registry only (default)
93 --scopes "admin:*:*" Admin CLI only
94 --scopes "push,pull,admin:*:*" Registry + full admin
95
96EXPIRY:
97
98Supports human-friendly durations:
99 d (days): 1d = 24 hours w (weeks): 1w = 7 days
100 M (months): 1M = 30 days y (years): 1y = 365 days
101
102Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
103Use --expiry=0 for a token that never expires (useful for CI).`,
104 RunE: func(cmd *cobra.Command, args []string) error {
105 return runTokenGenerate(subject, scopes, expiry, configPath)
106 },
107 }
108
109 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
110 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
111 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
112 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
113
114 _ = cmd.MarkFlagRequired("subject")
115
116 return cmd
117}
118
119// newTokenListCmd creates the token list command.
120func newTokenListCmd() *cobra.Command {
121 var configPath string
122
123 cmd := &cobra.Command{
124 Use: "list",
125 Short: "List all authentication tokens",
126 Long: `List all stored authentication tokens with their subjects and expiry information.`,
127 RunE: func(cmd *cobra.Command, args []string) error {
128 return runTokenList(configPath)
129 },
130 }
131
132 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
133
134 return cmd
135}
136
137// newTokenRevokeCmd creates the token revoke command.
138func newTokenRevokeCmd() *cobra.Command {
139 var (
140 configPath string
141 all bool
142 )
143
144 cmd := &cobra.Command{
145 Use: "revoke [token-id]",
146 Short: "Revoke authentication token(s)",
147 Long: `Revoke a token by its ID, or all tokens with --all.
148
149Examples:
150 gordon auth token revoke abc123-def456 Revoke specific token
151 gordon auth token revoke --all Revoke all tokens`,
152 Args: cobra.MaximumNArgs(1),
153 RunE: func(cmd *cobra.Command, args []string) error {
154 if all {
155 return runTokenRevokeAll(configPath)
156 }
157 if len(args) == 0 {
158 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
159 }
160 return runTokenRevoke(args[0], configPath)
161 },
162 }
163
164 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
165 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
166
167 return cmd
168}
169
170// newAuthPasswordCmd creates the password subcommand group.
171func newAuthPasswordCmd() *cobra.Command {
172 cmd := &cobra.Command{
173 Use: "password",
174 Short: "Password utilities",
175 Long: `Utilities for managing password authentication.`,
176 }
177
178 cmd.AddCommand(newPasswordHashCmd())
179
180 return cmd
181}
182
183// newAuthInternalCmd creates the internal credentials command.
184func newAuthInternalCmd() *cobra.Command {
185 return &cobra.Command{
186 Use: "internal",
187 Short: "Show internal registry credentials for manual recovery",
188 Long: `Display the auto-generated internal registry credentials.
189
190These credentials are used by Gordon for pulling images from the local registry
191(localhost:5000) during internal deploys. You can use them for manual recovery:
192
193 docker login localhost:5000 -u gordon-internal -p <password>
194
195Note: Credentials are regenerated each time Gordon starts, and are only
196available while Gordon is running.`,
197 RunE: func(cmd *cobra.Command, args []string) error {
198 return runShowInternalAuth()
199 },
200 }
201}
202
203// newAuthLoginCmd creates the login command for remote authentication.
204func newAuthLoginCmd() *cobra.Command {
205 var (
206 remoteName string
207 username string
208 )
209
210 cmd := &cobra.Command{
211 Use: "login",
212 Short: "Authenticate with a Gordon server",
213 Long: `Authenticate with a Gordon server using password authentication.
214
215This command prompts for your username and password, authenticates with the
216remote server's /auth/password endpoint, and stores the returned token.
217
218The token is stored in your remote configuration and used for subsequent
219CLI operations.
220
221Note: This command only works with servers configured for password authentication.
222For servers using token-based auth, use 'gordon remote set-token' instead.
223
224Examples:
225 gordon auth login Login using active remote
226 gordon auth login --remote prod Login to specific remote
227 gordon auth login --username admin Pre-fill username`,
228 RunE: func(cmd *cobra.Command, args []string) error {
229 return runAuthLogin(remoteName, username)
230 },
231 }
232
233 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
234 cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
235
236 return cmd
237}
238
239// runAuthLogin authenticates with a remote Gordon server.
240func runAuthLogin(remoteName, username string) error {
241 // Load remotes config
242 config, err := remote.LoadRemotes("")
243 if err != nil {
244 return fmt.Errorf("failed to load remotes: %w", err)
245 }
246
247 // Determine which remote to use
248 if remoteName == "" {
249 remoteName = config.Active
250 }
251 if remoteName == "" {
252 return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
253 }
254
255 remoteConfig, ok := config.Remotes[remoteName]
256 if !ok {
257 return fmt.Errorf("remote '%s' not found", remoteName)
258 }
259
260 // Prompt for username if not provided
261 if username == "" {
262 fmt.Print("Username: ")
263 reader := bufio.NewReader(os.Stdin)
264 input, err := reader.ReadString('\n')
265 if err != nil {
266 return fmt.Errorf("failed to read username: %w", err)
267 }
268 username = strings.TrimSpace(input)
269 }
270
271 if username == "" {
272 return fmt.Errorf("username cannot be empty")
273 }
274
275 // Prompt for password (hidden input)
276 fmt.Print("Password: ")
277 passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
278 if err != nil {
279 // Fallback for non-terminal input
280 reader := bufio.NewReader(os.Stdin)
281 password, readErr := reader.ReadString('\n')
282 if readErr != nil {
283 return fmt.Errorf("failed to read password: %w", readErr)
284 }
285 passwordBytes = []byte(strings.TrimSpace(password))
286 }
287 fmt.Println() // newline after hidden password input
288
289 if len(passwordBytes) == 0 {
290 return fmt.Errorf("password cannot be empty")
291 }
292
293 // Create remote client
294 client := remote.NewClient(remoteConfig.URL)
295
296 // Authenticate
297 ctx := context.Background()
298 fmt.Printf("Authenticating with %s...\n", remoteName)
299
300 resp, err := client.Authenticate(ctx, username, string(passwordBytes))
301 if err != nil {
302 return fmt.Errorf("authentication failed: %w", err)
303 }
304
305 // Update remote config with new token
306 if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil {
307 return fmt.Errorf("failed to save token: %w", err)
308 }
309
310 fmt.Println()
311 fmt.Println(styles.RenderSuccess("Authentication successful!"))
312 fmt.Printf("Token stored for remote '%s'\n", remoteName)
313 fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
314
315 return nil
316}
317
318// runShowInternalAuth displays the internal registry credentials.
319func runShowInternalAuth() error {
320 creds, err := app.GetInternalCredentials()
321 if err != nil {
322 return err
323 }
324
325 fmt.Println("Internal Registry Credentials")
326 fmt.Println("==============================")
327 fmt.Printf("Username: %s\n", creds.Username)
328 fmt.Printf("Password: %s\n", creds.Password)
329 fmt.Println()
330 fmt.Println("Usage:")
331 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
332
333 return nil
334}
335
336// newPasswordHashCmd creates the password hash command.
337func newPasswordHashCmd() *cobra.Command {
338 return &cobra.Command{
339 Use: "hash",
340 Short: "Generate a bcrypt hash for a password",
341 Long: `Interactively prompt for a password and output its bcrypt hash.
342
343The hash can be stored in your secrets backend and referenced in the config:
344
345 [auth]
346 type = "password"
347 password_hash = "gordon/auth/password_hash"`,
348 RunE: func(cmd *cobra.Command, args []string) error {
349 return runPasswordHash()
350 },
351 }
352}
353
354// runTokenGenerate generates a new authentication token.
355func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
356 cfg, err := loadAuthConfig(configPath)
357 if err != nil {
358 return err
359 }
360
361 // Parse expiry (supports human-friendly units: d, w, M, y)
362 var expiry time.Duration
363 if expiryStr != "0" {
364 expiry, err = duration.Parse(expiryStr)
365 if err != nil {
366 return fmt.Errorf("invalid expiry duration: %w", err)
367 }
368 }
369
370 // Parse scopes
371 scopes := strings.Split(scopesStr, ",")
372 for i := range scopes {
373 scopes[i] = strings.TrimSpace(scopes[i])
374 }
375
376 // Create auth service
377 log := zerowrap.New(zerowrap.Config{Level: "warn"})
378 authSvc, err := createAuthServiceForCLI(cfg, log)
379 if err != nil {
380 return err
381 }
382
383 // Generate token
384 ctx := context.Background()
385 token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
386 if err != nil {
387 return fmt.Errorf("failed to generate token: %w", err)
388 }
389
390 fmt.Println("Token generated successfully!")
391 fmt.Printf("Subject: %s\n", subject)
392 fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
393 if expiry == 0 {
394 fmt.Println("Expiry: never")
395 } else {
396 fmt.Printf("Expiry: %s\n", expiry)
397 }
398 fmt.Println()
399 fmt.Println("Token (use as password with docker login):")
400 fmt.Println(token)
401 fmt.Println()
402 fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
403
404 return nil
405}
406
407// runTokenList lists all stored tokens.
408func runTokenList(configPath string) error {
409 cfg, err := loadAuthConfig(configPath)
410 if err != nil {
411 return err
412 }
413
414 log := zerowrap.New(zerowrap.Config{Level: "warn"})
415 authSvc, err := createAuthServiceForCLI(cfg, log)
416 if err != nil {
417 return err
418 }
419
420 ctx := context.Background()
421 tokens, err := authSvc.ListTokens(ctx)
422 if err != nil {
423 return fmt.Errorf("failed to list tokens: %w", err)
424 }
425
426 if len(tokens) == 0 {
427 fmt.Println("No tokens found.")
428 return nil
429 }
430
431 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
432 fmt.Println(strings.Repeat("-", 90))
433
434 for _, t := range tokens {
435 expiry := "never"
436 if !t.ExpiresAt.IsZero() {
437 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
438 }
439 revoked := "no"
440 if t.Revoked {
441 revoked = "yes"
442 }
443 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
444 }
445
446 return nil
447}
448
449// runTokenRevoke revokes a token by ID.
450func runTokenRevoke(tokenID, configPath string) error {
451 cfg, err := loadAuthConfig(configPath)
452 if err != nil {
453 return err
454 }
455
456 log := zerowrap.New(zerowrap.Config{Level: "warn"})
457 authSvc, err := createAuthServiceForCLI(cfg, log)
458 if err != nil {
459 return err
460 }
461
462 ctx := context.Background()
463 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
464 return fmt.Errorf("failed to revoke token: %w", err)
465 }
466
467 fmt.Printf("Token %s has been revoked.\n", tokenID)
468 return nil
469}
470
471// runTokenRevokeAll revokes all tokens.
472func runTokenRevokeAll(configPath string) error {
473 cfg, err := loadAuthConfig(configPath)
474 if err != nil {
475 return err
476 }
477
478 log := zerowrap.New(zerowrap.Config{Level: "warn"})
479 authSvc, err := createAuthServiceForCLI(cfg, log)
480 if err != nil {
481 return err
482 }
483
484 ctx := context.Background()
485 count, err := authSvc.RevokeAllTokens(ctx)
486 if err != nil {
487 return fmt.Errorf("failed to revoke tokens: %w", err)
488 }
489
490 if count == 0 {
491 fmt.Println("No tokens to revoke.")
492 } else {
493 fmt.Printf("Revoked %d token(s).\n", count)
494 }
495 return nil
496}
497
498// runPasswordHash generates a bcrypt hash for a password.
499func runPasswordHash() error {
500 fmt.Print("Enter password: ")
501
502 // Read password without echo (use os.Stdin.Fd() for better compatibility)
503 passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
504 if err != nil {
505 // Fallback for non-terminal input
506 reader := bufio.NewReader(os.Stdin)
507 password, err := reader.ReadString('\n')
508 if err != nil {
509 return fmt.Errorf("failed to read password: %w", err)
510 }
511 passwordBytes = []byte(strings.TrimSpace(password))
512 }
513 fmt.Println()
514
515 if len(passwordBytes) == 0 {
516 return fmt.Errorf("password cannot be empty")
517 }
518
519 // Create a temporary auth service to generate the hash
520 authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
521 hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
522 if err != nil {
523 return fmt.Errorf("failed to generate hash: %w", err)
524 }
525
526 fmt.Println()
527 fmt.Println("Bcrypt hash (store in your secrets backend):")
528 fmt.Println(hash)
529 fmt.Println()
530 fmt.Println("Then reference the path in your config:")
531 fmt.Println(" [auth]")
532 fmt.Println(" type = \"password\"")
533 fmt.Println(" password_hash = \"gordon/auth/password_hash\"")
534
535 return nil
536}
537
538// cliConfig holds the configuration needed for CLI commands.
539type cliConfig struct {
540 Backend domain.SecretsBackend
541 DataDir string
542 TokenSecret []byte
543}
544
545// loadAuthConfig loads the configuration needed for auth CLI commands.
546func loadAuthConfig(configPath string) (*cliConfig, error) {
547 v := viper.New()
548
549 // Set defaults
550 v.SetDefault("server.data_dir", app.DefaultDataDir())
551 v.SetDefault("auth.secrets_backend", "unsafe")
552
553 app.ConfigureViper(v, configPath)
554
555 if err := v.ReadInConfig(); err != nil {
556 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
557 return nil, fmt.Errorf("failed to read config file: %w", err)
558 }
559 // Config file not found is OK for CLI commands
560 }
561
562 cfg := &cliConfig{
563 DataDir: v.GetString("server.data_dir"),
564 }
565
566 // Determine backend
567 switch v.GetString("auth.secrets_backend") {
568 case "pass":
569 cfg.Backend = domain.SecretsBackendPass
570 case "sops":
571 cfg.Backend = domain.SecretsBackendSops
572 default:
573 cfg.Backend = domain.SecretsBackendUnsafe
574 }
575
576 // Load token secret from secrets backend
577 tokenSecretPath := v.GetString("auth.token_secret")
578 if tokenSecretPath != "" {
579 // Load actual secret from the configured backend
580 ctx := context.Background()
581 log := zerowrap.New(zerowrap.Config{Level: "warn"})
582
583 switch cfg.Backend {
584 case domain.SecretsBackendPass:
585 provider := secrets.NewPassProvider(log)
586 secret, err := provider.GetSecret(ctx, tokenSecretPath)
587 if err != nil {
588 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
589 }
590 cfg.TokenSecret = []byte(secret)
591 default:
592 // For unsafe backend, use the path as the secret (backwards compatible)
593 cfg.TokenSecret = []byte(tokenSecretPath)
594 }
595 } else {
596 // Generate a default secret for CLI token generation
597 cfg.TokenSecret = []byte("gordon-cli-default-secret")
598 }
599
600 return cfg, nil
601}
602
603// createAuthServiceForCLI creates an auth service for CLI commands.
604func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
605 // Create token store
606 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
607 if err != nil {
608 return nil, fmt.Errorf("failed to create token store: %w", err)
609 }
610
611 // Create auth service config
612 authConfig := auth.Config{
613 Enabled: true,
614 AuthType: domain.AuthTypeToken,
615 TokenSecret: cfg.TokenSecret,
616 }
617
618 return auth.NewService(authConfig, store, log), nil
619}