diff --git a/internal/services/authorization.go b/internal/services/authorization.go index 9ad6f9a2..94878b2d 100644 --- a/internal/services/authorization.go +++ b/internal/services/authorization.go @@ -606,31 +606,44 @@ func (s *AuthorizationService) RevokeUserAuthorization( ctx context.Context, authUUID, userID string, ) error { - revoked, err := s.store.RevokeUserAuthorization(authUUID, userID) - if err != nil { - return ErrAuthorizationNotFound + // When the token cache is enabled, collect the token hashes BEFORE revoking + // anything (fail-closed): a collection failure aborts the whole operation + // without revoking the consent or its tokens, so the caller can retry as one + // unit and we never leave the DB revoked while the cache still serves the + // dead tokens until TTL expiry. The cache-disabled path (default) skips + // collection entirely and keeps today's behaviour. + // The cache-enabled branch fetches the consent row first (distinct from the + // other revoke paths) purely to resolve auth.ID for hash collection BEFORE + // any mutation — a not-found here returns ErrAuthorizationNotFound, while a + // hash-query failure fails the whole revoke closed. Only the pre-fetch must be + // inline (for the distinct not-found mapping); the collection itself routes + // through collectHashesForInvalidation so the error-wrap stays defined in + // exactly one place. + var hashes []string + if s.config.TokenCacheEnabled { + auth, err := s.store.GetUserAuthorizationByUUID(authUUID, userID) + if err != nil { + return ErrAuthorizationNotFound + } + hashes, err = collectHashesForInvalidation(true, func() ([]string, error) { + return s.store.GetActiveTokenHashesByAuthorizationID(auth.ID) + }) + if err != nil { + return err + } } - hashes, err := s.store.GetActiveTokenHashesByAuthorizationID(revoked.ID) + revoked, err := s.store.RevokeUserAuthorization(authUUID, userID) if err != nil { - log.Printf( - "[TokenCache] WARNING: failed to collect token hashes for authorization=%d, "+ - "revoked tokens may remain cached until TTL expires: %v", - revoked.ID, - err, - ) + return ErrAuthorizationNotFound } // Cascade-revoke all tokens tied to this authorization if revokeErr := s.store.RevokeTokensByAuthorizationID(revoked.ID); revokeErr != nil { - log.Printf( - "[Authorization] failed to revoke tokens for authorization=%d: %v", - revoked.ID, - revokeErr, - ) + return fmt.Errorf("failed to revoke tokens for authorization: %w", revokeErr) } - if len(hashes) > 0 && s.tokenService != nil { + if s.tokenService != nil { s.tokenService.InvalidateTokenCacheByHashes(ctx, hashes) } @@ -697,13 +710,17 @@ func (s *AuthorizationService) RevokeAllApplicationTokens( ctx context.Context, clientID, actorUserID string, ) (int64, error) { - hashes, err := s.store.GetActiveTokenHashesByClientID(clientID) + // Fail-closed cache invalidation (see collectHashesForInvalidation): abort the + // whole revoke if hash collection fails so a stale cache can never outlive the + // DB revoke. + hashes, err := collectHashesForInvalidation( + s.config.TokenCacheEnabled, + func() ([]string, error) { + return s.store.GetActiveTokenHashesByClientID(clientID) + }, + ) if err != nil { - log.Printf( - "[TokenCache] WARNING: failed to collect token hashes for client=%s, "+ - "revoked tokens may remain cached until TTL expires: %v", - clientID, err, - ) + return 0, err } revokedCount, err := s.store.RevokeAllActiveTokensByClientID(clientID) @@ -711,7 +728,7 @@ func (s *AuthorizationService) RevokeAllApplicationTokens( return 0, fmt.Errorf("failed to revoke tokens: %w", err) } - if len(hashes) > 0 && s.tokenService != nil { + if s.tokenService != nil { s.tokenService.InvalidateTokenCacheByHashes(ctx, hashes) } diff --git a/internal/services/token.go b/internal/services/token.go index 1f5f0330..1ec5a24c 100644 --- a/internal/services/token.go +++ b/internal/services/token.go @@ -158,6 +158,29 @@ func (s *TokenService) invalidateTokenCacheByHashes(ctx context.Context, hashes } } +// collectHashesForInvalidation centralizes the cache-invalidation gate shared by +// the bulk revoke paths: when caching is disabled it skips the collector entirely +// (returns no hashes, adding no new failure mode on the default cache-off path); +// when enabled it runs the collector and wraps any failure with a uniform +// message. It only SURFACES that error — each caller decides how to honor it (the +// error-returning revoke paths abort the whole revoke; the family path logs +// CRITICAL but still revokes, since cache invalidation there is best-effort). +// Centralizing the gate is what stops a future revoke path from silently +// forgetting it and reintroducing a failure mode on the default path. +func collectHashesForInvalidation( + enabled bool, + collect func() ([]string, error), +) ([]string, error) { + if !enabled { + return nil, nil + } + hashes, err := collect() + if err != nil { + return nil, fmt.Errorf("collect token hashes for cache invalidation: %w", err) + } + return hashes, nil +} + // InvalidateTokenCacheByHashes removes multiple tokens from cache by their hashes. // Exported for use by other services (e.g., AuthorizationService) during bulk revocation. func (s *TokenService) InvalidateTokenCacheByHashes(ctx context.Context, hashes []string) { diff --git a/internal/services/token_cache_test.go b/internal/services/token_cache_test.go index 17c9b01b..e0949fe4 100644 --- a/internal/services/token_cache_test.go +++ b/internal/services/token_cache_test.go @@ -38,28 +38,7 @@ func newCachedTokenServiceWithConfig( ) (*TokenService, *store.Store, *cache.MemoryCache[models.AccessToken]) { t.Helper() s := setupTestStore(t) - memCache := cache.NewMemoryCache[models.AccessToken]() - t.Cleanup(func() { _ = memCache.Close() }) - localProvider, err := token.NewLocalTokenProvider(cfg) - require.NoError(t, err) - clientService := NewClientService(s, NewNoopAuditService(), nil, 0, nil, 0) - deviceService := NewDeviceService( - s, - cfg, - NewNoopAuditService(), - metrics.NewNoopMetrics(), - clientService, - ) - svc := NewTokenService( - s, - cfg, - deviceService, - localProvider, - NewNoopAuditService(), - metrics.NewNoopMetrics(), - memCache, - clientService, - ) + svc, memCache := newCachedTokenServiceOverStore(t, s, cfg, NewNoopAuditService()) return svc, s, memCache } diff --git a/internal/services/token_management.go b/internal/services/token_management.go index 6f9c2a0c..aade26e1 100644 --- a/internal/services/token_management.go +++ b/internal/services/token_management.go @@ -3,7 +3,6 @@ package services import ( "context" "errors" - "log" "github.com/go-authgate/authgate/internal/core" "github.com/go-authgate/authgate/internal/models" @@ -84,25 +83,26 @@ func (s *TokenService) RevokeTokenByID(ctx context.Context, tokenID, actorUserID return nil } -// RevokeAllUserTokens revokes all tokens for a user +// RevokeAllUserTokens revokes all tokens for a user. Hash collection is gated and +// fail-closed via collectHashesForInvalidation: on a collection failure it +// returns without revoking so the caller can retry as one unit, never leaving the +// DB revoked while the cache still serves the dead tokens. func (s *TokenService) RevokeAllUserTokens(userID string) error { - // Collect hashes before deletion so we can invalidate the cache, - // but only invalidate if revocation succeeds. - hashes, err := s.store.GetTokenHashesByUserID(userID) + hashes, err := collectHashesForInvalidation( + s.config.TokenCacheEnabled, + func() ([]string, error) { + return s.store.GetTokenHashesByUserID(userID) + }, + ) if err != nil { - log.Printf( - "[TokenCache] failed to collect user token hashes for invalidation user=%s: %v", - userID, err, - ) + return err } if err := s.store.RevokeTokensByUserID(userID); err != nil { return err } - if len(hashes) > 0 { - s.invalidateTokenCacheByHashes(context.Background(), hashes) - } + s.invalidateTokenCacheByHashes(context.Background(), hashes) return nil } diff --git a/internal/services/token_refresh.go b/internal/services/token_refresh.go index c8837ad6..637aaacc 100644 --- a/internal/services/token_refresh.go +++ b/internal/services/token_refresh.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "maps" "time" "github.com/go-authgate/authgate/internal/core" @@ -14,6 +15,37 @@ import ( "github.com/google/uuid" ) +// logFamilyReuseEvent emits the CRITICAL refresh-token-reuse audit record for a +// token-family revoke. Keeping the envelope in one place keeps the +// SUSPICIOUS_ACTIVITY shape consistent; callers pass only the outcome-specific +// action, success flag, and one extra detail. LogSync (durable) matches the +// codebase's other suspicious-activity emitters. +func (s *TokenService) logFamilyReuseEvent( + ctx context.Context, + reusedToken *models.AccessToken, + familyID, action string, + success bool, + extra models.AuditDetails, +) { + details := models.AuditDetails{ + "family_id": familyID, + "reused_token_id": reusedToken.ID, + "client_id": reusedToken.ClientID, + "client_name": clientNameByID(ctx, s.clientService, reusedToken.ClientID), + } + maps.Copy(details, extra) + _ = s.auditService.LogSync(ctx, core.AuditLogEntry{ + EventType: models.EventSuspiciousActivity, + Severity: models.SeverityCritical, + ActorUserID: reusedToken.UserID, + ResourceType: models.ResourceToken, + ResourceID: reusedToken.ID, + Action: action, + Details: details, + Success: success, + }) +} + // revokeTokenFamilyWithAudit revokes all active tokens in a token family when refresh token // reuse is detected during rotation mode. This prevents stolen token abuse by invalidating // all active tokens derived from the same parent (RFC 6819 §4.14.2). @@ -29,13 +61,28 @@ func (s *TokenService) revokeTokenFamilyWithAudit( } } - // Collect hashes before revocation for cache invalidation - hashesToInvalidate, err := s.store.GetActiveTokenHashesByFamilyID(familyID) + // Collect hashes before revocation for cache invalidation. A hash-collection + // failure must NOT block the family revoke: revoking a known-compromised token + // family is the security-critical action (RFC 6819 §4.14.2), so we proceed + // even when the cache keys cannot be enumerated. The affected cache entries + // then lapse on their own TTL — the same best-effort bound the per-token + // Delete path already relies on — which is strictly safer than leaving every + // sibling access/refresh token in the family active until some future replay + // of the same already-dead token. When the cache is disabled (default) + // collection is skipped entirely. + hashesToInvalidate, err := collectHashesForInvalidation( + s.config.TokenCacheEnabled, + func() ([]string, error) { return s.store.GetActiveTokenHashesByFamilyID(familyID) }, + ) if err != nil { + // Revoke the family anyway; only the targeted cache invalidation is lost + // (cached entries fall away on TTL). Failing closed here would leave the + // whole compromised family usable, so we log CRITICAL and continue. log.Printf( - "[TokenCache] failed to collect family hashes for invalidation family=%s: %v", - familyID, err, + "[Token] CRITICAL: %v; revoking family=%s anyway — cached tokens may persist until TTL", + err, familyID, ) + hashesToInvalidate = nil } revokedCount, err := s.store.RevokeTokenFamily(familyID) @@ -54,23 +101,12 @@ func (s *TokenService) revokeTokenFamilyWithAudit( // Audit log — CRITICAL severity because this indicates potential token theft. // ActorUsername is auto-resolved by buildAuditLog. - clientName := clientNameByID(ctx, s.clientService, reusedToken.ClientID) - _ = s.auditService.LogSync(ctx, core.AuditLogEntry{ - EventType: models.EventSuspiciousActivity, - Severity: models.SeverityCritical, - ActorUserID: reusedToken.UserID, - ResourceType: models.ResourceToken, - ResourceID: reusedToken.ID, - Action: "Refresh token reuse detected — token family revoked", - Details: models.AuditDetails{ - "family_id": familyID, - "reused_token_id": reusedToken.ID, - "client_id": reusedToken.ClientID, - "client_name": clientName, - "tokens_revoked": revokedCount, - }, - Success: true, - }) + s.logFamilyReuseEvent( + ctx, reusedToken, familyID, + "Refresh token reuse detected — token family revoked", + true, + models.AuditDetails{"tokens_revoked": revokedCount}, + ) } // RefreshAccessToken generates new access token (and optionally new refresh token in rotation mode). diff --git a/internal/services/token_revoke_invalidation_test.go b/internal/services/token_revoke_invalidation_test.go new file mode 100644 index 00000000..69adfba3 --- /dev/null +++ b/internal/services/token_revoke_invalidation_test.go @@ -0,0 +1,368 @@ +package services + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/go-authgate/authgate/internal/cache" + "github.com/go-authgate/authgate/internal/config" + "github.com/go-authgate/authgate/internal/core" + "github.com/go-authgate/authgate/internal/metrics" + "github.com/go-authgate/authgate/internal/models" + "github.com/go-authgate/authgate/internal/store" + "github.com/go-authgate/authgate/internal/token" + "github.com/go-authgate/authgate/internal/util" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// errHashStore wraps a real core.Store and injects an error from a single +// cache-hash-collection method while delegating everything else to the embedded +// store. It lets the fail-closed revoke tests prove that a hash-collection +// failure aborts the whole revoke without touching the real DB. The error +// fields are settable mid-test so the family path can succeed during setup and +// only fail on the replay. +type errHashStore struct { + core.Store + userHashesErr error + clientHashesErr error + authzHashesErr error + familyHashesErr error +} + +func (e *errHashStore) GetTokenHashesByUserID(userID string) ([]string, error) { + if e.userHashesErr != nil { + return nil, e.userHashesErr + } + return e.Store.GetTokenHashesByUserID(userID) +} + +func (e *errHashStore) GetActiveTokenHashesByClientID(clientID string) ([]string, error) { + if e.clientHashesErr != nil { + return nil, e.clientHashesErr + } + return e.Store.GetActiveTokenHashesByClientID(clientID) +} + +func (e *errHashStore) GetActiveTokenHashesByAuthorizationID(authID uint) ([]string, error) { + if e.authzHashesErr != nil { + return nil, e.authzHashesErr + } + return e.Store.GetActiveTokenHashesByAuthorizationID(authID) +} + +func (e *errHashStore) GetActiveTokenHashesByFamilyID(familyID string) ([]string, error) { + if e.familyHashesErr != nil { + return nil, e.familyHashesErr + } + return e.Store.GetActiveTokenHashesByFamilyID(familyID) +} + +// newCachedTokenServiceOverStore builds a cache-backed TokenService over an +// arbitrary core.Store (e.g. an errHashStore wrapper) with a caller-supplied +// audit logger so the family path can be asserted on. +func newCachedTokenServiceOverStore( + t *testing.T, + st core.Store, + cfg *config.Config, + audit core.AuditLogger, +) (*TokenService, *cache.MemoryCache[models.AccessToken]) { + t.Helper() + if cfg.JWTPrivateClaimPrefix == "" { + c := *cfg + c.JWTPrivateClaimPrefix = config.DefaultJWTPrivateClaimPrefix + cfg = &c + } + memCache := cache.NewMemoryCache[models.AccessToken]() + t.Cleanup(func() { _ = memCache.Close() }) + provider, err := token.NewLocalTokenProvider(cfg) + require.NoError(t, err) + clientService := NewClientService(st, NewNoopAuditService(), nil, 0, nil, 0) + deviceService := NewDeviceService( + st, cfg, NewNoopAuditService(), metrics.NewNoopMetrics(), clientService, + ) + svc := NewTokenService( + st, cfg, deviceService, provider, audit, metrics.NewNoopMetrics(), memCache, clientService, + ) + return svc, memCache +} + +// seedActiveAccessToken inserts an active access-token row for the given owner. +func seedActiveAccessToken( + t *testing.T, + s *store.Store, + userID, clientID, rawToken string, + authorizationID *uint, +) *models.AccessToken { + t.Helper() + tok := &models.AccessToken{ + ID: uuid.New().String(), + TokenHash: util.SHA256Hex(rawToken), + RawToken: rawToken, + TokenType: "Bearer", + TokenCategory: models.TokenCategoryAccess, + Status: models.TokenStatusActive, + UserID: userID, + ClientID: clientID, + Scopes: "read", + ExpiresAt: time.Now().Add(time.Hour), + AuthorizationID: authorizationID, + } + require.NoError(t, s.CreateAccessToken(tok)) + return tok +} + +func assertTokenActive(t *testing.T, s *store.Store, hash string) { + t.Helper() + got, err := s.GetAccessTokenByHash(hash) + require.NoError(t, err) + assert.Equal( + t, models.TokenStatusActive, got.Status, + "token must remain active when the revoke fails closed", + ) +} + +// ---- Error case A: RevokeAllUserTokens (user path) ---- + +func TestRevokeAllUserTokens_FailClosedOnHashError(t *testing.T) { + realStore := setupTestStore(t) + wrapped := &errHashStore{Store: realStore, userHashesErr: errors.New("boom: hash query failed")} + cfg := &config.Config{ + JWTExpiration: time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + TokenCacheEnabled: true, + TokenCacheTTL: 5 * time.Minute, + } + svc, _ := newCachedTokenServiceOverStore(t, wrapped, cfg, NewNoopAuditService()) + + userID := "user-fail-closed" + tok := seedActiveAccessToken(t, realStore, userID, "client-1", "raw-token-1", nil) + + err := svc.RevokeAllUserTokens(userID) + require.Error(t, err, "hash collection failure must fail the revoke closed") + + // DB must be untouched — the operation failed as one unit and is retriable. + assertTokenActive(t, realStore, tok.TokenHash) +} + +// ---- Regression case: cache disabled skips hash collection ---- + +func TestRevokeAllUserTokens_CacheDisabledSkipsHashCollection(t *testing.T) { + realStore := setupTestStore(t) + // Inject a hash error that must never be hit because the cache is disabled. + wrapped := &errHashStore{Store: realStore, userHashesErr: errors.New("must not be called")} + cfg := &config.Config{ + JWTExpiration: time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + // TokenCacheEnabled defaults to false. + } + svc, _ := newCachedTokenServiceOverStore(t, wrapped, cfg, NewNoopAuditService()) + + userID := "user-cache-off" + tok := seedActiveAccessToken(t, realStore, userID, "client-1", "raw-token-off", nil) + + err := svc.RevokeAllUserTokens(userID) + require.NoError(t, err, "cache disabled must skip hash collection and not regress") + + // RevokeTokensByUserID hard-deletes the rows, so the token must be gone. + _, err = realStore.GetAccessTokenByHash(tok.TokenHash) + assert.Error(t, err, "token should be revoked (deleted) when cache is disabled") +} + +// ---- Error case A: RevokeUserAuthorization (authorization path) ---- + +func TestRevokeUserAuthorization_FailClosedOnHashError(t *testing.T) { + realStore := setupTestStore(t) + wrapped := &errHashStore{Store: realStore, authzHashesErr: errors.New("boom: authz hashes")} + cfg := &config.Config{ + JWTExpiration: time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + TokenCacheEnabled: true, + TokenCacheTTL: 5 * time.Minute, + } + tokenSvc, _ := newCachedTokenServiceOverStore(t, wrapped, cfg, NewNoopAuditService()) + clientService := NewClientService(wrapped, NewNoopAuditService(), nil, 0, nil, 0) + authzSvc := NewAuthorizationService( + wrapped, cfg, NewNoopAuditService(), tokenSvc, clientService, + ) + + client := createTestClient(t, realStore, true) + userID := uuid.New().String() + + auth := &models.UserAuthorization{ + UUID: uuid.New().String(), + UserID: userID, + ApplicationID: client.ID, + ClientID: client.ClientID, + Scopes: "read", + GrantedAt: time.Now(), + IsActive: true, + } + require.NoError(t, realStore.UpsertUserAuthorization(auth)) + stored, err := realStore.GetUserAuthorization(userID, client.ID) + require.NoError(t, err) + tok := seedActiveAccessToken(t, realStore, userID, client.ClientID, "raw-authz", &stored.ID) + + err = authzSvc.RevokeUserAuthorization(context.Background(), stored.UUID, userID) + require.Error(t, err, "authz hash collection failure must fail the revoke closed") + + // Neither the consent nor its tokens may be revoked — fully retriable. + got, err := realStore.GetUserAuthorization(userID, client.ID) + require.NoError(t, err) + require.NotNil(t, got) + assert.True(t, got.IsActive, "authorization must stay active on fail-closed abort") + assertTokenActive(t, realStore, tok.TokenHash) +} + +// ---- Error case A: RevokeAllApplicationTokens (client path) ---- + +func TestRevokeAllApplicationTokens_FailClosedOnHashError(t *testing.T) { + realStore := setupTestStore(t) + wrapped := &errHashStore{Store: realStore, clientHashesErr: errors.New("boom: client hashes")} + cfg := &config.Config{ + JWTExpiration: time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + TokenCacheEnabled: true, + TokenCacheTTL: 5 * time.Minute, + } + tokenSvc, _ := newCachedTokenServiceOverStore(t, wrapped, cfg, NewNoopAuditService()) + clientService := NewClientService(wrapped, NewNoopAuditService(), nil, 0, nil, 0) + authzSvc := NewAuthorizationService( + wrapped, cfg, NewNoopAuditService(), tokenSvc, clientService, + ) + + client := createTestClient(t, realStore, true) + tok := seedActiveAccessToken( + t, + realStore, + uuid.New().String(), + client.ClientID, + "raw-client", + nil, + ) + + count, err := authzSvc.RevokeAllApplicationTokens( + context.Background(), client.ClientID, "admin", + ) + require.Error(t, err, "client hash collection failure must fail the revoke closed") + assert.Zero(t, count) + + assertTokenActive(t, realStore, tok.TokenHash) +} + +// ---- Error case B: revokeTokenFamilyWithAudit (family path) ---- +// +// On detected refresh-token reuse, a cache-hash-collection failure must NOT +// block the family revoke: the compromised family is still revoked in the DB +// and only the targeted cache invalidation is skipped (entries lapse on TTL). + +func TestRevokeTokenFamily_RevokesDespiteHashError(t *testing.T) { + realStore := setupTestStore(t) + wrapped := &errHashStore{Store: realStore} + cfg := &config.Config{ + DeviceCodeExpiration: 30 * time.Minute, + PollingInterval: 5, + JWTExpiration: time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + EnableRefreshTokens: true, + EnableTokenRotation: true, + RefreshTokenExpiration: 720 * time.Hour, + TokenCacheEnabled: true, + TokenCacheTTL: 5 * time.Minute, + } + audit := &recordingAuditLogger{NoopAuditService: NewNoopAuditService()} + svc, _ := newCachedTokenServiceOverStore(t, wrapped, cfg, audit) + ctx := context.Background() + + client := createTestClient(t, realStore, true) + dc := createAuthorizedDeviceCode(t, realStore, client.ClientID) + _, initialRefresh, err := svc.ExchangeDeviceCode(ctx, dc.DeviceCode, client.ClientID, nil, nil) + require.NoError(t, err) + require.NotNil(t, initialRefresh) + + // First refresh succeeds and revokes the old refresh token, minting a new + // (active) sibling access token in the same family. + newAccess, newRefresh, err := svc.RefreshAccessToken( + ctx, initialRefresh.RawToken, client.ClientID, "", "read write", nil, nil) + require.NoError(t, err) + require.NotNil(t, newRefresh) + require.NotNil(t, newAccess) + + // Now arm the family-hash failure and replay the old (revoked) refresh token. + wrapped.familyHashesErr = errors.New("boom: family hashes") + + _, _, err = svc.RefreshAccessToken( + ctx, initialRefresh.RawToken, client.ClientID, "", "read write", nil, nil) + require.ErrorIs(t, err, token.ErrInvalidRefreshToken, "replay must still be rejected") + + // The family must be revoked DESPITE the hash-collection failure: the sibling + // access token is no longer active (RevokeTokenFamily ran anyway; only the + // targeted cache invalidation was skipped). + got, err := realStore.GetAccessTokenByHash(util.SHA256Hex(newAccess.RawToken)) + require.NoError(t, err) + assert.Equal( + t, models.TokenStatusRevoked, got.Status, + "sibling token must be revoked even when cache hash collection fails", + ) + + // A CRITICAL family-revoked audit event must have been recorded. + var found bool + for _, e := range audit.entries { + if e.EventType == models.EventSuspiciousActivity && + e.Severity == models.SeverityCritical && e.Success { + found = true + break + } + } + assert.True(t, found, "a CRITICAL family-revoked audit event must be logged") +} + +// ---- Happy path: cache-enabled revoke invalidates and succeeds ---- + +func TestRevokeAllApplicationTokens_CacheEnabledHappyPath(t *testing.T) { + realStore := setupTestStore(t) + wrapped := &errHashStore{Store: realStore} + cfg := &config.Config{ + JWTExpiration: time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + TokenCacheEnabled: true, + TokenCacheTTL: 5 * time.Minute, + } + tokenSvc, memCache := newCachedTokenServiceOverStore(t, wrapped, cfg, NewNoopAuditService()) + clientService := NewClientService(wrapped, NewNoopAuditService(), nil, 0, nil, 0) + authzSvc := NewAuthorizationService( + wrapped, cfg, NewNoopAuditService(), tokenSvc, clientService, + ) + ctx := context.Background() + + client := createTestClient(t, realStore, true) + tok := seedActiveAccessToken( + t, + realStore, + uuid.New().String(), + client.ClientID, + "raw-happy", + nil, + ) + + // Prime the cache for this token hash. + require.NoError(t, memCache.Set(ctx, tok.TokenHash, *tok, cfg.TokenCacheTTL)) + + count, err := authzSvc.RevokeAllApplicationTokens(ctx, client.ClientID, "admin") + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Cache entry must be evicted. + _, err = memCache.Get(ctx, tok.TokenHash) + assert.Error(t, err, "cache entry should be invalidated after a successful revoke") +}