diff --git a/src/Exceptionless.Core/Jobs/CleanupDataJob.cs b/src/Exceptionless.Core/Jobs/CleanupDataJob.cs index 01795f0e2..05601d7b9 100644 --- a/src/Exceptionless.Core/Jobs/CleanupDataJob.cs +++ b/src/Exceptionless.Core/Jobs/CleanupDataJob.cs @@ -21,12 +21,15 @@ namespace Exceptionless.Core.Jobs; [Job(Description = "Deletes soft deleted data and enforces data retention.", IsContinuous = false)] public class CleanupDataJob : JobWithLockBase, IHealthCheck { + private static readonly TimeSpan OAuthTokenCleanupSafetyWindow = TimeSpan.FromDays(1); + private readonly IOrganizationRepository _organizationRepository; private readonly OrganizationService _organizationService; private readonly IProjectRepository _projectRepository; private readonly IStackRepository _stackRepository; private readonly IEventRepository _eventRepository; private readonly ITokenRepository _tokenRepository; + private readonly IOAuthTokenRepository _oauthTokenRepository; private readonly IWebHookRepository _webHookRepository; private readonly BillingManager _billingManager; private readonly UsageService _usageService; @@ -43,6 +46,7 @@ public CleanupDataJob( IStackRepository stackRepository, IEventRepository eventRepository, ITokenRepository tokenRepository, + IOAuthTokenRepository oauthTokenRepository, IWebHookRepository webHookRepository, ILockProvider lockProvider, ICacheClient cacheClient, @@ -61,6 +65,7 @@ ILoggerFactory loggerFactory _stackRepository = stackRepository; _eventRepository = eventRepository; _tokenRepository = tokenRepository; + _oauthTokenRepository = oauthTokenRepository; _webHookRepository = webHookRepository; _billingManager = billingManager; _usageService = usageService; @@ -80,6 +85,7 @@ protected override async Task RunInternalAsync(JobContext context) _lastRun = _timeProvider.GetUtcNow().UtcDateTime; await MarkTokensSuspended(context); + await CleanupOAuthTokensAsync(context); await CleanupSoftDeletedOrganizationsAsync(context); await CleanupSoftDeletedProjectsAsync(context); await CleanupSoftDeletedStacksAsync(context); @@ -107,6 +113,13 @@ private async Task MarkTokensSuspended(JobContext context) } while (!context.CancellationToken.IsCancellationRequested && await suspendedOrganizations.NextPageAsync()); } + private async Task CleanupOAuthTokensAsync(JobContext context) + { + var utcCutoff = _timeProvider.GetUtcNow().UtcDateTime.Subtract(OAuthTokenCleanupSafetyWindow); + long removed = await _oauthTokenRepository.RemoveExpiredDisabledAsync(utcCutoff, context.CancellationToken); + _logger.LogInformation("Removed {OAuthTokenCount} expired disabled OAuth token(s)", removed); + } + private async Task CleanupSoftDeletedOrganizationsAsync(JobContext context) { var organizationResults = await _organizationRepository.GetAllAsync(o => o.SoftDeleteMode(SoftDeleteQueryMode.DeletedOnly).SearchAfterPaging().PageLimit(5)); diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IOAuthTokenRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IOAuthTokenRepository.cs index 2bbf81747..b5a0b821d 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IOAuthTokenRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IOAuthTokenRepository.cs @@ -9,7 +9,10 @@ public interface IOAuthTokenRepository : ISearchableRepository Task> GetByAccessTokenHashAsync(string accessTokenHash, CommandOptionsDescriptor? options = null); Task> GetByRefreshTokenHashAsync(string refreshTokenHash, CommandOptionsDescriptor? options = null); Task> GetByGrantIdAsync(string grantId, CommandOptionsDescriptor? options = null); + Task> GetByGrantIdForUpdateAsync(string grantId, CommandOptionsDescriptor? options = null); Task> GetByUserIdAsync(string userId, CommandOptionsDescriptor? options = null); Task> GetByUserIdAndClientIdAsync(string userId, string clientId, CommandOptionsDescriptor? options = null); + Task> GetByUserIdAndClientIdForUpdateAsync(string userId, string clientId, CommandOptionsDescriptor? options = null); + Task RemoveExpiredDisabledAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); Task RemoveAllByUserIdAsync(string userId, CommandOptionsDescriptor? options = null); } diff --git a/src/Exceptionless.Core/Repositories/OAuthTokenRepository.cs b/src/Exceptionless.Core/Repositories/OAuthTokenRepository.cs index c9994c9ae..ee9580ee4 100644 --- a/src/Exceptionless.Core/Repositories/OAuthTokenRepository.cs +++ b/src/Exceptionless.Core/Repositories/OAuthTokenRepository.cs @@ -8,6 +8,8 @@ namespace Exceptionless.Core.Repositories; public class OAuthTokenRepository : RepositoryBase, IOAuthTokenRepository { + private const int CleanupBatchSize = 500; + public OAuthTokenRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) : base(configuration.OAuthTokens, validator, options) { @@ -31,6 +33,11 @@ public Task> GetByGrantIdAsync(string grantId, CommandOp return FindAsync(q => q.FieldEquals(t => t.GrantId, grantId).SortDescending(t => t.UpdatedUtc), options); } + public Task> GetByGrantIdForUpdateAsync(string grantId, CommandOptionsDescriptor? options = null) + { + return FindAsync(q => q.FieldEquals(t => t.GrantId, grantId).SortAscending(t => t.Id), options); + } + public Task> GetByUserIdAsync(string userId, CommandOptionsDescriptor? options = null) { return FindAsync(q => q.FieldEquals(t => t.UserId, userId).SortDescending(t => t.UpdatedUtc), options); @@ -41,6 +48,47 @@ public Task> GetByUserIdAndClientIdAsync(string userId, return FindAsync(q => q.FieldEquals(t => t.UserId, userId).FieldEquals(t => t.ClientId, clientId).SortDescending(t => t.UpdatedUtc), options); } + public Task> GetByUserIdAndClientIdForUpdateAsync(string userId, string clientId, CommandOptionsDescriptor? options = null) + { + return FindAsync(q => q.FieldEquals(t => t.UserId, userId).FieldEquals(t => t.ClientId, clientId).SortAscending(t => t.Id), options); + } + + public async Task RemoveExpiredDisabledAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + var expiredSpentTokenResults = await FindAsync(q => q + .FieldEquals(t => t.IsDisabled, true) + .DateRange(null, utcCutoff, (OAuthToken t) => t.RefreshExpiresUtc) + .SortAscending(t => t.Id), o => o.ImmediateConsistency().SearchAfterPaging().PageLimit(CleanupBatchSize)); + + long removed = await RemoveMatchingAsync(expiredSpentTokenResults, t => !String.IsNullOrEmpty(t.RefreshTokenHash)); + + var clearedRefreshTokenResults = await FindAsync(q => q + .FieldEquals(t => t.IsDisabled, true) + .SortAscending(t => t.Id), o => o.ImmediateConsistency().SearchAfterPaging().PageLimit(CleanupBatchSize)); + + removed += await RemoveMatchingAsync(clearedRefreshTokenResults, t => String.IsNullOrEmpty(t.RefreshTokenHash) && t.UpdatedUtc < utcCutoff); + return removed; + + async Task RemoveMatchingAsync(FindResults results, Func shouldRemove) + { + long removedCount = 0; + do + { + var tokensToRemove = results.Documents + .Where(shouldRemove) + .ToArray(); + + if (tokensToRemove.Length > 0) + { + await RemoveAsync(tokensToRemove, o => o.ImmediateConsistency()); + removedCount += tokensToRemove.Length; + } + } while (!cancellationToken.IsCancellationRequested && await results.NextPageAsync()); + + return removedCount; + } + } + public Task RemoveAllByUserIdAsync(string userId, CommandOptionsDescriptor? options = null) { return RemoveAllAsync(q => q.FieldEquals(t => t.UserId, userId), options); diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index 35a5be337..b6a1d5342 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -517,10 +517,19 @@ private async Task RevokeOAuthGrantFamilyAsync(OAuthToken token) return; } - var results = await oauthTokenRepository.GetByGrantIdAsync(token.GrantId, o => o.ImmediateConsistency().PageLimit(OAuthGrantFamilyPageLimit)); - IEnumerable familyTokens = results.Documents.Count > 0 ? results.Documents : [token]; - foreach (var familyToken in familyTokens.Where(t => String.Equals(t.GrantId, token.GrantId, StringComparison.Ordinal))) - await DisableTokenAsync(familyToken); + bool disabledAny = false; + var results = await oauthTokenRepository.GetByGrantIdForUpdateAsync(token.GrantId, o => o.ImmediateConsistency().SearchAfterPaging().PageLimit(OAuthGrantFamilyPageLimit)); + do + { + foreach (var familyToken in results.Documents.Where(t => String.Equals(t.GrantId, token.GrantId, StringComparison.Ordinal))) + { + disabledAny = true; + await DisableTokenAsync(familyToken); + } + } while (await results.NextPageAsync()); + + if (!disabledAny) + await DisableTokenAsync(token); } private static RefreshScopeValidationResult ValidateRefreshScopes(OAuthToken token, OAuthClientOptions client) diff --git a/src/Exceptionless.Web/Controllers/UserController.cs b/src/Exceptionless.Web/Controllers/UserController.cs index 88f962dee..a0d157c74 100644 --- a/src/Exceptionless.Web/Controllers/UserController.cs +++ b/src/Exceptionless.Web/Controllers/UserController.cs @@ -75,9 +75,14 @@ public async Task> GetCurrentUserAsync() [HttpGet("me/oauth-grants")] public async Task>> GetOAuthGrantsAsync() { - var results = await _oauthTokenRepository.GetByUserIdAsync(CurrentUser.Id, o => o.PageLimit(MAXIMUM_SKIP)); - var tokens = results.Documents.Where(IsActiveOAuthGrantToken).ToArray(); - if (tokens.Length == 0) + var tokens = new List(); + var results = await _oauthTokenRepository.GetByUserIdAsync(CurrentUser.Id, o => o.SearchAfterPaging().PageLimit(MAXIMUM_SKIP)); + do + { + tokens.AddRange(results.Documents.Where(IsActiveOAuthGrantToken)); + } while (!HttpContext.RequestAborted.IsCancellationRequested && await results.NextPageAsync()); + + if (tokens.Count == 0) return Ok(Array.Empty()); var applicationsByClientId = new Dictionary(StringComparer.Ordinal); @@ -102,24 +107,34 @@ public async Task>> GetOAuthGra [HttpDelete("me/oauth-grants/{id:minlength(1)}")] public async Task RevokeOAuthGrantAsync(string id) { - var grantResults = await _oauthTokenRepository.GetByGrantIdAsync(id, o => o.ImmediateConsistency().PageLimit(MAXIMUM_SKIP)); - var token = grantResults.Documents.FirstOrDefault(t => - String.Equals(t.UserId, CurrentUser.Id, StringComparison.Ordinal) - && !String.IsNullOrWhiteSpace(t.ClientId)); + OAuthToken? token = null; + var grantResults = await _oauthTokenRepository.GetByGrantIdForUpdateAsync(id, o => o.ImmediateConsistency().SearchAfterPaging().PageLimit(MAXIMUM_SKIP)); + do + { + token = grantResults.Documents.FirstOrDefault(t => + String.Equals(t.UserId, CurrentUser.Id, StringComparison.Ordinal) + && !String.IsNullOrWhiteSpace(t.ClientId)); + if (token is not null) + break; + } while (!HttpContext.RequestAborted.IsCancellationRequested && await grantResults.NextPageAsync()); + if (token is null) return NotFound(); string clientId = token.ClientId; - var results = await _oauthTokenRepository.GetByUserIdAndClientIdAsync(CurrentUser.Id, clientId, o => o.ImmediateConsistency().PageLimit(MAXIMUM_SKIP)); + var results = await _oauthTokenRepository.GetByUserIdAndClientIdForUpdateAsync(CurrentUser.Id, clientId, o => o.ImmediateConsistency().SearchAfterPaging().PageLimit(MAXIMUM_SKIP)); var utcNow = _timeProvider.GetUtcNow().UtcDateTime; - foreach (var oauthToken in results.Documents) + do { - oauthToken.IsDisabled = true; - oauthToken.RefreshTokenHash = null; - oauthToken.UpdatedUtc = utcNow; - await _oauthTokenRepository.SaveAsync(oauthToken, o => o.ImmediateConsistency()); - } + foreach (var oauthToken in results.Documents) + { + oauthToken.IsDisabled = true; + oauthToken.RefreshTokenHash = null; + oauthToken.UpdatedUtc = utcNow; + await _oauthTokenRepository.SaveAsync(oauthToken, o => o.ImmediateConsistency()); + } + } while (!HttpContext.RequestAborted.IsCancellationRequested && await results.NextPageAsync()); return NoContent(); } diff --git a/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs index f7ad45277..ae005800e 100644 --- a/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs @@ -1052,6 +1052,56 @@ public async Task TokenAsync_RefreshTokenWithRemovedOfflineAccess_RevokesGrantFa await AssertRefreshFailsAndRevokesGrantFamilyAsync(token); } + [Fact] + public async Task TokenAsync_RefreshTokenWithLargeGrantFamily_RevokesAllTokens() + { + var token = await IssueTokenAsync( + resource: RestApiResource, + scope: $"{AuthorizationRoles.ProjectsRead} {AuthorizationRoles.OfflineAccess}"); + var storedToken = await GetStoredOAuthTokenAsync(token.AccessToken); + Assert.NotNull(storedToken); + string grantId = storedToken.GrantId; + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var familyTokens = Enumerable.Range(0, 1005) + .Select(i => new OAuthToken + { + Id = ObjectId.GenerateNewId().ToString(), + UserId = storedToken.UserId, + ClientId = storedToken.ClientId, + GrantId = grantId, + Resource = storedToken.Resource, + AccessTokenHash = OAuthService.CreateTokenHash(StringExtensions.GetRandomString(OAuthService.OAuthTokenLength)), + RefreshTokenHash = OAuthService.CreateTokenHash(StringExtensions.GetRandomString(OAuthService.OAuthTokenLength)), + Scopes = [AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess], + OrganizationIds = [TestConstants.OrganizationId], + ExpiresUtc = utcNow.AddHours(1), + RefreshExpiresUtc = utcNow.AddDays(30), + IsDisabled = false, + CreatedBy = storedToken.UserId, + CreatedUtc = utcNow, + UpdatedUtc = utcNow + }) + .ToArray(); + await _oauthTokenRepository.AddAsync(familyTokens, o => o.ImmediateConsistency()); + await SetStoredOAuthApplicationScopesAsync(ClientId, AuthorizationRoles.ProjectsRead); + + await AssertRefreshFailsAndRevokesGrantFamilyAsync(token); + + var results = await _oauthTokenRepository.GetByGrantIdForUpdateAsync(grantId, o => o.ImmediateConsistency().SearchAfterPaging().PageLimit(1000)); + int tokenCount = 0; + do + { + foreach (var familyToken in results.Documents) + { + tokenCount++; + Assert.True(familyToken.IsDisabled); + Assert.Null(familyToken.RefreshTokenHash); + } + } while (await results.NextPageAsync()); + + Assert.Equal(familyTokens.Length + 1, tokenCount); + } + [Fact] public async Task TokenAsync_RefreshTokenWithRemovedRequiredResourceScope_RevokesGrantFamily() { diff --git a/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs b/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs index c83626781..c76de9c93 100644 --- a/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs @@ -347,6 +347,40 @@ public async Task GetOAuthGrantsAsync_WithActiveOAuthTokens_ReturnsGroupedApplic Assert.Contains(grant.Resources, r => r.Resource == "http://localhost:7110/api/v2" && r.Scopes.Contains(AuthorizationRoles.StacksRead)); } + [Fact] + public async Task GetOAuthGrantsAsync_WhenDisabledTokensExceedPageLimit_ReturnsActiveGrant() + { + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + const string clientId = "paged-oauth-grant-client"; + await CreateOAuthApplicationAsync(clientId, "Paged OAuth Grant Client"); + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var disabledTokens = Enumerable.Range(0, 1005) + .Select(i => CreateOAuthGrantToken(user.Id, $"disabled-paged-client-{i}", "http://localhost:7110/mcp", [AuthorizationRoles.McpRead], utcNow.AddMinutes(1), isDisabled: true)) + .ToArray(); + await _oauthTokenRepository.AddAsync(disabledTokens, o => o.ImmediateConsistency()); + string grantId = StringExtensions.GetNewToken(); + await _oauthTokenRepository.AddAsync(CreateOAuthGrantToken( + user.Id, + clientId, + "http://localhost:7110/mcp", + [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], + utcNow, + grantId: grantId), o => o.ImmediateConsistency()); + + var grants = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPath("users/me/oauth-grants") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(grants); + var grant = Assert.Single(grants); + Assert.Equal(clientId, grant.ClientId); + Assert.Equal("Paged OAuth Grant Client", grant.ApplicationName); + Assert.Contains(AuthorizationRoles.McpRead, grant.Scopes); + } + [Fact] public async Task RevokeOAuthGrantAsync_WithCurrentUserGrant_DisablesAllClientTokens() { @@ -384,6 +418,44 @@ await SendRequestAsync(r => r Assert.NotNull(stillActiveToken.RefreshTokenHash); } + [Fact] + public async Task RevokeOAuthGrantAsync_WhenClientTokensExceedPageLimit_DisablesAllClientTokens() + { + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + const string clientId = "paged-revoke-client"; + await CreateOAuthApplicationAsync(clientId, "Paged Revoke Client"); + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var tokens = Enumerable.Range(0, 1005) + .Select(i => CreateOAuthGrantToken(user.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], utcNow)) + .ToList(); + string targetGrantId = StringExtensions.GetNewToken(); + var targetToken = CreateOAuthGrantToken(user.Id, clientId, "http://localhost:7110/api/v2", [AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess], utcNow, grantId: targetGrantId); + tokens.Add(targetToken); + await _oauthTokenRepository.AddAsync(tokens, o => o.ImmediateConsistency()); + + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "oauth-grants", targetGrantId) + .StatusCodeShouldBeNoContent() + ); + + var results = await _oauthTokenRepository.GetByUserIdAndClientIdForUpdateAsync(user.Id, clientId, o => o.ImmediateConsistency().SearchAfterPaging().PageLimit(1000)); + int tokenCount = 0; + do + { + foreach (var token in results.Documents) + { + tokenCount++; + Assert.True(token.IsDisabled); + Assert.Null(token.RefreshTokenHash); + } + } while (await results.NextPageAsync()); + + Assert.Equal(tokens.Count, tokenCount); + } + [Fact] public async Task RevokeOAuthGrantAsync_ForAnotherUserGrant_ReturnsNotFound() { @@ -726,6 +798,30 @@ private async Task CreateOAuthGrantTokenAsync(string userId, string return token; } + private static OAuthToken CreateOAuthGrantToken(string userId, string clientId, string resource, string[] scopes, DateTime utcNow, bool isDisabled = false, string[]? organizationIds = null, string? grantId = null) + { + var accessToken = StringExtensions.GetRandomString(OAuthService.OAuthTokenLength); + var refreshToken = scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) ? StringExtensions.GetRandomString(OAuthService.OAuthTokenLength) : null; + return new OAuthToken + { + Id = ObjectId.GenerateNewId().ToString(), + UserId = userId, + ClientId = clientId, + GrantId = String.IsNullOrWhiteSpace(grantId) ? StringExtensions.GetNewToken() : grantId, + Resource = resource, + AccessTokenHash = OAuthService.CreateTokenHash(accessToken), + RefreshTokenHash = refreshToken is null ? null : OAuthService.CreateTokenHash(refreshToken), + Scopes = scopes.ToHashSet(StringComparer.Ordinal), + OrganizationIds = (organizationIds ?? [SampleDataService.TEST_ORG_ID]).ToHashSet(StringComparer.Ordinal), + ExpiresUtc = utcNow.AddHours(1), + RefreshExpiresUtc = refreshToken is not null ? utcNow.AddDays(30) : null, + IsDisabled = isDisabled, + CreatedBy = userId, + CreatedUtc = utcNow, + UpdatedUtc = utcNow + }; + } + private async Task GetTestOrganizationUserAsync() { var user = await SendRequestAsAsync(r => r diff --git a/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs b/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs index fbf525bee..6470a2a7d 100644 --- a/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs @@ -1,6 +1,8 @@ using Exceptionless.Core; +using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; using Exceptionless.Core.Jobs; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; @@ -8,6 +10,7 @@ using Exceptionless.DateTimeExtensions; using Exceptionless.Tests.Utility; using Foundatio.Repositories; +using Foundatio.Repositories.Models; using Foundatio.Repositories.Utility; using Foundatio.Storage; using Xunit; @@ -28,6 +31,7 @@ public class CleanupDataJobTests : IntegrationTestsBase private readonly IEventRepository _eventRepository; private readonly TokenData _tokenData; private readonly ITokenRepository _tokenRepository; + private readonly IOAuthTokenRepository _oauthTokenRepository; private readonly BillingManager _billingManager; private readonly BillingPlans _plans; private readonly IFileStorage _fileStorage; @@ -46,6 +50,7 @@ public CleanupDataJobTests(ITestOutputHelper output, AppWebHostFactory factory) _eventRepository = GetService(); _tokenData = GetService(); _tokenRepository = GetService(); + _oauthTokenRepository = GetService(); _billingManager = GetService(); _plans = GetService(); _fileStorage = GetService(); @@ -73,6 +78,59 @@ public async Task CanCleanupSuspendedTokens() Assert.True(token.IsSuspended); } + [Fact] + public async Task CanCleanupExpiredDisabledOAuthTokens() + { + var utcNow = DateTime.UtcNow; + var cutoff = utcNow.Subtract(TimeSpan.FromDays(1)); + var expiredSpentToken = CreateOAuthToken(cutoff.SubtractMinutes(1), isDisabled: true, refreshTokenHash: "expired-spent-refresh", refreshExpiresUtc: cutoff.SubtractMinutes(1)); + var retainedSpentToken = CreateOAuthToken(cutoff.SubtractMinutes(1), isDisabled: true, refreshTokenHash: "retained-spent-refresh", refreshExpiresUtc: cutoff.AddMinutes(1)); + var activeExpiredToken = CreateOAuthToken(cutoff.SubtractMinutes(1), isDisabled: false, refreshTokenHash: "active-expired-refresh", refreshExpiresUtc: cutoff.SubtractMinutes(1)); + var expiredClearedRefreshToken = CreateOAuthToken(cutoff.SubtractMinutes(1), isDisabled: true, refreshTokenHash: null, refreshExpiresUtc: null); + var retainedClearedRefreshToken = CreateOAuthToken(cutoff.AddMinutes(1), isDisabled: true, refreshTokenHash: null, refreshExpiresUtc: null); + + await _oauthTokenRepository.AddAsync([ + expiredSpentToken, + retainedSpentToken, + activeExpiredToken, + expiredClearedRefreshToken, + retainedClearedRefreshToken + ], o => o.ImmediateConsistency()); + + await _oauthTokenRepository.PatchAsync(expiredClearedRefreshToken.Id, new PartialPatch(new { updated_utc = cutoff.AddMinutes(-1) }), o => o.ImmediateConsistency()); + await _oauthTokenRepository.PatchAsync(retainedClearedRefreshToken.Id, new PartialPatch(new { updated_utc = cutoff.AddMinutes(1) }), o => o.ImmediateConsistency()); + + await _job.RunAsync(TestCancellationToken); + + Assert.Null(await _oauthTokenRepository.GetByIdAsync(expiredSpentToken.Id, o => o.ImmediateConsistency())); + Assert.NotNull(await _oauthTokenRepository.GetByIdAsync(retainedSpentToken.Id, o => o.ImmediateConsistency())); + Assert.NotNull(await _oauthTokenRepository.GetByIdAsync(activeExpiredToken.Id, o => o.ImmediateConsistency())); + Assert.Null(await _oauthTokenRepository.GetByIdAsync(expiredClearedRefreshToken.Id, o => o.ImmediateConsistency())); + Assert.NotNull(await _oauthTokenRepository.GetByIdAsync(retainedClearedRefreshToken.Id, o => o.ImmediateConsistency())); + + OAuthToken CreateOAuthToken(DateTime updatedUtc, bool isDisabled, string? refreshTokenHash, DateTime? refreshExpiresUtc) + { + return new OAuthToken + { + Id = ObjectId.GenerateNewId().ToString(), + UserId = TestConstants.UserId, + ClientId = "cleanup-job-oauth-client", + GrantId = StringExtensions.GetNewToken(), + Resource = "http://localhost:7110/mcp", + AccessTokenHash = OAuthService.CreateTokenHash(StringExtensions.GetRandomString(OAuthService.OAuthTokenLength)), + RefreshTokenHash = refreshTokenHash, + Scopes = [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], + OrganizationIds = [TestConstants.OrganizationId], + ExpiresUtc = utcNow.AddHours(1), + RefreshExpiresUtc = refreshExpiresUtc, + IsDisabled = isDisabled, + CreatedBy = TestConstants.UserId, + CreatedUtc = updatedUtc, + UpdatedUtc = updatedUtc + }; + } + } + [Fact] public async Task CanCleanupSoftDeletedOrganization() { diff --git a/tests/Exceptionless.Tests/Repositories/OAuthTokenRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/OAuthTokenRepositoryTests.cs index 8807dbd0d..ffe40c34f 100644 --- a/tests/Exceptionless.Tests/Repositories/OAuthTokenRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/OAuthTokenRepositoryTests.cs @@ -4,6 +4,7 @@ using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; using Exceptionless.Tests.Utility; +using Foundatio.Repositories.Models; using Foundatio.Repositories; using Foundatio.Repositories.Utility; using Xunit; @@ -51,4 +52,69 @@ public async Task GetByOAuthTokenHashAsync_ReturnsOAuthAccessToken() Assert.Equal(token.Id, Assert.Single(refreshResults.Documents).Id); Assert.Empty(rawRefreshResults.Documents); } -} \ No newline at end of file + + [Fact] + public async Task RemoveExpiredDisabledAsync_DisabledExpiredTokens_RemovesOnlyExpiredRows() + { + var utcNow = DateTime.UtcNow; + var cutoff = utcNow.Subtract(TimeSpan.FromDays(1)); + var expiredSpentToken = CreateOAuthToken(utcNow.AddDays(-40), isDisabled: true, refreshExpiresUtc: cutoff.AddMinutes(-1)); + var retainedSpentToken = CreateOAuthToken(utcNow.AddDays(-40), isDisabled: true, refreshExpiresUtc: cutoff.AddMinutes(1)); + var activeExpiredToken = CreateOAuthToken(utcNow.AddDays(-40), isDisabled: false, refreshExpiresUtc: cutoff.AddMinutes(-1)); + var expiredClearedRefreshToken = CreateOAuthToken(cutoff.AddMinutes(-1), isDisabled: true, refreshTokenHash: null, refreshExpiresUtc: null); + var retainedClearedRefreshToken = CreateOAuthToken(cutoff.AddMinutes(1), isDisabled: true, refreshTokenHash: null, refreshExpiresUtc: null); + var recentClearedExpiredRefreshToken = CreateOAuthToken(cutoff.AddMinutes(1), isDisabled: true, refreshTokenHash: null, refreshExpiresUtc: cutoff.AddMinutes(-1)); + + await _repository.AddAsync([ + expiredSpentToken, + retainedSpentToken, + activeExpiredToken, + expiredClearedRefreshToken, + retainedClearedRefreshToken, + recentClearedExpiredRefreshToken + ], o => o.ImmediateConsistency()); + + await _repository.PatchAsync(expiredClearedRefreshToken.Id, new PartialPatch(new { updated_utc = cutoff.AddMinutes(-1) }), o => o.ImmediateConsistency()); + await _repository.PatchAsync(retainedClearedRefreshToken.Id, new PartialPatch(new { updated_utc = cutoff.AddMinutes(1) }), o => o.ImmediateConsistency()); + await _repository.PatchAsync(recentClearedExpiredRefreshToken.Id, new PartialPatch(new { updated_utc = cutoff.AddMinutes(1) }), o => o.ImmediateConsistency()); + + long removed = await _repository.RemoveExpiredDisabledAsync(cutoff, TestContext.Current.CancellationToken); + + Assert.Equal(2, removed); + Assert.Null(await _repository.GetByIdAsync(expiredSpentToken.Id, o => o.ImmediateConsistency())); + Assert.NotNull(await _repository.GetByIdAsync(retainedSpentToken.Id, o => o.ImmediateConsistency())); + Assert.NotNull(await _repository.GetByIdAsync(activeExpiredToken.Id, o => o.ImmediateConsistency())); + Assert.NotNull(await _repository.GetByIdAsync(recentClearedExpiredRefreshToken.Id, o => o.ImmediateConsistency())); + Assert.Null(await _repository.GetByIdAsync(expiredClearedRefreshToken.Id, o => o.ImmediateConsistency())); + Assert.NotNull(await _repository.GetByIdAsync(retainedClearedRefreshToken.Id, o => o.ImmediateConsistency())); + } + + private static OAuthToken CreateOAuthToken( + DateTime utcNow, + bool isDisabled = false, + string? refreshTokenHash = "refresh-token-hash", + DateTime? refreshExpiresUtc = null, + string? userId = null, + string? clientId = null, + string? grantId = null) + { + return new OAuthToken + { + Id = ObjectId.GenerateNewId().ToString(), + UserId = userId ?? TestConstants.UserId, + ClientId = clientId ?? "repository-oauth-client", + GrantId = grantId ?? StringExtensions.GetNewToken(), + Resource = "http://localhost:7110/mcp", + AccessTokenHash = OAuthService.CreateTokenHash(StringExtensions.GetRandomString(OAuthService.OAuthTokenLength)), + RefreshTokenHash = refreshTokenHash, + Scopes = [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], + OrganizationIds = [TestConstants.OrganizationId], + ExpiresUtc = utcNow.AddHours(1), + RefreshExpiresUtc = refreshExpiresUtc ?? utcNow.AddDays(30), + IsDisabled = isDisabled, + CreatedBy = userId ?? TestConstants.UserId, + CreatedUtc = utcNow, + UpdatedUtc = utcNow + }; + } +}