detached eb1babf

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) {
168168 return nil, domain.ErrInvalidToken
169169 }
170170 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+ )
172175 if err != nil {
173176 return nil, domain.ErrInvalidToken
174177 }
@@ -265,6 +268,10 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
265268 // stolen secrets from creating arbitrary short-lived tokens
266269 now := time.Now().UTC().Unix()
267270 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+ }
268275 if tokenAge > maxAccessTokenLifetimeSecs {
269276 return false // Old tokens require store validation
270277 }
@@ -168,7 +168,10 @@ func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) {
168 return nil, domain.ErrInvalidToken168 return nil, domain.ErrInvalidToken
169 }169 }
170 return s.config.TokenSecret, nil170 return s.config.TokenSecret, nil
171- }, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation171+ },
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.ErrInvalidToken176 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 tokens268 // stolen secrets from creating arbitrary short-lived tokens
266 now := time.Now().UTC().Unix()269 now := time.Now().UTC().Unix()
267 tokenAge := now - claims.IssuedAt270 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 validation276 return false // Old tokens require store validation
270 }277 }
M internal/usecase/auth/service_test.go +49 -0
@@ -7,6 +7,7 @@ import (
77 "time"
88
99 "github.com/bnema/zerowrap"
10+ "github.com/golang-jwt/jwt/v5"
1011 "github.com/stretchr/testify/assert"
1112 "github.com/stretchr/testify/mock"
1213 "github.com/stretchr/testify/require"
@@ -976,6 +977,54 @@ func TestExtendTokenSkipsEphemeral(t *testing.T) {
976977 assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended")
977978 }
978979
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+
9791028 func TestExtendTokenSkipsServiceToken(t *testing.T) {
9801029 tokenStore := mocks.NewMockTokenStore(t)
9811030
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close