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}