v3.5.0 8345e76

service.go RawHistoryFolder

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

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