detached 2a0009c

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
4141
4242 // ExtendToken re-issues the token with expiry slid forward by 24h.
4343 // 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.
4545 ExtendToken(ctx context.Context, tokenString string) (string, error)
4646
4747 // 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 {
2929
3030 // TokenClaims represents the JWT claims for a token.
3131 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)
3839 }
3940
4041 // 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 expires36+ 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
9494
9595 tokenClaims := buildTokenClaims(claims)
9696
97- // Access tokens (short-lived, ≤5min, recently issued) skip store validation.
97+ // Access tokens (short-lived, ≤1h, recently issued) skip store validation.
9898 // CLI-generated tokens must exist in store to prevent use of externally-created tokens.
9999 // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent
100100 // attackers with stolen secrets from creating arbitrary short-lived tokens.
101101 isAccessToken := s.isEphemeralAccessToken(tokenClaims)
102+ tokenClaims.IsEphemeral = isAccessToken
102103
103104 if !isAccessToken {
104105 if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
@@ -430,7 +431,7 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
430431
431432 // ExtendToken re-issues the token with expiry slid forward by 24h.
432433 // 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.
434435 func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) {
435436 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
436437 zerowrap.FieldLayer: "usecase",
@@ -446,7 +447,7 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
446447
447448 tokenClaims := buildTokenClaims(rawClaims)
448449
449- // Skip ephemeral access tokens (≤5min lifetime)
450+ // Skip ephemeral access tokens (≤1h lifetime)
450451 if s.isEphemeralAccessToken(tokenClaims) {
451452 log.Debug().Msg("skipping extension for ephemeral access token")
452453 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 prevent99 // 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, nil453 return tokenString, nil
M internal/usecase/auth/service_test.go +90 -2
@@ -881,7 +881,7 @@ func TestExtendTokenSkipsEphemeral(t *testing.T) {
881881
882882 ctx := testContext()
883883
884- // Generate an ephemeral access token (≤5min)
884+ // Generate an ephemeral access token (≤MaxAccessTokenLifetime)
885885 tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute)
886886 require.NoError(t, err)
887887
@@ -907,7 +907,7 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
907907 // (otherwise it bypasses revocation checks)
908908 svc, _ := newTestAuthService(t)
909909
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).
911911 // nbf is set to current time (not future) so the JWT library's nbf check does not reject it —
912912 // we need to isolate the iat bypass specifically.
913913 now := time.Now().UTC()
@@ -964,3 +964,91 @@ func TestExtendTokenSkipsServiceToken(t *testing.T) {
964964 require.NoError(t, err)
965965 assert.Equal(t, tokenStr, result, "service tokens should not be extended")
966966 }
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+}

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