v2.30.0-pre 7341e0e

service.go RawHistoryFolder

1// Package auth implements the authentication use case for the registry.
2package auth
3
4import (
5	"context"
6	"crypto/subtle"
7	"errors"
8	"fmt"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/golang-jwt/jwt/v5"
13	"github.com/google/uuid"
14	"golang.org/x/crypto/bcrypt"
15
16	"github.com/bnema/gordon/internal/boundaries/out"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20const (
21	// TokenIssuer is the issuer claim for generated tokens.
22	TokenIssuer = "gordon-registry"
23	// DefaultBcryptCost is the default cost for bcrypt hashing.
24	DefaultBcryptCost = 12
25	// MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.
26	// Tokens with this lifetime or less skip store validation.
27	MaxAccessTokenLifetime = 5 * time.Minute
28	// maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
29	maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
30	// tokenExtensionTTL is the amount of time added to a token's expiry when extended.
31	tokenExtensionTTL = 24 * time.Hour
32	// tokenExtensionDebounce is the minimum time between token extensions.
33	tokenExtensionDebounce = time.Hour
34	// serviceTokenSubject is the subject used for internal service tokens.
35	// Service tokens are not extended to avoid churn on the token store.
36	serviceTokenSubject = "gordon-service"
37)
38
39// Config holds the authentication configuration.
40type Config struct {
41	Enabled      bool
42	AuthType     domain.AuthType
43	Username     string
44	PasswordHash string        // bcrypt hash for password auth
45	TokenSecret  []byte        // signing secret for token auth
46	TokenExpiry  time.Duration // default token expiry (0 = never)
47}
48
49// Service implements the AuthService interface.
50type Service struct {
51	config     Config
52	tokenStore out.TokenStore
53	log        zerowrap.Logger
54}
55
56// NewService creates a new auth service.
57func NewService(config Config, tokenStore out.TokenStore, log zerowrap.Logger) *Service {
58	return &Service{
59		config:     config,
60		tokenStore: tokenStore,
61		log:        log,
62	}
63}
64
65// GetAuthType returns the configured authentication type.
66func (s *Service) GetAuthType() domain.AuthType {
67	return s.config.AuthType
68}
69
70// IsEnabled returns whether authentication is enabled.
71func (s *Service) IsEnabled() bool {
72	return s.config.Enabled
73}
74
75// ValidatePassword checks if the username and password are valid.
76func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
77	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
78		zerowrap.FieldLayer:   "usecase",
79		zerowrap.FieldUseCase: "ValidatePassword",
80		"username":            username,
81	})
82	log := zerowrap.FromCtx(ctx)
83
84	// Constant-time username comparison
85	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
86
87	// Bcrypt comparison (already constant-time)
88	err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
89	passwordMatch := err == nil
90
91	if !usernameMatch || !passwordMatch {
92		log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
93		return false
94	}
95
96	log.Debug().Msg("password validation successful")
97	return true
98}
99
100// ValidateToken validates a JWT token and returns its claims.
101func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
102	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
103		zerowrap.FieldLayer:   "usecase",
104		zerowrap.FieldUseCase: "ValidateToken",
105	})
106	log := zerowrap.FromCtx(ctx)
107
108	claims, err := s.parseTokenClaims(tokenString)
109	if err != nil {
110		log.Debug().Err(err).Msg("failed to parse token")
111		return nil, err
112	}
113
114	tokenClaims := buildTokenClaims(claims)
115
116	// Access tokens (short-lived, ≤5min, recently issued) skip store validation.
117	// CLI-generated tokens must exist in store to prevent use of externally-created tokens.
118	// Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent
119	// attackers with stolen secrets from creating arbitrary short-lived tokens.
120	isAccessToken := s.isEphemeralAccessToken(tokenClaims)
121
122	if !isAccessToken {
123		if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
124			return nil, err
125		}
126		if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
127			return nil, err
128		}
129	}
130
131	if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
132		return nil, err
133	}
134
135	log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful")
136	return tokenClaims, nil
137}
138
139// ensureTokenExists verifies the token exists in the store and the JTI matches.
140func (s *Service) ensureTokenExists(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
141	_, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject)
142	if err != nil {
143		if errors.Is(err, domain.ErrTokenNotFound) {
144			log.Debug().Str("subject", tokenClaims.Subject).Msg("token not found in store")
145			return domain.ErrInvalidToken
146		}
147		// SECURITY: Fail closed on store errors
148		log.Error().Err(err).Msg("failed to verify token existence")
149		return fmt.Errorf("unable to verify token: %w", err)
150	}
151
152	// Verify the token ID matches the stored token
153	if storedToken.ID != tokenClaims.ID {
154		log.Debug().
155			Str("expected_id", storedToken.ID).
156			Str("actual_id", tokenClaims.ID).
157			Msg("token ID mismatch")
158		return domain.ErrInvalidToken
159	}
160
161	return nil
162}
163
164func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) {
165	// Parse token with issuer validation
166	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
167		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
168			return nil, domain.ErrInvalidToken
169		}
170		return s.config.TokenSecret, nil
171	},
172		jwt.WithIssuer(TokenIssuer), // SECURITY: Enforce issuer validation
173		jwt.WithIssuedAt(),          // SECURITY: Reject tokens with iat in the future
174	)
175	if err != nil {
176		return nil, domain.ErrInvalidToken
177	}
178
179	if !token.Valid {
180		return nil, domain.ErrInvalidToken
181	}
182
183	claims, ok := token.Claims.(jwt.MapClaims)
184	if !ok {
185		return nil, domain.ErrInvalidToken
186	}
187
188	// SECURITY: Double-check issuer claim matches expected value
189	if iss, ok := claims["iss"].(string); !ok || iss != TokenIssuer {
190		return nil, domain.ErrInvalidToken
191	}
192
193	return claims, nil
194}
195
196func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims {
197	tokenClaims := &domain.TokenClaims{
198		Issuer: getStringClaim(claims, "iss"),
199	}
200
201	if jti, ok := claims["jti"].(string); ok {
202		tokenClaims.ID = jti
203	}
204	if sub, ok := claims["sub"].(string); ok {
205		tokenClaims.Subject = sub
206	}
207	if iat, ok := claims["iat"].(float64); ok {
208		tokenClaims.IssuedAt = int64(iat)
209	}
210	if exp, ok := claims["exp"].(float64); ok {
211		tokenClaims.ExpiresAt = int64(exp)
212	}
213	if scopes, ok := claims["scopes"].([]any); ok {
214		for _, scope := range scopes {
215			if s, ok := scope.(string); ok {
216				tokenClaims.Scopes = append(tokenClaims.Scopes, s)
217			}
218		}
219	}
220
221	return tokenClaims
222}
223
224func (s *Service) ensureTokenNotRevoked(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
225	revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID)
226	if err != nil {
227		// SECURITY: Fail closed - deny access on revocation check error
228		log.Error().Err(err).Str("token_id", tokenClaims.ID).Msg("failed to check token revocation, denying access")
229		return fmt.Errorf("unable to verify token status: %w", err)
230	}
231	if revoked {
232		log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked")
233		return domain.ErrRevokedToken
234	}
235
236	return nil
237}
238
239func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
240	if tokenClaims.ExpiresAt <= 0 {
241		return nil
242	}
243
244	if time.Now().UTC().Unix() > tokenClaims.ExpiresAt {
245		log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
246		return domain.ErrExpiredToken
247	}
248
249	return nil
250}
251
252// isEphemeralAccessToken checks if a token is a short-lived access token that
253// doesn't require store validation. Access tokens must:
254// 1. Have a positive expiry (not never-expiring)
255// 2. Have lifetime ≤ MaxAccessTokenLifetime
256// 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks
257func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
258	if claims.ExpiresAt <= 0 {
259		return false // Never-expiring tokens require store validation
260	}
261
262	lifetime := claims.ExpiresAt - claims.IssuedAt
263	if lifetime > maxAccessTokenLifetimeSecs {
264		return false // Long-lived tokens require store validation
265	}
266
267	// Security: ensure token was recently issued to prevent attackers with
268	// stolen secrets from creating arbitrary short-lived tokens
269	now := time.Now().UTC().Unix()
270	tokenAge := now - claims.IssuedAt
271	if tokenAge < 0 {
272		// iat is in the future — do not classify as ephemeral (defense-in-depth: prevents bypass)
273		return false
274	}
275	if tokenAge > maxAccessTokenLifetimeSecs {
276		return false // Old tokens require store validation
277	}
278
279	return true
280}
281
282// GenerateToken creates a new JWT token for the given subject.
283func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
284	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
285		zerowrap.FieldLayer:   "usecase",
286		zerowrap.FieldUseCase: "GenerateToken",
287		"subject":             subject,
288	})
289	log := zerowrap.FromCtx(ctx)
290
291	tokenID := uuid.New().String()
292	now := time.Now().UTC()
293
294	// Build token
295	token := &domain.Token{
296		ID:       tokenID,
297		Subject:  subject,
298		Scopes:   scopes,
299		IssuedAt: now,
300		Revoked:  false,
301	}
302
303	// Build JWT claims
304	claims := jwt.MapClaims{
305		"jti":    tokenID,
306		"sub":    subject,
307		"iss":    TokenIssuer,
308		"iat":    now.Unix(),
309		"nbf":    now.Unix(), // SECURITY: Not-before claim for clock skew protection
310		"scopes": scopes,
311	}
312
313	// Set expiry if specified
314	if expiry > 0 {
315		expiresAt := now.Add(expiry)
316		token.ExpiresAt = expiresAt
317		claims["exp"] = expiresAt.Unix()
318	}
319	// If expiry is 0, token never expires (no exp claim)
320
321	// Sign token
322	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
323	tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
324	if err != nil {
325		return "", log.WrapErr(err, "failed to sign token")
326	}
327
328	// Store token
329	if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil {
330		return "", log.WrapErr(err, "failed to save token")
331	}
332
333	log.Info().
334		Str("token_id", tokenID).
335		Str("subject", subject).
336		Time("expires_at", token.ExpiresAt).
337		Msg("token generated")
338
339	return tokenString, nil
340}
341
342// GenerateAccessToken creates a short-lived JWT for registry access without storing it.
343// Used by /v2/token endpoint - these tokens don't need persistence.
344// Expiry must be > 0 and <= MaxAccessTokenLifetime to prevent misuse.
345func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
346	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
347		zerowrap.FieldLayer:   "usecase",
348		zerowrap.FieldUseCase: "GenerateAccessToken",
349		"subject":             subject,
350	})
351	log := zerowrap.FromCtx(ctx)
352
353	// Enforce expiry constraints to prevent misuse
354	if expiry <= 0 {
355		return "", fmt.Errorf("access token expiry must be positive")
356	}
357	if expiry > MaxAccessTokenLifetime {
358		return "", fmt.Errorf("access token expiry exceeds maximum of %v", MaxAccessTokenLifetime)
359	}
360
361	tokenID := uuid.New().String()
362	now := time.Now().UTC()
363
364	claims := jwt.MapClaims{
365		"jti":    tokenID,
366		"sub":    subject,
367		"iss":    TokenIssuer,
368		"iat":    now.Unix(),
369		"nbf":    now.Unix(), // SECURITY: Not-before claim for clock skew protection
370		"scopes": scopes,
371		"exp":    now.Add(expiry).Unix(),
372	}
373
374	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
375	tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
376	if err != nil {
377		return "", log.WrapErr(err, "failed to sign access token")
378	}
379
380	log.Debug().
381		Str("token_id", tokenID).
382		Str("subject", subject).
383		Msg("access token generated")
384
385	return tokenString, nil
386}
387
388// RevokeToken revokes a token by its ID.
389func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
390	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
391		zerowrap.FieldLayer:   "usecase",
392		zerowrap.FieldUseCase: "RevokeToken",
393		"token_id":            tokenID,
394	})
395	log := zerowrap.FromCtx(ctx)
396
397	if err := s.tokenStore.Revoke(ctx, tokenID); err != nil {
398		return log.WrapErr(err, "failed to revoke token")
399	}
400
401	log.Info().Msg("token revoked")
402	return nil
403}
404
405// RevokeAllTokens revokes all stored tokens and returns the count of revoked tokens.
406func (s *Service) RevokeAllTokens(ctx context.Context) (int, error) {
407	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
408		zerowrap.FieldLayer:   "usecase",
409		zerowrap.FieldUseCase: "RevokeAllTokens",
410	})
411	log := zerowrap.FromCtx(ctx)
412
413	tokens, err := s.tokenStore.ListTokens(ctx)
414	if err != nil {
415		return 0, log.WrapErr(err, "failed to list tokens")
416	}
417
418	revoked := 0
419	for _, token := range tokens {
420		if token.Revoked {
421			continue // Already revoked
422		}
423		if err := s.tokenStore.Revoke(ctx, token.ID); err != nil {
424			log.Warn().Err(err).Str("token_id", token.ID).Msg("failed to revoke token")
425			continue
426		}
427		revoked++
428	}
429
430	log.Info().Int("count", revoked).Msg("all tokens revoked")
431	return revoked, nil
432}
433
434// ListTokens returns all stored tokens.
435func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
436	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
437		zerowrap.FieldLayer:   "usecase",
438		zerowrap.FieldUseCase: "ListTokens",
439	})
440	log := zerowrap.FromCtx(ctx)
441
442	tokens, err := s.tokenStore.ListTokens(ctx)
443	if err != nil {
444		return nil, log.WrapErr(err, "failed to list tokens")
445	}
446
447	return tokens, nil
448}
449
450// ExtendToken re-issues the token with expiry slid forward by 24h.
451// Returns the same token string if it was already extended within the debounce window (1h).
452// Skips extension for ephemeral access tokens (≤5min) and service tokens.
453func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) {
454	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
455		zerowrap.FieldLayer:   "usecase",
456		zerowrap.FieldUseCase: "ExtendToken",
457	})
458	log := zerowrap.FromCtx(ctx)
459
460	// Parse claims without store validation first (to check skip conditions cheaply)
461	rawClaims, err := s.parseTokenClaims(tokenString)
462	if err != nil {
463		return "", domain.ErrInvalidToken
464	}
465
466	tokenClaims := buildTokenClaims(rawClaims)
467
468	// Skip ephemeral access tokens (≤5min lifetime)
469	if s.isEphemeralAccessToken(tokenClaims) {
470		log.Debug().Msg("skipping extension for ephemeral access token")
471		return tokenString, nil
472	}
473
474	// Skip service tokens
475	if tokenClaims.Subject == serviceTokenSubject {
476		log.Debug().Msg("skipping extension for service token")
477		return tokenString, nil
478	}
479
480	// Skip tokens with no expiry (permanent tokens must not be silently converted to 24h tokens)
481	if tokenClaims.ExpiresAt <= 0 {
482		log.Debug().Str("subject", tokenClaims.Subject).Msg("skipping extension for non-expiring token")
483		return tokenString, nil
484	}
485
486	// Full validation including store checks (not expired, not revoked, exists in store)
487	if _, err := s.ValidateToken(ctx, tokenString); err != nil {
488		return "", fmt.Errorf("token validation failed: %w", err)
489	}
490
491	// Fetch stored token to check debounce and get metadata
492	_, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject)
493	if err != nil {
494		return "", fmt.Errorf("failed to fetch stored token: %w", err)
495	}
496
497	// TOCTOU guard: confirm the store's current JTI matches the JWT we validated.
498	// A mismatch means the token was rotated between ValidateToken and GetToken;
499	// in that case return the original (still-valid) token without re-signing.
500	if storedToken.ID != tokenClaims.ID {
501		log.Debug().
502			Str("subject", tokenClaims.Subject).
503			Str("jwt_jti", tokenClaims.ID).
504			Str("store_id", storedToken.ID).
505			Msg("token ID mismatch after rotation, skipping extension")
506		return tokenString, nil
507	}
508
509	// Debounce: skip if already extended within the last hour
510	if !storedToken.LastExtendedAt.IsZero() && time.Since(storedToken.LastExtendedAt) < tokenExtensionDebounce {
511		log.Debug().
512			Str("subject", tokenClaims.Subject).
513			Time("last_extended_at", storedToken.LastExtendedAt).
514			Msg("token extension debounced")
515		return tokenString, nil
516	}
517
518	// Re-sign with same JTI but new expiry
519	now := time.Now().UTC()
520	newExpiresAt := now.Add(tokenExtensionTTL)
521
522	newClaims := jwt.MapClaims{
523		"jti":    tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests
524		"sub":    tokenClaims.Subject,
525		"iss":    TokenIssuer,
526		"iat":    now.Unix(), // Update to current time (token is being re-issued)
527		"nbf":    now.Unix(), // SECURITY: not-before matches issuance time
528		"scopes": tokenClaims.Scopes,
529		"exp":    newExpiresAt.Unix(),
530	}
531
532	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
533	newTokenString, err := jwtToken.SignedString(s.config.TokenSecret)
534	if err != nil {
535		return "", log.WrapErr(err, "failed to re-sign token")
536	}
537
538	// Update stored token with new JWT, expiry, and LastExtendedAt
539	updatedToken := &domain.Token{
540		ID:             storedToken.ID,
541		Subject:        storedToken.Subject,
542		Scopes:         storedToken.Scopes,
543		IssuedAt:       storedToken.IssuedAt,
544		ExpiresAt:      newExpiresAt,
545		Revoked:        storedToken.Revoked,
546		LastExtendedAt: now,
547	}
548
549	if err := s.tokenStore.UpdateTokenExpiry(ctx, updatedToken, newTokenString); err != nil {
550		// Non-fatal: log and return original token rather than failing the request
551		log.Warn().Err(err).Str("subject", tokenClaims.Subject).Msg("failed to persist extended token, returning original")
552		return tokenString, nil
553	}
554
555	log.Debug().
556		Str("subject", tokenClaims.Subject).
557		Time("new_expires_at", newExpiresAt).
558		Msg("token expiry extended")
559
560	return newTokenString, nil
561}
562
563// GeneratePasswordHash generates a bcrypt hash for a password.
564func (s *Service) GeneratePasswordHash(password string) (string, error) {
565	hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
566	if err != nil {
567		return "", err
568	}
569	return string(hash), nil
570}
571
572// getStringClaim safely extracts a string claim from JWT claims.
573func getStringClaim(claims jwt.MapClaims, key string) string {
574	if val, ok := claims[key].(string); ok {
575		return val
576	}
577	return ""
578}
579
580// GetAuthStatus returns authentication status from context.
581// Claims are already validated by AdminAuth middleware and stored in context.
582func (s *Service) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {
583	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
584		zerowrap.FieldLayer:   "usecase",
585		zerowrap.FieldUseCase: "GetAuthStatus",
586	})
587
588	// If auth is disabled, return valid status with no claims
589	if !s.IsEnabled() {
590		return &domain.AuthStatus{Valid: true}, nil
591	}
592
593	// Extract claims from context (set by AdminAuth middleware)
594	claims := domain.GetTokenClaims(ctx)
595	if claims == nil {
596		return &domain.AuthStatus{Valid: false}, nil
597	}
598
599	return &domain.AuthStatus{
600		Valid:     true,
601		Subject:   claims.Subject,
602		Scopes:    claims.Scopes,
603		ExpiresAt: claims.ExpiresAt,
604		IssuedAt:  claims.IssuedAt,
605	}, nil
606}

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