fix(auth): use UTC timestamps for token generation and validation
Ensures consistent timezone handling across servers by using time.Now().UTC() instead of time.Now() in all token-related code.
- commit
- b90df448cca1765d30aaefbaa1cb0e3bc76b973e
- author
- bnema · · 2026-02-03 19:47 UTC
- parent
- 43db1bb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +8 -8
M internal/adapters/in/http/auth/handler.go +3 -3
| @@ -147,7 +147,7 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||
| 147 | 147 | response := PasswordResponse{ | |
| 148 | 148 | Token: token, | |
| 149 | 149 | ExpiresIn: int(expiry.Seconds()), | |
| 150 | - | IssuedAt: time.Now().Format(time.RFC3339), | |
| 150 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 151 | 151 | } | |
| 152 | 152 | ||
| 153 | 153 | w.Header().Set("Content-Type", "application/json") | |
| @@ -244,7 +244,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 244 | 244 | response := dto.TokenResponse{ | |
| 245 | 245 | Token: accessToken, | |
| 246 | 246 | ExpiresIn: 300, // 5 minutes in seconds | |
| 247 | - | IssuedAt: time.Now().Format(time.RFC3339), | |
| 247 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 248 | 248 | } | |
| 249 | 249 | ||
| 250 | 250 | w.Header().Set("Content-Type", "application/json") | |
| @@ -266,7 +266,7 @@ func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) | |||
| 266 | 266 | response := dto.TokenResponse{ | |
| 267 | 267 | Token: "", // Empty token indicates limited access | |
| 268 | 268 | ExpiresIn: 60, // 1 minute | |
| 269 | - | IssuedAt: time.Now().Format(time.RFC3339), | |
| 269 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | 272 | w.Header().Set("Content-Type", "application/json") | |
| @@ -147,7 +147,7 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||||
| 147 | response := PasswordResponse{ | 147 | response := PasswordResponse{ | ||
| 148 | Token: token, | 148 | Token: token, | ||
| 149 | ExpiresIn: int(expiry.Seconds()), | 149 | ExpiresIn: int(expiry.Seconds()), | ||
| 150 | - | IssuedAt: time.Now().Format(time.RFC3339), | 150 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), |
| 151 | } | 151 | } | ||
| 152 | 152 | ||||
| 153 | w.Header().Set("Content-Type", "application/json") | 153 | w.Header().Set("Content-Type", "application/json") | ||
| @@ -244,7 +244,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 244 | response := dto.TokenResponse{ | 244 | response := dto.TokenResponse{ | ||
| 245 | Token: accessToken, | 245 | Token: accessToken, | ||
| 246 | ExpiresIn: 300, // 5 minutes in seconds | 246 | ExpiresIn: 300, // 5 minutes in seconds | ||
| 247 | - | IssuedAt: time.Now().Format(time.RFC3339), | 247 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), |
| 248 | } | 248 | } | ||
| 249 | 249 | ||||
| 250 | w.Header().Set("Content-Type", "application/json") | 250 | w.Header().Set("Content-Type", "application/json") | ||
| @@ -266,7 +266,7 @@ func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) | |||||
| 266 | response := dto.TokenResponse{ | 266 | response := dto.TokenResponse{ | ||
| 267 | Token: "", // Empty token indicates limited access | 267 | Token: "", // Empty token indicates limited access | ||
| 268 | ExpiresIn: 60, // 1 minute | 268 | ExpiresIn: 60, // 1 minute | ||
| 269 | - | IssuedAt: time.Now().Format(time.RFC3339), | 269 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), |
| 270 | } | 270 | } | ||
| 271 | 271 | ||||
| 272 | w.Header().Set("Content-Type", "application/json") | 272 | w.Header().Set("Content-Type", "application/json") | ||
M internal/domain/auth.go +1 -1
| @@ -43,7 +43,7 @@ func (t *Token) IsExpired() bool { | |||
| 43 | 43 | if t.ExpiresAt.IsZero() { | |
| 44 | 44 | return false // Never expires | |
| 45 | 45 | } | |
| 46 | - | return time.Now().After(t.ExpiresAt) | |
| 46 | + | return time.Now().UTC().After(t.ExpiresAt) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 49 | // SecretsBackend represents the type of secrets storage backend. | |
| @@ -43,7 +43,7 @@ func (t *Token) IsExpired() bool { | |||||
| 43 | if t.ExpiresAt.IsZero() { | 43 | if t.ExpiresAt.IsZero() { | ||
| 44 | return false // Never expires | 44 | return false // Never expires | ||
| 45 | } | 45 | } | ||
| 46 | - | return time.Now().After(t.ExpiresAt) | 46 | + | return time.Now().UTC().After(t.ExpiresAt) |
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | // SecretsBackend represents the type of secrets storage backend. | 49 | // SecretsBackend represents the type of secrets storage backend. | ||
M internal/usecase/auth/service.go +4 -4
| @@ -231,7 +231,7 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) | |||
| 231 | 231 | return nil | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | - | if time.Now().Unix() > tokenClaims.ExpiresAt { | |
| 234 | + | if time.Now().UTC().Unix() > tokenClaims.ExpiresAt { | |
| 235 | 235 | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | |
| 236 | 236 | return domain.ErrExpiredToken | |
| 237 | 237 | } | |
| @@ -256,7 +256,7 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | |||
| 256 | 256 | ||
| 257 | 257 | // Security: ensure token was recently issued to prevent attackers with | |
| 258 | 258 | // stolen secrets from creating arbitrary short-lived tokens | |
| 259 | - | now := time.Now().Unix() | |
| 259 | + | now := time.Now().UTC().Unix() | |
| 260 | 260 | tokenAge := now - claims.IssuedAt | |
| 261 | 261 | if tokenAge > maxAccessTokenLifetimeSecs { | |
| 262 | 262 | return false // Old tokens require store validation | |
| @@ -275,7 +275,7 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st | |||
| 275 | 275 | log := zerowrap.FromCtx(ctx) | |
| 276 | 276 | ||
| 277 | 277 | tokenID := uuid.New().String() | |
| 278 | - | now := time.Now() | |
| 278 | + | now := time.Now().UTC() | |
| 279 | 279 | ||
| 280 | 280 | // Build token | |
| 281 | 281 | token := &domain.Token{ | |
| @@ -345,7 +345,7 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope | |||
| 345 | 345 | } | |
| 346 | 346 | ||
| 347 | 347 | tokenID := uuid.New().String() | |
| 348 | - | now := time.Now() | |
| 348 | + | now := time.Now().UTC() | |
| 349 | 349 | ||
| 350 | 350 | claims := jwt.MapClaims{ | |
| 351 | 351 | "jti": tokenID, | |
| @@ -231,7 +231,7 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) | |||||
| 231 | return nil | 231 | return nil | ||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | - | if time.Now().Unix() > tokenClaims.ExpiresAt { | 234 | + | if time.Now().UTC().Unix() > tokenClaims.ExpiresAt { |
| 235 | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | 235 | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | ||
| 236 | return domain.ErrExpiredToken | 236 | return domain.ErrExpiredToken | ||
| 237 | } | 237 | } | ||
| @@ -256,7 +256,7 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | |||||
| 256 | 256 | ||||
| 257 | // Security: ensure token was recently issued to prevent attackers with | 257 | // Security: ensure token was recently issued to prevent attackers with | ||
| 258 | // stolen secrets from creating arbitrary short-lived tokens | 258 | // stolen secrets from creating arbitrary short-lived tokens | ||
| 259 | - | now := time.Now().Unix() | 259 | + | now := time.Now().UTC().Unix() |
| 260 | tokenAge := now - claims.IssuedAt | 260 | tokenAge := now - claims.IssuedAt | ||
| 261 | if tokenAge > maxAccessTokenLifetimeSecs { | 261 | if tokenAge > maxAccessTokenLifetimeSecs { | ||
| 262 | return false // Old tokens require store validation | 262 | return false // Old tokens require store validation | ||
| @@ -275,7 +275,7 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st | |||||
| 275 | log := zerowrap.FromCtx(ctx) | 275 | log := zerowrap.FromCtx(ctx) | ||
| 276 | 276 | ||||
| 277 | tokenID := uuid.New().String() | 277 | tokenID := uuid.New().String() | ||
| 278 | - | now := time.Now() | 278 | + | now := time.Now().UTC() |
| 279 | 279 | ||||
| 280 | // Build token | 280 | // Build token | ||
| 281 | token := &domain.Token{ | 281 | token := &domain.Token{ | ||
| @@ -345,7 +345,7 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope | |||||
| 345 | } | 345 | } | ||
| 346 | 346 | ||||
| 347 | tokenID := uuid.New().String() | 347 | tokenID := uuid.New().String() | ||
| 348 | - | now := time.Now() | 348 | + | now := time.Now().UTC() |
| 349 | 349 | ||||
| 350 | claims := jwt.MapClaims{ | 350 | claims := jwt.MapClaims{ | ||
| 351 | "jti": tokenID, | 351 | "jti": tokenID, | ||