Merge pull request #17 from bnema/fix/registry-auth-401-token-overwrite
fix(auth): prevent access tokens from overwriting CLI tokens
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 | |||
| 481 | 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 | 488 | func (s stubAuthService) RevokeToken(context.Context, string) error { | |
| 485 | 489 | return errors.New("not implemented") | |
| 486 | 490 | } | |
| @@ -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) { | |||
| 111 | 111 | // Format: scope=repository:name:action1,action2 (can appear multiple times) | |
| 112 | 112 | requestedScopes := h.parseRequestedScopes(r, log) | |
| 113 | 113 | ||
| 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) | |
| 116 | 116 | if err != nil { | |
| 117 | 117 | log.Error().Err(err).Msg("failed to generate access token") | |
| 118 | 118 | 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 { | |||
| 23 | 23 | // ValidateToken validates a JWT token and returns its claims. | |
| 24 | 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 | 27 | // If expiry is 0, the token never expires. | |
| 28 | 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 | 34 | // RevokeToken revokes a token by its ID. | |
| 31 | 35 | RevokeToken(ctx context.Context, tokenID string) error | |
| 32 | 36 | ||
| @@ -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) error | 35 | 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 { | |||
| 39 | 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 | 120 | // GeneratePasswordHash provides a mock function for the type MockAuthService | |
| 43 | 121 | func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) { | |
| 44 | 122 | 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 MockAuthService | 120 | // 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 ( | |||
| 22 | 22 | TokenIssuer = "gordon-registry" | |
| 23 | 23 | // DefaultBcryptCost is the default cost for bcrypt hashing. | |
| 24 | 24 | 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 | 32 | // Config holds the authentication configuration. | |
| @@ -101,13 +106,19 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai | |||
| 101 | 106 | ||
| 102 | 107 | tokenClaims := buildTokenClaims(claims) | |
| 103 | 108 | ||
| 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) | |
| 108 | 114 | ||
| 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 | + | } | |
| 111 | 122 | } | |
| 112 | 123 | ||
| 113 | 124 | if err := ensureTokenNotExpired(tokenClaims, log); err != nil { | |
| @@ -228,6 +239,32 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) | |||
| 228 | 239 | 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 | 268 | // GenerateToken creates a new JWT token for the given subject. | |
| 232 | 269 | func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | |
| 233 | 270 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -287,6 +324,51 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st | |||
| 287 | 324 | 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 | 372 | // RevokeToken revokes a token by its ID. | |
| 291 | 373 | func (s *Service) RevokeToken(ctx context.Context, tokenID string) error { | |
| 292 | 374 | 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 = 12 | 24 | 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, err | 111 | + | // 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, err | 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 | + | } | |||
| 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 nil | 239 | 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, nil | 324 | 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) { | |||
| 179 | 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 | 310 | func TestService_ValidateToken_Success(t *testing.T) { | |
| 183 | 311 | tokenStore := mocks.NewMockTokenStore(t) | |
| 184 | 312 | ||
| @@ -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 | ||||