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()
{