detached 99f5ba4

Merge pull request #17 from bnema/fix/registry-auth-401-token-overwrite

fix(auth): prevent access tokens from overwriting CLI tokens
commit
99f5ba4b0b111c2cd803d768d7309809a0681276
author
bnema · · 2026-01-15 11:46 UTC
committer
GitHub · · 2026-01-15 11:46 UTC
merge of
64d39bf + 43a7f63 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.2.1
6 files · +305 -9
M internal/adapters/in/http/middleware/auth_test.go +4 -0
@@ -481,6 +481,10 @@ func (s stubAuthService) GenerateToken(context.Context, string, []string, time.D
481481 return "", errors.New("not implemented")
482482 }
483483
484+func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
485+ return "", errors.New("not implemented")
486+}
487+
484488 func (s stubAuthService) RevokeToken(context.Context, string) error {
485489 return errors.New("not implemented")
486490 }
@@ -481,6 +481,10 @@ func (s stubAuthService) GenerateToken(context.Context, string, []string, time.D
481 return "", errors.New("not implemented")481 return "", errors.New("not implemented")
482 }482 }
483 483
484+func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
485+ return "", errors.New("not implemented")
486+}
487+
484 func (s stubAuthService) RevokeToken(context.Context, string) error {488 func (s stubAuthService) RevokeToken(context.Context, string) error {
485 return errors.New("not implemented")489 return errors.New("not implemented")
486 }490 }
M internal/adapters/in/http/registry/token.go +2 -2
@@ -111,8 +111,8 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
111111 // Format: scope=repository:name:action1,action2 (can appear multiple times)
112112 requestedScopes := h.parseRequestedScopes(r, log)
113113
114- // Generate a short-lived access token (5 minutes)
115- accessToken, err := h.authSvc.GenerateToken(ctx, username, requestedScopes, 5*time.Minute)
114+ // Generate a short-lived access token (5 minutes) - not stored
115+ accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
116116 if err != nil {
117117 log.Error().Err(err).Msg("failed to generate access token")
118118 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -111,8 +111,8 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
111 // Format: scope=repository:name:action1,action2 (can appear multiple times)111 // Format: scope=repository:name:action1,action2 (can appear multiple times)
112 requestedScopes := h.parseRequestedScopes(r, log)112 requestedScopes := h.parseRequestedScopes(r, log)
113 113
114- // Generate a short-lived access token (5 minutes)114+ // Generate a short-lived access token (5 minutes) - not stored
115- accessToken, err := h.authSvc.GenerateToken(ctx, username, requestedScopes, 5*time.Minute)115+ accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
116 if err != nil {116 if err != nil {
117 log.Error().Err(err).Msg("failed to generate access token")117 log.Error().Err(err).Msg("failed to generate access token")
118 http.Error(w, "Internal Server Error", http.StatusInternalServerError)118 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
M internal/boundaries/in/auth.go +5 -1
@@ -23,10 +23,14 @@ type AuthService interface {
2323 // ValidateToken validates a JWT token and returns its claims.
2424 ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
2525
26- // GenerateToken creates a new JWT token for the given subject.
26+ // GenerateToken creates a new JWT token for the given subject and stores it.
2727 // If expiry is 0, the token never expires.
2828 GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)
2929
30+ // GenerateAccessToken creates a short-lived JWT for registry access without storing it.
31+ // Used by /v2/token endpoint for Docker client sessions.
32+ GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)
33+
3034 // RevokeToken revokes a token by its ID.
3135 RevokeToken(ctx context.Context, tokenID string) error
3236
@@ -23,10 +23,14 @@ type AuthService interface {
23 // ValidateToken validates a JWT token and returns its claims.23 // ValidateToken validates a JWT token and returns its claims.
24 ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)24 ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
25 25
26- // GenerateToken creates a new JWT token for the given subject.26+ // GenerateToken creates a new JWT token for the given subject and stores it.
27 // If expiry is 0, the token never expires.27 // If expiry is 0, the token never expires.
28 GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)28 GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)
29 29
30+ // GenerateAccessToken creates a short-lived JWT for registry access without storing it.
31+ // Used by /v2/token endpoint for Docker client sessions.
32+ GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)
33+
30 // RevokeToken revokes a token by its ID.34 // RevokeToken revokes a token by its ID.
31 RevokeToken(ctx context.Context, tokenID string) error35 RevokeToken(ctx context.Context, tokenID string) error
32 36
M internal/boundaries/in/mocks/mock_auth_service.go +78 -0
@@ -39,6 +39,84 @@ func (_m *MockAuthService) EXPECT() *MockAuthService_Expecter {
3939 return &MockAuthService_Expecter{mock: &_m.Mock}
4040 }
4141
42+// GenerateAccessToken provides a mock function for the type MockAuthService
43+func (_mock *MockAuthService) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
44+ ret := _mock.Called(ctx, subject, scopes, expiry)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for GenerateAccessToken")
48+ }
49+
50+ var r0 string
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) (string, error)); ok {
53+ return returnFunc(ctx, subject, scopes, expiry)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) string); ok {
56+ r0 = returnFunc(ctx, subject, scopes, expiry)
57+ } else {
58+ r0 = ret.Get(0).(string)
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, time.Duration) error); ok {
61+ r1 = returnFunc(ctx, subject, scopes, expiry)
62+ } else {
63+ r1 = ret.Error(1)
64+ }
65+ return r0, r1
66+}
67+
68+// MockAuthService_GenerateAccessToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateAccessToken'
69+type MockAuthService_GenerateAccessToken_Call struct {
70+ *mock.Call
71+}
72+
73+// GenerateAccessToken is a helper method to define mock.On call
74+// - ctx context.Context
75+// - subject string
76+// - scopes []string
77+// - expiry time.Duration
78+func (_e *MockAuthService_Expecter) GenerateAccessToken(ctx interface{}, subject interface{}, scopes interface{}, expiry interface{}) *MockAuthService_GenerateAccessToken_Call {
79+ return &MockAuthService_GenerateAccessToken_Call{Call: _e.mock.On("GenerateAccessToken", ctx, subject, scopes, expiry)}
80+}
81+
82+func (_c *MockAuthService_GenerateAccessToken_Call) Run(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration)) *MockAuthService_GenerateAccessToken_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 string
89+ if args[1] != nil {
90+ arg1 = args[1].(string)
91+ }
92+ var arg2 []string
93+ if args[2] != nil {
94+ arg2 = args[2].([]string)
95+ }
96+ var arg3 time.Duration
97+ if args[3] != nil {
98+ arg3 = args[3].(time.Duration)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ arg2,
104+ arg3,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *MockAuthService_GenerateAccessToken_Call) Return(s string, err error) *MockAuthService_GenerateAccessToken_Call {
111+ _c.Call.Return(s, err)
112+ return _c
113+}
114+
115+func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)) *MockAuthService_GenerateAccessToken_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
119+
42120 // GeneratePasswordHash provides a mock function for the type MockAuthService
43121 func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) {
44122 ret := _mock.Called(password)
@@ -39,6 +39,84 @@ func (_m *MockAuthService) EXPECT() *MockAuthService_Expecter {
39 return &MockAuthService_Expecter{mock: &_m.Mock}39 return &MockAuthService_Expecter{mock: &_m.Mock}
40 }40 }
41 41
42+// GenerateAccessToken provides a mock function for the type MockAuthService
43+func (_mock *MockAuthService) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
44+ ret := _mock.Called(ctx, subject, scopes, expiry)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for GenerateAccessToken")
48+ }
49+
50+ var r0 string
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) (string, error)); ok {
53+ return returnFunc(ctx, subject, scopes, expiry)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) string); ok {
56+ r0 = returnFunc(ctx, subject, scopes, expiry)
57+ } else {
58+ r0 = ret.Get(0).(string)
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, time.Duration) error); ok {
61+ r1 = returnFunc(ctx, subject, scopes, expiry)
62+ } else {
63+ r1 = ret.Error(1)
64+ }
65+ return r0, r1
66+}
67+
68+// MockAuthService_GenerateAccessToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateAccessToken'
69+type MockAuthService_GenerateAccessToken_Call struct {
70+ *mock.Call
71+}
72+
73+// GenerateAccessToken is a helper method to define mock.On call
74+// - ctx context.Context
75+// - subject string
76+// - scopes []string
77+// - expiry time.Duration
78+func (_e *MockAuthService_Expecter) GenerateAccessToken(ctx interface{}, subject interface{}, scopes interface{}, expiry interface{}) *MockAuthService_GenerateAccessToken_Call {
79+ return &MockAuthService_GenerateAccessToken_Call{Call: _e.mock.On("GenerateAccessToken", ctx, subject, scopes, expiry)}
80+}
81+
82+func (_c *MockAuthService_GenerateAccessToken_Call) Run(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration)) *MockAuthService_GenerateAccessToken_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 string
89+ if args[1] != nil {
90+ arg1 = args[1].(string)
91+ }
92+ var arg2 []string
93+ if args[2] != nil {
94+ arg2 = args[2].([]string)
95+ }
96+ var arg3 time.Duration
97+ if args[3] != nil {
98+ arg3 = args[3].(time.Duration)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ arg2,
104+ arg3,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *MockAuthService_GenerateAccessToken_Call) Return(s string, err error) *MockAuthService_GenerateAccessToken_Call {
111+ _c.Call.Return(s, err)
112+ return _c
113+}
114+
115+func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)) *MockAuthService_GenerateAccessToken_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
119+
42 // GeneratePasswordHash provides a mock function for the type MockAuthService120 // GeneratePasswordHash provides a mock function for the type MockAuthService
43 func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) {121 func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) {
44 ret := _mock.Called(password)122 ret := _mock.Called(password)
M internal/usecase/auth/service.go +88 -6
@@ -22,6 +22,11 @@ const (
2222 TokenIssuer = "gordon-registry"
2323 // DefaultBcryptCost is the default cost for bcrypt hashing.
2424 DefaultBcryptCost = 12
25+ // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.
26+ // Tokens with this lifetime or less skip store validation.
27+ MaxAccessTokenLifetime = 5 * time.Minute
28+ // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
29+ maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
2530 )
2631
2732 // Config holds the authentication configuration.
@@ -101,13 +106,19 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai
101106
102107 tokenClaims := buildTokenClaims(claims)
103108
104- // SECURITY: Verify token exists in store (prevents use of externally-created tokens)
105- if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
106- return nil, err
107- }
109+ // Access tokens (short-lived, ≤5min, recently issued) skip store validation.
110+ // CLI-generated tokens must exist in store to prevent use of externally-created tokens.
111+ // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent
112+ // attackers with stolen secrets from creating arbitrary short-lived tokens.
113+ isAccessToken := s.isEphemeralAccessToken(tokenClaims)
108114
109- if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
110- return nil, err
115+ if !isAccessToken {
116+ if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
117+ return nil, err
118+ }
119+ if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
120+ return nil, err
121+ }
111122 }
112123
113124 if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
@@ -228,6 +239,32 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger)
228239 return nil
229240 }
230241
242+// isEphemeralAccessToken checks if a token is a short-lived access token that
243+// doesn't require store validation. Access tokens must:
244+// 1. Have a positive expiry (not never-expiring)
245+// 2. Have lifetime ≤ MaxAccessTokenLifetime
246+// 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks
247+func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
248+ if claims.ExpiresAt <= 0 {
249+ return false // Never-expiring tokens require store validation
250+ }
251+
252+ lifetime := claims.ExpiresAt - claims.IssuedAt
253+ if lifetime > maxAccessTokenLifetimeSecs {
254+ return false // Long-lived tokens require store validation
255+ }
256+
257+ // Security: ensure token was recently issued to prevent attackers with
258+ // stolen secrets from creating arbitrary short-lived tokens
259+ now := time.Now().Unix()
260+ tokenAge := now - claims.IssuedAt
261+ if tokenAge > maxAccessTokenLifetimeSecs {
262+ return false // Old tokens require store validation
263+ }
264+
265+ return true
266+}
267+
231268 // GenerateToken creates a new JWT token for the given subject.
232269 func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
233270 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -287,6 +324,51 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
287324 return tokenString, nil
288325 }
289326
327+// GenerateAccessToken creates a short-lived JWT for registry access without storing it.
328+// Used by /v2/token endpoint - these tokens don't need persistence.
329+// Expiry must be > 0 and <= MaxAccessTokenLifetime to prevent misuse.
330+func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
331+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
332+ zerowrap.FieldLayer: "usecase",
333+ zerowrap.FieldUseCase: "GenerateAccessToken",
334+ "subject": subject,
335+ })
336+ log := zerowrap.FromCtx(ctx)
337+
338+ // Enforce expiry constraints to prevent misuse
339+ if expiry <= 0 {
340+ return "", fmt.Errorf("access token expiry must be positive")
341+ }
342+ if expiry > MaxAccessTokenLifetime {
343+ return "", fmt.Errorf("access token expiry exceeds maximum of %v", MaxAccessTokenLifetime)
344+ }
345+
346+ tokenID := uuid.New().String()
347+ now := time.Now()
348+
349+ claims := jwt.MapClaims{
350+ "jti": tokenID,
351+ "sub": subject,
352+ "iss": TokenIssuer,
353+ "iat": now.Unix(),
354+ "scopes": scopes,
355+ "exp": now.Add(expiry).Unix(),
356+ }
357+
358+ jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
359+ tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
360+ if err != nil {
361+ return "", log.WrapErr(err, "failed to sign access token")
362+ }
363+
364+ log.Debug().
365+ Str("token_id", tokenID).
366+ Str("subject", subject).
367+ Msg("access token generated")
368+
369+ return tokenString, nil
370+}
371+
290372 // RevokeToken revokes a token by its ID.
291373 func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
292374 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -22,6 +22,11 @@ const (
22 TokenIssuer = "gordon-registry"22 TokenIssuer = "gordon-registry"
23 // DefaultBcryptCost is the default cost for bcrypt hashing.23 // DefaultBcryptCost is the default cost for bcrypt hashing.
24 DefaultBcryptCost = 1224 DefaultBcryptCost = 12
25+ // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.
26+ // Tokens with this lifetime or less skip store validation.
27+ MaxAccessTokenLifetime = 5 * time.Minute
28+ // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
29+ maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
25 )30 )
26 31
27 // Config holds the authentication configuration.32 // Config holds the authentication configuration.
@@ -101,13 +106,19 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai
101 106
102 tokenClaims := buildTokenClaims(claims)107 tokenClaims := buildTokenClaims(claims)
103 108
104- // SECURITY: Verify token exists in store (prevents use of externally-created tokens)109+ // Access tokens (short-lived, ≤5min, recently issued) skip store validation.
105- if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {110+ // CLI-generated tokens must exist in store to prevent use of externally-created tokens.
106- return nil, err111+ // Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent
107- }112+ // attackers with stolen secrets from creating arbitrary short-lived tokens.
113+ isAccessToken := s.isEphemeralAccessToken(tokenClaims)
108 114
109- if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {115+ if !isAccessToken {
110- return nil, err116+ if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
117+ return nil, err
118+ }
119+ if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
120+ return nil, err
121+ }
111 }122 }
112 123
113 if err := ensureTokenNotExpired(tokenClaims, log); err != nil {124 if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
@@ -228,6 +239,32 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger)
228 return nil239 return nil
229 }240 }
230 241
242+// isEphemeralAccessToken checks if a token is a short-lived access token that
243+// doesn't require store validation. Access tokens must:
244+// 1. Have a positive expiry (not never-expiring)
245+// 2. Have lifetime ≤ MaxAccessTokenLifetime
246+// 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks
247+func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
248+ if claims.ExpiresAt <= 0 {
249+ return false // Never-expiring tokens require store validation
250+ }
251+
252+ lifetime := claims.ExpiresAt - claims.IssuedAt
253+ if lifetime > maxAccessTokenLifetimeSecs {
254+ return false // Long-lived tokens require store validation
255+ }
256+
257+ // Security: ensure token was recently issued to prevent attackers with
258+ // stolen secrets from creating arbitrary short-lived tokens
259+ now := time.Now().Unix()
260+ tokenAge := now - claims.IssuedAt
261+ if tokenAge > maxAccessTokenLifetimeSecs {
262+ return false // Old tokens require store validation
263+ }
264+
265+ return true
266+}
267+
231 // GenerateToken creates a new JWT token for the given subject.268 // GenerateToken creates a new JWT token for the given subject.
232 func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {269 func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
233 ctx = zerowrap.CtxWithFields(ctx, map[string]any{270 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -287,6 +324,51 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
287 return tokenString, nil324 return tokenString, nil
288 }325 }
289 326
327+// GenerateAccessToken creates a short-lived JWT for registry access without storing it.
328+// Used by /v2/token endpoint - these tokens don't need persistence.
329+// Expiry must be > 0 and <= MaxAccessTokenLifetime to prevent misuse.
330+func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
331+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
332+ zerowrap.FieldLayer: "usecase",
333+ zerowrap.FieldUseCase: "GenerateAccessToken",
334+ "subject": subject,
335+ })
336+ log := zerowrap.FromCtx(ctx)
337+
338+ // Enforce expiry constraints to prevent misuse
339+ if expiry <= 0 {
340+ return "", fmt.Errorf("access token expiry must be positive")
341+ }
342+ if expiry > MaxAccessTokenLifetime {
343+ return "", fmt.Errorf("access token expiry exceeds maximum of %v", MaxAccessTokenLifetime)
344+ }
345+
346+ tokenID := uuid.New().String()
347+ now := time.Now()
348+
349+ claims := jwt.MapClaims{
350+ "jti": tokenID,
351+ "sub": subject,
352+ "iss": TokenIssuer,
353+ "iat": now.Unix(),
354+ "scopes": scopes,
355+ "exp": now.Add(expiry).Unix(),
356+ }
357+
358+ jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
359+ tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
360+ if err != nil {
361+ return "", log.WrapErr(err, "failed to sign access token")
362+ }
363+
364+ log.Debug().
365+ Str("token_id", tokenID).
366+ Str("subject", subject).
367+ Msg("access token generated")
368+
369+ return tokenString, nil
370+}
371+
290 // RevokeToken revokes a token by its ID.372 // RevokeToken revokes a token by its ID.
291 func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {373 func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
292 ctx = zerowrap.CtxWithFields(ctx, map[string]any{374 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/usecase/auth/service_test.go +128 -0
@@ -179,6 +179,134 @@ func TestService_GenerateToken_NeverExpires(t *testing.T) {
179179 assert.NotEmpty(t, token)
180180 }
181181
182+func TestService_GenerateAccessToken_NoStorage(t *testing.T) {
183+ // Access tokens should NOT be stored - this is the fix for the 401 bug
184+ tokenStore := mocks.NewMockTokenStore(t)
185+ // No SaveToken expectation - it should NOT be called
186+
187+ svc := NewService(Config{
188+ Enabled: true,
189+ AuthType: domain.AuthTypeToken,
190+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
191+ }, tokenStore, zerowrap.Default())
192+
193+ ctx := testContext()
194+ token, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:push,pull"}, 5*time.Minute)
195+
196+ assert.NoError(t, err)
197+ assert.NotEmpty(t, token)
198+
199+ // Verify the token is valid and has correct claims
200+ claims, err := svc.ValidateToken(ctx, token)
201+ assert.NoError(t, err)
202+ assert.Equal(t, "testuser", claims.Subject)
203+ assert.Contains(t, claims.Scopes, "repository:myrepo:push,pull")
204+ // Access token should have exactly 5 min expiry (allow 2 sec tolerance for test execution)
205+ assert.InDelta(t, 300, claims.ExpiresAt-claims.IssuedAt, 2)
206+}
207+
208+func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
209+ tokenStore := mocks.NewMockTokenStore(t)
210+
211+ svc := NewService(Config{
212+ Enabled: true,
213+ AuthType: domain.AuthTypeToken,
214+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
215+ }, tokenStore, zerowrap.Default())
216+
217+ ctx := testContext()
218+
219+ // Zero expiry should be rejected
220+ _, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 0)
221+ assert.Error(t, err)
222+ assert.Contains(t, err.Error(), "expiry must be positive")
223+
224+ // Negative expiry should be rejected
225+ _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, -time.Minute)
226+ assert.Error(t, err)
227+ assert.Contains(t, err.Error(), "expiry must be positive")
228+
229+ // Expiry exceeding max should be rejected
230+ _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)
231+ assert.Error(t, err)
232+ assert.Contains(t, err.Error(), "exceeds maximum")
233+}
234+
235+func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
236+ // Tokens with expiry > 5 minutes must exist in store
237+ tokenStore := mocks.NewMockTokenStore(t)
238+
239+ svc := NewService(Config{
240+ Enabled: true,
241+ AuthType: domain.AuthTypeToken,
242+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
243+ }, tokenStore, zerowrap.Default())
244+
245+ ctx := testContext()
246+
247+ // Generate a long-lived token (stored)
248+ var capturedToken *domain.Token
249+ tokenStore.EXPECT().
250+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
251+ Run(func(_ context.Context, t *domain.Token, _ string) {
252+ capturedToken = t
253+ }).
254+ Return(nil)
255+
256+ token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)
257+ assert.NoError(t, err)
258+
259+ // Validation should call GetToken and IsRevoked
260+ tokenStore.EXPECT().
261+ GetToken(mock.Anything, "testuser").
262+ Return(token, capturedToken, nil)
263+ tokenStore.EXPECT().
264+ IsRevoked(mock.Anything, capturedToken.ID).
265+ Return(false, nil)
266+
267+ claims, err := svc.ValidateToken(ctx, token)
268+ assert.NoError(t, err)
269+ assert.Equal(t, "testuser", claims.Subject)
270+}
271+
272+func TestService_ValidateToken_NeverExpiringRequiresStore(t *testing.T) {
273+ // Tokens with ExpiresAt = 0 (never expires) must exist in store
274+ tokenStore := mocks.NewMockTokenStore(t)
275+
276+ svc := NewService(Config{
277+ Enabled: true,
278+ AuthType: domain.AuthTypeToken,
279+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
280+ }, tokenStore, zerowrap.Default())
281+
282+ ctx := testContext()
283+
284+ // Generate a never-expiring token (stored)
285+ var capturedToken *domain.Token
286+ tokenStore.EXPECT().
287+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
288+ Run(func(_ context.Context, t *domain.Token, _ string) {
289+ capturedToken = t
290+ }).
291+ Return(nil)
292+
293+ token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
294+ assert.NoError(t, err)
295+
296+ // Validation should call GetToken and IsRevoked (not bypass store)
297+ tokenStore.EXPECT().
298+ GetToken(mock.Anything, "ci-bot").
299+ Return(token, capturedToken, nil)
300+ tokenStore.EXPECT().
301+ IsRevoked(mock.Anything, capturedToken.ID).
302+ Return(false, nil)
303+
304+ claims, err := svc.ValidateToken(ctx, token)
305+ assert.NoError(t, err)
306+ assert.Equal(t, "ci-bot", claims.Subject)
307+ assert.Equal(t, int64(0), claims.ExpiresAt) // Never expires
308+}
309+
182310 func TestService_ValidateToken_Success(t *testing.T) {
183311 tokenStore := mocks.NewMockTokenStore(t)
184312
@@ -179,6 +179,134 @@ func TestService_GenerateToken_NeverExpires(t *testing.T) {
179 assert.NotEmpty(t, token)179 assert.NotEmpty(t, token)
180 }180 }
181 181
182+func TestService_GenerateAccessToken_NoStorage(t *testing.T) {
183+ // Access tokens should NOT be stored - this is the fix for the 401 bug
184+ tokenStore := mocks.NewMockTokenStore(t)
185+ // No SaveToken expectation - it should NOT be called
186+
187+ svc := NewService(Config{
188+ Enabled: true,
189+ AuthType: domain.AuthTypeToken,
190+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
191+ }, tokenStore, zerowrap.Default())
192+
193+ ctx := testContext()
194+ token, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:push,pull"}, 5*time.Minute)
195+
196+ assert.NoError(t, err)
197+ assert.NotEmpty(t, token)
198+
199+ // Verify the token is valid and has correct claims
200+ claims, err := svc.ValidateToken(ctx, token)
201+ assert.NoError(t, err)
202+ assert.Equal(t, "testuser", claims.Subject)
203+ assert.Contains(t, claims.Scopes, "repository:myrepo:push,pull")
204+ // Access token should have exactly 5 min expiry (allow 2 sec tolerance for test execution)
205+ assert.InDelta(t, 300, claims.ExpiresAt-claims.IssuedAt, 2)
206+}
207+
208+func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
209+ tokenStore := mocks.NewMockTokenStore(t)
210+
211+ svc := NewService(Config{
212+ Enabled: true,
213+ AuthType: domain.AuthTypeToken,
214+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
215+ }, tokenStore, zerowrap.Default())
216+
217+ ctx := testContext()
218+
219+ // Zero expiry should be rejected
220+ _, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 0)
221+ assert.Error(t, err)
222+ assert.Contains(t, err.Error(), "expiry must be positive")
223+
224+ // Negative expiry should be rejected
225+ _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, -time.Minute)
226+ assert.Error(t, err)
227+ assert.Contains(t, err.Error(), "expiry must be positive")
228+
229+ // Expiry exceeding max should be rejected
230+ _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)
231+ assert.Error(t, err)
232+ assert.Contains(t, err.Error(), "exceeds maximum")
233+}
234+
235+func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
236+ // Tokens with expiry > 5 minutes must exist in store
237+ tokenStore := mocks.NewMockTokenStore(t)
238+
239+ svc := NewService(Config{
240+ Enabled: true,
241+ AuthType: domain.AuthTypeToken,
242+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
243+ }, tokenStore, zerowrap.Default())
244+
245+ ctx := testContext()
246+
247+ // Generate a long-lived token (stored)
248+ var capturedToken *domain.Token
249+ tokenStore.EXPECT().
250+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
251+ Run(func(_ context.Context, t *domain.Token, _ string) {
252+ capturedToken = t
253+ }).
254+ Return(nil)
255+
256+ token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)
257+ assert.NoError(t, err)
258+
259+ // Validation should call GetToken and IsRevoked
260+ tokenStore.EXPECT().
261+ GetToken(mock.Anything, "testuser").
262+ Return(token, capturedToken, nil)
263+ tokenStore.EXPECT().
264+ IsRevoked(mock.Anything, capturedToken.ID).
265+ Return(false, nil)
266+
267+ claims, err := svc.ValidateToken(ctx, token)
268+ assert.NoError(t, err)
269+ assert.Equal(t, "testuser", claims.Subject)
270+}
271+
272+func TestService_ValidateToken_NeverExpiringRequiresStore(t *testing.T) {
273+ // Tokens with ExpiresAt = 0 (never expires) must exist in store
274+ tokenStore := mocks.NewMockTokenStore(t)
275+
276+ svc := NewService(Config{
277+ Enabled: true,
278+ AuthType: domain.AuthTypeToken,
279+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
280+ }, tokenStore, zerowrap.Default())
281+
282+ ctx := testContext()
283+
284+ // Generate a never-expiring token (stored)
285+ var capturedToken *domain.Token
286+ tokenStore.EXPECT().
287+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
288+ Run(func(_ context.Context, t *domain.Token, _ string) {
289+ capturedToken = t
290+ }).
291+ Return(nil)
292+
293+ token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
294+ assert.NoError(t, err)
295+
296+ // Validation should call GetToken and IsRevoked (not bypass store)
297+ tokenStore.EXPECT().
298+ GetToken(mock.Anything, "ci-bot").
299+ Return(token, capturedToken, nil)
300+ tokenStore.EXPECT().
301+ IsRevoked(mock.Anything, capturedToken.ID).
302+ Return(false, nil)
303+
304+ claims, err := svc.ValidateToken(ctx, token)
305+ assert.NoError(t, err)
306+ assert.Equal(t, "ci-bot", claims.Subject)
307+ assert.Equal(t, int64(0), claims.ExpiresAt) // Never expires
308+}
309+
182 func TestService_ValidateToken_Success(t *testing.T) {310 func TestService_ValidateToken_Success(t *testing.T) {
183 tokenStore := mocks.NewMockTokenStore(t)311 tokenStore := mocks.NewMockTokenStore(t)
184 312

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