diff --git a/src/Exceptionless.Core/Extensions/UserExtensions.cs b/src/Exceptionless.Core/Extensions/UserExtensions.cs index 31691cdb38..a04982adbc 100644 --- a/src/Exceptionless.Core/Extensions/UserExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UserExtensions.cs @@ -1,10 +1,22 @@ -using Exceptionless.Core.Models; +using System.Diagnostics.CodeAnalysis; +using Exceptionless.Core.Models; using Exceptionless.DateTimeExtensions; namespace Exceptionless.Core.Extensions; public static class UserExtensions { + /// + /// Checks whether an active user's session generation matches the presented credential. + /// A missing generation matches only another missing generation for legacy credentials. + /// This method does not load or refresh the user. + /// + public static bool HasValidAuthenticationVersion([NotNullWhen(true)] this User? user, string? authenticationVersion) + { + return user is { IsActive: true } + && String.Equals(user.AuthenticationVersion, authenticationVersion, StringComparison.Ordinal); + } + public static bool IsCorrectPassword(this User user, string password) { if (String.IsNullOrEmpty(user.Salt) || String.IsNullOrEmpty(user.Password)) diff --git a/src/Exceptionless.Core/Models/OAuthToken.cs b/src/Exceptionless.Core/Models/OAuthToken.cs index 96807c44f6..1cbba61017 100644 --- a/src/Exceptionless.Core/Models/OAuthToken.cs +++ b/src/Exceptionless.Core/Models/OAuthToken.cs @@ -30,6 +30,12 @@ public class OAuthToken : IIdentity, IHaveDates, IValidatableObject [MaxLength(100)] public string AccessTokenHash { get; set; } = null!; + /// + /// The user session generation captured when this OAuth token is issued. + /// A legacy null value matches only an active user whose generation is also null; it does not describe a password format. + /// + public string? AuthenticationVersion { get; set; } + [MaxLength(100)] public string? RefreshTokenHash { get; set; } diff --git a/src/Exceptionless.Core/Models/Token.cs b/src/Exceptionless.Core/Models/Token.cs index d2592769f2..23b4bddb44 100644 --- a/src/Exceptionless.Core/Models/Token.cs +++ b/src/Exceptionless.Core/Models/Token.cs @@ -9,6 +9,12 @@ public class Token : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, IVa [Required] public string Id { get; set; } = null!; + /// + /// The user session generation captured when this token is issued. + /// A legacy null value matches only an active user whose generation is also null; it does not describe a password format. + /// + public string? AuthenticationVersion { get; set; } + /// /// Null for user-scoped tokens (where UserId is set instead). /// Required when ProjectId is set. diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index 0a8f2c9143..b234f57e25 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -19,6 +19,13 @@ public record User : IIdentity, IHaveDates, IValidatableObject /// public ISet OrganizationIds { get; init; } = new HashSet(); + /// + /// Session revocation generation, rotated when a password changes or resets, or an external login is removed or transferred away. + /// Remains null until rotation; active users with a null generation accept only matching legacy null token generations. + /// This value does not describe the password hashing format. + /// + public string? AuthenticationVersion { get; set; } + public string? Password { get; set; } public string? Salt { get; set; } public string? PasswordResetToken { get; set; } diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index 138d6c2de0..fb0d7ce3c4 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -383,8 +383,13 @@ public async Task ValidateAuthorizationRequestAsync(OAuth return OAuthValidationResult.Valid(client, requestedScopes); } - public async Task CreateAuthorizationCodeAsync(OAuthAuthorizeRequest request, string userId, IReadOnlyCollection organizationIds) + public async Task CreateAuthorizationCodeAsync(OAuthAuthorizeRequest request, User user, IReadOnlyCollection organizationIds) { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(organizationIds); + ArgumentException.ThrowIfNullOrWhiteSpace(user.Id); + await oauthApplicationRepository.AddOrganizationIdsAsync(request.ClientId, organizationIds, o => o.ImmediateConsistency().Notifications(false)); string code = StringExtensions.GetNewToken(); @@ -392,7 +397,8 @@ public async Task CreateAuthorizationCodeAsync(OAuthAuthorizeRequest req { ClientId = request.ClientId, RedirectUri = request.RedirectUri, - UserId = userId, + UserId = user.Id, + AuthenticationVersion = user.AuthenticationVersion, CodeChallenge = request.CodeChallenge, Resource = request.Resource ?? throw new InvalidOperationException("OAuth resource must be validated before creating an authorization code."), Scopes = NormalizeScopes(request.Scope), @@ -432,7 +438,11 @@ public async Task ExchangeAuthorizationCodeAsync(OAuthTok if (!ValidateCodeVerifier(code.CodeChallenge, request.CodeVerifier)) return OAuthTokenIssueResult.Invalid("invalid_grant", "Invalid PKCE verifier."); - return OAuthTokenIssueResult.Success(await CreateTokenAsync(code.UserId, code.ClientId, code.Resource, code.Scopes, code.OrganizationIds)); + var user = await userRepository.GetByIdAsync(code.UserId); + if (!user.HasValidAuthenticationVersion(code.AuthenticationVersion)) + return OAuthTokenIssueResult.Invalid("invalid_grant", "Authorization code is invalid or expired."); + + return OAuthTokenIssueResult.Success(await CreateTokenAsync(code.UserId, code.ClientId, code.Resource, code.Scopes, code.OrganizationIds, authenticationVersion: code.AuthenticationVersion)); } public async Task RefreshAsync(OAuthTokenRequest request) @@ -473,8 +483,8 @@ public async Task RefreshAsync(OAuthTokenRequest request) return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); } - var user = await userRepository.GetByIdAsync(token.UserId, o => o.ImmediateConsistency()); - if (user is null || !user.IsActive) + var user = await userRepository.GetByIdAsync(token.UserId); + if (!user.HasValidAuthenticationVersion(token.AuthenticationVersion)) { await RevokeOAuthGrantFamilyAsync(token); return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); @@ -490,7 +500,7 @@ public async Task RefreshAsync(OAuthTokenRequest request) if (!token.IsDisabled) await SpendRefreshTokenAsync(token, utcNow); - return OAuthTokenIssueResult.Success(await CreateTokenAsync(token.UserId, token.ClientId, token.Resource, scopeValidation.Scopes, activeOrganizationIds, token.GrantId)); + return OAuthTokenIssueResult.Success(await CreateTokenAsync(token.UserId, token.ClientId, token.Resource, scopeValidation.Scopes, activeOrganizationIds, token.GrantId, token.AuthenticationVersion)); } public async Task RevokeAsync(string? tokenValue, string? clientId) @@ -590,7 +600,7 @@ private Task DisableTokenAsync(OAuthToken token) return oauthTokenRepository.SaveAsync(token, o => o.ImmediateConsistency()); } - private async Task CreateTokenAsync(string userId, string clientId, string resource, IReadOnlyCollection scopes, IReadOnlyCollection organizationIds, string? grantId = null) + private async Task CreateTokenAsync(string userId, string clientId, string resource, IReadOnlyCollection scopes, IReadOnlyCollection organizationIds, string? grantId = null, string? authenticationVersion = null) { var utcNow = timeProvider.GetUtcNow().UtcDateTime; var accessToken = CreateOAuthToken(); @@ -599,6 +609,7 @@ private async Task CreateTokenAsync(string userId, string cl { Id = ObjectId.GenerateNewId().ToString(), UserId = userId, + AuthenticationVersion = authenticationVersion, ClientId = clientId, GrantId = String.IsNullOrWhiteSpace(grantId) ? StringExtensions.GetNewToken() : grantId, Resource = resource, @@ -868,6 +879,7 @@ public record OAuthAuthorizationCode public required string UserId { get; init; } public required string CodeChallenge { get; init; } public required string Resource { get; init; } + public string? AuthenticationVersion { get; init; } public IReadOnlyCollection Scopes { get; set; } = []; public DateTime CreatedUtc { get; init; } public IReadOnlyCollection OrganizationIds { get; init; } = []; diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index b02c4325c2..fc37943515 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -311,7 +311,10 @@ public async Task> Handle(RemoveExternalLogin message) try { if (user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) + { + user.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken(); await userRepository.SaveAsync(user, o => o.Cache()); + } } catch (Exception ex) { @@ -501,6 +504,7 @@ private async Task AddGlobalAdminRoleIfFirstUserAsync(User user) private async Task> ExternalLoginAsync(ExternalAuthInfo authInfo, HttpContext httpContext, string? appId, string? appSecret, Func> getUserInfoAsync) { using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").SetHttpContext(httpContext)); + if (String.IsNullOrEmpty(appId) || String.IsNullOrEmpty(appSecret)) throw new ConfigurationErrorsException("Missing Configuration for OAuth provider"); @@ -552,12 +556,14 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit var currentUser = httpContext.Request.GetUser(); if (existingUser is not null) { - if (existingUser.Id != currentUser.Id) + if (!String.Equals(existingUser.Id, currentUser.Id, StringComparison.Ordinal)) { if (!existingUser.RemoveOAuthAccount(userInfo.ProviderName, userInfo.Id)) throw new Exception($"Unable to remove existing oauth account for existing user: {existingUser.EmailAddress}"); + existingUser.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken(); await userRepository.SaveAsync(existingUser, o => o.Cache()); + await ResetUserTokensAsync(existingUser, "Link external account to another user", httpContext); } else { @@ -621,6 +627,7 @@ private async Task AddInvitedUserToOrganizationAsync(string? token, User user, H return; using var _ = logger.BeginScope(new ExceptionlessState().Tag("Invite").Identity(user.EmailAddress).SetHttpContext(httpContext)); + var organization = await organizationRepository.GetByInviteTokenAsync(token); var invite = organization?.GetInvite(token); if (organization is null || invite is null) @@ -650,10 +657,12 @@ private async Task AddInvitedUserToOrganizationAsync(string? token, User user, H private async Task ChangePasswordAsync(User user, string password, string tag, HttpContext httpContext) { using var _ = logger.BeginScope(new ExceptionlessState().Tag(tag).Identity(user.EmailAddress).SetHttpContext(httpContext)); + if (String.IsNullOrEmpty(user.Salt)) user.Salt = Core.Extensions.StringExtensions.GetNewToken(); user.Password = password.ToSaltedHash(user.Salt); + user.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken(); user.ResetPasswordResetToken(); try @@ -671,16 +680,27 @@ private async Task ChangePasswordAsync(User user, string password, string tag, H private async Task ResetUserTokensAsync(User user, string tag, HttpContext httpContext) { using var _ = logger.BeginScope(new ExceptionlessState().Tag(tag).Identity(user.EmailAddress).SetHttpContext(httpContext)); + + long total = 0; + try + { + total += await tokenRepository.RemoveAllByUserIdAsync(user.Id); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Error removing user tokens for {EmailAddress} ({UserId}): {Message}", user.EmailAddress, user.Id, ex.Message); + } + try { - long total = await tokenRepository.RemoveAllByUserIdAsync(user.Id); total += await oauthTokenRepository.RemoveAllByUserIdAsync(user.Id); - logger.RemovedUserTokens(total, user.EmailAddress); } catch (Exception ex) { - logger.LogCritical(ex, "Error removing user tokens for {EmailAddress}: {Message}", user.EmailAddress, ex.Message); + logger.LogCritical(ex, "Error removing OAuth tokens for {EmailAddress} ({UserId}): {Message}", user.EmailAddress, user.Id, ex.Message); } + + logger.RemovedUserTokens(total, user.EmailAddress); } private async Task GetOrCreateAuthenticationTokenAsync(User user) @@ -688,7 +708,9 @@ private async Task GetOrCreateAuthenticationTokenAsync(User user) var userTokens = await tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id); var utcNow = timeProvider.GetUtcNow().UtcDateTime; - var validAccessToken = userTokens.Documents.FirstOrDefault(token => !token.ExpiresUtc.HasValue || token.ExpiresUtc > utcNow); + var validAccessToken = userTokens.Documents.FirstOrDefault(token => !token.IsDisabled && !token.IsSuspended + && user.HasValidAuthenticationVersion(token.AuthenticationVersion) + && (!token.ExpiresUtc.HasValue || token.ExpiresUtc > utcNow)); if (validAccessToken is not null) return validAccessToken.Id; @@ -696,6 +718,7 @@ private async Task GetOrCreateAuthenticationTokenAsync(User user) { Id = Core.Extensions.StringExtensions.GetNewToken(), UserId = user.Id, + AuthenticationVersion = user.AuthenticationVersion, CreatedUtc = utcNow, UpdatedUtc = utcNow, ExpiresUtc = utcNow.AddMonths(3), diff --git a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs index b615d9960c..68b7a215b2 100644 --- a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs @@ -194,7 +194,7 @@ private async Task CompleteAuthorizationAsync(OAuthAuthorizeRequest req if (!organizationValidation.IsValid) return OAuthError("invalid_request", organizationValidation.ErrorDescription); - string code = await oauthService.CreateAuthorizationCodeAsync(request, HttpContext.Request.GetUser().Id, organizationValidation.OrganizationIds); + string code = await oauthService.CreateAuthorizationCodeAsync(request, HttpContext.Request.GetUser(), organizationValidation.OrganizationIds); string redirectUri = BuildRedirectUri(request.RedirectUri, code, request.State, GetOrigin()); return HttpResults.Ok(new OAuthAuthorizeResponse { RedirectUri = redirectUri }); } diff --git a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs index 6dea7c7a46..99d1f44c4f 100644 --- a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs +++ b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs @@ -136,6 +136,9 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.Fail("Token is not valid"); } + if (!user.HasValidAuthenticationVersion(tokenRecord.AuthenticationVersion)) + return AuthenticateResult.Fail("Token is not valid"); + return AuthenticateResult.Success(CreateUserAuthenticationTicket(user, tokenRecord)); } @@ -252,6 +255,9 @@ private async Task AuthenticateOAuthBearerAsync(string token return AuthenticateResult.Fail("User is not valid"); } + if (!user.HasValidAuthenticationVersion(tokenRecord.AuthenticationVersion)) + return AuthenticateResult.Fail("User is not valid"); + var activeOAuthOrganizationIds = user.GetActiveOAuthOrganizationIds(tokenRecord); if (activeOAuthOrganizationIds.Count == 0) { diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 273d98100f..17f157531e 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -14124,6 +14124,13 @@ }, "description": "The organizations that the user has access to." }, + "authentication_version": { + "type": [ + "null", + "string" + ], + "description": "Session revocation generation, rotated when a password changes or resets, or an external login is removed or transferred away.\nRemains null until rotation; active users with a null generation accept only matching legacy null token generations.\nThis value does not describe the password hashing format." + }, "password": { "type": [ "null", diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 2bedd9a8bf..9e834ef68c 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -1038,6 +1038,46 @@ public async Task BasicAuthentication_MalformedHeader_ReturnsUnauthorized(string Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task AuthenticateAsync_InactiveUserToken_ReturnsUnauthorized() + { + // Arrange + const string email = "inactive-token-user@exceptionless.test"; + const string salt = "1234567890123456"; + + var user = new User + { + EmailAddress = email, + Password = TestConstants.UserPassword.ToSaltedHash(salt), + Salt = salt, + FullName = "Inactive Token User", + Roles = AuthorizationRoles.AllScopes + }; + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + var login = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = email, Password = TestConstants.UserPassword }) + .StatusCodeShouldBeOk()); + Assert.NotNull(login); + Assert.False(String.IsNullOrEmpty(login.Token)); + + var storedUser = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(storedUser); + await _userRepository.SaveAsync(storedUser with { IsActive = false }, o => o.ImmediateConsistency()); + + // Act + var response = await SendRequestAsync(r => r + .BearerToken(login.Token) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + [Fact] public async Task BasicAuthentication_PasswordContainingColons_ReturnsCurrentUser() { @@ -1286,6 +1326,39 @@ public async Task PasswordLogin_ThrottlingExpires_LogsInWithoutPasswordResetOrRe Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); } + [Fact] + public async Task ChangePassword_StoredOldTokenSurvivesCleanup_CannotAuthenticate() + { + // Arrange + const string newPassword = "ChangedPassword2$"; + var login = await SendRequestAsAsync(r => r.Post().AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }) + .StatusCodeShouldBeOk()); + Assert.NotNull(login); + var oldToken = await _tokenRepository.GetByIdAsync(login.Token, o => o.Cache(false)); + Assert.NotNull(oldToken); + await SendRequestAsync(r => r.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeOk()); + + // Act + var changed = await SendRequestAsAsync(r => r.Post().AppendPath("auth/change-password") + .BearerToken(login.Token) + .Content(new ChangePasswordModel { CurrentPassword = SampleDataService.TEST_USER_PASSWORD, Password = newPassword }) + .StatusCodeShouldBeOk()); + // Retain a record as a failed cleanup would, after warming the authentication cache. + await _tokenRepository.AddAsync(oldToken, o => o.ImmediateConsistency()); + + // Assert + Assert.NotNull(changed); + Assert.NotEqual(login.Token, changed.Token); + await SendRequestAsync(r => r.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized()); + await SendRequestAsync(r => r.BearerToken(changed.Token).AppendPath("users/me").StatusCodeShouldBeOk()); + await SendRequestAsync(r => r.BasicAuthorization("client", login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized()); + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + Assert.False(String.IsNullOrEmpty(user.AuthenticationVersion)); + Assert.Equal(user.AuthenticationVersion, (await _tokenRepository.GetByIdAsync(changed.Token))!.AuthenticationVersion); + } + [Fact] public async Task CanChangePasswordAsync() { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index 86dec3f35f..064f099964 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -1835,6 +1835,41 @@ public async Task TokenAsync_ConcurrentAuthorizationCodeUse_OnlyOneSucceeds() Assert.Equal("invalid_grant", error.Error); } + [Fact] + public async Task TokenAsync_AccountGenerationChanged_RejectsExistingCodesAndRefreshTokens() + { + // Arrange + var issued = await IssueTokenAsync(resource: RestApiResource, scope: $"{AuthorizationRoles.ProjectsRead} {AuthorizationRoles.OfflineAccess}"); + string code = await CreateAuthorizationCodeAsync(PkceVerifier); + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + user.AuthenticationVersion = StringExtensions.GetNewToken(); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency()); + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "projects"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", issued.AccessToken); + using var exchange = CreateTokenExchangeContent(code, PkceVerifier); + using var refresh = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = OAuthGrantTypes.RefreshToken, + ["client_id"] = ClientId, + ["refresh_token"] = issued.RefreshToken! + }); + + // Act + using var accessResponse = await client.SendAsync(request, TestCancellationToken); + using var codeResponse = await client.PostAsync("oauth/token", exchange, TestCancellationToken); + using var refreshResponse = await client.PostAsync("oauth/token", refresh, TestCancellationToken); + var stored = await GetStoredOAuthTokenAsync(issued.AccessToken); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, accessResponse.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, codeResponse.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, refreshResponse.StatusCode); + Assert.NotNull(stored); + Assert.True(stored.IsDisabled); + } + private async Task RemoveTestUserFromOrganizationAsync(string organizationId) { var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index a70baa3de7..14f653cb09 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -1,15 +1,23 @@ +using System.Security.Claims; using Exceptionless.Core; using Exceptionless.Core.Authentication; using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; +using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; +using Exceptionless.Core.Validation; +using Exceptionless.Tests.Utility; using Exceptionless.Web.Api.Handlers; using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Extensions; using Exceptionless.Web.Models; using Exceptionless.Web.Security; using Foundatio.Caching; using Foundatio.Mediator; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; using Microsoft.AspNetCore.Http; using Moq; using Xunit; @@ -20,17 +28,88 @@ public sealed class AuthHandlerTests : TestWithServices { public AuthHandlerTests(ITestOutputHelper output) : base(output) { } + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task Handle_LinkExternalAccountOwnedByAnotherUser_RevokesPreviousOwnerSessions(bool cleanupFails, bool hasPreviousVersion) + { + // Arrange + const string provider = "github"; + const string providerId = "123456789"; + string? previousVersion = hasPreviousVersion ? StringExtensions.GetNewToken() : null; + string currentVersion = StringExtensions.GetNewToken(); + var displaced = GetService().GenerateUser(id: TestConstants.UserId, emailAddress: TestConstants.UserEmail) with { AuthenticationVersion = previousVersion }; + displaced.AddOAuthAccount(provider, providerId, displaced.EmailAddress); + displaced.AddOAuthAccount("google", "104567890123456789012", displaced.EmailAddress); + var current = GetService().GenerateUser(id: TestConstants.UserId2, emailAddress: TestConstants.UserEmail2) with { AuthenticationVersion = currentVersion }; + displaced.MarkEmailAddressVerified(); + current.MarkEmailAddressVerified(); + await GetService().ValidateAndThrowAsync(displaced); + await GetService().ValidateAndThrowAsync(current); + var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(IdentityUtils.UserAuthenticationType)) }; + context.Request.SetUser(current); + var calls = new List(); + string? persistedVersion = null; + var users = new Mock(MockBehavior.Strict); + users.Setup(repository => repository.GetUserByOAuthProviderAsync(provider, providerId)).ReturnsAsync(displaced); + users.Setup(repository => repository.SaveAsync(displaced, It.IsAny>())) + .Callback(() => + { + persistedVersion = displaced.AuthenticationVersion; + calls.Add("save-displaced"); + }).ReturnsAsync(displaced); + users.Setup(repository => repository.SaveAsync(current, It.IsAny>())) + .Callback(() => calls.Add("save-current")).ReturnsAsync(current); + var tokens = new Mock(MockBehavior.Strict); + tokens.Setup(repository => repository.RemoveAllByUserIdAsync(displaced.Id, null)).Returns(() => + { + calls.Add("remove-tokens"); + return cleanupFails ? Task.FromException(new IOException("Token store unavailable.")) : Task.FromResult(1L); + }); + tokens.Setup(repository => repository.GetByTypeAndUserIdAsync(TokenType.Authentication, current.Id, null)).ReturnsAsync(FindResults.Empty); + tokens.Setup(repository => repository.AddAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((Token token, CommandOptionsDescriptor _) => token); + var oauthTokens = new Mock(MockBehavior.Strict); + oauthTokens.Setup(repository => repository.RemoveAllByUserIdAsync(displaced.Id, null)) + .Callback(() => calls.Add("remove-oauth-tokens")).ReturnsAsync(1L); + var providerClient = new Mock(MockBehavior.Strict); + providerClient.Setup(client => client.GetGitHubUserInfoAsync(It.IsAny(), "test-client", "test-secret")) + .ReturnsAsync(new OAuth2.Models.UserInfo { ProviderName = provider, Id = providerId, Email = displaced.EmailAddress }); + var options = GetService().AuthOptions; + options.GitHubId = "test-client"; + options.GitHubSecret = "test-secret"; + var handler = CreateHandler(users.Object, tokens.Object, oauthTokens.Object, providerClient.Object); + + // Act + var result = await handler.Handle(new GitHubLogin(new ExternalAuthInfo(), context)); + + // Assert + Assert.Equal(ResultStatus.Success, result.Status); + Assert.False(String.IsNullOrEmpty(persistedVersion)); + Assert.False(String.Equals(previousVersion, persistedVersion, StringComparison.Ordinal)); + Assert.Equal(["save-displaced", "remove-tokens", "remove-oauth-tokens", "save-current"], calls); + Assert.DoesNotContain(displaced.OAuthAccounts, account => String.Equals(account.ProviderUserId, providerId, StringComparison.Ordinal)); + Assert.Contains(current.OAuthAccounts, account => String.Equals(account.ProviderUserId, providerId, StringComparison.Ordinal)); + Assert.Equal(currentVersion, current.AuthenticationVersion); + users.VerifyAll(); + tokens.VerifyAll(); + oauthTokens.VerifyAll(); + providerClient.VerifyAll(); + } + [Theory] [MemberData(nameof(LoginRepositoryExceptions))] public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exception exception) { // Arrange - var userRepository = new Mock(); + var userRepository = new Mock(MockBehavior.Strict); userRepository.Setup(repository => repository.GetByEmailAddressAsync("test@example.com")) .ThrowsAsync(exception); - var handler = CreateHandler(userRepository.Object); + var handler = CreateHandler(userRepository.Object, GetService(), GetService(), GetService()); var message = new LoginMessage( - new Login { Email = "test@example.com", Password = "password" }, + new Login { Email = "test@example.com", Password = TestConstants.UserPassword }, new DefaultHttpContext()); // Act @@ -49,7 +128,7 @@ public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exce new OperationCanceledException("Repository operation was canceled.") }; - private AuthHandler CreateHandler(IUserRepository userRepository) + private AuthHandler CreateHandler(IUserRepository userRepository, ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, IOAuthProviderClient providerClient) { var appOptions = GetService(); @@ -58,9 +137,9 @@ private AuthHandler CreateHandler(IUserRepository userRepository) appOptions.IntercomOptions, GetService(), userRepository, - GetService(), - GetService(), - GetService(), + tokenRepository, + oauthTokenRepository, + providerClient, GetService(), GetService(), GetService(), diff --git a/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs b/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs index 3a223e9ebc..4b104a4d30 100644 --- a/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs +++ b/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs @@ -3,12 +3,16 @@ using System.Text.Encodings.Web; using Exceptionless.Core; using Exceptionless.Core.Authorization; +using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; using Exceptionless.Web.Security; using Foundatio.Caching; +using Foundatio.Lock; +using Foundatio.Repositories; +using Foundatio.Repositories.Options; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -24,6 +28,133 @@ public sealed class ApiKeyAuthenticationHandlerTests(ITestOutputHelper output) : private const string Salt = "1234567890123456"; private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); + [Theory] + [InlineData(true, null)] + [InlineData(true, "previous")] + [InlineData(false, "current")] + public async Task AuthenticateAsync_OAuthCachedUser_RejectsMismatchWithoutRevokingActiveUserToken(bool isActive, string? cachedVersion) + { + // Arrange + const string tokenId = "oauth-token"; + const string clientId = "generation-test-client"; + const string refreshHash = "valid-refresh-hash"; + string accessToken = new('a', OAuthService.OAuthTokenLength); + var user = new User { Id = "123456789012345678901234", IsActive = isActive, AuthenticationVersion = cachedVersion }; + var appOptions = GetService(); + string origin = (String.IsNullOrWhiteSpace(appOptions.ApiUrl) ? appOptions.BaseURL : appOptions.ApiUrl).GetOrigin(); + var token = new OAuthToken + { + Id = tokenId, UserId = user.Id, ClientId = clientId, AuthenticationVersion = "current", + AccessTokenHash = OAuthService.CreateTokenHash(accessToken), RefreshTokenHash = refreshHash, + Resource = OAuthService.CreateResourceUri(origin, OAuthService.McpResource), OrganizationIds = ["organization"] + }; + var cache = GetService(); + await cache.SetAsync($"oauth:access:{token.AccessTokenHash}", tokenId); + var users = new Mock(MockBehavior.Strict); + users.Setup(repository => repository.GetByIdAsync(user.Id, It.IsAny>())) + .Callback>((_, configure) => + { + var commandOptions = new CommandOptions(); + configure(commandOptions); + Assert.True(commandOptions.ShouldReadCache()); + }).ReturnsAsync(user); + var tokens = new Mock(MockBehavior.Strict); + var oauthTokens = new Mock(MockBehavior.Strict); + oauthTokens.Setup(repository => repository.GetByIdAsync(tokenId, It.IsAny>())).ReturnsAsync(token); + oauthTokens.Setup(repository => repository.SaveAsync(token, It.IsAny>())).ReturnsAsync(token); + var applications = new Mock(MockBehavior.Strict); + applications.Setup(repository => repository.GetByClientIdAsync(clientId, null)).ReturnsAsync(new OAuthApplication { ClientId = clientId }); + var oauthService = new OAuthService(new OAuthServerOptions(), cache, GetService(), applications.Object, + GetService(), oauthTokens.Object, users.Object, TimeProvider); + var options = new Mock>(MockBehavior.Strict); + options.Setup(monitor => monitor.Get(It.IsAny())).Returns(new ApiKeyAuthenticationOptions()); + var handler = new ApiKeyAuthenticationHandler(tokens.Object, oauthTokens.Object, cache, GetService(), users.Object, + oauthService, appOptions, options.Object, TimeProvider, Log, UrlEncoder.Default); + var context = new DefaultHttpContext { RequestServices = GetService() }; + context.Request.Path = OAuthService.McpResource.Path; + context.Request.Headers.Authorization = $"Bearer {accessToken}"; + await handler.InitializeAsync(new AuthenticationScheme(ApiKeyAuthenticationOptions.ApiKeySchema, null, typeof(ApiKeyAuthenticationHandler)), context); + + // Act + var result = await handler.AuthenticateAsync(); + await handler.ChallengeAsync(new AuthenticationProperties()); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + Assert.Equal(!isActive, token.IsDisabled); + Assert.Equal(isActive ? refreshHash : null, token.RefreshTokenHash); + oauthTokens.Verify(repository => repository.SaveAsync(token, It.IsAny>()), Times.Exactly(isActive ? 0 : 1)); + oauthTokens.Verify(repository => repository.GetByIdAsync(tokenId, It.IsAny>()), Times.Once); + users.Verify(repository => repository.GetByIdAsync(user.Id, It.IsAny>()), Times.Once); + applications.Verify(repository => repository.GetByClientIdAsync(clientId, null), Times.Once); + oauthTokens.VerifyNoOtherCalls(); + users.VerifyNoOtherCalls(); + applications.VerifyNoOtherCalls(); + tokens.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData("Bearer", true, null, null, true)] + [InlineData("Basic", true, null, null, true)] + [InlineData("Bearer", true, "current", null, false)] + [InlineData("Basic", true, "current", null, false)] + [InlineData("Bearer", true, "current", "CURRENT", false)] + [InlineData("Basic", true, "current", "CURRENT", false)] + [InlineData("Bearer", false, "current", "current", false)] + [InlineData("Basic", false, "current", "current", false)] + [InlineData("Bearer", true, "current", "current", true)] + [InlineData("Basic", true, "current", "current", true)] + public async Task AuthenticateAsync_AuthenticationVersion_RequiresActiveUserAndMatchingGeneration(string scheme, bool isActive, string? userVersion, string? tokenVersion, bool expectedSuccess) + { + // Arrange + const string tokenId = "legacy-user-token"; + var user = new User + { + Id = "123456789012345678901234", + EmailAddress = EmailAddress, + FullName = "Token Test User", + IsActive = isActive, + AuthenticationVersion = userVersion, + Roles = new HashSet { AuthorizationRoles.User } + }; + var token = new Token { Id = tokenId, UserId = user.Id, Type = TokenType.Authentication, AuthenticationVersion = tokenVersion }; + var users = new Mock(MockBehavior.Strict); + users.Setup(repository => repository.GetByIdAsync(user.Id, It.IsAny>())) + .Callback>((_, configure) => + { + var commandOptions = new CommandOptions(); + configure(commandOptions); + Assert.True(commandOptions.ShouldReadCache()); + }) + .ReturnsAsync(user); + var tokens = new Mock(MockBehavior.Strict); + tokens.Setup(repository => repository.GetByIdAsync(tokenId, It.IsAny>())).ReturnsAsync(token); + var oauthTokens = new Mock(MockBehavior.Strict); + var options = new Mock>(MockBehavior.Strict); + options.Setup(monitor => monitor.Get(It.IsAny())).Returns(new ApiKeyAuthenticationOptions()); + var handler = new ApiKeyAuthenticationHandler(tokens.Object, oauthTokens.Object, GetService(), GetService(), users.Object, + GetService(), GetService(), options.Object, TimeProvider, Log, UrlEncoder.Default); + var context = new DefaultHttpContext { RequestServices = GetService() }; + context.Request.Path = "/api/v2/users/me"; + string credentials = String.Equals(scheme, "Basic", StringComparison.Ordinal) + ? Convert.ToBase64String(Encoding.UTF8.GetBytes($"client:{tokenId}")) : tokenId; + context.Request.Headers.Authorization = $"{scheme} {credentials}"; + await handler.InitializeAsync(new AuthenticationScheme(ApiKeyAuthenticationOptions.ApiKeySchema, null, typeof(ApiKeyAuthenticationHandler)), context); + + // Act + var result = await handler.AuthenticateAsync(); + + // Assert + Assert.Equal(expectedSuccess, result.Succeeded); + users.Verify(repository => repository.GetByIdAsync(user.Id, It.IsAny>()), Times.Once); + tokens.Verify(repository => repository.GetByIdAsync(tokenId, It.IsAny>()), Times.Once); + users.VerifyNoOtherCalls(); + tokens.VerifyNoOtherCalls(); + oauthTokens.VerifyNoOtherCalls(); + } + [Theory] [InlineData(false, true, 5)] [InlineData(true, true, 15)] diff --git a/tests/Exceptionless.Tests/Serializer/Models/OAuthTokenSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/OAuthTokenSerializerTests.cs index bf7ff09673..cd746ec394 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/OAuthTokenSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/OAuthTokenSerializerTests.cs @@ -8,6 +8,7 @@ namespace Exceptionless.Tests.Serializer.Models; public class OAuthTokenSerializerTests : TestWithServices { + private const string AuthenticationVersion = "current-version"; private readonly ITextSerializer _serializer; public OAuthTokenSerializerTests(ITestOutputHelper output) : base(output) @@ -15,13 +16,17 @@ public OAuthTokenSerializerTests(ITestOutputHelper output) : base(output) _serializer = GetService(); } - [Fact] - public void RoundTrip_WithOAuthAccessToken_PreservesValues() + [Theory] + [InlineData(null)] + [InlineData(AuthenticationVersion)] + public void RoundTrip_WithOAuthAccessToken_PreservesValues(string? authenticationVersion) { + // Arrange var token = new OAuthToken { Id = "650000000000000000000005", UserId = "660000000000000000000001", + AuthenticationVersion = authenticationVersion, ClientId = "test-oauth-client", GrantId = "test-grant-id", Resource = "http://localhost:7110/api/v2", @@ -36,11 +41,16 @@ public void RoundTrip_WithOAuthAccessToken_PreservesValues() UpdatedUtc = DateTime.UtcNow }; + // Act string? json = _serializer.SerializeToString(token); var result = _serializer.Deserialize(json); + // Assert Assert.NotNull(result); + if (authenticationVersion is null) + Assert.DoesNotContain("authentication_version", json); Assert.Equal("test-oauth-client", result.ClientId); + Assert.Equal(authenticationVersion, result.AuthenticationVersion); Assert.Equal("test-grant-id", result.GrantId); Assert.Equal("http://localhost:7110/api/v2", result.Resource); Assert.Equal(OAuthService.CreateTokenHash("serializer-access-token"), result.AccessTokenHash); diff --git a/tests/Exceptionless.Tests/Serializer/Models/TokenSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/TokenSerializerTests.cs index 9b86475713..fb812a2075 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/TokenSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/TokenSerializerTests.cs @@ -50,9 +50,11 @@ public void RoundTrip_WithAccessToken_PreservesValues() public void RoundTrip_WithUserScopedToken_PreservesValues() { // Arrange + const string authenticationVersion = "current-version"; var token = new Token { Id = "650000000000000000000002", + AuthenticationVersion = authenticationVersion, OrganizationId = "", ProjectId = "", UserId = "660000000000000000000001", @@ -72,6 +74,7 @@ public void RoundTrip_WithUserScopedToken_PreservesValues() // Assert Assert.NotNull(result); Assert.Equal("660000000000000000000001", result.UserId); + Assert.Equal(authenticationVersion, result.AuthenticationVersion); Assert.Equal("540000000000000000000001", result.DefaultProjectId); Assert.Equal(TokenType.Authentication, result.Type); Assert.Equal("refresh_token_abc", result.Refresh); @@ -118,6 +121,7 @@ public void Deserialize_SnakeCaseJson_ParsesCorrectly() // Assert Assert.NotNull(result); Assert.Equal("650000000000000000000004", result.Id); + Assert.Null(result.AuthenticationVersion); Assert.Equal("550000000000000000000001", result.OrganizationId); Assert.Equal(TokenType.Access, result.Type); Assert.Equal(2, result.Scopes.Count); diff --git a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs index 8abd964e15..37d82962e2 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs @@ -230,6 +230,7 @@ public void Deserialize_SnakeCaseJson_PreservesOrganizationIds() // Assert - Critical: organization_ids must be populated Assert.NotNull(user); Assert.Equal("user-from-cache", user.Id); + Assert.Null(user.AuthenticationVersion); Assert.Equal(2, user.OrganizationIds.Count); Assert.Contains("cached-org-1", user.OrganizationIds); Assert.Contains("cached-org-2", user.OrganizationIds); @@ -419,9 +420,11 @@ public void Deserialize_UserWithEmptyCollections_ReturnsEmptyCollections() public void Deserialize_UserWithAllProperties_PreservesAllValues() { // Arrange + const string authenticationVersion = "current-version"; var original = new User { Id = "full-user", + AuthenticationVersion = authenticationVersion, FullName = "Full Test User", EmailAddress = "full@example.com", Password = "hashedpassword", @@ -444,6 +447,7 @@ public void Deserialize_UserWithAllProperties_PreservesAllValues() // Assert Assert.NotNull(deserialized); Assert.Equal("full-user", deserialized.Id); + Assert.Equal(authenticationVersion, deserialized.AuthenticationVersion); Assert.Equal("Full Test User", deserialized.FullName); Assert.Equal("full@example.com", deserialized.EmailAddress); Assert.Equal("hashedpassword", deserialized.Password); diff --git a/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs b/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs index 4899d4ac8e..8e877f4f86 100644 --- a/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs +++ b/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs @@ -9,6 +9,7 @@ using Exceptionless.Tests.Utility; using Foundatio.Caching; using Foundatio.Lock; +using Foundatio.Repositories; using Foundatio.Repositories.Utility; using Moq; using Xunit; @@ -28,6 +29,60 @@ protected override void RegisterServices(IServiceCollection services, AppOptions services.AddSingleton(new OAuthServerOptions()); } + [Theory] + [InlineData(true, null, null, true)] + [InlineData(true, null, "current", false)] + [InlineData(true, "CURRENT", "current", false)] + [InlineData(false, "current", "current", false)] + [InlineData(true, "current", "current", true)] + public async Task ExchangeAuthorizationCodeAsync_AccountGeneration_RequiresActiveUserAndMatchingGeneration(bool isActive, string? issuedVersion, string? currentVersion, bool expectedSuccess) + { + // Arrange + string verifier = new('a', 43); + var issuedUser = new User { Id = TestConstants.UserId, AuthenticationVersion = issuedVersion }; + var currentUser = issuedUser with { IsActive = isActive, AuthenticationVersion = currentVersion }; + var users = new Mock(MockBehavior.Strict); + users.Setup(repository => repository.GetByIdAsync(issuedUser.Id, (ICommandOptions?)null)).ReturnsAsync(currentUser); + var applications = new Mock(MockBehavior.Strict); + applications.Setup(repository => repository.GetByClientIdAsync("test-client", null)) + .ReturnsAsync(new OAuthApplication { ClientId = "test-client", RedirectUris = [RedirectUri], Scopes = [AuthorizationRoles.McpRead] }); + applications.Setup(repository => repository.AddOrganizationIdsAsync("test-client", It.IsAny>(), It.IsAny>())) + .ReturnsAsync(1L); + OAuthToken? storedToken = null; + var tokens = new Mock(MockBehavior.Strict); + tokens.Setup(repository => repository.AddAsync(It.IsAny(), It.IsAny>())) + .Callback((OAuthToken token, CommandOptionsDescriptor _) => storedToken = token) + .ReturnsAsync((OAuthToken token, CommandOptionsDescriptor _) => token); + var service = new OAuthService(GetService(), GetService(), GetService(), + applications.Object, GetService(), tokens.Object, users.Object, TimeProvider); + string code = await service.CreateAuthorizationCodeAsync(CreateRequest(AuthorizationRoles.McpRead) with + { + CodeChallenge = OAuthService.CreateCodeChallenge(verifier) + }, issuedUser, [TestConstants.OrganizationId]); + var request = new OAuthTokenRequest + { + GrantType = OAuthGrantTypes.AuthorizationCode, + ClientId = "test-client", + Code = code, + CodeVerifier = verifier, + RedirectUri = RedirectUri, + Resource = Resource + }; + + // Act + var result = await service.ExchangeAuthorizationCodeAsync(request); + + // Assert + Assert.Equal(expectedSuccess, result.IsSuccess); + Assert.Equal(expectedSuccess ? null : "invalid_grant", result.Error); + Assert.Equal(expectedSuccess, storedToken is not null); + Assert.Equal(expectedSuccess ? issuedVersion : null, storedToken?.AuthenticationVersion); + tokens.Verify(repository => repository.AddAsync(It.IsAny(), It.IsAny>()), Times.Exactly(expectedSuccess ? 1 : 0)); + users.Verify(repository => repository.GetByIdAsync(issuedUser.Id, (ICommandOptions?)null), Times.Once); + tokens.VerifyNoOtherCalls(); + users.VerifyNoOtherCalls(); + } + [Fact] public void GetActiveOAuthOrganizationIds_RemovedMembership_ReturnsNoOrganizations() {