feat(auth): add IsEphemeral flag to TokenClaims for token type discrimination
- commit
- 2a0009c70ed88ad89142cdf1480c9ae71a9c4f43
- author
- brice · · 2026-03-22 19:39 UTC
- committer
- brice · · 2026-03-22 19:41 UTC
- parent
- 70ea140
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +102 -12
M internal/boundaries/in/auth.go +1 -1
| @@ -41,7 +41,7 @@ type AuthService interface { | |||
| 41 | 41 | ||
| 42 | 42 | // ExtendToken re-issues the token with expiry slid forward by 24h. | |
| 43 | 43 | // Returns the same token string if it was already extended within the debounce window (1h). | |
| 44 | - | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | |
| 44 | + | // Skips extension for ephemeral access tokens (≤1h) and service tokens. | |
| 45 | 45 | ExtendToken(ctx context.Context, tokenString string) (string, error) | |
| 46 | 46 | ||
| 47 | 47 | // GetAccessTokenTTL returns the configured ephemeral access token lifetime. | |
| @@ -41,7 +41,7 @@ type AuthService interface { | |||||
| 41 | 41 | ||||
| 42 | // ExtendToken re-issues the token with expiry slid forward by 24h. | 42 | // ExtendToken re-issues the token with expiry slid forward by 24h. | ||
| 43 | // Returns the same token string if it was already extended within the debounce window (1h). | 43 | // Returns the same token string if it was already extended within the debounce window (1h). | ||
| 44 | - | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | 44 | + | // Skips extension for ephemeral access tokens (≤1h) and service tokens. |
| 45 | ExtendToken(ctx context.Context, tokenString string) (string, error) | 45 | ExtendToken(ctx context.Context, tokenString string) (string, error) | ||
| 46 | 46 | ||||
| 47 | // GetAccessTokenTTL returns the configured ephemeral access token lifetime. | 47 | // GetAccessTokenTTL returns the configured ephemeral access token lifetime. | ||
M internal/domain/auth.go +7 -6
| @@ -29,12 +29,13 @@ type Token struct { | |||
| 29 | 29 | ||
| 30 | 30 | // TokenClaims represents the JWT claims for a token. | |
| 31 | 31 | type TokenClaims struct { | |
| 32 | - | ID string `json:"jti"` | |
| 33 | - | Subject string `json:"sub"` | |
| 34 | - | Scopes []string `json:"scopes"` | |
| 35 | - | IssuedAt int64 `json:"iat"` | |
| 36 | - | ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires | |
| 37 | - | Issuer string `json:"iss"` | |
| 32 | + | ID string `json:"jti"` | |
| 33 | + | Subject string `json:"sub"` | |
| 34 | + | Scopes []string `json:"scopes"` | |
| 35 | + | IssuedAt int64 `json:"iat"` | |
| 36 | + | ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires | |
| 37 | + | Issuer string `json:"iss"` | |
| 38 | + | IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored) | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 40 | 41 | // IsExpired checks if the token has expired. | |
| @@ -29,12 +29,13 @@ type Token struct { | |||||
| 29 | 29 | ||||
| 30 | // TokenClaims represents the JWT claims for a token. | 30 | // TokenClaims represents the JWT claims for a token. | ||
| 31 | type TokenClaims struct { | 31 | type TokenClaims struct { | ||
| 32 | - | ID string `json:"jti"` | 32 | + | ID string `json:"jti"` |
| 33 | - | Subject string `json:"sub"` | 33 | + | Subject string `json:"sub"` |
| 34 | - | Scopes []string `json:"scopes"` | 34 | + | Scopes []string `json:"scopes"` |
| 35 | - | IssuedAt int64 `json:"iat"` | 35 | + | IssuedAt int64 `json:"iat"` |
| 36 | - | ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires | 36 | + | ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires |
| 37 | - | Issuer string `json:"iss"` | 37 | + | Issuer string `json:"iss"` |
| 38 | + | IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored) | |||
| 38 | } | 39 | } | ||
| 39 | 40 | ||||
| 40 | // IsExpired checks if the token has expired. | 41 | // IsExpired checks if the token has expired. | ||
M internal/usecase/auth/service.go +4 -3
| @@ -94,11 +94,12 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai | |||
| 94 | 94 | ||
| 95 | 95 | tokenClaims := buildTokenClaims(claims) | |
| 96 | 96 | ||
| 97 | - | // Access tokens (short-lived, ≤5min, recently issued) skip store validation. | |
| 97 | + | // Access tokens (short-lived, ≤1h, recently issued) skip store validation. | |
| 98 | 98 | // CLI-generated tokens must exist in store to prevent use of externally-created tokens. | |
| 99 | 99 | // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent | |
| 100 | 100 | // attackers with stolen secrets from creating arbitrary short-lived tokens. | |
| 101 | 101 | isAccessToken := s.isEphemeralAccessToken(tokenClaims) | |
| 102 | + | tokenClaims.IsEphemeral = isAccessToken | |
| 102 | 103 | ||
| 103 | 104 | if !isAccessToken { | |
| 104 | 105 | if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil { | |
| @@ -430,7 +431,7 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||
| 430 | 431 | ||
| 431 | 432 | // ExtendToken re-issues the token with expiry slid forward by 24h. | |
| 432 | 433 | // Returns the same token string if it was already extended within the debounce window (1h). | |
| 433 | - | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | |
| 434 | + | // Skips extension for ephemeral access tokens (≤1h) and service tokens. | |
| 434 | 435 | func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) { | |
| 435 | 436 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 436 | 437 | zerowrap.FieldLayer: "usecase", | |
| @@ -446,7 +447,7 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||
| 446 | 447 | ||
| 447 | 448 | tokenClaims := buildTokenClaims(rawClaims) | |
| 448 | 449 | ||
| 449 | - | // Skip ephemeral access tokens (≤5min lifetime) | |
| 450 | + | // Skip ephemeral access tokens (≤1h lifetime) | |
| 450 | 451 | if s.isEphemeralAccessToken(tokenClaims) { | |
| 451 | 452 | log.Debug().Msg("skipping extension for ephemeral access token") | |
| 452 | 453 | return tokenString, nil | |
| @@ -94,11 +94,12 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai | |||||
| 94 | 94 | ||||
| 95 | tokenClaims := buildTokenClaims(claims) | 95 | tokenClaims := buildTokenClaims(claims) | ||
| 96 | 96 | ||||
| 97 | - | // Access tokens (short-lived, ≤5min, recently issued) skip store validation. | 97 | + | // Access tokens (short-lived, ≤1h, recently issued) skip store validation. |
| 98 | // CLI-generated tokens must exist in store to prevent use of externally-created tokens. | 98 | // CLI-generated tokens must exist in store to prevent use of externally-created tokens. | ||
| 99 | // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent | 99 | // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent | ||
| 100 | // attackers with stolen secrets from creating arbitrary short-lived tokens. | 100 | // attackers with stolen secrets from creating arbitrary short-lived tokens. | ||
| 101 | isAccessToken := s.isEphemeralAccessToken(tokenClaims) | 101 | isAccessToken := s.isEphemeralAccessToken(tokenClaims) | ||
| 102 | + | tokenClaims.IsEphemeral = isAccessToken | |||
| 102 | 103 | ||||
| 103 | if !isAccessToken { | 104 | if !isAccessToken { | ||
| 104 | if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil { | 105 | if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil { | ||
| @@ -430,7 +431,7 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||||
| 430 | 431 | ||||
| 431 | // ExtendToken re-issues the token with expiry slid forward by 24h. | 432 | // ExtendToken re-issues the token with expiry slid forward by 24h. | ||
| 432 | // Returns the same token string if it was already extended within the debounce window (1h). | 433 | // Returns the same token string if it was already extended within the debounce window (1h). | ||
| 433 | - | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | 434 | + | // Skips extension for ephemeral access tokens (≤1h) and service tokens. |
| 434 | func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) { | 435 | func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) { | ||
| 435 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 436 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 436 | zerowrap.FieldLayer: "usecase", | 437 | zerowrap.FieldLayer: "usecase", | ||
| @@ -446,7 +447,7 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||||
| 446 | 447 | ||||
| 447 | tokenClaims := buildTokenClaims(rawClaims) | 448 | tokenClaims := buildTokenClaims(rawClaims) | ||
| 448 | 449 | ||||
| 449 | - | // Skip ephemeral access tokens (≤5min lifetime) | 450 | + | // Skip ephemeral access tokens (≤1h lifetime) |
| 450 | if s.isEphemeralAccessToken(tokenClaims) { | 451 | if s.isEphemeralAccessToken(tokenClaims) { | ||
| 451 | log.Debug().Msg("skipping extension for ephemeral access token") | 452 | log.Debug().Msg("skipping extension for ephemeral access token") | ||
| 452 | return tokenString, nil | 453 | return tokenString, nil | ||
M internal/usecase/auth/service_test.go +90 -2
| @@ -881,7 +881,7 @@ func TestExtendTokenSkipsEphemeral(t *testing.T) { | |||
| 881 | 881 | ||
| 882 | 882 | ctx := testContext() | |
| 883 | 883 | ||
| 884 | - | // Generate an ephemeral access token (≤5min) | |
| 884 | + | // Generate an ephemeral access token (≤MaxAccessTokenLifetime) | |
| 885 | 885 | tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute) | |
| 886 | 886 | require.NoError(t, err) | |
| 887 | 887 | ||
| @@ -907,7 +907,7 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |||
| 907 | 907 | // (otherwise it bypasses revocation checks) | |
| 908 | 908 | svc, _ := newTestAuthService(t) | |
| 909 | 909 | ||
| 910 | - | // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min). | |
| 910 | + | // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < MaxAccessTokenLifetime). | |
| 911 | 911 | // nbf is set to current time (not future) so the JWT library's nbf check does not reject it — | |
| 912 | 912 | // we need to isolate the iat bypass specifically. | |
| 913 | 913 | now := time.Now().UTC() | |
| @@ -964,3 +964,91 @@ func TestExtendTokenSkipsServiceToken(t *testing.T) { | |||
| 964 | 964 | require.NoError(t, err) | |
| 965 | 965 | assert.Equal(t, tokenStr, result, "service tokens should not be extended") | |
| 966 | 966 | } | |
| 967 | + | ||
| 968 | + | func TestValidateToken_IsEphemeral_EphemeralToken(t *testing.T) { | |
| 969 | + | // Ephemeral access tokens (generated by GenerateAccessToken) should have IsEphemeral = true | |
| 970 | + | svc := NewService(Config{ | |
| 971 | + | Enabled: true, | |
| 972 | + | AuthType: domain.AuthTypeToken, | |
| 973 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 974 | + | }, nil, zerowrap.Default()) | |
| 975 | + | ||
| 976 | + | ctx := testContext() | |
| 977 | + | ||
| 978 | + | tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 5*time.Minute) | |
| 979 | + | require.NoError(t, err) | |
| 980 | + | ||
| 981 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |
| 982 | + | require.NoError(t, err) | |
| 983 | + | assert.True(t, claims.IsEphemeral, "ephemeral access token should have IsEphemeral = true") | |
| 984 | + | } | |
| 985 | + | ||
| 986 | + | func TestValidateToken_IsEphemeral_StoredToken(t *testing.T) { | |
| 987 | + | // Long-lived stored tokens should have IsEphemeral = false | |
| 988 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 989 | + | ||
| 990 | + | svc := NewService(Config{ | |
| 991 | + | Enabled: true, | |
| 992 | + | AuthType: domain.AuthTypeToken, | |
| 993 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 994 | + | }, tokenStore, zerowrap.Default()) | |
| 995 | + | ||
| 996 | + | ctx := testContext() | |
| 997 | + | ||
| 998 | + | var capturedToken *domain.Token | |
| 999 | + | tokenStore.EXPECT(). | |
| 1000 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |
| 1001 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |
| 1002 | + | capturedToken = t | |
| 1003 | + | }). | |
| 1004 | + | Return(nil) | |
| 1005 | + | ||
| 1006 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute) | |
| 1007 | + | require.NoError(t, err) | |
| 1008 | + | ||
| 1009 | + | tokenStore.EXPECT(). | |
| 1010 | + | GetToken(mock.Anything, "testuser"). | |
| 1011 | + | Return(tokenStr, capturedToken, nil) | |
| 1012 | + | tokenStore.EXPECT(). | |
| 1013 | + | IsRevoked(mock.Anything, capturedToken.ID). | |
| 1014 | + | Return(false, nil) | |
| 1015 | + | ||
| 1016 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |
| 1017 | + | require.NoError(t, err) | |
| 1018 | + | assert.False(t, claims.IsEphemeral, "stored token should have IsEphemeral = false") | |
| 1019 | + | } | |
| 1020 | + | ||
| 1021 | + | func TestValidateToken_IsEphemeral_NeverExpiringToken(t *testing.T) { | |
| 1022 | + | // Never-expiring tokens must not be ephemeral | |
| 1023 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 1024 | + | ||
| 1025 | + | svc := NewService(Config{ | |
| 1026 | + | Enabled: true, | |
| 1027 | + | AuthType: domain.AuthTypeToken, | |
| 1028 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 1029 | + | }, tokenStore, zerowrap.Default()) | |
| 1030 | + | ||
| 1031 | + | ctx := testContext() | |
| 1032 | + | ||
| 1033 | + | var capturedToken *domain.Token | |
| 1034 | + | tokenStore.EXPECT(). | |
| 1035 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |
| 1036 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |
| 1037 | + | capturedToken = t | |
| 1038 | + | }). | |
| 1039 | + | Return(nil) | |
| 1040 | + | ||
| 1041 | + | tokenStr, err := svc.GenerateToken(ctx, "ci-bot", []string{"push"}, 0) | |
| 1042 | + | require.NoError(t, err) | |
| 1043 | + | ||
| 1044 | + | tokenStore.EXPECT(). | |
| 1045 | + | GetToken(mock.Anything, "ci-bot"). | |
| 1046 | + | Return(tokenStr, capturedToken, nil) | |
| 1047 | + | tokenStore.EXPECT(). | |
| 1048 | + | IsRevoked(mock.Anything, capturedToken.ID). | |
| 1049 | + | Return(false, nil) | |
| 1050 | + | ||
| 1051 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |
| 1052 | + | require.NoError(t, err) | |
| 1053 | + | assert.False(t, claims.IsEphemeral, "never-expiring token should have IsEphemeral = false") | |
| 1054 | + | } | |
| @@ -881,7 +881,7 @@ func TestExtendTokenSkipsEphemeral(t *testing.T) { | |||||
| 881 | 881 | ||||
| 882 | ctx := testContext() | 882 | ctx := testContext() | ||
| 883 | 883 | ||||
| 884 | - | // Generate an ephemeral access token (≤5min) | 884 | + | // Generate an ephemeral access token (≤MaxAccessTokenLifetime) |
| 885 | tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute) | 885 | tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute) | ||
| 886 | require.NoError(t, err) | 886 | require.NoError(t, err) | ||
| 887 | 887 | ||||
| @@ -907,7 +907,7 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |||||
| 907 | // (otherwise it bypasses revocation checks) | 907 | // (otherwise it bypasses revocation checks) | ||
| 908 | svc, _ := newTestAuthService(t) | 908 | svc, _ := newTestAuthService(t) | ||
| 909 | 909 | ||||
| 910 | - | // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min). | 910 | + | // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < MaxAccessTokenLifetime). |
| 911 | // nbf is set to current time (not future) so the JWT library's nbf check does not reject it — | 911 | // nbf is set to current time (not future) so the JWT library's nbf check does not reject it — | ||
| 912 | // we need to isolate the iat bypass specifically. | 912 | // we need to isolate the iat bypass specifically. | ||
| 913 | now := time.Now().UTC() | 913 | now := time.Now().UTC() | ||
| @@ -964,3 +964,91 @@ func TestExtendTokenSkipsServiceToken(t *testing.T) { | |||||
| 964 | require.NoError(t, err) | 964 | require.NoError(t, err) | ||
| 965 | assert.Equal(t, tokenStr, result, "service tokens should not be extended") | 965 | assert.Equal(t, tokenStr, result, "service tokens should not be extended") | ||
| 966 | } | 966 | } | ||
| 967 | + | ||||
| 968 | + | func TestValidateToken_IsEphemeral_EphemeralToken(t *testing.T) { | |||
| 969 | + | // Ephemeral access tokens (generated by GenerateAccessToken) should have IsEphemeral = true | |||
| 970 | + | svc := NewService(Config{ | |||
| 971 | + | Enabled: true, | |||
| 972 | + | AuthType: domain.AuthTypeToken, | |||
| 973 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 974 | + | }, nil, zerowrap.Default()) | |||
| 975 | + | ||||
| 976 | + | ctx := testContext() | |||
| 977 | + | ||||
| 978 | + | tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 5*time.Minute) | |||
| 979 | + | require.NoError(t, err) | |||
| 980 | + | ||||
| 981 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |||
| 982 | + | require.NoError(t, err) | |||
| 983 | + | assert.True(t, claims.IsEphemeral, "ephemeral access token should have IsEphemeral = true") | |||
| 984 | + | } | |||
| 985 | + | ||||
| 986 | + | func TestValidateToken_IsEphemeral_StoredToken(t *testing.T) { | |||
| 987 | + | // Long-lived stored tokens should have IsEphemeral = false | |||
| 988 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 989 | + | ||||
| 990 | + | svc := NewService(Config{ | |||
| 991 | + | Enabled: true, | |||
| 992 | + | AuthType: domain.AuthTypeToken, | |||
| 993 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 994 | + | }, tokenStore, zerowrap.Default()) | |||
| 995 | + | ||||
| 996 | + | ctx := testContext() | |||
| 997 | + | ||||
| 998 | + | var capturedToken *domain.Token | |||
| 999 | + | tokenStore.EXPECT(). | |||
| 1000 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |||
| 1001 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |||
| 1002 | + | capturedToken = t | |||
| 1003 | + | }). | |||
| 1004 | + | Return(nil) | |||
| 1005 | + | ||||
| 1006 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute) | |||
| 1007 | + | require.NoError(t, err) | |||
| 1008 | + | ||||
| 1009 | + | tokenStore.EXPECT(). | |||
| 1010 | + | GetToken(mock.Anything, "testuser"). | |||
| 1011 | + | Return(tokenStr, capturedToken, nil) | |||
| 1012 | + | tokenStore.EXPECT(). | |||
| 1013 | + | IsRevoked(mock.Anything, capturedToken.ID). | |||
| 1014 | + | Return(false, nil) | |||
| 1015 | + | ||||
| 1016 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |||
| 1017 | + | require.NoError(t, err) | |||
| 1018 | + | assert.False(t, claims.IsEphemeral, "stored token should have IsEphemeral = false") | |||
| 1019 | + | } | |||
| 1020 | + | ||||
| 1021 | + | func TestValidateToken_IsEphemeral_NeverExpiringToken(t *testing.T) { | |||
| 1022 | + | // Never-expiring tokens must not be ephemeral | |||
| 1023 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 1024 | + | ||||
| 1025 | + | svc := NewService(Config{ | |||
| 1026 | + | Enabled: true, | |||
| 1027 | + | AuthType: domain.AuthTypeToken, | |||
| 1028 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 1029 | + | }, tokenStore, zerowrap.Default()) | |||
| 1030 | + | ||||
| 1031 | + | ctx := testContext() | |||
| 1032 | + | ||||
| 1033 | + | var capturedToken *domain.Token | |||
| 1034 | + | tokenStore.EXPECT(). | |||
| 1035 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |||
| 1036 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |||
| 1037 | + | capturedToken = t | |||
| 1038 | + | }). | |||
| 1039 | + | Return(nil) | |||
| 1040 | + | ||||
| 1041 | + | tokenStr, err := svc.GenerateToken(ctx, "ci-bot", []string{"push"}, 0) | |||
| 1042 | + | require.NoError(t, err) | |||
| 1043 | + | ||||
| 1044 | + | tokenStore.EXPECT(). | |||
| 1045 | + | GetToken(mock.Anything, "ci-bot"). | |||
| 1046 | + | Return(tokenStr, capturedToken, nil) | |||
| 1047 | + | tokenStore.EXPECT(). | |||
| 1048 | + | IsRevoked(mock.Anything, capturedToken.ID). | |||
| 1049 | + | Return(false, nil) | |||
| 1050 | + | ||||
| 1051 | + | claims, err := svc.ValidateToken(ctx, tokenStr) | |||
| 1052 | + | require.NoError(t, err) | |||
| 1053 | + | assert.False(t, claims.IsEphemeral, "never-expiring token should have IsEphemeral = false") | |||
| 1054 | + | } | |||