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)
26
27// Config holds the authentication configuration.
28type Config struct {
29 Enabled bool
30 AuthType domain.AuthType
31 Username string
32 PasswordHash string // bcrypt hash for password auth
33 TokenSecret []byte // signing secret for token auth
34 TokenExpiry time.Duration // default token expiry (0 = never)
35}
36
37// Service implements the AuthService interface.
38type Service struct {
39 config Config
40 tokenStore out.TokenStore
41 log zerowrap.Logger
42}
43
44// NewService creates a new auth service.
45func NewService(config Config, tokenStore out.TokenStore, log zerowrap.Logger) *Service {
46 return &Service{
47 config: config,
48 tokenStore: tokenStore,
49 log: log,
50 }
51}
52
53// GetAuthType returns the configured authentication type.
54func (s *Service) GetAuthType() domain.AuthType {
55 return s.config.AuthType
56}
57
58// IsEnabled returns whether authentication is enabled.
59func (s *Service) IsEnabled() bool {
60 return s.config.Enabled
61}
62
63// ValidatePassword checks if the username and password are valid.
64func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
65 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
66 zerowrap.FieldLayer: "usecase",
67 zerowrap.FieldUseCase: "ValidatePassword",
68 "username": username,
69 })
70 log := zerowrap.FromCtx(ctx)
71
72 // Constant-time username comparison
73 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
74
75 // Bcrypt comparison (already constant-time)
76 err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
77 passwordMatch := err == nil
78
79 if !usernameMatch || !passwordMatch {
80 log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
81 return false
82 }
83
84 log.Debug().Msg("password validation successful")
85 return true
86}
87
88// ValidateToken validates a JWT token and returns its claims.
89func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
90 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
91 zerowrap.FieldLayer: "usecase",
92 zerowrap.FieldUseCase: "ValidateToken",
93 })
94 log := zerowrap.FromCtx(ctx)
95
96 claims, err := s.parseTokenClaims(tokenString)
97 if err != nil {
98 log.Debug().Err(err).Msg("failed to parse token")
99 return nil, err
100 }
101
102 tokenClaims := buildTokenClaims(claims)
103
104 // SECURITY: Verify token exists in store (prevents use of externally-created tokens)
105 if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
106 return nil, err
107 }
108
109 if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
110 return nil, err
111 }
112
113 if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
114 return nil, err
115 }
116
117 log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful")
118 return tokenClaims, nil
119}
120
121// ensureTokenExists verifies the token exists in the store and the JTI matches.
122func (s *Service) ensureTokenExists(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
123 _, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject)
124 if err != nil {
125 if errors.Is(err, domain.ErrTokenNotFound) {
126 log.Debug().Str("subject", tokenClaims.Subject).Msg("token not found in store")
127 return domain.ErrInvalidToken
128 }
129 // SECURITY: Fail closed on store errors
130 log.Error().Err(err).Msg("failed to verify token existence")
131 return fmt.Errorf("unable to verify token: %w", err)
132 }
133
134 // Verify the token ID matches the stored token
135 if storedToken.ID != tokenClaims.ID {
136 log.Debug().
137 Str("expected_id", storedToken.ID).
138 Str("actual_id", tokenClaims.ID).
139 Msg("token ID mismatch")
140 return domain.ErrInvalidToken
141 }
142
143 return nil
144}
145
146func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) {
147 // Parse token with issuer validation
148 token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
149 if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
150 return nil, domain.ErrInvalidToken
151 }
152 return s.config.TokenSecret, nil
153 }, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation
154 if err != nil {
155 return nil, domain.ErrInvalidToken
156 }
157
158 if !token.Valid {
159 return nil, domain.ErrInvalidToken
160 }
161
162 claims, ok := token.Claims.(jwt.MapClaims)
163 if !ok {
164 return nil, domain.ErrInvalidToken
165 }
166
167 // SECURITY: Double-check issuer claim matches expected value
168 if iss, ok := claims["iss"].(string); !ok || iss != TokenIssuer {
169 return nil, domain.ErrInvalidToken
170 }
171
172 return claims, nil
173}
174
175func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims {
176 tokenClaims := &domain.TokenClaims{
177 Issuer: getStringClaim(claims, "iss"),
178 }
179
180 if jti, ok := claims["jti"].(string); ok {
181 tokenClaims.ID = jti
182 }
183 if sub, ok := claims["sub"].(string); ok {
184 tokenClaims.Subject = sub
185 }
186 if iat, ok := claims["iat"].(float64); ok {
187 tokenClaims.IssuedAt = int64(iat)
188 }
189 if exp, ok := claims["exp"].(float64); ok {
190 tokenClaims.ExpiresAt = int64(exp)
191 }
192 if scopes, ok := claims["scopes"].([]any); ok {
193 for _, scope := range scopes {
194 if s, ok := scope.(string); ok {
195 tokenClaims.Scopes = append(tokenClaims.Scopes, s)
196 }
197 }
198 }
199
200 return tokenClaims
201}
202
203func (s *Service) ensureTokenNotRevoked(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
204 revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID)
205 if err != nil {
206 // SECURITY: Fail closed - deny access on revocation check error
207 log.Error().Err(err).Str("token_id", tokenClaims.ID).Msg("failed to check token revocation, denying access")
208 return fmt.Errorf("unable to verify token status: %w", err)
209 }
210 if revoked {
211 log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked")
212 return domain.ErrRevokedToken
213 }
214
215 return nil
216}
217
218func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
219 if tokenClaims.ExpiresAt <= 0 {
220 return nil
221 }
222
223 if time.Now().Unix() > tokenClaims.ExpiresAt {
224 log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
225 return domain.ErrExpiredToken
226 }
227
228 return nil
229}
230
231// GenerateToken creates a new JWT token for the given subject.
232func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
233 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
234 zerowrap.FieldLayer: "usecase",
235 zerowrap.FieldUseCase: "GenerateToken",
236 "subject": subject,
237 })
238 log := zerowrap.FromCtx(ctx)
239
240 tokenID := uuid.New().String()
241 now := time.Now()
242
243 // Build token
244 token := &domain.Token{
245 ID: tokenID,
246 Subject: subject,
247 Scopes: scopes,
248 IssuedAt: now,
249 Revoked: false,
250 }
251
252 // Build JWT claims
253 claims := jwt.MapClaims{
254 "jti": tokenID,
255 "sub": subject,
256 "iss": TokenIssuer,
257 "iat": now.Unix(),
258 "scopes": scopes,
259 }
260
261 // Set expiry if specified
262 if expiry > 0 {
263 expiresAt := now.Add(expiry)
264 token.ExpiresAt = expiresAt
265 claims["exp"] = expiresAt.Unix()
266 }
267 // If expiry is 0, token never expires (no exp claim)
268
269 // Sign token
270 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
271 tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
272 if err != nil {
273 return "", log.WrapErr(err, "failed to sign token")
274 }
275
276 // Store token
277 if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil {
278 return "", log.WrapErr(err, "failed to save token")
279 }
280
281 log.Info().
282 Str("token_id", tokenID).
283 Str("subject", subject).
284 Time("expires_at", token.ExpiresAt).
285 Msg("token generated")
286
287 return tokenString, nil
288}
289
290// RevokeToken revokes a token by its ID.
291func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
292 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
293 zerowrap.FieldLayer: "usecase",
294 zerowrap.FieldUseCase: "RevokeToken",
295 "token_id": tokenID,
296 })
297 log := zerowrap.FromCtx(ctx)
298
299 if err := s.tokenStore.Revoke(ctx, tokenID); err != nil {
300 return log.WrapErr(err, "failed to revoke token")
301 }
302
303 log.Info().Msg("token revoked")
304 return nil
305}
306
307// ListTokens returns all stored tokens.
308func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
309 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
310 zerowrap.FieldLayer: "usecase",
311 zerowrap.FieldUseCase: "ListTokens",
312 })
313 log := zerowrap.FromCtx(ctx)
314
315 tokens, err := s.tokenStore.ListTokens(ctx)
316 if err != nil {
317 return nil, log.WrapErr(err, "failed to list tokens")
318 }
319
320 return tokens, nil
321}
322
323// GeneratePasswordHash generates a bcrypt hash for a password.
324func (s *Service) GeneratePasswordHash(password string) (string, error) {
325 hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
326 if err != nil {
327 return "", err
328 }
329 return string(hash), nil
330}
331
332// getStringClaim safely extracts a string claim from JWT claims.
333func getStringClaim(claims jwt.MapClaims, key string) string {
334 if val, ok := claims[key].(string); ok {
335 return val
336 }
337 return ""
338}