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}