1// Package auth implements the authentication use case for the registry.
2package auth
3
4import (
5 "context"
6 "crypto/subtle"
7 "time"
8
9 "github.com/bnema/zerowrap"
10 "github.com/golang-jwt/jwt/v5"
11 "github.com/google/uuid"
12 "golang.org/x/crypto/bcrypt"
13
14 "gordon/internal/boundaries/out"
15 "gordon/internal/domain"
16)
17
18const (
19 // TokenIssuer is the issuer claim for generated tokens.
20 TokenIssuer = "gordon-registry"
21 // DefaultBcryptCost is the default cost for bcrypt hashing.
22 DefaultBcryptCost = 12
23)
24
25// Config holds the authentication configuration.
26type Config struct {
27 Enabled bool
28 AuthType domain.AuthType
29 Username string
30 PasswordHash string // bcrypt hash for password auth
31 TokenSecret []byte // signing secret for token auth
32 TokenExpiry time.Duration // default token expiry (0 = never)
33}
34
35// Service implements the AuthService interface.
36type Service struct {
37 config Config
38 tokenStore out.TokenStore
39 log zerowrap.Logger
40}
41
42// NewService creates a new auth service.
43func NewService(config Config, tokenStore out.TokenStore, log zerowrap.Logger) *Service {
44 return &Service{
45 config: config,
46 tokenStore: tokenStore,
47 log: log,
48 }
49}
50
51// GetAuthType returns the configured authentication type.
52func (s *Service) GetAuthType() domain.AuthType {
53 return s.config.AuthType
54}
55
56// IsEnabled returns whether authentication is enabled.
57func (s *Service) IsEnabled() bool {
58 return s.config.Enabled
59}
60
61// ValidatePassword checks if the username and password are valid.
62func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
63 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
64 zerowrap.FieldLayer: "usecase",
65 zerowrap.FieldUseCase: "ValidatePassword",
66 "username": username,
67 })
68 log := zerowrap.FromCtx(ctx)
69
70 // Constant-time username comparison
71 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
72
73 // Bcrypt comparison (already constant-time)
74 err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
75 passwordMatch := err == nil
76
77 if !usernameMatch || !passwordMatch {
78 log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
79 return false
80 }
81
82 log.Debug().Msg("password validation successful")
83 return true
84}
85
86// ValidateToken validates a JWT token and returns its claims.
87func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
88 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
89 zerowrap.FieldLayer: "usecase",
90 zerowrap.FieldUseCase: "ValidateToken",
91 })
92 log := zerowrap.FromCtx(ctx)
93
94 claims, err := s.parseTokenClaims(tokenString)
95 if err != nil {
96 log.Debug().Err(err).Msg("failed to parse token")
97 return nil, err
98 }
99
100 tokenClaims := buildTokenClaims(claims)
101
102 if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
103 return nil, err
104 }
105
106 if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
107 return nil, err
108 }
109
110 log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful")
111 return tokenClaims, nil
112}
113
114func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) {
115 token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
116 if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
117 return nil, domain.ErrInvalidToken
118 }
119 return s.config.TokenSecret, nil
120 })
121 if err != nil {
122 return nil, domain.ErrInvalidToken
123 }
124
125 if !token.Valid {
126 return nil, domain.ErrInvalidToken
127 }
128
129 claims, ok := token.Claims.(jwt.MapClaims)
130 if !ok {
131 return nil, domain.ErrInvalidToken
132 }
133
134 return claims, nil
135}
136
137func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims {
138 tokenClaims := &domain.TokenClaims{
139 Issuer: getStringClaim(claims, "iss"),
140 }
141
142 if jti, ok := claims["jti"].(string); ok {
143 tokenClaims.ID = jti
144 }
145 if sub, ok := claims["sub"].(string); ok {
146 tokenClaims.Subject = sub
147 }
148 if iat, ok := claims["iat"].(float64); ok {
149 tokenClaims.IssuedAt = int64(iat)
150 }
151 if exp, ok := claims["exp"].(float64); ok {
152 tokenClaims.ExpiresAt = int64(exp)
153 }
154 if scopes, ok := claims["scopes"].([]any); ok {
155 for _, scope := range scopes {
156 if s, ok := scope.(string); ok {
157 tokenClaims.Scopes = append(tokenClaims.Scopes, s)
158 }
159 }
160 }
161
162 return tokenClaims
163}
164
165func (s *Service) ensureTokenNotRevoked(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
166 revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID)
167 if err != nil {
168 log.Warn().Err(err).Msg("failed to check token revocation")
169 return nil
170 }
171 if revoked {
172 log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked")
173 return domain.ErrRevokedToken
174 }
175
176 return nil
177}
178
179func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
180 if tokenClaims.ExpiresAt <= 0 {
181 return nil
182 }
183
184 if time.Now().Unix() > tokenClaims.ExpiresAt {
185 log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
186 return domain.ErrExpiredToken
187 }
188
189 return nil
190}
191
192// GenerateToken creates a new JWT token for the given subject.
193func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
194 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
195 zerowrap.FieldLayer: "usecase",
196 zerowrap.FieldUseCase: "GenerateToken",
197 "subject": subject,
198 })
199 log := zerowrap.FromCtx(ctx)
200
201 tokenID := uuid.New().String()
202 now := time.Now()
203
204 // Build token
205 token := &domain.Token{
206 ID: tokenID,
207 Subject: subject,
208 Scopes: scopes,
209 IssuedAt: now,
210 Revoked: false,
211 }
212
213 // Build JWT claims
214 claims := jwt.MapClaims{
215 "jti": tokenID,
216 "sub": subject,
217 "iss": TokenIssuer,
218 "iat": now.Unix(),
219 "scopes": scopes,
220 }
221
222 // Set expiry if specified
223 if expiry > 0 {
224 expiresAt := now.Add(expiry)
225 token.ExpiresAt = expiresAt
226 claims["exp"] = expiresAt.Unix()
227 }
228 // If expiry is 0, token never expires (no exp claim)
229
230 // Sign token
231 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
232 tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
233 if err != nil {
234 return "", log.WrapErr(err, "failed to sign token")
235 }
236
237 // Store token
238 if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil {
239 return "", log.WrapErr(err, "failed to save token")
240 }
241
242 log.Info().
243 Str("token_id", tokenID).
244 Str("subject", subject).
245 Time("expires_at", token.ExpiresAt).
246 Msg("token generated")
247
248 return tokenString, nil
249}
250
251// RevokeToken revokes a token by its ID.
252func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
253 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
254 zerowrap.FieldLayer: "usecase",
255 zerowrap.FieldUseCase: "RevokeToken",
256 "token_id": tokenID,
257 })
258 log := zerowrap.FromCtx(ctx)
259
260 if err := s.tokenStore.Revoke(ctx, tokenID); err != nil {
261 return log.WrapErr(err, "failed to revoke token")
262 }
263
264 log.Info().Msg("token revoked")
265 return nil
266}
267
268// ListTokens returns all stored tokens.
269func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
270 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
271 zerowrap.FieldLayer: "usecase",
272 zerowrap.FieldUseCase: "ListTokens",
273 })
274 log := zerowrap.FromCtx(ctx)
275
276 tokens, err := s.tokenStore.ListTokens(ctx)
277 if err != nil {
278 return nil, log.WrapErr(err, "failed to list tokens")
279 }
280
281 return tokens, nil
282}
283
284// GeneratePasswordHash generates a bcrypt hash for a password.
285func (s *Service) GeneratePasswordHash(password string) (string, error) {
286 hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
287 if err != nil {
288 return "", err
289 }
290 return string(hash), nil
291}
292
293// getStringClaim safely extracts a string claim from JWT claims.
294func getStringClaim(claims jwt.MapClaims, key string) string {
295 if val, ok := claims[key].(string); ok {
296 return val
297 }
298 return ""
299}