detached 136d617

feat(adapters/in): add authentication middleware, token endpoint and CLI commands

Add user-facing authentication interfaces:
- RegistryAuthV2 middleware supporting password and token auth
- /v2/token endpoint for Docker registry token server
- CLI commands: gordon auth token generate/list/revoke
- CLI command: gordon auth password hash
commit
136d617da93a2ddd40f2192f5cee689fff2b505c
author
brice · · 2026-01-12 19:58 UTC
committer
brice · · 2026-01-12 17:05 UTC
parent
70e008c
signature
unsigned
3 files · +696 -0
A internal/adapters/in/cli/auth.go +385 -0
@@ -0,0 +1,385 @@
1+package cli
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "os"
8+ "strings"
9+ "syscall"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/spf13/cobra"
14+ "github.com/spf13/viper"
15+ "golang.org/x/term"
16+
17+ "gordon/internal/adapters/out/tokenstore"
18+ "gordon/internal/domain"
19+ "gordon/internal/usecase/auth"
20+)
21+
22+// newAuthCmd creates the auth command group.
23+func newAuthCmd() *cobra.Command {
24+ cmd := &cobra.Command{
25+ Use: "auth",
26+ Short: "Manage registry authentication",
27+ Long: `Commands for managing registry authentication tokens and passwords.`,
28+ }
29+
30+ cmd.AddCommand(newAuthTokenCmd())
31+ cmd.AddCommand(newAuthPasswordCmd())
32+
33+ return cmd
34+}
35+
36+// newAuthTokenCmd creates the token subcommand group.
37+func newAuthTokenCmd() *cobra.Command {
38+ cmd := &cobra.Command{
39+ Use: "token",
40+ Short: "Manage authentication tokens",
41+ Long: `Commands for generating, listing, and revoking authentication tokens.`,
42+ }
43+
44+ cmd.AddCommand(newTokenGenerateCmd())
45+ cmd.AddCommand(newTokenListCmd())
46+ cmd.AddCommand(newTokenRevokeCmd())
47+
48+ return cmd
49+}
50+
51+// newTokenGenerateCmd creates the token generate command.
52+func newTokenGenerateCmd() *cobra.Command {
53+ var (
54+ subject string
55+ scopes string
56+ expiry string
57+ configPath string
58+ )
59+
60+ cmd := &cobra.Command{
61+ Use: "generate",
62+ Short: "Generate a new authentication token",
63+ Long: `Generate a new JWT authentication token for registry access.
64+
65+The token will be stored in the configured secrets backend and can be used
66+for docker login as: docker login -u <subject> -p <token>
67+
68+Use --expiry=0 to generate a token that never expires (useful for CI).`,
69+ RunE: func(cmd *cobra.Command, args []string) error {
70+ return runTokenGenerate(subject, scopes, expiry, configPath)
71+ },
72+ }
73+
74+ cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
75+ cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
76+ cmd.Flags().StringVar(&expiry, "expiry", "720h", "Token expiry duration (e.g., 720h, 24h, 0 for never)")
77+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
78+
79+ _ = cmd.MarkFlagRequired("subject")
80+
81+ return cmd
82+}
83+
84+// newTokenListCmd creates the token list command.
85+func newTokenListCmd() *cobra.Command {
86+ var configPath string
87+
88+ cmd := &cobra.Command{
89+ Use: "list",
90+ Short: "List all authentication tokens",
91+ Long: `List all stored authentication tokens with their subjects and expiry information.`,
92+ RunE: func(cmd *cobra.Command, args []string) error {
93+ return runTokenList(configPath)
94+ },
95+ }
96+
97+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
98+
99+ return cmd
100+}
101+
102+// newTokenRevokeCmd creates the token revoke command.
103+func newTokenRevokeCmd() *cobra.Command {
104+ var configPath string
105+
106+ cmd := &cobra.Command{
107+ Use: "revoke <token-id>",
108+ Short: "Revoke an authentication token",
109+ Long: `Revoke a token by its ID. The token will no longer be valid for authentication.`,
110+ Args: cobra.ExactArgs(1),
111+ RunE: func(cmd *cobra.Command, args []string) error {
112+ return runTokenRevoke(args[0], configPath)
113+ },
114+ }
115+
116+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
117+
118+ return cmd
119+}
120+
121+// newAuthPasswordCmd creates the password subcommand group.
122+func newAuthPasswordCmd() *cobra.Command {
123+ cmd := &cobra.Command{
124+ Use: "password",
125+ Short: "Password utilities",
126+ Long: `Utilities for managing password authentication.`,
127+ }
128+
129+ cmd.AddCommand(newPasswordHashCmd())
130+
131+ return cmd
132+}
133+
134+// newPasswordHashCmd creates the password hash command.
135+func newPasswordHashCmd() *cobra.Command {
136+ return &cobra.Command{
137+ Use: "hash",
138+ Short: "Generate a bcrypt hash for a password",
139+ Long: `Interactively prompt for a password and output its bcrypt hash.
140+
141+The hash can be stored in your secrets backend and referenced in the config:
142+
143+ [registry_auth]
144+ type = "password"
145+ password_hash = "gordon/registry/password_hash"`,
146+ RunE: func(cmd *cobra.Command, args []string) error {
147+ return runPasswordHash()
148+ },
149+ }
150+}
151+
152+// runTokenGenerate generates a new authentication token.
153+func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
154+ cfg, err := loadAuthConfig(configPath)
155+ if err != nil {
156+ return err
157+ }
158+
159+ // Parse expiry
160+ var expiry time.Duration
161+ if expiryStr != "0" {
162+ expiry, err = time.ParseDuration(expiryStr)
163+ if err != nil {
164+ return fmt.Errorf("invalid expiry duration: %w", err)
165+ }
166+ }
167+
168+ // Parse scopes
169+ scopes := strings.Split(scopesStr, ",")
170+ for i := range scopes {
171+ scopes[i] = strings.TrimSpace(scopes[i])
172+ }
173+
174+ // Create auth service
175+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
176+ authSvc, err := createAuthServiceForCLI(cfg, log)
177+ if err != nil {
178+ return err
179+ }
180+
181+ // Generate token
182+ ctx := context.Background()
183+ token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
184+ if err != nil {
185+ return fmt.Errorf("failed to generate token: %w", err)
186+ }
187+
188+ fmt.Println("Token generated successfully!")
189+ fmt.Printf("Subject: %s\n", subject)
190+ fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
191+ if expiry == 0 {
192+ fmt.Println("Expiry: never")
193+ } else {
194+ fmt.Printf("Expiry: %s\n", expiry)
195+ }
196+ fmt.Println()
197+ fmt.Println("Token (use as password with docker login):")
198+ fmt.Println(token)
199+ fmt.Println()
200+ fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
201+
202+ return nil
203+}
204+
205+// runTokenList lists all stored tokens.
206+func runTokenList(configPath string) error {
207+ cfg, err := loadAuthConfig(configPath)
208+ if err != nil {
209+ return err
210+ }
211+
212+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
213+ authSvc, err := createAuthServiceForCLI(cfg, log)
214+ if err != nil {
215+ return err
216+ }
217+
218+ ctx := context.Background()
219+ tokens, err := authSvc.ListTokens(ctx)
220+ if err != nil {
221+ return fmt.Errorf("failed to list tokens: %w", err)
222+ }
223+
224+ if len(tokens) == 0 {
225+ fmt.Println("No tokens found.")
226+ return nil
227+ }
228+
229+ fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
230+ fmt.Println(strings.Repeat("-", 90))
231+
232+ for _, t := range tokens {
233+ expiry := "never"
234+ if !t.ExpiresAt.IsZero() {
235+ expiry = t.ExpiresAt.Format("2006-01-02 15:04")
236+ }
237+ revoked := "no"
238+ if t.Revoked {
239+ revoked = "yes"
240+ }
241+ fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
242+ }
243+
244+ return nil
245+}
246+
247+// runTokenRevoke revokes a token by ID.
248+func runTokenRevoke(tokenID, configPath string) error {
249+ cfg, err := loadAuthConfig(configPath)
250+ if err != nil {
251+ return err
252+ }
253+
254+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
255+ authSvc, err := createAuthServiceForCLI(cfg, log)
256+ if err != nil {
257+ return err
258+ }
259+
260+ ctx := context.Background()
261+ if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
262+ return fmt.Errorf("failed to revoke token: %w", err)
263+ }
264+
265+ fmt.Printf("Token %s has been revoked.\n", tokenID)
266+ return nil
267+}
268+
269+// runPasswordHash generates a bcrypt hash for a password.
270+func runPasswordHash() error {
271+ fmt.Print("Enter password: ")
272+
273+ // Read password without echo
274+ passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
275+ if err != nil {
276+ // Fallback for non-terminal input
277+ reader := bufio.NewReader(os.Stdin)
278+ password, err := reader.ReadString('\n')
279+ if err != nil {
280+ return fmt.Errorf("failed to read password: %w", err)
281+ }
282+ passwordBytes = []byte(strings.TrimSpace(password))
283+ }
284+ fmt.Println()
285+
286+ if len(passwordBytes) == 0 {
287+ return fmt.Errorf("password cannot be empty")
288+ }
289+
290+ // Create a temporary auth service to generate the hash
291+ authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
292+ hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
293+ if err != nil {
294+ return fmt.Errorf("failed to generate hash: %w", err)
295+ }
296+
297+ fmt.Println()
298+ fmt.Println("Bcrypt hash (store in your secrets backend):")
299+ fmt.Println(hash)
300+ fmt.Println()
301+ fmt.Println("Then reference the path in your config:")
302+ fmt.Println(" [registry_auth]")
303+ fmt.Println(" type = \"password\"")
304+ fmt.Println(" password_hash = \"gordon/registry/password_hash\"")
305+
306+ return nil
307+}
308+
309+// cliConfig holds the configuration needed for CLI commands.
310+type cliConfig struct {
311+ Backend domain.SecretsBackend
312+ DataDir string
313+ TokenSecret []byte
314+}
315+
316+// loadAuthConfig loads the configuration needed for auth CLI commands.
317+func loadAuthConfig(configPath string) (*cliConfig, error) {
318+ v := viper.New()
319+
320+ // Set defaults
321+ v.SetDefault("server.data_dir", "/var/lib/gordon")
322+ v.SetDefault("secrets.backend", "unsafe")
323+
324+ if configPath != "" {
325+ v.SetConfigFile(configPath)
326+ } else {
327+ v.SetConfigName("gordon")
328+ v.SetConfigType("yaml")
329+ v.AddConfigPath("/etc/gordon")
330+ v.AddConfigPath("$HOME/.gordon")
331+ v.AddConfigPath(".")
332+ }
333+
334+ if err := v.ReadInConfig(); err != nil {
335+ if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
336+ return nil, fmt.Errorf("failed to read config file: %w", err)
337+ }
338+ // Config file not found is OK for CLI commands
339+ }
340+
341+ cfg := &cliConfig{
342+ DataDir: v.GetString("server.data_dir"),
343+ }
344+
345+ // Determine backend
346+ switch v.GetString("secrets.backend") {
347+ case "pass":
348+ cfg.Backend = domain.SecretsBackendPass
349+ case "sops":
350+ cfg.Backend = domain.SecretsBackendSops
351+ default:
352+ cfg.Backend = domain.SecretsBackendUnsafe
353+ }
354+
355+ // Load token secret if specified
356+ tokenSecretPath := v.GetString("registry_auth.token_secret")
357+ if tokenSecretPath != "" {
358+ // For CLI, we generate a default secret if not available
359+ // In production, the secret should be loaded from the secrets backend
360+ cfg.TokenSecret = []byte(tokenSecretPath) // Use path as seed for now
361+ } else {
362+ // Generate a default secret for CLI token generation
363+ cfg.TokenSecret = []byte("gordon-cli-default-secret")
364+ }
365+
366+ return cfg, nil
367+}
368+
369+// createAuthServiceForCLI creates an auth service for CLI commands.
370+func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
371+ // Create token store
372+ store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
373+ if err != nil {
374+ return nil, fmt.Errorf("failed to create token store: %w", err)
375+ }
376+
377+ // Create auth service config
378+ authConfig := auth.Config{
379+ Enabled: true,
380+ AuthType: domain.AuthTypeToken,
381+ TokenSecret: cfg.TokenSecret,
382+ }
383+
384+ return auth.NewService(authConfig, store, log), nil
385+}
@@ -0,0 +1,385 @@
1+package cli
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "os"
8+ "strings"
9+ "syscall"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/spf13/cobra"
14+ "github.com/spf13/viper"
15+ "golang.org/x/term"
16+
17+ "gordon/internal/adapters/out/tokenstore"
18+ "gordon/internal/domain"
19+ "gordon/internal/usecase/auth"
20+)
21+
22+// newAuthCmd creates the auth command group.
23+func newAuthCmd() *cobra.Command {
24+ cmd := &cobra.Command{
25+ Use: "auth",
26+ Short: "Manage registry authentication",
27+ Long: `Commands for managing registry authentication tokens and passwords.`,
28+ }
29+
30+ cmd.AddCommand(newAuthTokenCmd())
31+ cmd.AddCommand(newAuthPasswordCmd())
32+
33+ return cmd
34+}
35+
36+// newAuthTokenCmd creates the token subcommand group.
37+func newAuthTokenCmd() *cobra.Command {
38+ cmd := &cobra.Command{
39+ Use: "token",
40+ Short: "Manage authentication tokens",
41+ Long: `Commands for generating, listing, and revoking authentication tokens.`,
42+ }
43+
44+ cmd.AddCommand(newTokenGenerateCmd())
45+ cmd.AddCommand(newTokenListCmd())
46+ cmd.AddCommand(newTokenRevokeCmd())
47+
48+ return cmd
49+}
50+
51+// newTokenGenerateCmd creates the token generate command.
52+func newTokenGenerateCmd() *cobra.Command {
53+ var (
54+ subject string
55+ scopes string
56+ expiry string
57+ configPath string
58+ )
59+
60+ cmd := &cobra.Command{
61+ Use: "generate",
62+ Short: "Generate a new authentication token",
63+ Long: `Generate a new JWT authentication token for registry access.
64+
65+The token will be stored in the configured secrets backend and can be used
66+for docker login as: docker login -u <subject> -p <token>
67+
68+Use --expiry=0 to generate a token that never expires (useful for CI).`,
69+ RunE: func(cmd *cobra.Command, args []string) error {
70+ return runTokenGenerate(subject, scopes, expiry, configPath)
71+ },
72+ }
73+
74+ cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
75+ cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
76+ cmd.Flags().StringVar(&expiry, "expiry", "720h", "Token expiry duration (e.g., 720h, 24h, 0 for never)")
77+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
78+
79+ _ = cmd.MarkFlagRequired("subject")
80+
81+ return cmd
82+}
83+
84+// newTokenListCmd creates the token list command.
85+func newTokenListCmd() *cobra.Command {
86+ var configPath string
87+
88+ cmd := &cobra.Command{
89+ Use: "list",
90+ Short: "List all authentication tokens",
91+ Long: `List all stored authentication tokens with their subjects and expiry information.`,
92+ RunE: func(cmd *cobra.Command, args []string) error {
93+ return runTokenList(configPath)
94+ },
95+ }
96+
97+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
98+
99+ return cmd
100+}
101+
102+// newTokenRevokeCmd creates the token revoke command.
103+func newTokenRevokeCmd() *cobra.Command {
104+ var configPath string
105+
106+ cmd := &cobra.Command{
107+ Use: "revoke <token-id>",
108+ Short: "Revoke an authentication token",
109+ Long: `Revoke a token by its ID. The token will no longer be valid for authentication.`,
110+ Args: cobra.ExactArgs(1),
111+ RunE: func(cmd *cobra.Command, args []string) error {
112+ return runTokenRevoke(args[0], configPath)
113+ },
114+ }
115+
116+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
117+
118+ return cmd
119+}
120+
121+// newAuthPasswordCmd creates the password subcommand group.
122+func newAuthPasswordCmd() *cobra.Command {
123+ cmd := &cobra.Command{
124+ Use: "password",
125+ Short: "Password utilities",
126+ Long: `Utilities for managing password authentication.`,
127+ }
128+
129+ cmd.AddCommand(newPasswordHashCmd())
130+
131+ return cmd
132+}
133+
134+// newPasswordHashCmd creates the password hash command.
135+func newPasswordHashCmd() *cobra.Command {
136+ return &cobra.Command{
137+ Use: "hash",
138+ Short: "Generate a bcrypt hash for a password",
139+ Long: `Interactively prompt for a password and output its bcrypt hash.
140+
141+The hash can be stored in your secrets backend and referenced in the config:
142+
143+ [registry_auth]
144+ type = "password"
145+ password_hash = "gordon/registry/password_hash"`,
146+ RunE: func(cmd *cobra.Command, args []string) error {
147+ return runPasswordHash()
148+ },
149+ }
150+}
151+
152+// runTokenGenerate generates a new authentication token.
153+func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
154+ cfg, err := loadAuthConfig(configPath)
155+ if err != nil {
156+ return err
157+ }
158+
159+ // Parse expiry
160+ var expiry time.Duration
161+ if expiryStr != "0" {
162+ expiry, err = time.ParseDuration(expiryStr)
163+ if err != nil {
164+ return fmt.Errorf("invalid expiry duration: %w", err)
165+ }
166+ }
167+
168+ // Parse scopes
169+ scopes := strings.Split(scopesStr, ",")
170+ for i := range scopes {
171+ scopes[i] = strings.TrimSpace(scopes[i])
172+ }
173+
174+ // Create auth service
175+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
176+ authSvc, err := createAuthServiceForCLI(cfg, log)
177+ if err != nil {
178+ return err
179+ }
180+
181+ // Generate token
182+ ctx := context.Background()
183+ token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
184+ if err != nil {
185+ return fmt.Errorf("failed to generate token: %w", err)
186+ }
187+
188+ fmt.Println("Token generated successfully!")
189+ fmt.Printf("Subject: %s\n", subject)
190+ fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
191+ if expiry == 0 {
192+ fmt.Println("Expiry: never")
193+ } else {
194+ fmt.Printf("Expiry: %s\n", expiry)
195+ }
196+ fmt.Println()
197+ fmt.Println("Token (use as password with docker login):")
198+ fmt.Println(token)
199+ fmt.Println()
200+ fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
201+
202+ return nil
203+}
204+
205+// runTokenList lists all stored tokens.
206+func runTokenList(configPath string) error {
207+ cfg, err := loadAuthConfig(configPath)
208+ if err != nil {
209+ return err
210+ }
211+
212+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
213+ authSvc, err := createAuthServiceForCLI(cfg, log)
214+ if err != nil {
215+ return err
216+ }
217+
218+ ctx := context.Background()
219+ tokens, err := authSvc.ListTokens(ctx)
220+ if err != nil {
221+ return fmt.Errorf("failed to list tokens: %w", err)
222+ }
223+
224+ if len(tokens) == 0 {
225+ fmt.Println("No tokens found.")
226+ return nil
227+ }
228+
229+ fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
230+ fmt.Println(strings.Repeat("-", 90))
231+
232+ for _, t := range tokens {
233+ expiry := "never"
234+ if !t.ExpiresAt.IsZero() {
235+ expiry = t.ExpiresAt.Format("2006-01-02 15:04")
236+ }
237+ revoked := "no"
238+ if t.Revoked {
239+ revoked = "yes"
240+ }
241+ fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
242+ }
243+
244+ return nil
245+}
246+
247+// runTokenRevoke revokes a token by ID.
248+func runTokenRevoke(tokenID, configPath string) error {
249+ cfg, err := loadAuthConfig(configPath)
250+ if err != nil {
251+ return err
252+ }
253+
254+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
255+ authSvc, err := createAuthServiceForCLI(cfg, log)
256+ if err != nil {
257+ return err
258+ }
259+
260+ ctx := context.Background()
261+ if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
262+ return fmt.Errorf("failed to revoke token: %w", err)
263+ }
264+
265+ fmt.Printf("Token %s has been revoked.\n", tokenID)
266+ return nil
267+}
268+
269+// runPasswordHash generates a bcrypt hash for a password.
270+func runPasswordHash() error {
271+ fmt.Print("Enter password: ")
272+
273+ // Read password without echo
274+ passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
275+ if err != nil {
276+ // Fallback for non-terminal input
277+ reader := bufio.NewReader(os.Stdin)
278+ password, err := reader.ReadString('\n')
279+ if err != nil {
280+ return fmt.Errorf("failed to read password: %w", err)
281+ }
282+ passwordBytes = []byte(strings.TrimSpace(password))
283+ }
284+ fmt.Println()
285+
286+ if len(passwordBytes) == 0 {
287+ return fmt.Errorf("password cannot be empty")
288+ }
289+
290+ // Create a temporary auth service to generate the hash
291+ authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
292+ hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
293+ if err != nil {
294+ return fmt.Errorf("failed to generate hash: %w", err)
295+ }
296+
297+ fmt.Println()
298+ fmt.Println("Bcrypt hash (store in your secrets backend):")
299+ fmt.Println(hash)
300+ fmt.Println()
301+ fmt.Println("Then reference the path in your config:")
302+ fmt.Println(" [registry_auth]")
303+ fmt.Println(" type = \"password\"")
304+ fmt.Println(" password_hash = \"gordon/registry/password_hash\"")
305+
306+ return nil
307+}
308+
309+// cliConfig holds the configuration needed for CLI commands.
310+type cliConfig struct {
311+ Backend domain.SecretsBackend
312+ DataDir string
313+ TokenSecret []byte
314+}
315+
316+// loadAuthConfig loads the configuration needed for auth CLI commands.
317+func loadAuthConfig(configPath string) (*cliConfig, error) {
318+ v := viper.New()
319+
320+ // Set defaults
321+ v.SetDefault("server.data_dir", "/var/lib/gordon")
322+ v.SetDefault("secrets.backend", "unsafe")
323+
324+ if configPath != "" {
325+ v.SetConfigFile(configPath)
326+ } else {
327+ v.SetConfigName("gordon")
328+ v.SetConfigType("yaml")
329+ v.AddConfigPath("/etc/gordon")
330+ v.AddConfigPath("$HOME/.gordon")
331+ v.AddConfigPath(".")
332+ }
333+
334+ if err := v.ReadInConfig(); err != nil {
335+ if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
336+ return nil, fmt.Errorf("failed to read config file: %w", err)
337+ }
338+ // Config file not found is OK for CLI commands
339+ }
340+
341+ cfg := &cliConfig{
342+ DataDir: v.GetString("server.data_dir"),
343+ }
344+
345+ // Determine backend
346+ switch v.GetString("secrets.backend") {
347+ case "pass":
348+ cfg.Backend = domain.SecretsBackendPass
349+ case "sops":
350+ cfg.Backend = domain.SecretsBackendSops
351+ default:
352+ cfg.Backend = domain.SecretsBackendUnsafe
353+ }
354+
355+ // Load token secret if specified
356+ tokenSecretPath := v.GetString("registry_auth.token_secret")
357+ if tokenSecretPath != "" {
358+ // For CLI, we generate a default secret if not available
359+ // In production, the secret should be loaded from the secrets backend
360+ cfg.TokenSecret = []byte(tokenSecretPath) // Use path as seed for now
361+ } else {
362+ // Generate a default secret for CLI token generation
363+ cfg.TokenSecret = []byte("gordon-cli-default-secret")
364+ }
365+
366+ return cfg, nil
367+}
368+
369+// createAuthServiceForCLI creates an auth service for CLI commands.
370+func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
371+ // Create token store
372+ store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
373+ if err != nil {
374+ return nil, fmt.Errorf("failed to create token store: %w", err)
375+ }
376+
377+ // Create auth service config
378+ authConfig := auth.Config{
379+ Enabled: true,
380+ AuthType: domain.AuthTypeToken,
381+ TokenSecret: cfg.TokenSecret,
382+ }
383+
384+ return auth.NewService(authConfig, store, log), nil
385+}
M internal/adapters/in/http/middleware/auth.go +172 -0
@@ -1,10 +1,15 @@
11 package middleware
22
33 import (
4+ "context"
45 "crypto/subtle"
56 "net/http"
7+ "strings"
68
79 "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/boundaries/in"
12+ "gordon/internal/domain"
813 )
914
1015 // RegistryAuth middleware provides Docker Registry authentication.
@@ -73,3 +78,170 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
7378
7479 return authenticated
7580 }
81+
82+// RegistryAuthV2 middleware provides enhanced Docker Registry authentication
83+// supporting both password and token-based authentication.
84+func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {
85+ return func(next http.Handler) http.Handler {
86+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+ // Warn if not using TLS
88+ if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
89+ log.Warn().
90+ Str(zerowrap.FieldLayer, "adapter").
91+ Str(zerowrap.FieldAdapter, "http").
92+ Str(zerowrap.FieldMethod, r.Method).
93+ Str(zerowrap.FieldPath, r.URL.Path).
94+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
95+ Msg("registry auth over insecure HTTP connection")
96+ }
97+
98+ // Check if auth is enabled
99+ if !authSvc.IsEnabled() {
100+ next.ServeHTTP(w, r)
101+ return
102+ }
103+
104+ ctx := r.Context()
105+
106+ // Authenticate based on auth type
107+ var authenticated bool
108+ switch authSvc.GetAuthType() {
109+ case domain.AuthTypePassword:
110+ authenticated = authenticatePassword(ctx, r, authSvc, log)
111+ case domain.AuthTypeToken:
112+ authenticated = authenticateToken(ctx, r, authSvc, log)
113+ default:
114+ log.Error().
115+ Str("auth_type", string(authSvc.GetAuthType())).
116+ Msg("unknown auth type")
117+ authenticated = false
118+ }
119+
120+ if !authenticated {
121+ sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
122+ return
123+ }
124+
125+ next.ServeHTTP(w, r)
126+ })
127+ }
128+}
129+
130+// authenticatePassword handles password-based authentication.
131+func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
132+ username, password, ok := r.BasicAuth()
133+ if !ok {
134+ log.Debug().
135+ Str(zerowrap.FieldMethod, r.Method).
136+ Str(zerowrap.FieldPath, r.URL.Path).
137+ Msg("no basic auth provided")
138+ return false
139+ }
140+
141+ if authSvc.ValidatePassword(ctx, username, password) {
142+ log.Debug().
143+ Str("username", username).
144+ Str(zerowrap.FieldMethod, r.Method).
145+ Str(zerowrap.FieldPath, r.URL.Path).
146+ Msg("password authentication successful")
147+ return true
148+ }
149+
150+ log.Debug().
151+ Str("provided_username", username).
152+ Str(zerowrap.FieldMethod, r.Method).
153+ Str(zerowrap.FieldPath, r.URL.Path).
154+ Msg("password authentication failed")
155+ return false
156+}
157+
158+// authenticateToken handles token-based authentication.
159+// It supports both Bearer token in Authorization header and token-as-password for CI.
160+func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
161+ // First, check for Bearer token
162+ authHeader := r.Header.Get("Authorization")
163+ if strings.HasPrefix(authHeader, "Bearer ") {
164+ token := strings.TrimPrefix(authHeader, "Bearer ")
165+ claims, err := authSvc.ValidateToken(ctx, token)
166+ if err != nil {
167+ log.Debug().
168+ Err(err).
169+ Str(zerowrap.FieldMethod, r.Method).
170+ Str(zerowrap.FieldPath, r.URL.Path).
171+ Msg("bearer token validation failed")
172+ return false
173+ }
174+
175+ log.Debug().
176+ Str("subject", claims.Subject).
177+ Str(zerowrap.FieldMethod, r.Method).
178+ Str(zerowrap.FieldPath, r.URL.Path).
179+ Msg("bearer token authentication successful")
180+ return true
181+ }
182+
183+ // Fall back to token-as-password (for CI/automation)
184+ // Username is the subject, password is the JWT token
185+ username, password, ok := r.BasicAuth()
186+ if !ok {
187+ log.Debug().
188+ Str(zerowrap.FieldMethod, r.Method).
189+ Str(zerowrap.FieldPath, r.URL.Path).
190+ Msg("no auth credentials provided")
191+ return false
192+ }
193+
194+ // Try to validate the password as a JWT token
195+ claims, err := authSvc.ValidateToken(ctx, password)
196+ if err != nil {
197+ log.Debug().
198+ Err(err).
199+ Str("provided_username", username).
200+ Str(zerowrap.FieldMethod, r.Method).
201+ Str(zerowrap.FieldPath, r.URL.Path).
202+ Msg("token-as-password validation failed")
203+ return false
204+ }
205+
206+ // Verify the username matches the token subject
207+ if claims.Subject != username {
208+ log.Debug().
209+ Str("provided_username", username).
210+ Str("token_subject", claims.Subject).
211+ Str(zerowrap.FieldMethod, r.Method).
212+ Str(zerowrap.FieldPath, r.URL.Path).
213+ Msg("username does not match token subject")
214+ return false
215+ }
216+
217+ log.Debug().
218+ Str("subject", claims.Subject).
219+ Str(zerowrap.FieldMethod, r.Method).
220+ Str(zerowrap.FieldPath, r.URL.Path).
221+ Msg("token-as-password authentication successful")
222+ return true
223+}
224+
225+// sendUnauthorized sends an HTTP 401 response with appropriate headers.
226+func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
227+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
228+
229+ switch authType {
230+ case domain.AuthTypeToken:
231+ // For token auth, indicate the token server endpoint
232+ realm := "https://" + host + "/v2/token"
233+ w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
234+ default:
235+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
236+ }
237+
238+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
239+
240+ log.Warn().
241+ Str(zerowrap.FieldLayer, "adapter").
242+ Str(zerowrap.FieldAdapter, "http").
243+ Str(zerowrap.FieldMethod, r.Method).
244+ Str(zerowrap.FieldPath, r.URL.Path).
245+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
246+ Msg("unauthorized registry access attempt")
247+}
@@ -1,10 +1,15 @@
1 package middleware1 package middleware
2 2
3 import (3 import (
4+ "context"
4 "crypto/subtle"5 "crypto/subtle"
5 "net/http"6 "net/http"
7+ "strings"
6 8
7 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/boundaries/in"
12+ "gordon/internal/domain"
8 )13 )
9 14
10 // RegistryAuth middleware provides Docker Registry authentication.15 // RegistryAuth middleware provides Docker Registry authentication.
@@ -73,3 +78,170 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
73 78
74 return authenticated79 return authenticated
75 }80 }
81+
82+// RegistryAuthV2 middleware provides enhanced Docker Registry authentication
83+// supporting both password and token-based authentication.
84+func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {
85+ return func(next http.Handler) http.Handler {
86+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+ // Warn if not using TLS
88+ if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
89+ log.Warn().
90+ Str(zerowrap.FieldLayer, "adapter").
91+ Str(zerowrap.FieldAdapter, "http").
92+ Str(zerowrap.FieldMethod, r.Method).
93+ Str(zerowrap.FieldPath, r.URL.Path).
94+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
95+ Msg("registry auth over insecure HTTP connection")
96+ }
97+
98+ // Check if auth is enabled
99+ if !authSvc.IsEnabled() {
100+ next.ServeHTTP(w, r)
101+ return
102+ }
103+
104+ ctx := r.Context()
105+
106+ // Authenticate based on auth type
107+ var authenticated bool
108+ switch authSvc.GetAuthType() {
109+ case domain.AuthTypePassword:
110+ authenticated = authenticatePassword(ctx, r, authSvc, log)
111+ case domain.AuthTypeToken:
112+ authenticated = authenticateToken(ctx, r, authSvc, log)
113+ default:
114+ log.Error().
115+ Str("auth_type", string(authSvc.GetAuthType())).
116+ Msg("unknown auth type")
117+ authenticated = false
118+ }
119+
120+ if !authenticated {
121+ sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
122+ return
123+ }
124+
125+ next.ServeHTTP(w, r)
126+ })
127+ }
128+}
129+
130+// authenticatePassword handles password-based authentication.
131+func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
132+ username, password, ok := r.BasicAuth()
133+ if !ok {
134+ log.Debug().
135+ Str(zerowrap.FieldMethod, r.Method).
136+ Str(zerowrap.FieldPath, r.URL.Path).
137+ Msg("no basic auth provided")
138+ return false
139+ }
140+
141+ if authSvc.ValidatePassword(ctx, username, password) {
142+ log.Debug().
143+ Str("username", username).
144+ Str(zerowrap.FieldMethod, r.Method).
145+ Str(zerowrap.FieldPath, r.URL.Path).
146+ Msg("password authentication successful")
147+ return true
148+ }
149+
150+ log.Debug().
151+ Str("provided_username", username).
152+ Str(zerowrap.FieldMethod, r.Method).
153+ Str(zerowrap.FieldPath, r.URL.Path).
154+ Msg("password authentication failed")
155+ return false
156+}
157+
158+// authenticateToken handles token-based authentication.
159+// It supports both Bearer token in Authorization header and token-as-password for CI.
160+func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
161+ // First, check for Bearer token
162+ authHeader := r.Header.Get("Authorization")
163+ if strings.HasPrefix(authHeader, "Bearer ") {
164+ token := strings.TrimPrefix(authHeader, "Bearer ")
165+ claims, err := authSvc.ValidateToken(ctx, token)
166+ if err != nil {
167+ log.Debug().
168+ Err(err).
169+ Str(zerowrap.FieldMethod, r.Method).
170+ Str(zerowrap.FieldPath, r.URL.Path).
171+ Msg("bearer token validation failed")
172+ return false
173+ }
174+
175+ log.Debug().
176+ Str("subject", claims.Subject).
177+ Str(zerowrap.FieldMethod, r.Method).
178+ Str(zerowrap.FieldPath, r.URL.Path).
179+ Msg("bearer token authentication successful")
180+ return true
181+ }
182+
183+ // Fall back to token-as-password (for CI/automation)
184+ // Username is the subject, password is the JWT token
185+ username, password, ok := r.BasicAuth()
186+ if !ok {
187+ log.Debug().
188+ Str(zerowrap.FieldMethod, r.Method).
189+ Str(zerowrap.FieldPath, r.URL.Path).
190+ Msg("no auth credentials provided")
191+ return false
192+ }
193+
194+ // Try to validate the password as a JWT token
195+ claims, err := authSvc.ValidateToken(ctx, password)
196+ if err != nil {
197+ log.Debug().
198+ Err(err).
199+ Str("provided_username", username).
200+ Str(zerowrap.FieldMethod, r.Method).
201+ Str(zerowrap.FieldPath, r.URL.Path).
202+ Msg("token-as-password validation failed")
203+ return false
204+ }
205+
206+ // Verify the username matches the token subject
207+ if claims.Subject != username {
208+ log.Debug().
209+ Str("provided_username", username).
210+ Str("token_subject", claims.Subject).
211+ Str(zerowrap.FieldMethod, r.Method).
212+ Str(zerowrap.FieldPath, r.URL.Path).
213+ Msg("username does not match token subject")
214+ return false
215+ }
216+
217+ log.Debug().
218+ Str("subject", claims.Subject).
219+ Str(zerowrap.FieldMethod, r.Method).
220+ Str(zerowrap.FieldPath, r.URL.Path).
221+ Msg("token-as-password authentication successful")
222+ return true
223+}
224+
225+// sendUnauthorized sends an HTTP 401 response with appropriate headers.
226+func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
227+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
228+
229+ switch authType {
230+ case domain.AuthTypeToken:
231+ // For token auth, indicate the token server endpoint
232+ realm := "https://" + host + "/v2/token"
233+ w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
234+ default:
235+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
236+ }
237+
238+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
239+
240+ log.Warn().
241+ Str(zerowrap.FieldLayer, "adapter").
242+ Str(zerowrap.FieldAdapter, "http").
243+ Str(zerowrap.FieldMethod, r.Method).
244+ Str(zerowrap.FieldPath, r.URL.Path).
245+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
246+ Msg("unauthorized registry access attempt")
247+}
A internal/adapters/in/http/registry/token.go +139 -0
@@ -0,0 +1,139 @@
1+package registry
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "gordon/internal/boundaries/in"
11+ "gordon/internal/domain"
12+)
13+
14+// TokenResponse represents the response from the token server.
15+type TokenResponse struct {
16+ Token string `json:"token"`
17+ AccessToken string `json:"access_token,omitempty"`
18+ ExpiresIn int `json:"expires_in,omitempty"`
19+ IssuedAt string `json:"issued_at,omitempty"`
20+}
21+
22+// TokenHandler handles token server requests for Docker Registry authentication.
23+type TokenHandler struct {
24+ authSvc in.AuthService
25+ log zerowrap.Logger
26+}
27+
28+// NewTokenHandler creates a new token handler.
29+func NewTokenHandler(authSvc in.AuthService, log zerowrap.Logger) *TokenHandler {
30+ return &TokenHandler{
31+ authSvc: authSvc,
32+ log: log,
33+ }
34+}
35+
36+// ServeHTTP implements http.Handler for the token endpoint.
37+func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
38+ h.handleToken(w, r)
39+}
40+
41+// handleToken handles GET /v2/token requests.
42+// This endpoint issues short-lived access tokens per the Docker Registry auth spec.
43+func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
44+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
45+ zerowrap.FieldLayer: "adapter",
46+ zerowrap.FieldAdapter: "http",
47+ zerowrap.FieldHandler: "token",
48+ zerowrap.FieldMethod: r.Method,
49+ zerowrap.FieldPath: r.URL.Path,
50+ })
51+ log := zerowrap.FromCtx(ctx)
52+
53+ if r.Method != http.MethodGet {
54+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
55+ return
56+ }
57+
58+ // Check if auth is enabled
59+ if h.authSvc == nil || !h.authSvc.IsEnabled() {
60+ // If auth is disabled, return an anonymous token
61+ h.sendAnonymousToken(w, log)
62+ return
63+ }
64+
65+ // Get credentials from Basic Auth
66+ username, password, ok := r.BasicAuth()
67+ if !ok {
68+ // No credentials provided - return anonymous token with limited scope
69+ h.sendAnonymousToken(w, log)
70+ return
71+ }
72+
73+ // Validate credentials based on auth type
74+ var authenticated bool
75+ switch h.authSvc.GetAuthType() {
76+ case domain.AuthTypePassword:
77+ authenticated = h.authSvc.ValidatePassword(ctx, username, password)
78+ case domain.AuthTypeToken:
79+ // For token auth, the password might be an existing JWT
80+ claims, err := h.authSvc.ValidateToken(ctx, password)
81+ if err == nil && claims.Subject == username {
82+ authenticated = true
83+ }
84+ }
85+
86+ if !authenticated {
87+ log.Debug().
88+ Str("username", username).
89+ Msg("token request authentication failed")
90+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
91+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
92+ return
93+ }
94+
95+ // Generate a short-lived access token (5 minutes)
96+ accessToken, err := h.authSvc.GenerateToken(ctx, username, []string{"push", "pull"}, 5*time.Minute)
97+ if err != nil {
98+ log.Error().Err(err).Msg("failed to generate access token")
99+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
100+ return
101+ }
102+
103+ response := TokenResponse{
104+ Token: accessToken,
105+ ExpiresIn: 300, // 5 minutes in seconds
106+ IssuedAt: time.Now().Format(time.RFC3339),
107+ }
108+
109+ w.Header().Set("Content-Type", "application/json")
110+ w.WriteHeader(http.StatusOK)
111+
112+ if err := json.NewEncoder(w).Encode(response); err != nil {
113+ log.Error().Err(err).Msg("failed to encode token response")
114+ }
115+
116+ log.Debug().
117+ Str("username", username).
118+ Int("expires_in", response.ExpiresIn).
119+ Msg("access token issued")
120+}
121+
122+// sendAnonymousToken sends a token for anonymous/unauthenticated access.
123+func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
124+ // For anonymous access, we issue a very short-lived token with limited scope
125+ response := TokenResponse{
126+ Token: "", // Empty token indicates limited access
127+ ExpiresIn: 60, // 1 minute
128+ IssuedAt: time.Now().Format(time.RFC3339),
129+ }
130+
131+ w.Header().Set("Content-Type", "application/json")
132+ w.WriteHeader(http.StatusOK)
133+
134+ if err := json.NewEncoder(w).Encode(response); err != nil {
135+ log.Error().Err(err).Msg("failed to encode anonymous token response")
136+ }
137+
138+ log.Debug().Msg("anonymous token issued")
139+}
@@ -0,0 +1,139 @@
1+package registry
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "gordon/internal/boundaries/in"
11+ "gordon/internal/domain"
12+)
13+
14+// TokenResponse represents the response from the token server.
15+type TokenResponse struct {
16+ Token string `json:"token"`
17+ AccessToken string `json:"access_token,omitempty"`
18+ ExpiresIn int `json:"expires_in,omitempty"`
19+ IssuedAt string `json:"issued_at,omitempty"`
20+}
21+
22+// TokenHandler handles token server requests for Docker Registry authentication.
23+type TokenHandler struct {
24+ authSvc in.AuthService
25+ log zerowrap.Logger
26+}
27+
28+// NewTokenHandler creates a new token handler.
29+func NewTokenHandler(authSvc in.AuthService, log zerowrap.Logger) *TokenHandler {
30+ return &TokenHandler{
31+ authSvc: authSvc,
32+ log: log,
33+ }
34+}
35+
36+// ServeHTTP implements http.Handler for the token endpoint.
37+func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
38+ h.handleToken(w, r)
39+}
40+
41+// handleToken handles GET /v2/token requests.
42+// This endpoint issues short-lived access tokens per the Docker Registry auth spec.
43+func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
44+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
45+ zerowrap.FieldLayer: "adapter",
46+ zerowrap.FieldAdapter: "http",
47+ zerowrap.FieldHandler: "token",
48+ zerowrap.FieldMethod: r.Method,
49+ zerowrap.FieldPath: r.URL.Path,
50+ })
51+ log := zerowrap.FromCtx(ctx)
52+
53+ if r.Method != http.MethodGet {
54+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
55+ return
56+ }
57+
58+ // Check if auth is enabled
59+ if h.authSvc == nil || !h.authSvc.IsEnabled() {
60+ // If auth is disabled, return an anonymous token
61+ h.sendAnonymousToken(w, log)
62+ return
63+ }
64+
65+ // Get credentials from Basic Auth
66+ username, password, ok := r.BasicAuth()
67+ if !ok {
68+ // No credentials provided - return anonymous token with limited scope
69+ h.sendAnonymousToken(w, log)
70+ return
71+ }
72+
73+ // Validate credentials based on auth type
74+ var authenticated bool
75+ switch h.authSvc.GetAuthType() {
76+ case domain.AuthTypePassword:
77+ authenticated = h.authSvc.ValidatePassword(ctx, username, password)
78+ case domain.AuthTypeToken:
79+ // For token auth, the password might be an existing JWT
80+ claims, err := h.authSvc.ValidateToken(ctx, password)
81+ if err == nil && claims.Subject == username {
82+ authenticated = true
83+ }
84+ }
85+
86+ if !authenticated {
87+ log.Debug().
88+ Str("username", username).
89+ Msg("token request authentication failed")
90+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
91+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
92+ return
93+ }
94+
95+ // Generate a short-lived access token (5 minutes)
96+ accessToken, err := h.authSvc.GenerateToken(ctx, username, []string{"push", "pull"}, 5*time.Minute)
97+ if err != nil {
98+ log.Error().Err(err).Msg("failed to generate access token")
99+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
100+ return
101+ }
102+
103+ response := TokenResponse{
104+ Token: accessToken,
105+ ExpiresIn: 300, // 5 minutes in seconds
106+ IssuedAt: time.Now().Format(time.RFC3339),
107+ }
108+
109+ w.Header().Set("Content-Type", "application/json")
110+ w.WriteHeader(http.StatusOK)
111+
112+ if err := json.NewEncoder(w).Encode(response); err != nil {
113+ log.Error().Err(err).Msg("failed to encode token response")
114+ }
115+
116+ log.Debug().
117+ Str("username", username).
118+ Int("expires_in", response.ExpiresIn).
119+ Msg("access token issued")
120+}
121+
122+// sendAnonymousToken sends a token for anonymous/unauthenticated access.
123+func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
124+ // For anonymous access, we issue a very short-lived token with limited scope
125+ response := TokenResponse{
126+ Token: "", // Empty token indicates limited access
127+ ExpiresIn: 60, // 1 minute
128+ IssuedAt: time.Now().Format(time.RFC3339),
129+ }
130+
131+ w.Header().Set("Content-Type", "application/json")
132+ w.WriteHeader(http.StatusOK)
133+
134+ if err := json.NewEncoder(w).Encode(response); err != nil {
135+ log.Error().Err(err).Msg("failed to encode anonymous token response")
136+ }
137+
138+ log.Debug().Msg("anonymous token issued")
139+}

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