feat(usecase/auth): implement authentication service with password and token support
Add auth service implementation: - Password validation with bcrypt - JWT token generation and validation (HS256) - Token revocation checking - Support for never-expiring tokens (expiry=0) - Comprehensive test coverage
- commit
- ccd5bf87ca843e853c194851f85719478d35846e
- author
- brice · · 2026-01-12 18:55 UTC
- committer
- brice · · 2026-01-12 18:21 UTC
- parent
- e858e17
- signature
- unsigned
2 files · +743 -0
A internal/usecase/auth/service.go +275 -0
| @@ -0,0 +1,275 @@ | |||
| 1 | + | // Package auth implements the authentication use case for the registry. | |
| 2 | + | package auth | |
| 3 | + | ||
| 4 | + | import ( | |
| 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 | + | ||
| 18 | + | const ( | |
| 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. | |
| 26 | + | type 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. | |
| 36 | + | type Service struct { | |
| 37 | + | config Config | |
| 38 | + | tokenStore out.TokenStore | |
| 39 | + | log zerowrap.Logger | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // NewService creates a new auth service. | |
| 43 | + | func 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. | |
| 52 | + | func (s *Service) GetAuthType() domain.AuthType { | |
| 53 | + | return s.config.AuthType | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // IsEnabled returns whether authentication is enabled. | |
| 57 | + | func (s *Service) IsEnabled() bool { | |
| 58 | + | return s.config.Enabled | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // ValidatePassword checks if the username and password are valid. | |
| 62 | + | func (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. | |
| 87 | + | func (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 | + | // Parse the token | |
| 95 | + | token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { | |
| 96 | + | // Validate signing method | |
| 97 | + | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | |
| 98 | + | return nil, domain.ErrInvalidToken | |
| 99 | + | } | |
| 100 | + | return s.config.TokenSecret, nil | |
| 101 | + | }) | |
| 102 | + | if err != nil { | |
| 103 | + | log.Debug().Err(err).Msg("failed to parse token") | |
| 104 | + | return nil, domain.ErrInvalidToken | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | if !token.Valid { | |
| 108 | + | log.Debug().Msg("token is invalid") | |
| 109 | + | return nil, domain.ErrInvalidToken | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Extract claims | |
| 113 | + | claims, ok := token.Claims.(jwt.MapClaims) | |
| 114 | + | if !ok { | |
| 115 | + | log.Debug().Msg("failed to extract claims") | |
| 116 | + | return nil, domain.ErrInvalidToken | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Build TokenClaims from JWT claims | |
| 120 | + | tokenClaims := &domain.TokenClaims{ | |
| 121 | + | Issuer: getStringClaim(claims, "iss"), | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Get standard claims | |
| 125 | + | if jti, ok := claims["jti"].(string); ok { | |
| 126 | + | tokenClaims.ID = jti | |
| 127 | + | } | |
| 128 | + | if sub, ok := claims["sub"].(string); ok { | |
| 129 | + | tokenClaims.Subject = sub | |
| 130 | + | } | |
| 131 | + | if iat, ok := claims["iat"].(float64); ok { | |
| 132 | + | tokenClaims.IssuedAt = int64(iat) | |
| 133 | + | } | |
| 134 | + | if exp, ok := claims["exp"].(float64); ok { | |
| 135 | + | tokenClaims.ExpiresAt = int64(exp) | |
| 136 | + | } | |
| 137 | + | if scopes, ok := claims["scopes"].([]any); ok { | |
| 138 | + | for _, scope := range scopes { | |
| 139 | + | if s, ok := scope.(string); ok { | |
| 140 | + | tokenClaims.Scopes = append(tokenClaims.Scopes, s) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Check if token is revoked | |
| 146 | + | revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID) | |
| 147 | + | if err != nil { | |
| 148 | + | log.Warn().Err(err).Msg("failed to check token revocation") | |
| 149 | + | // Continue anyway - revocation check is best effort | |
| 150 | + | } | |
| 151 | + | if revoked { | |
| 152 | + | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked") | |
| 153 | + | return nil, domain.ErrRevokedToken | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Check expiry (if set) | |
| 157 | + | if tokenClaims.ExpiresAt > 0 { | |
| 158 | + | if time.Now().Unix() > tokenClaims.ExpiresAt { | |
| 159 | + | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | |
| 160 | + | return nil, domain.ErrExpiredToken | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful") | |
| 165 | + | return tokenClaims, nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // GenerateToken creates a new JWT token for the given subject. | |
| 169 | + | func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | |
| 170 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 171 | + | zerowrap.FieldLayer: "usecase", | |
| 172 | + | zerowrap.FieldUseCase: "GenerateToken", | |
| 173 | + | "subject": subject, | |
| 174 | + | }) | |
| 175 | + | log := zerowrap.FromCtx(ctx) | |
| 176 | + | ||
| 177 | + | tokenID := uuid.New().String() | |
| 178 | + | now := time.Now() | |
| 179 | + | ||
| 180 | + | // Build token | |
| 181 | + | token := &domain.Token{ | |
| 182 | + | ID: tokenID, | |
| 183 | + | Subject: subject, | |
| 184 | + | Scopes: scopes, | |
| 185 | + | IssuedAt: now, | |
| 186 | + | Revoked: false, | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // Build JWT claims | |
| 190 | + | claims := jwt.MapClaims{ | |
| 191 | + | "jti": tokenID, | |
| 192 | + | "sub": subject, | |
| 193 | + | "iss": TokenIssuer, | |
| 194 | + | "iat": now.Unix(), | |
| 195 | + | "scopes": scopes, | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // Set expiry if specified | |
| 199 | + | if expiry > 0 { | |
| 200 | + | expiresAt := now.Add(expiry) | |
| 201 | + | token.ExpiresAt = expiresAt | |
| 202 | + | claims["exp"] = expiresAt.Unix() | |
| 203 | + | } | |
| 204 | + | // If expiry is 0, token never expires (no exp claim) | |
| 205 | + | ||
| 206 | + | // Sign token | |
| 207 | + | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |
| 208 | + | tokenString, err := jwtToken.SignedString(s.config.TokenSecret) | |
| 209 | + | if err != nil { | |
| 210 | + | return "", log.WrapErr(err, "failed to sign token") | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // Store token | |
| 214 | + | if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil { | |
| 215 | + | return "", log.WrapErr(err, "failed to save token") | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | log.Info(). | |
| 219 | + | Str("token_id", tokenID). | |
| 220 | + | Str("subject", subject). | |
| 221 | + | Time("expires_at", token.ExpiresAt). | |
| 222 | + | Msg("token generated") | |
| 223 | + | ||
| 224 | + | return tokenString, nil | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // RevokeToken revokes a token by its ID. | |
| 228 | + | func (s *Service) RevokeToken(ctx context.Context, tokenID string) error { | |
| 229 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 230 | + | zerowrap.FieldLayer: "usecase", | |
| 231 | + | zerowrap.FieldUseCase: "RevokeToken", | |
| 232 | + | "token_id": tokenID, | |
| 233 | + | }) | |
| 234 | + | log := zerowrap.FromCtx(ctx) | |
| 235 | + | ||
| 236 | + | if err := s.tokenStore.Revoke(ctx, tokenID); err != nil { | |
| 237 | + | return log.WrapErr(err, "failed to revoke token") | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | log.Info().Msg("token revoked") | |
| 241 | + | return nil | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // ListTokens returns all stored tokens. | |
| 245 | + | func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | |
| 246 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 247 | + | zerowrap.FieldLayer: "usecase", | |
| 248 | + | zerowrap.FieldUseCase: "ListTokens", | |
| 249 | + | }) | |
| 250 | + | log := zerowrap.FromCtx(ctx) | |
| 251 | + | ||
| 252 | + | tokens, err := s.tokenStore.ListTokens(ctx) | |
| 253 | + | if err != nil { | |
| 254 | + | return nil, log.WrapErr(err, "failed to list tokens") | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | return tokens, nil | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // GeneratePasswordHash generates a bcrypt hash for a password. | |
| 261 | + | func (s *Service) GeneratePasswordHash(password string) (string, error) { | |
| 262 | + | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | |
| 263 | + | if err != nil { | |
| 264 | + | return "", err | |
| 265 | + | } | |
| 266 | + | return string(hash), nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // getStringClaim safely extracts a string claim from JWT claims. | |
| 270 | + | func getStringClaim(claims jwt.MapClaims, key string) string { | |
| 271 | + | if val, ok := claims[key].(string); ok { | |
| 272 | + | return val | |
| 273 | + | } | |
| 274 | + | return "" | |
| 275 | + | } | |
| @@ -0,0 +1,275 @@ | |||||
| 1 | + | // Package auth implements the authentication use case for the registry. | |||
| 2 | + | package auth | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 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 | + | ||||
| 18 | + | const ( | |||
| 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. | |||
| 26 | + | type 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. | |||
| 36 | + | type Service struct { | |||
| 37 | + | config Config | |||
| 38 | + | tokenStore out.TokenStore | |||
| 39 | + | log zerowrap.Logger | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // NewService creates a new auth service. | |||
| 43 | + | func 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. | |||
| 52 | + | func (s *Service) GetAuthType() domain.AuthType { | |||
| 53 | + | return s.config.AuthType | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // IsEnabled returns whether authentication is enabled. | |||
| 57 | + | func (s *Service) IsEnabled() bool { | |||
| 58 | + | return s.config.Enabled | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // ValidatePassword checks if the username and password are valid. | |||
| 62 | + | func (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. | |||
| 87 | + | func (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 | + | // Parse the token | |||
| 95 | + | token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { | |||
| 96 | + | // Validate signing method | |||
| 97 | + | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | |||
| 98 | + | return nil, domain.ErrInvalidToken | |||
| 99 | + | } | |||
| 100 | + | return s.config.TokenSecret, nil | |||
| 101 | + | }) | |||
| 102 | + | if err != nil { | |||
| 103 | + | log.Debug().Err(err).Msg("failed to parse token") | |||
| 104 | + | return nil, domain.ErrInvalidToken | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | if !token.Valid { | |||
| 108 | + | log.Debug().Msg("token is invalid") | |||
| 109 | + | return nil, domain.ErrInvalidToken | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Extract claims | |||
| 113 | + | claims, ok := token.Claims.(jwt.MapClaims) | |||
| 114 | + | if !ok { | |||
| 115 | + | log.Debug().Msg("failed to extract claims") | |||
| 116 | + | return nil, domain.ErrInvalidToken | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Build TokenClaims from JWT claims | |||
| 120 | + | tokenClaims := &domain.TokenClaims{ | |||
| 121 | + | Issuer: getStringClaim(claims, "iss"), | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Get standard claims | |||
| 125 | + | if jti, ok := claims["jti"].(string); ok { | |||
| 126 | + | tokenClaims.ID = jti | |||
| 127 | + | } | |||
| 128 | + | if sub, ok := claims["sub"].(string); ok { | |||
| 129 | + | tokenClaims.Subject = sub | |||
| 130 | + | } | |||
| 131 | + | if iat, ok := claims["iat"].(float64); ok { | |||
| 132 | + | tokenClaims.IssuedAt = int64(iat) | |||
| 133 | + | } | |||
| 134 | + | if exp, ok := claims["exp"].(float64); ok { | |||
| 135 | + | tokenClaims.ExpiresAt = int64(exp) | |||
| 136 | + | } | |||
| 137 | + | if scopes, ok := claims["scopes"].([]any); ok { | |||
| 138 | + | for _, scope := range scopes { | |||
| 139 | + | if s, ok := scope.(string); ok { | |||
| 140 | + | tokenClaims.Scopes = append(tokenClaims.Scopes, s) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // Check if token is revoked | |||
| 146 | + | revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID) | |||
| 147 | + | if err != nil { | |||
| 148 | + | log.Warn().Err(err).Msg("failed to check token revocation") | |||
| 149 | + | // Continue anyway - revocation check is best effort | |||
| 150 | + | } | |||
| 151 | + | if revoked { | |||
| 152 | + | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked") | |||
| 153 | + | return nil, domain.ErrRevokedToken | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // Check expiry (if set) | |||
| 157 | + | if tokenClaims.ExpiresAt > 0 { | |||
| 158 | + | if time.Now().Unix() > tokenClaims.ExpiresAt { | |||
| 159 | + | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | |||
| 160 | + | return nil, domain.ErrExpiredToken | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful") | |||
| 165 | + | return tokenClaims, nil | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // GenerateToken creates a new JWT token for the given subject. | |||
| 169 | + | func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | |||
| 170 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 171 | + | zerowrap.FieldLayer: "usecase", | |||
| 172 | + | zerowrap.FieldUseCase: "GenerateToken", | |||
| 173 | + | "subject": subject, | |||
| 174 | + | }) | |||
| 175 | + | log := zerowrap.FromCtx(ctx) | |||
| 176 | + | ||||
| 177 | + | tokenID := uuid.New().String() | |||
| 178 | + | now := time.Now() | |||
| 179 | + | ||||
| 180 | + | // Build token | |||
| 181 | + | token := &domain.Token{ | |||
| 182 | + | ID: tokenID, | |||
| 183 | + | Subject: subject, | |||
| 184 | + | Scopes: scopes, | |||
| 185 | + | IssuedAt: now, | |||
| 186 | + | Revoked: false, | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // Build JWT claims | |||
| 190 | + | claims := jwt.MapClaims{ | |||
| 191 | + | "jti": tokenID, | |||
| 192 | + | "sub": subject, | |||
| 193 | + | "iss": TokenIssuer, | |||
| 194 | + | "iat": now.Unix(), | |||
| 195 | + | "scopes": scopes, | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // Set expiry if specified | |||
| 199 | + | if expiry > 0 { | |||
| 200 | + | expiresAt := now.Add(expiry) | |||
| 201 | + | token.ExpiresAt = expiresAt | |||
| 202 | + | claims["exp"] = expiresAt.Unix() | |||
| 203 | + | } | |||
| 204 | + | // If expiry is 0, token never expires (no exp claim) | |||
| 205 | + | ||||
| 206 | + | // Sign token | |||
| 207 | + | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |||
| 208 | + | tokenString, err := jwtToken.SignedString(s.config.TokenSecret) | |||
| 209 | + | if err != nil { | |||
| 210 | + | return "", log.WrapErr(err, "failed to sign token") | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // Store token | |||
| 214 | + | if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil { | |||
| 215 | + | return "", log.WrapErr(err, "failed to save token") | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | log.Info(). | |||
| 219 | + | Str("token_id", tokenID). | |||
| 220 | + | Str("subject", subject). | |||
| 221 | + | Time("expires_at", token.ExpiresAt). | |||
| 222 | + | Msg("token generated") | |||
| 223 | + | ||||
| 224 | + | return tokenString, nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // RevokeToken revokes a token by its ID. | |||
| 228 | + | func (s *Service) RevokeToken(ctx context.Context, tokenID string) error { | |||
| 229 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 230 | + | zerowrap.FieldLayer: "usecase", | |||
| 231 | + | zerowrap.FieldUseCase: "RevokeToken", | |||
| 232 | + | "token_id": tokenID, | |||
| 233 | + | }) | |||
| 234 | + | log := zerowrap.FromCtx(ctx) | |||
| 235 | + | ||||
| 236 | + | if err := s.tokenStore.Revoke(ctx, tokenID); err != nil { | |||
| 237 | + | return log.WrapErr(err, "failed to revoke token") | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | log.Info().Msg("token revoked") | |||
| 241 | + | return nil | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // ListTokens returns all stored tokens. | |||
| 245 | + | func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||
| 246 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 247 | + | zerowrap.FieldLayer: "usecase", | |||
| 248 | + | zerowrap.FieldUseCase: "ListTokens", | |||
| 249 | + | }) | |||
| 250 | + | log := zerowrap.FromCtx(ctx) | |||
| 251 | + | ||||
| 252 | + | tokens, err := s.tokenStore.ListTokens(ctx) | |||
| 253 | + | if err != nil { | |||
| 254 | + | return nil, log.WrapErr(err, "failed to list tokens") | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | return tokens, nil | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // GeneratePasswordHash generates a bcrypt hash for a password. | |||
| 261 | + | func (s *Service) GeneratePasswordHash(password string) (string, error) { | |||
| 262 | + | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | |||
| 263 | + | if err != nil { | |||
| 264 | + | return "", err | |||
| 265 | + | } | |||
| 266 | + | return string(hash), nil | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // getStringClaim safely extracts a string claim from JWT claims. | |||
| 270 | + | func getStringClaim(claims jwt.MapClaims, key string) string { | |||
| 271 | + | if val, ok := claims[key].(string); ok { | |||
| 272 | + | return val | |||
| 273 | + | } | |||
| 274 | + | return "" | |||
| 275 | + | } | |||
A internal/usecase/auth/service_test.go +468 -0
| @@ -0,0 +1,468 @@ | |||
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | "golang.org/x/crypto/bcrypt" | |
| 13 | + | ||
| 14 | + | "gordon/internal/boundaries/out/mocks" | |
| 15 | + | "gordon/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func testContext() context.Context { | |
| 19 | + | return zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestService_GetAuthType(t *testing.T) { | |
| 23 | + | tests := []struct { | |
| 24 | + | name string | |
| 25 | + | authType domain.AuthType | |
| 26 | + | }{ | |
| 27 | + | {"password type", domain.AuthTypePassword}, | |
| 28 | + | {"token type", domain.AuthTypeToken}, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |
| 33 | + | svc := NewService(Config{AuthType: tt.authType}, nil, zerowrap.Default()) | |
| 34 | + | assert.Equal(t, tt.authType, svc.GetAuthType()) | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestService_IsEnabled(t *testing.T) { | |
| 40 | + | tests := []struct { | |
| 41 | + | name string | |
| 42 | + | enabled bool | |
| 43 | + | }{ | |
| 44 | + | {"enabled", true}, | |
| 45 | + | {"disabled", false}, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | for _, tt := range tests { | |
| 49 | + | t.Run(tt.name, func(t *testing.T) { | |
| 50 | + | svc := NewService(Config{Enabled: tt.enabled}, nil, zerowrap.Default()) | |
| 51 | + | assert.Equal(t, tt.enabled, svc.IsEnabled()) | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestService_ValidatePassword_Success(t *testing.T) { | |
| 57 | + | password := "testpassword123" | |
| 58 | + | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | ||
| 61 | + | svc := NewService(Config{ | |
| 62 | + | Enabled: true, | |
| 63 | + | AuthType: domain.AuthTypePassword, | |
| 64 | + | Username: "testuser", | |
| 65 | + | PasswordHash: string(hash), | |
| 66 | + | }, nil, zerowrap.Default()) | |
| 67 | + | ||
| 68 | + | ctx := testContext() | |
| 69 | + | assert.True(t, svc.ValidatePassword(ctx, "testuser", password)) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestService_ValidatePassword_WrongPassword(t *testing.T) { | |
| 73 | + | hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost) | |
| 74 | + | require.NoError(t, err) | |
| 75 | + | ||
| 76 | + | svc := NewService(Config{ | |
| 77 | + | Enabled: true, | |
| 78 | + | AuthType: domain.AuthTypePassword, | |
| 79 | + | Username: "testuser", | |
| 80 | + | PasswordHash: string(hash), | |
| 81 | + | }, nil, zerowrap.Default()) | |
| 82 | + | ||
| 83 | + | ctx := testContext() | |
| 84 | + | assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword")) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestService_ValidatePassword_WrongUsername(t *testing.T) { | |
| 88 | + | password := "testpassword123" | |
| 89 | + | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | ||
| 92 | + | svc := NewService(Config{ | |
| 93 | + | Enabled: true, | |
| 94 | + | AuthType: domain.AuthTypePassword, | |
| 95 | + | Username: "testuser", | |
| 96 | + | PasswordHash: string(hash), | |
| 97 | + | }, nil, zerowrap.Default()) | |
| 98 | + | ||
| 99 | + | ctx := testContext() | |
| 100 | + | assert.False(t, svc.ValidatePassword(ctx, "wronguser", password)) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestService_GenerateToken_Success(t *testing.T) { | |
| 104 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 105 | + | ||
| 106 | + | // Capture the token to verify its properties | |
| 107 | + | var capturedToken *domain.Token | |
| 108 | + | var capturedJWT string | |
| 109 | + | ||
| 110 | + | tokenStore.EXPECT(). | |
| 111 | + | SaveToken( | |
| 112 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |
| 113 | + | mock.MatchedBy(func(token *domain.Token) bool { | |
| 114 | + | capturedToken = token | |
| 115 | + | return token.Subject == "testsubject" && | |
| 116 | + | len(token.ID) > 0 && | |
| 117 | + | len(token.Scopes) == 2 && | |
| 118 | + | token.Scopes[0] == "push" && | |
| 119 | + | token.Scopes[1] == "pull" && | |
| 120 | + | !token.ExpiresAt.IsZero() && | |
| 121 | + | !token.Revoked | |
| 122 | + | }), | |
| 123 | + | mock.MatchedBy(func(jwt string) bool { | |
| 124 | + | capturedJWT = jwt | |
| 125 | + | // JWT should have 3 parts separated by dots | |
| 126 | + | parts := 0 | |
| 127 | + | for _, c := range jwt { | |
| 128 | + | if c == '.' { | |
| 129 | + | parts++ | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return parts == 2 && len(jwt) > 50 | |
| 133 | + | }), | |
| 134 | + | ). | |
| 135 | + | Return(nil) | |
| 136 | + | ||
| 137 | + | svc := NewService(Config{ | |
| 138 | + | Enabled: true, | |
| 139 | + | AuthType: domain.AuthTypeToken, | |
| 140 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 141 | + | }, tokenStore, zerowrap.Default()) | |
| 142 | + | ||
| 143 | + | ctx := testContext() | |
| 144 | + | token, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |
| 145 | + | ||
| 146 | + | assert.NoError(t, err) | |
| 147 | + | assert.NotEmpty(t, token) | |
| 148 | + | assert.Equal(t, capturedJWT, token) | |
| 149 | + | assert.NotNil(t, capturedToken) | |
| 150 | + | assert.WithinDuration(t, time.Now().Add(time.Hour), capturedToken.ExpiresAt, 5*time.Second) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestService_GenerateToken_NeverExpires(t *testing.T) { | |
| 154 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 155 | + | ||
| 156 | + | tokenStore.EXPECT(). | |
| 157 | + | SaveToken( | |
| 158 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |
| 159 | + | mock.MatchedBy(func(token *domain.Token) bool { | |
| 160 | + | return token.Subject == "ci-bot" && | |
| 161 | + | token.ExpiresAt.IsZero() // Never expires | |
| 162 | + | }), | |
| 163 | + | mock.MatchedBy(func(jwt string) bool { | |
| 164 | + | return len(jwt) > 50 | |
| 165 | + | }), | |
| 166 | + | ). | |
| 167 | + | Return(nil) | |
| 168 | + | ||
| 169 | + | svc := NewService(Config{ | |
| 170 | + | Enabled: true, | |
| 171 | + | AuthType: domain.AuthTypeToken, | |
| 172 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 173 | + | }, tokenStore, zerowrap.Default()) | |
| 174 | + | ||
| 175 | + | ctx := testContext() | |
| 176 | + | token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0) | |
| 177 | + | ||
| 178 | + | assert.NoError(t, err) | |
| 179 | + | assert.NotEmpty(t, token) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestService_ValidateToken_Success(t *testing.T) { | |
| 183 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 184 | + | ||
| 185 | + | svc := NewService(Config{ | |
| 186 | + | Enabled: true, | |
| 187 | + | AuthType: domain.AuthTypeToken, | |
| 188 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 189 | + | }, tokenStore, zerowrap.Default()) | |
| 190 | + | ||
| 191 | + | // Capture the token ID when saving | |
| 192 | + | var savedTokenID string | |
| 193 | + | tokenStore.EXPECT(). | |
| 194 | + | SaveToken( | |
| 195 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |
| 196 | + | mock.MatchedBy(func(token *domain.Token) bool { | |
| 197 | + | savedTokenID = token.ID | |
| 198 | + | return token.Subject == "testsubject" | |
| 199 | + | }), | |
| 200 | + | mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }), | |
| 201 | + | ). | |
| 202 | + | Return(nil) | |
| 203 | + | ||
| 204 | + | ctx := testContext() | |
| 205 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | ||
| 208 | + | // Expect IsRevoked to be called with the same token ID | |
| 209 | + | tokenStore.EXPECT(). | |
| 210 | + | IsRevoked( | |
| 211 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |
| 212 | + | mock.MatchedBy(func(tokenID string) bool { | |
| 213 | + | return tokenID == savedTokenID | |
| 214 | + | }), | |
| 215 | + | ). | |
| 216 | + | Return(false, nil) | |
| 217 | + | ||
| 218 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |
| 219 | + | ||
| 220 | + | assert.NoError(t, err) | |
| 221 | + | assert.NotNil(t, claims) | |
| 222 | + | assert.Equal(t, "testsubject", claims.Subject) | |
| 223 | + | assert.Equal(t, savedTokenID, claims.ID) | |
| 224 | + | assert.Equal(t, TokenIssuer, claims.Issuer) | |
| 225 | + | assert.Contains(t, claims.Scopes, "push") | |
| 226 | + | assert.Contains(t, claims.Scopes, "pull") | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestService_ValidateToken_Revoked(t *testing.T) { | |
| 230 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 231 | + | ||
| 232 | + | svc := NewService(Config{ | |
| 233 | + | Enabled: true, | |
| 234 | + | AuthType: domain.AuthTypeToken, | |
| 235 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 236 | + | }, tokenStore, zerowrap.Default()) | |
| 237 | + | ||
| 238 | + | var savedTokenID string | |
| 239 | + | tokenStore.EXPECT(). | |
| 240 | + | SaveToken( | |
| 241 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |
| 242 | + | mock.MatchedBy(func(token *domain.Token) bool { | |
| 243 | + | savedTokenID = token.ID | |
| 244 | + | return token.Subject == "testsubject" | |
| 245 | + | }), | |
| 246 | + | mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }), | |
| 247 | + | ). | |
| 248 | + | Return(nil) | |
| 249 | + | ||
| 250 | + | ctx := testContext() | |
| 251 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |
| 252 | + | require.NoError(t, err) | |
| 253 | + | ||
| 254 | + | // Token is revoked | |
| 255 | + | tokenStore.EXPECT(). | |
| 256 | + | IsRevoked( | |
| 257 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |
| 258 | + | mock.MatchedBy(func(tokenID string) bool { return tokenID == savedTokenID }), | |
| 259 | + | ). | |
| 260 | + | Return(true, nil) | |
| 261 | + | ||
| 262 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |
| 263 | + | ||
| 264 | + | assert.Error(t, err) | |
| 265 | + | assert.Nil(t, claims) | |
| 266 | + | assert.ErrorIs(t, err, domain.ErrRevokedToken) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func TestService_ValidateToken_InvalidSignature(t *testing.T) { | |
| 270 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 271 | + | ||
| 272 | + | // Service with different secret for validation | |
| 273 | + | svc := NewService(Config{ | |
| 274 | + | Enabled: true, | |
| 275 | + | AuthType: domain.AuthTypeToken, | |
| 276 | + | TokenSecret: []byte("different-secret-key"), | |
| 277 | + | }, tokenStore, zerowrap.Default()) | |
| 278 | + | ||
| 279 | + | // Create a token with a different secret | |
| 280 | + | otherSvc := NewService(Config{ | |
| 281 | + | Enabled: true, | |
| 282 | + | AuthType: domain.AuthTypeToken, | |
| 283 | + | TokenSecret: []byte("original-secret-key"), | |
| 284 | + | }, tokenStore, zerowrap.Default()) | |
| 285 | + | ||
| 286 | + | tokenStore.EXPECT(). | |
| 287 | + | SaveToken( | |
| 288 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |
| 289 | + | mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }), | |
| 290 | + | mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }), | |
| 291 | + | ). | |
| 292 | + | Return(nil) | |
| 293 | + | ||
| 294 | + | ctx := testContext() | |
| 295 | + | tokenStr, err := otherSvc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |
| 296 | + | require.NoError(t, err) | |
| 297 | + | ||
| 298 | + | // Try to validate with wrong secret - should fail before checking revocation | |
| 299 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |
| 300 | + | ||
| 301 | + | assert.Error(t, err) | |
| 302 | + | assert.Nil(t, claims) | |
| 303 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func TestService_ValidateToken_Expired(t *testing.T) { | |
| 307 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 308 | + | ||
| 309 | + | svc := NewService(Config{ | |
| 310 | + | Enabled: true, | |
| 311 | + | AuthType: domain.AuthTypeToken, | |
| 312 | + | TokenSecret: []byte("test-secret-key"), | |
| 313 | + | }, tokenStore, zerowrap.Default()) | |
| 314 | + | ||
| 315 | + | // Generate a token that expires immediately | |
| 316 | + | tokenStore.EXPECT(). | |
| 317 | + | SaveToken( | |
| 318 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |
| 319 | + | mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }), | |
| 320 | + | mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }), | |
| 321 | + | ). | |
| 322 | + | Return(nil) | |
| 323 | + | ||
| 324 | + | ctx := testContext() | |
| 325 | + | // Generate with very short expiry | |
| 326 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Millisecond) | |
| 327 | + | require.NoError(t, err) | |
| 328 | + | ||
| 329 | + | // Wait for token to expire | |
| 330 | + | time.Sleep(10 * time.Millisecond) | |
| 331 | + | ||
| 332 | + | // JWT library handles expiry during parsing - returns invalid token error | |
| 333 | + | // No IsRevoked call is made because parsing fails first | |
| 334 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |
| 335 | + | ||
| 336 | + | assert.Error(t, err) | |
| 337 | + | assert.Nil(t, claims) | |
| 338 | + | // JWT library returns invalid token for expired tokens during parsing | |
| 339 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func TestService_ValidateToken_MalformedToken(t *testing.T) { | |
| 343 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 344 | + | ||
| 345 | + | svc := NewService(Config{ | |
| 346 | + | Enabled: true, | |
| 347 | + | AuthType: domain.AuthTypeToken, | |
| 348 | + | TokenSecret: []byte("test-secret-key"), | |
| 349 | + | }, tokenStore, zerowrap.Default()) | |
| 350 | + | ||
| 351 | + | ctx := testContext() | |
| 352 | + | ||
| 353 | + | // Test with various malformed tokens | |
| 354 | + | malformedTokens := []string{ | |
| 355 | + | "", | |
| 356 | + | "not-a-jwt", | |
| 357 | + | "only.two.parts.here.extra", | |
| 358 | + | "invalid.base64.token", | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | for _, token := range malformedTokens { | |
| 362 | + | claims, err := svc.ValidateToken(ctx, token) | |
| 363 | + | assert.Error(t, err, "expected error for token: %s", token) | |
| 364 | + | assert.Nil(t, claims) | |
| 365 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | func TestService_RevokeToken(t *testing.T) { | |
| 370 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 371 | + | ||
| 372 | + | tokenID := "550e8400-e29b-41d4-a716-446655440000" | |
| 373 | + | ||
| 374 | + | tokenStore.EXPECT(). | |
| 375 | + | Revoke( | |
| 376 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |
| 377 | + | tokenID, | |
| 378 | + | ). | |
| 379 | + | Return(nil) | |
| 380 | + | ||
| 381 | + | svc := NewService(Config{ | |
| 382 | + | Enabled: true, | |
| 383 | + | AuthType: domain.AuthTypeToken, | |
| 384 | + | }, tokenStore, zerowrap.Default()) | |
| 385 | + | ||
| 386 | + | ctx := testContext() | |
| 387 | + | err := svc.RevokeToken(ctx, tokenID) | |
| 388 | + | ||
| 389 | + | assert.NoError(t, err) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func TestService_ListTokens(t *testing.T) { | |
| 393 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 394 | + | ||
| 395 | + | expectedTokens := []domain.Token{ | |
| 396 | + | { | |
| 397 | + | ID: "token-1", | |
| 398 | + | Subject: "user1", | |
| 399 | + | Scopes: []string{"push", "pull"}, | |
| 400 | + | IssuedAt: time.Now().Add(-24 * time.Hour), | |
| 401 | + | ExpiresAt: time.Now().Add(24 * time.Hour), | |
| 402 | + | Revoked: false, | |
| 403 | + | }, | |
| 404 | + | { | |
| 405 | + | ID: "token-2", | |
| 406 | + | Subject: "ci-bot", | |
| 407 | + | Scopes: []string{"push", "pull"}, | |
| 408 | + | IssuedAt: time.Now().Add(-48 * time.Hour), | |
| 409 | + | ExpiresAt: time.Time{}, // Never expires | |
| 410 | + | Revoked: false, | |
| 411 | + | }, | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | tokenStore.EXPECT(). | |
| 415 | + | ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })). | |
| 416 | + | Return(expectedTokens, nil) | |
| 417 | + | ||
| 418 | + | svc := NewService(Config{ | |
| 419 | + | Enabled: true, | |
| 420 | + | AuthType: domain.AuthTypeToken, | |
| 421 | + | }, tokenStore, zerowrap.Default()) | |
| 422 | + | ||
| 423 | + | ctx := testContext() | |
| 424 | + | tokens, err := svc.ListTokens(ctx) | |
| 425 | + | ||
| 426 | + | assert.NoError(t, err) | |
| 427 | + | assert.Len(t, tokens, 2) | |
| 428 | + | assert.Equal(t, "user1", tokens[0].Subject) | |
| 429 | + | assert.Equal(t, "ci-bot", tokens[1].Subject) | |
| 430 | + | assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire") | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | func TestService_GeneratePasswordHash(t *testing.T) { | |
| 434 | + | svc := NewService(Config{}, nil, zerowrap.Default()) | |
| 435 | + | ||
| 436 | + | password := "securePassword123!" | |
| 437 | + | hash, err := svc.GeneratePasswordHash(password) | |
| 438 | + | ||
| 439 | + | assert.NoError(t, err) | |
| 440 | + | assert.NotEmpty(t, hash) | |
| 441 | + | assert.NotEqual(t, password, hash, "hash should not equal plain password") | |
| 442 | + | ||
| 443 | + | // Verify the hash works with bcrypt | |
| 444 | + | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) | |
| 445 | + | assert.NoError(t, err) | |
| 446 | + | ||
| 447 | + | // Verify wrong password fails | |
| 448 | + | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword")) | |
| 449 | + | assert.Error(t, err) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) { | |
| 453 | + | svc := NewService(Config{}, nil, zerowrap.Default()) | |
| 454 | + | ||
| 455 | + | password := "testpassword" | |
| 456 | + | hash1, err := svc.GeneratePasswordHash(password) | |
| 457 | + | require.NoError(t, err) | |
| 458 | + | ||
| 459 | + | hash2, err := svc.GeneratePasswordHash(password) | |
| 460 | + | require.NoError(t, err) | |
| 461 | + | ||
| 462 | + | // Bcrypt should generate different hashes each time due to salt | |
| 463 | + | assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes") | |
| 464 | + | ||
| 465 | + | // But both should validate correctly | |
| 466 | + | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password))) | |
| 467 | + | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password))) | |
| 468 | + | } | |
| @@ -0,0 +1,468 @@ | |||||
| 1 | + | package auth | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | "golang.org/x/crypto/bcrypt" | |||
| 13 | + | ||||
| 14 | + | "gordon/internal/boundaries/out/mocks" | |||
| 15 | + | "gordon/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func testContext() context.Context { | |||
| 19 | + | return zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestService_GetAuthType(t *testing.T) { | |||
| 23 | + | tests := []struct { | |||
| 24 | + | name string | |||
| 25 | + | authType domain.AuthType | |||
| 26 | + | }{ | |||
| 27 | + | {"password type", domain.AuthTypePassword}, | |||
| 28 | + | {"token type", domain.AuthTypeToken}, | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | for _, tt := range tests { | |||
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 33 | + | svc := NewService(Config{AuthType: tt.authType}, nil, zerowrap.Default()) | |||
| 34 | + | assert.Equal(t, tt.authType, svc.GetAuthType()) | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestService_IsEnabled(t *testing.T) { | |||
| 40 | + | tests := []struct { | |||
| 41 | + | name string | |||
| 42 | + | enabled bool | |||
| 43 | + | }{ | |||
| 44 | + | {"enabled", true}, | |||
| 45 | + | {"disabled", false}, | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | for _, tt := range tests { | |||
| 49 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 50 | + | svc := NewService(Config{Enabled: tt.enabled}, nil, zerowrap.Default()) | |||
| 51 | + | assert.Equal(t, tt.enabled, svc.IsEnabled()) | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestService_ValidatePassword_Success(t *testing.T) { | |||
| 57 | + | password := "testpassword123" | |||
| 58 | + | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | ||||
| 61 | + | svc := NewService(Config{ | |||
| 62 | + | Enabled: true, | |||
| 63 | + | AuthType: domain.AuthTypePassword, | |||
| 64 | + | Username: "testuser", | |||
| 65 | + | PasswordHash: string(hash), | |||
| 66 | + | }, nil, zerowrap.Default()) | |||
| 67 | + | ||||
| 68 | + | ctx := testContext() | |||
| 69 | + | assert.True(t, svc.ValidatePassword(ctx, "testuser", password)) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestService_ValidatePassword_WrongPassword(t *testing.T) { | |||
| 73 | + | hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost) | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | ||||
| 76 | + | svc := NewService(Config{ | |||
| 77 | + | Enabled: true, | |||
| 78 | + | AuthType: domain.AuthTypePassword, | |||
| 79 | + | Username: "testuser", | |||
| 80 | + | PasswordHash: string(hash), | |||
| 81 | + | }, nil, zerowrap.Default()) | |||
| 82 | + | ||||
| 83 | + | ctx := testContext() | |||
| 84 | + | assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword")) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestService_ValidatePassword_WrongUsername(t *testing.T) { | |||
| 88 | + | password := "testpassword123" | |||
| 89 | + | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | ||||
| 92 | + | svc := NewService(Config{ | |||
| 93 | + | Enabled: true, | |||
| 94 | + | AuthType: domain.AuthTypePassword, | |||
| 95 | + | Username: "testuser", | |||
| 96 | + | PasswordHash: string(hash), | |||
| 97 | + | }, nil, zerowrap.Default()) | |||
| 98 | + | ||||
| 99 | + | ctx := testContext() | |||
| 100 | + | assert.False(t, svc.ValidatePassword(ctx, "wronguser", password)) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestService_GenerateToken_Success(t *testing.T) { | |||
| 104 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 105 | + | ||||
| 106 | + | // Capture the token to verify its properties | |||
| 107 | + | var capturedToken *domain.Token | |||
| 108 | + | var capturedJWT string | |||
| 109 | + | ||||
| 110 | + | tokenStore.EXPECT(). | |||
| 111 | + | SaveToken( | |||
| 112 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |||
| 113 | + | mock.MatchedBy(func(token *domain.Token) bool { | |||
| 114 | + | capturedToken = token | |||
| 115 | + | return token.Subject == "testsubject" && | |||
| 116 | + | len(token.ID) > 0 && | |||
| 117 | + | len(token.Scopes) == 2 && | |||
| 118 | + | token.Scopes[0] == "push" && | |||
| 119 | + | token.Scopes[1] == "pull" && | |||
| 120 | + | !token.ExpiresAt.IsZero() && | |||
| 121 | + | !token.Revoked | |||
| 122 | + | }), | |||
| 123 | + | mock.MatchedBy(func(jwt string) bool { | |||
| 124 | + | capturedJWT = jwt | |||
| 125 | + | // JWT should have 3 parts separated by dots | |||
| 126 | + | parts := 0 | |||
| 127 | + | for _, c := range jwt { | |||
| 128 | + | if c == '.' { | |||
| 129 | + | parts++ | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return parts == 2 && len(jwt) > 50 | |||
| 133 | + | }), | |||
| 134 | + | ). | |||
| 135 | + | Return(nil) | |||
| 136 | + | ||||
| 137 | + | svc := NewService(Config{ | |||
| 138 | + | Enabled: true, | |||
| 139 | + | AuthType: domain.AuthTypeToken, | |||
| 140 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 141 | + | }, tokenStore, zerowrap.Default()) | |||
| 142 | + | ||||
| 143 | + | ctx := testContext() | |||
| 144 | + | token, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |||
| 145 | + | ||||
| 146 | + | assert.NoError(t, err) | |||
| 147 | + | assert.NotEmpty(t, token) | |||
| 148 | + | assert.Equal(t, capturedJWT, token) | |||
| 149 | + | assert.NotNil(t, capturedToken) | |||
| 150 | + | assert.WithinDuration(t, time.Now().Add(time.Hour), capturedToken.ExpiresAt, 5*time.Second) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestService_GenerateToken_NeverExpires(t *testing.T) { | |||
| 154 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 155 | + | ||||
| 156 | + | tokenStore.EXPECT(). | |||
| 157 | + | SaveToken( | |||
| 158 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |||
| 159 | + | mock.MatchedBy(func(token *domain.Token) bool { | |||
| 160 | + | return token.Subject == "ci-bot" && | |||
| 161 | + | token.ExpiresAt.IsZero() // Never expires | |||
| 162 | + | }), | |||
| 163 | + | mock.MatchedBy(func(jwt string) bool { | |||
| 164 | + | return len(jwt) > 50 | |||
| 165 | + | }), | |||
| 166 | + | ). | |||
| 167 | + | Return(nil) | |||
| 168 | + | ||||
| 169 | + | svc := NewService(Config{ | |||
| 170 | + | Enabled: true, | |||
| 171 | + | AuthType: domain.AuthTypeToken, | |||
| 172 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 173 | + | }, tokenStore, zerowrap.Default()) | |||
| 174 | + | ||||
| 175 | + | ctx := testContext() | |||
| 176 | + | token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0) | |||
| 177 | + | ||||
| 178 | + | assert.NoError(t, err) | |||
| 179 | + | assert.NotEmpty(t, token) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestService_ValidateToken_Success(t *testing.T) { | |||
| 183 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 184 | + | ||||
| 185 | + | svc := NewService(Config{ | |||
| 186 | + | Enabled: true, | |||
| 187 | + | AuthType: domain.AuthTypeToken, | |||
| 188 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 189 | + | }, tokenStore, zerowrap.Default()) | |||
| 190 | + | ||||
| 191 | + | // Capture the token ID when saving | |||
| 192 | + | var savedTokenID string | |||
| 193 | + | tokenStore.EXPECT(). | |||
| 194 | + | SaveToken( | |||
| 195 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |||
| 196 | + | mock.MatchedBy(func(token *domain.Token) bool { | |||
| 197 | + | savedTokenID = token.ID | |||
| 198 | + | return token.Subject == "testsubject" | |||
| 199 | + | }), | |||
| 200 | + | mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }), | |||
| 201 | + | ). | |||
| 202 | + | Return(nil) | |||
| 203 | + | ||||
| 204 | + | ctx := testContext() | |||
| 205 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | ||||
| 208 | + | // Expect IsRevoked to be called with the same token ID | |||
| 209 | + | tokenStore.EXPECT(). | |||
| 210 | + | IsRevoked( | |||
| 211 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |||
| 212 | + | mock.MatchedBy(func(tokenID string) bool { | |||
| 213 | + | return tokenID == savedTokenID | |||
| 214 | + | }), | |||
| 215 | + | ). | |||
| 216 | + | Return(false, nil) | |||
| 217 | + | ||||
| 218 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |||
| 219 | + | ||||
| 220 | + | assert.NoError(t, err) | |||
| 221 | + | assert.NotNil(t, claims) | |||
| 222 | + | assert.Equal(t, "testsubject", claims.Subject) | |||
| 223 | + | assert.Equal(t, savedTokenID, claims.ID) | |||
| 224 | + | assert.Equal(t, TokenIssuer, claims.Issuer) | |||
| 225 | + | assert.Contains(t, claims.Scopes, "push") | |||
| 226 | + | assert.Contains(t, claims.Scopes, "pull") | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func TestService_ValidateToken_Revoked(t *testing.T) { | |||
| 230 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 231 | + | ||||
| 232 | + | svc := NewService(Config{ | |||
| 233 | + | Enabled: true, | |||
| 234 | + | AuthType: domain.AuthTypeToken, | |||
| 235 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 236 | + | }, tokenStore, zerowrap.Default()) | |||
| 237 | + | ||||
| 238 | + | var savedTokenID string | |||
| 239 | + | tokenStore.EXPECT(). | |||
| 240 | + | SaveToken( | |||
| 241 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |||
| 242 | + | mock.MatchedBy(func(token *domain.Token) bool { | |||
| 243 | + | savedTokenID = token.ID | |||
| 244 | + | return token.Subject == "testsubject" | |||
| 245 | + | }), | |||
| 246 | + | mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }), | |||
| 247 | + | ). | |||
| 248 | + | Return(nil) | |||
| 249 | + | ||||
| 250 | + | ctx := testContext() | |||
| 251 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |||
| 252 | + | require.NoError(t, err) | |||
| 253 | + | ||||
| 254 | + | // Token is revoked | |||
| 255 | + | tokenStore.EXPECT(). | |||
| 256 | + | IsRevoked( | |||
| 257 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |||
| 258 | + | mock.MatchedBy(func(tokenID string) bool { return tokenID == savedTokenID }), | |||
| 259 | + | ). | |||
| 260 | + | Return(true, nil) | |||
| 261 | + | ||||
| 262 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |||
| 263 | + | ||||
| 264 | + | assert.Error(t, err) | |||
| 265 | + | assert.Nil(t, claims) | |||
| 266 | + | assert.ErrorIs(t, err, domain.ErrRevokedToken) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func TestService_ValidateToken_InvalidSignature(t *testing.T) { | |||
| 270 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 271 | + | ||||
| 272 | + | // Service with different secret for validation | |||
| 273 | + | svc := NewService(Config{ | |||
| 274 | + | Enabled: true, | |||
| 275 | + | AuthType: domain.AuthTypeToken, | |||
| 276 | + | TokenSecret: []byte("different-secret-key"), | |||
| 277 | + | }, tokenStore, zerowrap.Default()) | |||
| 278 | + | ||||
| 279 | + | // Create a token with a different secret | |||
| 280 | + | otherSvc := NewService(Config{ | |||
| 281 | + | Enabled: true, | |||
| 282 | + | AuthType: domain.AuthTypeToken, | |||
| 283 | + | TokenSecret: []byte("original-secret-key"), | |||
| 284 | + | }, tokenStore, zerowrap.Default()) | |||
| 285 | + | ||||
| 286 | + | tokenStore.EXPECT(). | |||
| 287 | + | SaveToken( | |||
| 288 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |||
| 289 | + | mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }), | |||
| 290 | + | mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }), | |||
| 291 | + | ). | |||
| 292 | + | Return(nil) | |||
| 293 | + | ||||
| 294 | + | ctx := testContext() | |||
| 295 | + | tokenStr, err := otherSvc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |||
| 296 | + | require.NoError(t, err) | |||
| 297 | + | ||||
| 298 | + | // Try to validate with wrong secret - should fail before checking revocation | |||
| 299 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |||
| 300 | + | ||||
| 301 | + | assert.Error(t, err) | |||
| 302 | + | assert.Nil(t, claims) | |||
| 303 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func TestService_ValidateToken_Expired(t *testing.T) { | |||
| 307 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 308 | + | ||||
| 309 | + | svc := NewService(Config{ | |||
| 310 | + | Enabled: true, | |||
| 311 | + | AuthType: domain.AuthTypeToken, | |||
| 312 | + | TokenSecret: []byte("test-secret-key"), | |||
| 313 | + | }, tokenStore, zerowrap.Default()) | |||
| 314 | + | ||||
| 315 | + | // Generate a token that expires immediately | |||
| 316 | + | tokenStore.EXPECT(). | |||
| 317 | + | SaveToken( | |||
| 318 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |||
| 319 | + | mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }), | |||
| 320 | + | mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }), | |||
| 321 | + | ). | |||
| 322 | + | Return(nil) | |||
| 323 | + | ||||
| 324 | + | ctx := testContext() | |||
| 325 | + | // Generate with very short expiry | |||
| 326 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Millisecond) | |||
| 327 | + | require.NoError(t, err) | |||
| 328 | + | ||||
| 329 | + | // Wait for token to expire | |||
| 330 | + | time.Sleep(10 * time.Millisecond) | |||
| 331 | + | ||||
| 332 | + | // JWT library handles expiry during parsing - returns invalid token error | |||
| 333 | + | // No IsRevoked call is made because parsing fails first | |||
| 334 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |||
| 335 | + | ||||
| 336 | + | assert.Error(t, err) | |||
| 337 | + | assert.Nil(t, claims) | |||
| 338 | + | // JWT library returns invalid token for expired tokens during parsing | |||
| 339 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func TestService_ValidateToken_MalformedToken(t *testing.T) { | |||
| 343 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 344 | + | ||||
| 345 | + | svc := NewService(Config{ | |||
| 346 | + | Enabled: true, | |||
| 347 | + | AuthType: domain.AuthTypeToken, | |||
| 348 | + | TokenSecret: []byte("test-secret-key"), | |||
| 349 | + | }, tokenStore, zerowrap.Default()) | |||
| 350 | + | ||||
| 351 | + | ctx := testContext() | |||
| 352 | + | ||||
| 353 | + | // Test with various malformed tokens | |||
| 354 | + | malformedTokens := []string{ | |||
| 355 | + | "", | |||
| 356 | + | "not-a-jwt", | |||
| 357 | + | "only.two.parts.here.extra", | |||
| 358 | + | "invalid.base64.token", | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | for _, token := range malformedTokens { | |||
| 362 | + | claims, err := svc.ValidateToken(ctx, token) | |||
| 363 | + | assert.Error(t, err, "expected error for token: %s", token) | |||
| 364 | + | assert.Nil(t, claims) | |||
| 365 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |||
| 366 | + | } | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | func TestService_RevokeToken(t *testing.T) { | |||
| 370 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 371 | + | ||||
| 372 | + | tokenID := "550e8400-e29b-41d4-a716-446655440000" | |||
| 373 | + | ||||
| 374 | + | tokenStore.EXPECT(). | |||
| 375 | + | Revoke( | |||
| 376 | + | mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), | |||
| 377 | + | tokenID, | |||
| 378 | + | ). | |||
| 379 | + | Return(nil) | |||
| 380 | + | ||||
| 381 | + | svc := NewService(Config{ | |||
| 382 | + | Enabled: true, | |||
| 383 | + | AuthType: domain.AuthTypeToken, | |||
| 384 | + | }, tokenStore, zerowrap.Default()) | |||
| 385 | + | ||||
| 386 | + | ctx := testContext() | |||
| 387 | + | err := svc.RevokeToken(ctx, tokenID) | |||
| 388 | + | ||||
| 389 | + | assert.NoError(t, err) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func TestService_ListTokens(t *testing.T) { | |||
| 393 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 394 | + | ||||
| 395 | + | expectedTokens := []domain.Token{ | |||
| 396 | + | { | |||
| 397 | + | ID: "token-1", | |||
| 398 | + | Subject: "user1", | |||
| 399 | + | Scopes: []string{"push", "pull"}, | |||
| 400 | + | IssuedAt: time.Now().Add(-24 * time.Hour), | |||
| 401 | + | ExpiresAt: time.Now().Add(24 * time.Hour), | |||
| 402 | + | Revoked: false, | |||
| 403 | + | }, | |||
| 404 | + | { | |||
| 405 | + | ID: "token-2", | |||
| 406 | + | Subject: "ci-bot", | |||
| 407 | + | Scopes: []string{"push", "pull"}, | |||
| 408 | + | IssuedAt: time.Now().Add(-48 * time.Hour), | |||
| 409 | + | ExpiresAt: time.Time{}, // Never expires | |||
| 410 | + | Revoked: false, | |||
| 411 | + | }, | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | tokenStore.EXPECT(). | |||
| 415 | + | ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })). | |||
| 416 | + | Return(expectedTokens, nil) | |||
| 417 | + | ||||
| 418 | + | svc := NewService(Config{ | |||
| 419 | + | Enabled: true, | |||
| 420 | + | AuthType: domain.AuthTypeToken, | |||
| 421 | + | }, tokenStore, zerowrap.Default()) | |||
| 422 | + | ||||
| 423 | + | ctx := testContext() | |||
| 424 | + | tokens, err := svc.ListTokens(ctx) | |||
| 425 | + | ||||
| 426 | + | assert.NoError(t, err) | |||
| 427 | + | assert.Len(t, tokens, 2) | |||
| 428 | + | assert.Equal(t, "user1", tokens[0].Subject) | |||
| 429 | + | assert.Equal(t, "ci-bot", tokens[1].Subject) | |||
| 430 | + | assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire") | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | func TestService_GeneratePasswordHash(t *testing.T) { | |||
| 434 | + | svc := NewService(Config{}, nil, zerowrap.Default()) | |||
| 435 | + | ||||
| 436 | + | password := "securePassword123!" | |||
| 437 | + | hash, err := svc.GeneratePasswordHash(password) | |||
| 438 | + | ||||
| 439 | + | assert.NoError(t, err) | |||
| 440 | + | assert.NotEmpty(t, hash) | |||
| 441 | + | assert.NotEqual(t, password, hash, "hash should not equal plain password") | |||
| 442 | + | ||||
| 443 | + | // Verify the hash works with bcrypt | |||
| 444 | + | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) | |||
| 445 | + | assert.NoError(t, err) | |||
| 446 | + | ||||
| 447 | + | // Verify wrong password fails | |||
| 448 | + | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword")) | |||
| 449 | + | assert.Error(t, err) | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) { | |||
| 453 | + | svc := NewService(Config{}, nil, zerowrap.Default()) | |||
| 454 | + | ||||
| 455 | + | password := "testpassword" | |||
| 456 | + | hash1, err := svc.GeneratePasswordHash(password) | |||
| 457 | + | require.NoError(t, err) | |||
| 458 | + | ||||
| 459 | + | hash2, err := svc.GeneratePasswordHash(password) | |||
| 460 | + | require.NoError(t, err) | |||
| 461 | + | ||||
| 462 | + | // Bcrypt should generate different hashes each time due to salt | |||
| 463 | + | assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes") | |||
| 464 | + | ||||
| 465 | + | // But both should validate correctly | |||
| 466 | + | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password))) | |||
| 467 | + | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password))) | |||
| 468 | + | } | |||