fix(auth): reject tokens with future iat to prevent ephemeral classification bypass
- commit
- eb1babffdf3b5bcd77cce2d3461d78176193cb29
- author
- bnema · · 2026-02-25 01:31 UTC
- parent
- c84a77a
- signature
- unsigned
2 files · +57 -1
M internal/usecase/auth/service.go +8 -1
| @@ -168,7 +168,10 @@ func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) { | |||
| 168 | 168 | return nil, domain.ErrInvalidToken | |
| 169 | 169 | } | |
| 170 | 170 | return s.config.TokenSecret, nil | |
| 171 | - | }, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation | |
| 171 | + | }, | |
| 172 | + | jwt.WithIssuer(TokenIssuer), // SECURITY: Enforce issuer validation | |
| 173 | + | jwt.WithIssuedAt(), // SECURITY: Reject tokens with iat in the future | |
| 174 | + | ) | |
| 172 | 175 | if err != nil { | |
| 173 | 176 | return nil, domain.ErrInvalidToken | |
| 174 | 177 | } | |
| @@ -265,6 +268,10 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | |||
| 265 | 268 | // stolen secrets from creating arbitrary short-lived tokens | |
| 266 | 269 | now := time.Now().UTC().Unix() | |
| 267 | 270 | tokenAge := now - claims.IssuedAt | |
| 271 | + | if tokenAge < 0 { | |
| 272 | + | // iat is in the future — do not classify as ephemeral (defense-in-depth: prevents bypass) | |
| 273 | + | return false | |
| 274 | + | } | |
| 268 | 275 | if tokenAge > maxAccessTokenLifetimeSecs { | |
| 269 | 276 | return false // Old tokens require store validation | |
| 270 | 277 | } | |
| @@ -168,7 +168,10 @@ func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) { | |||||
| 168 | return nil, domain.ErrInvalidToken | 168 | return nil, domain.ErrInvalidToken | ||
| 169 | } | 169 | } | ||
| 170 | return s.config.TokenSecret, nil | 170 | return s.config.TokenSecret, nil | ||
| 171 | - | }, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation | 171 | + | }, |
| 172 | + | jwt.WithIssuer(TokenIssuer), // SECURITY: Enforce issuer validation | |||
| 173 | + | jwt.WithIssuedAt(), // SECURITY: Reject tokens with iat in the future | |||
| 174 | + | ) | |||
| 172 | if err != nil { | 175 | if err != nil { | ||
| 173 | return nil, domain.ErrInvalidToken | 176 | return nil, domain.ErrInvalidToken | ||
| 174 | } | 177 | } | ||
| @@ -265,6 +268,10 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | |||||
| 265 | // stolen secrets from creating arbitrary short-lived tokens | 268 | // stolen secrets from creating arbitrary short-lived tokens | ||
| 266 | now := time.Now().UTC().Unix() | 269 | now := time.Now().UTC().Unix() | ||
| 267 | tokenAge := now - claims.IssuedAt | 270 | tokenAge := now - claims.IssuedAt | ||
| 271 | + | if tokenAge < 0 { | |||
| 272 | + | // iat is in the future — do not classify as ephemeral (defense-in-depth: prevents bypass) | |||
| 273 | + | return false | |||
| 274 | + | } | |||
| 268 | if tokenAge > maxAccessTokenLifetimeSecs { | 275 | if tokenAge > maxAccessTokenLifetimeSecs { | ||
| 269 | return false // Old tokens require store validation | 276 | return false // Old tokens require store validation | ||
| 270 | } | 277 | } | ||
M internal/usecase/auth/service_test.go +49 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zerowrap" | |
| 10 | + | "github.com/golang-jwt/jwt/v5" | |
| 10 | 11 | "github.com/stretchr/testify/assert" | |
| 11 | 12 | "github.com/stretchr/testify/mock" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| @@ -976,6 +977,54 @@ func TestExtendTokenSkipsEphemeral(t *testing.T) { | |||
| 976 | 977 | assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended") | |
| 977 | 978 | } | |
| 978 | 979 | ||
| 980 | + | func newTestAuthService(t *testing.T) (*Service, *mocks.MockTokenStore) { | |
| 981 | + | t.Helper() | |
| 982 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 983 | + | svc := NewService(Config{ | |
| 984 | + | Enabled: true, | |
| 985 | + | AuthType: domain.AuthTypeToken, | |
| 986 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 987 | + | }, tokenStore, zerowrap.Default()) | |
| 988 | + | return svc, tokenStore | |
| 989 | + | } | |
| 990 | + | ||
| 991 | + | func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |
| 992 | + | // A token with iat in the future should NOT be treated as ephemeral | |
| 993 | + | // (otherwise it bypasses revocation checks) | |
| 994 | + | svc, _ := newTestAuthService(t) | |
| 995 | + | ||
| 996 | + | // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min). | |
| 997 | + | // nbf is set to current time (not future) so the JWT library's nbf check does not reject it — | |
| 998 | + | // we need to isolate the iat bypass specifically. | |
| 999 | + | now := time.Now().UTC() | |
| 1000 | + | claims := jwt.MapClaims{ | |
| 1001 | + | "jti": "test-jti", | |
| 1002 | + | "sub": "testuser", | |
| 1003 | + | "iss": TokenIssuer, | |
| 1004 | + | "iat": now.Add(10 * time.Minute).Unix(), // future iat — the attack vector | |
| 1005 | + | "nbf": now.Add(-1 * time.Second).Unix(), // current nbf so nbf check passes | |
| 1006 | + | "exp": now.Add(15 * time.Minute).Unix(), // exp - iat = 5min → looks ephemeral | |
| 1007 | + | "scopes": []string{"admin:*:*"}, | |
| 1008 | + | } | |
| 1009 | + | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |
| 1010 | + | tokenStr, err := token.SignedString(svc.config.TokenSecret) | |
| 1011 | + | if err != nil { | |
| 1012 | + | t.Fatal(err) | |
| 1013 | + | } | |
| 1014 | + | ||
| 1015 | + | // Parsing should either reject the token outright (future iat) or not classify it as ephemeral | |
| 1016 | + | rawClaims, err := svc.parseTokenClaims(tokenStr) | |
| 1017 | + | if err != nil { | |
| 1018 | + | // Acceptable: future iat rejected at parse time | |
| 1019 | + | t.Logf("token with future iat rejected at parse: %v", err) | |
| 1020 | + | return | |
| 1021 | + | } | |
| 1022 | + | tokenClaims := buildTokenClaims(rawClaims) | |
| 1023 | + | if svc.isEphemeralAccessToken(tokenClaims) { | |
| 1024 | + | t.Error("token with future iat must NOT be classified as ephemeral — it bypasses revocation") | |
| 1025 | + | } | |
| 1026 | + | } | |
| 1027 | + | ||
| 979 | 1028 | func TestExtendTokenSkipsServiceToken(t *testing.T) { | |
| 980 | 1029 | tokenStore := mocks.NewMockTokenStore(t) | |
| 981 | 1030 | ||
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 10 | + | "github.com/golang-jwt/jwt/v5" | |||
| 10 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/mock" | 12 | "github.com/stretchr/testify/mock" | ||
| 12 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| @@ -976,6 +977,54 @@ func TestExtendTokenSkipsEphemeral(t *testing.T) { | |||||
| 976 | assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended") | 977 | assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended") | ||
| 977 | } | 978 | } | ||
| 978 | 979 | ||||
| 980 | + | func newTestAuthService(t *testing.T) (*Service, *mocks.MockTokenStore) { | |||
| 981 | + | t.Helper() | |||
| 982 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 983 | + | svc := NewService(Config{ | |||
| 984 | + | Enabled: true, | |||
| 985 | + | AuthType: domain.AuthTypeToken, | |||
| 986 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 987 | + | }, tokenStore, zerowrap.Default()) | |||
| 988 | + | return svc, tokenStore | |||
| 989 | + | } | |||
| 990 | + | ||||
| 991 | + | func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |||
| 992 | + | // A token with iat in the future should NOT be treated as ephemeral | |||
| 993 | + | // (otherwise it bypasses revocation checks) | |||
| 994 | + | svc, _ := newTestAuthService(t) | |||
| 995 | + | ||||
| 996 | + | // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min). | |||
| 997 | + | // nbf is set to current time (not future) so the JWT library's nbf check does not reject it — | |||
| 998 | + | // we need to isolate the iat bypass specifically. | |||
| 999 | + | now := time.Now().UTC() | |||
| 1000 | + | claims := jwt.MapClaims{ | |||
| 1001 | + | "jti": "test-jti", | |||
| 1002 | + | "sub": "testuser", | |||
| 1003 | + | "iss": TokenIssuer, | |||
| 1004 | + | "iat": now.Add(10 * time.Minute).Unix(), // future iat — the attack vector | |||
| 1005 | + | "nbf": now.Add(-1 * time.Second).Unix(), // current nbf so nbf check passes | |||
| 1006 | + | "exp": now.Add(15 * time.Minute).Unix(), // exp - iat = 5min → looks ephemeral | |||
| 1007 | + | "scopes": []string{"admin:*:*"}, | |||
| 1008 | + | } | |||
| 1009 | + | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |||
| 1010 | + | tokenStr, err := token.SignedString(svc.config.TokenSecret) | |||
| 1011 | + | if err != nil { | |||
| 1012 | + | t.Fatal(err) | |||
| 1013 | + | } | |||
| 1014 | + | ||||
| 1015 | + | // Parsing should either reject the token outright (future iat) or not classify it as ephemeral | |||
| 1016 | + | rawClaims, err := svc.parseTokenClaims(tokenStr) | |||
| 1017 | + | if err != nil { | |||
| 1018 | + | // Acceptable: future iat rejected at parse time | |||
| 1019 | + | t.Logf("token with future iat rejected at parse: %v", err) | |||
| 1020 | + | return | |||
| 1021 | + | } | |||
| 1022 | + | tokenClaims := buildTokenClaims(rawClaims) | |||
| 1023 | + | if svc.isEphemeralAccessToken(tokenClaims) { | |||
| 1024 | + | t.Error("token with future iat must NOT be classified as ephemeral — it bypasses revocation") | |||
| 1025 | + | } | |||
| 1026 | + | } | |||
| 1027 | + | ||||
| 979 | func TestExtendTokenSkipsServiceToken(t *testing.T) { | 1028 | func TestExtendTokenSkipsServiceToken(t *testing.T) { | ||
| 980 | tokenStore := mocks.NewMockTokenStore(t) | 1029 | tokenStore := mocks.NewMockTokenStore(t) | ||
| 981 | 1030 | ||||