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 "gordon/internal/boundaries/out"
17 "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)
31
32// Config holds the authentication configuration.
33type Config struct {
34 Enabled bool
35 AuthType domain.AuthType
36 Username string
37 PasswordHash string // bcrypt hash for password auth
38 TokenSecret []byte // signing secret for token auth
39 TokenExpiry time.Duration // default token expiry (0 = never)
40}
41
42// Service implements the AuthService interface.
43type Service struct {
44 config Config
45 tokenStore out.TokenStore
46 log zerowrap.Logger
47}
48
49// NewService creates a new auth service.
50func NewService(config Config, tokenStore out.TokenStore, log zerowrap.Logger) *Service {
51 return &Service{
52 config: config,
53 tokenStore: tokenStore,
54 log: log,
55 }
56}
57
58// GetAuthType returns the configured authentication type.
59func (s *Service) GetAuthType() domain.AuthType {
60 return s.config.AuthType
61}
62
63// IsEnabled returns whether authentication is enabled.
64func (s *Service) IsEnabled() bool {
65 return s.config.Enabled
66}
67
68// ValidatePassword checks if the username and password are valid.
69func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
70 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
71 zerowrap.FieldLayer: "usecase",
72 zerowrap.FieldUseCase: "ValidatePassword",
73 "username": username,
74 })
75 log := zerowrap.FromCtx(ctx)
76
77 // Constant-time username comparison
78 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
79
80 // Bcrypt comparison (already constant-time)
81 err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
82 passwordMatch := err == nil
83
84 if !usernameMatch || !passwordMatch {
85 log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
86 return false
87 }
88
89 log.Debug().Msg("password validation successful")
90 return true
91}
92
93// ValidateToken validates a JWT token and returns its claims.
94func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
95 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
96 zerowrap.FieldLayer: "usecase",
97 zerowrap.FieldUseCase: "ValidateToken",
98 })
99 log := zerowrap.FromCtx(ctx)
100
101 claims, err := s.parseTokenClaims(tokenString)
102 if err != nil {
103 log.Debug().Err(err).Msg("failed to parse token")
104 return nil, err
105 }
106
107 tokenClaims := buildTokenClaims(claims)
108
109 // Access tokens (short-lived, ≤5min, recently issued) skip store validation.
110 // CLI-generated tokens must exist in store to prevent use of externally-created tokens.
111 // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent
112 // attackers with stolen secrets from creating arbitrary short-lived tokens.
113 isAccessToken := s.isEphemeralAccessToken(tokenClaims)
114
115 if !isAccessToken {
116 if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
117 return nil, err
118 }
119 if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
120 return nil, err
121 }
122 }
123
124 if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
125 return nil, err
126 }
127
128 log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful")
129 return tokenClaims, nil
130}
131
132// ensureTokenExists verifies the token exists in the store and the JTI matches.
133func (s *Service) ensureTokenExists(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
134 _, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject)
135 if err != nil {
136 if errors.Is(err, domain.ErrTokenNotFound) {
137 log.Debug().Str("subject", tokenClaims.Subject).Msg("token not found in store")
138 return domain.ErrInvalidToken
139 }
140 // SECURITY: Fail closed on store errors
141 log.Error().Err(err).Msg("failed to verify token existence")
142 return fmt.Errorf("unable to verify token: %w", err)
143 }
144
145 // Verify the token ID matches the stored token
146 if storedToken.ID != tokenClaims.ID {
147 log.Debug().
148 Str("expected_id", storedToken.ID).
149 Str("actual_id", tokenClaims.ID).
150 Msg("token ID mismatch")
151 return domain.ErrInvalidToken
152 }
153
154 return nil
155}
156
157func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) {
158 // Parse token with issuer validation
159 token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
160 if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
161 return nil, domain.ErrInvalidToken
162 }
163 return s.config.TokenSecret, nil
164 }, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation
165 if err != nil {
166 return nil, domain.ErrInvalidToken
167 }
168
169 if !token.Valid {
170 return nil, domain.ErrInvalidToken
171 }
172
173 claims, ok := token.Claims.(jwt.MapClaims)
174 if !ok {
175 return nil, domain.ErrInvalidToken
176 }
177
178 // SECURITY: Double-check issuer claim matches expected value
179 if iss, ok := claims["iss"].(string); !ok || iss != TokenIssuer {
180 return nil, domain.ErrInvalidToken
181 }
182
183 return claims, nil
184}
185
186func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims {
187 tokenClaims := &domain.TokenClaims{
188 Issuer: getStringClaim(claims, "iss"),
189 }
190
191 if jti, ok := claims["jti"].(string); ok {
192 tokenClaims.ID = jti
193 }
194 if sub, ok := claims["sub"].(string); ok {
195 tokenClaims.Subject = sub
196 }
197 if iat, ok := claims["iat"].(float64); ok {
198 tokenClaims.IssuedAt = int64(iat)
199 }
200 if exp, ok := claims["exp"].(float64); ok {
201 tokenClaims.ExpiresAt = int64(exp)
202 }
203 if scopes, ok := claims["scopes"].([]any); ok {
204 for _, scope := range scopes {
205 if s, ok := scope.(string); ok {
206 tokenClaims.Scopes = append(tokenClaims.Scopes, s)
207 }
208 }
209 }
210
211 return tokenClaims
212}
213
214func (s *Service) ensureTokenNotRevoked(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
215 revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID)
216 if err != nil {
217 // SECURITY: Fail closed - deny access on revocation check error
218 log.Error().Err(err).Str("token_id", tokenClaims.ID).Msg("failed to check token revocation, denying access")
219 return fmt.Errorf("unable to verify token status: %w", err)
220 }
221 if revoked {
222 log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked")
223 return domain.ErrRevokedToken
224 }
225
226 return nil
227}
228
229func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
230 if tokenClaims.ExpiresAt <= 0 {
231 return nil
232 }
233
234 if time.Now().Unix() > tokenClaims.ExpiresAt {
235 log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
236 return domain.ErrExpiredToken
237 }
238
239 return nil
240}
241
242// isEphemeralAccessToken checks if a token is a short-lived access token that
243// doesn't require store validation. Access tokens must:
244// 1. Have a positive expiry (not never-expiring)
245// 2. Have lifetime ≤ MaxAccessTokenLifetime
246// 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks
247func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
248 if claims.ExpiresAt <= 0 {
249 return false // Never-expiring tokens require store validation
250 }
251
252 lifetime := claims.ExpiresAt - claims.IssuedAt
253 if lifetime > maxAccessTokenLifetimeSecs {
254 return false // Long-lived tokens require store validation
255 }
256
257 // Security: ensure token was recently issued to prevent attackers with
258 // stolen secrets from creating arbitrary short-lived tokens
259 now := time.Now().Unix()
260 tokenAge := now - claims.IssuedAt
261 if tokenAge > maxAccessTokenLifetimeSecs {
262 return false // Old tokens require store validation
263 }
264
265 return true
266}
267
268// GenerateToken creates a new JWT token for the given subject.
269func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
270 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
271 zerowrap.FieldLayer: "usecase",
272 zerowrap.FieldUseCase: "GenerateToken",
273 "subject": subject,
274 })
275 log := zerowrap.FromCtx(ctx)
276
277 tokenID := uuid.New().String()
278 now := time.Now()
279
280 // Build token
281 token := &domain.Token{
282 ID: tokenID,
283 Subject: subject,
284 Scopes: scopes,
285 IssuedAt: now,
286 Revoked: false,
287 }
288
289 // Build JWT claims
290 claims := jwt.MapClaims{
291 "jti": tokenID,
292 "sub": subject,
293 "iss": TokenIssuer,
294 "iat": now.Unix(),
295 "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
296 "scopes": scopes,
297 }
298
299 // Set expiry if specified
300 if expiry > 0 {
301 expiresAt := now.Add(expiry)
302 token.ExpiresAt = expiresAt
303 claims["exp"] = expiresAt.Unix()
304 }
305 // If expiry is 0, token never expires (no exp claim)
306
307 // Sign token
308 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
309 tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
310 if err != nil {
311 return "", log.WrapErr(err, "failed to sign token")
312 }
313
314 // Store token
315 if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil {
316 return "", log.WrapErr(err, "failed to save token")
317 }
318
319 log.Info().
320 Str("token_id", tokenID).
321 Str("subject", subject).
322 Time("expires_at", token.ExpiresAt).
323 Msg("token generated")
324
325 return tokenString, nil
326}
327
328// GenerateAccessToken creates a short-lived JWT for registry access without storing it.
329// Used by /v2/token endpoint - these tokens don't need persistence.
330// Expiry must be > 0 and <= MaxAccessTokenLifetime to prevent misuse.
331func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
332 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
333 zerowrap.FieldLayer: "usecase",
334 zerowrap.FieldUseCase: "GenerateAccessToken",
335 "subject": subject,
336 })
337 log := zerowrap.FromCtx(ctx)
338
339 // Enforce expiry constraints to prevent misuse
340 if expiry <= 0 {
341 return "", fmt.Errorf("access token expiry must be positive")
342 }
343 if expiry > MaxAccessTokenLifetime {
344 return "", fmt.Errorf("access token expiry exceeds maximum of %v", MaxAccessTokenLifetime)
345 }
346
347 tokenID := uuid.New().String()
348 now := time.Now()
349
350 claims := jwt.MapClaims{
351 "jti": tokenID,
352 "sub": subject,
353 "iss": TokenIssuer,
354 "iat": now.Unix(),
355 "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
356 "scopes": scopes,
357 "exp": now.Add(expiry).Unix(),
358 }
359
360 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
361 tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
362 if err != nil {
363 return "", log.WrapErr(err, "failed to sign access token")
364 }
365
366 log.Debug().
367 Str("token_id", tokenID).
368 Str("subject", subject).
369 Msg("access token generated")
370
371 return tokenString, nil
372}
373
374// RevokeToken revokes a token by its ID.
375func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
376 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
377 zerowrap.FieldLayer: "usecase",
378 zerowrap.FieldUseCase: "RevokeToken",
379 "token_id": tokenID,
380 })
381 log := zerowrap.FromCtx(ctx)
382
383 if err := s.tokenStore.Revoke(ctx, tokenID); err != nil {
384 return log.WrapErr(err, "failed to revoke token")
385 }
386
387 log.Info().Msg("token revoked")
388 return nil
389}
390
391// RevokeAllTokens revokes all stored tokens and returns the count of revoked tokens.
392func (s *Service) RevokeAllTokens(ctx context.Context) (int, error) {
393 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
394 zerowrap.FieldLayer: "usecase",
395 zerowrap.FieldUseCase: "RevokeAllTokens",
396 })
397 log := zerowrap.FromCtx(ctx)
398
399 tokens, err := s.tokenStore.ListTokens(ctx)
400 if err != nil {
401 return 0, log.WrapErr(err, "failed to list tokens")
402 }
403
404 revoked := 0
405 for _, token := range tokens {
406 if token.Revoked {
407 continue // Already revoked
408 }
409 if err := s.tokenStore.Revoke(ctx, token.ID); err != nil {
410 log.Warn().Err(err).Str("token_id", token.ID).Msg("failed to revoke token")
411 continue
412 }
413 revoked++
414 }
415
416 log.Info().Int("count", revoked).Msg("all tokens revoked")
417 return revoked, nil
418}
419
420// ListTokens returns all stored tokens.
421func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
422 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
423 zerowrap.FieldLayer: "usecase",
424 zerowrap.FieldUseCase: "ListTokens",
425 })
426 log := zerowrap.FromCtx(ctx)
427
428 tokens, err := s.tokenStore.ListTokens(ctx)
429 if err != nil {
430 return nil, log.WrapErr(err, "failed to list tokens")
431 }
432
433 return tokens, nil
434}
435
436// GeneratePasswordHash generates a bcrypt hash for a password.
437func (s *Service) GeneratePasswordHash(password string) (string, error) {
438 hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
439 if err != nil {
440 return "", err
441 }
442 return string(hash), nil
443}
444
445// getStringClaim safely extracts a string claim from JWT claims.
446func getStringClaim(claims jwt.MapClaims, key string) string {
447 if val, ok := claims[key].(string); ok {
448 return val
449 }
450 return ""
451}