From ee75910adb64e1b25b3d1928840c0479b019d616 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 12 Oct 2026 21:24:33 -0500 Subject: [PATCH 01/27] Validate external account state and identity --- .../Repositories/UserRepository.cs | 15 +- .../Api/Endpoints/AuthEndpoints.cs | 8 ++ .../Api/Handlers/AuthHandler.cs | 16 +++ .../Exceptionless.Tests/Api/Data/openapi.json | 42 +++++- .../Api/Endpoints/AuthEndpointTests.cs | 128 ++++++++++++++++++ tests/http/users.http | 2 + 6 files changed, 208 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index d82d9650b9..cb29def90a 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -129,8 +129,19 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId))).Documents; - return results.FirstOrDefault(u => u.OAuthAccounts.Any(o => o.Provider == provider)); + var results = await FindAsync(q => q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId), + o => o.SearchAfterPaging().PageLimit(100)); + User? owner = null; + do + { + foreach (var user in results.Documents.Where(u => u.OAuthAccounts.Any(o => o.Provider == provider && o.ProviderUserId == providerUserId))) + { + if (owner is not null) + throw new UnauthorizedAccessException("The external account is linked to multiple users."); + owner = user; + } + } while (await results.NextPageAsync()); + return owner; } public async Task GetByVerifyEmailAddressTokenAsync(string token) diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs index 7b1fc2b41a..2e117f7a64 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -104,12 +104,14 @@ headers api_key input box. .AllowAnonymous() .Accepts("application/json", "application/*+json") .Produces() + .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Sign in with GitHub") .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", + ["401"] = "Sign-in failed", ["403"] = "Account Creation is currently disabled", ["422"] = "Validation error", } @@ -122,12 +124,14 @@ headers api_key input box. .AllowAnonymous() .Accepts("application/json", "application/*+json") .Produces() + .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Sign in with Google") .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", + ["401"] = "Sign-in failed", ["403"] = "Account Creation is currently disabled", ["422"] = "Validation error", } @@ -140,12 +144,14 @@ headers api_key input box. .AllowAnonymous() .Accepts("application/json", "application/*+json") .Produces() + .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Sign in with Facebook") .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", + ["401"] = "Sign-in failed", ["403"] = "Account Creation is currently disabled", ["422"] = "Validation error", } @@ -158,12 +164,14 @@ headers api_key input box. .AllowAnonymous() .Accepts("application/json", "application/*+json") .Produces() + .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Sign in with Microsoft") .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", + ["401"] = "Sign-in failed", ["403"] = "Account Creation is currently disabled", ["422"] = "Validation error", } diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index fc37943515..f6f3fc0a96 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -524,6 +524,11 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth { user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext); } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "External login failed for {EmailAddress}: Account unavailable", userInfo.Email); + return Result.Unauthorized("Login failed."); + } catch (ApplicationException ex) { logger.LogCritical(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); @@ -551,9 +556,13 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id); using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(userInfo.ProviderName).Identity(userInfo.Email).SetHttpContext(httpContext)); + if (existingUser is not null) + EnsureUserIsActive(existingUser); + if (String.IsNullOrWhiteSpace(inviteToken) && httpContext.User.IsUserAuthType()) { var currentUser = httpContext.Request.GetUser(); + EnsureUserIsActive(currentUser); if (existingUser is not null) { if (!String.Equals(existingUser.Id, currentUser.Id, StringComparison.Ordinal)) @@ -598,6 +607,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit await AddGlobalAdminRoleIfFirstUserAsync(user); } + EnsureUserIsActive(user); user.MarkEmailAddressVerified(); user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); @@ -609,6 +619,12 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit return user; } + private static void EnsureUserIsActive(User user) + { + if (!user.IsActive) + throw new UnauthorizedAccessException("The user is inactive."); + } + private async Task IsAccountCreationEnabledAsync(string? token) { if (authOptions.EnableAccountCreation) diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 71f0aaacba..9075c9bd26 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -1763,6 +1763,16 @@ } } }, + "401": { + "description": "Sign-in failed", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "403": { "description": "Account Creation is currently disabled", "content": { @@ -1818,6 +1828,16 @@ } } }, + "401": { + "description": "Sign-in failed", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "403": { "description": "Account Creation is currently disabled", "content": { @@ -1873,6 +1893,16 @@ } } }, + "401": { + "description": "Sign-in failed", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "403": { "description": "Account Creation is currently disabled", "content": { @@ -1928,6 +1958,16 @@ } } }, + "401": { + "description": "Sign-in failed", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "403": { "description": "Account Creation is currently disabled", "content": { @@ -15273,4 +15313,4 @@ "name": "Source Map" } ] -} +} \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 9e834ef68c..e6641d126e 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -1995,6 +1995,134 @@ await SendRequestAsync(r => r Assert.False(token.IsSuspended); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExternalLogin_InactiveUser_IsRejectedWithoutChangingAccountOrInvite(bool hasExistingLink) + { + // Arrange + const string code = "github-inactive-user"; + string email = TestOAuthProviderClient.GetEmailAddress(code); + var user = new User + { + EmailAddress = email, + FullName = "Inactive User", + IsActive = false, + Roles = new HashSet([AuthorizationRoles.Client, AuthorizationRoles.User]) + }; + user.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + if (hasExistingLink) + user.AddOAuthAccount("github", code, email); + await _userRepository.AddAsync(user, options => options.ImmediateConsistency()); + var organization = (await _organizationRepository.GetAllAsync()).Documents.First(); + var invite = new Invite + { + Token = StringExtensions.GetNewToken(), + EmailAddress = email, + DateAdded = TimeProvider.GetUtcNow().UtcDateTime + }; + organization.Invites.Add(invite); + await _organizationRepository.SaveAsync(organization, options => options.ImmediateConsistency()); + + // Act + await SendRequestAsync(request => request + .Post() + .AppendPaths("auth", "github") + .Content(new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + InviteToken = invite.Token, + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeUnauthorized()); + + // Assert + var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedUser); + Assert.False(storedUser.IsActive); + Assert.False(storedUser.IsEmailAddressVerified); + Assert.Equal(user.VerifyEmailAddressToken, storedUser.VerifyEmailAddressToken); + Assert.Equal(user.VerifyEmailAddressTokenExpiration, storedUser.VerifyEmailAddressTokenExpiration); + Assert.Equal(user.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username)), + storedUser.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username))); + Assert.Empty(storedUser.OrganizationIds); + var tokens = await _tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id, options => options.ImmediateConsistency()); + Assert.Empty(tokens.Documents); + var storedOrganization = await _organizationRepository.GetByIdAsync(organization.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedOrganization); + Assert.Contains(storedOrganization.Invites, candidate => candidate.Token == invite.Token); + } + + [Fact] + public async Task ExternalLogin_InactiveProviderOwner_DoesNotTransferAccount() + { + // Arrange + const string code = "github-inactive-provider-owner"; + var owner = new User + { + EmailAddress = TestOAuthProviderClient.GetEmailAddress(code), + FullName = "Inactive Owner", + IsActive = false + }; + owner.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + owner.AddOAuthAccount("github", code, owner.EmailAddress); + await _userRepository.AddAsync(owner, options => options.ImmediateConsistency()); + var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(currentUser); + var originalAccounts = currentUser.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username)).ToArray(); + long originalTokenCount = (await _tokenRepository.CountAsync()).Total; + + // Act + await SendRequestAsync(request => request + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "github") + .Content(new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeUnauthorized()); + + // Assert + var storedOwner = await _userRepository.GetByIdAsync(owner.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedOwner); + Assert.False(storedOwner.IsEmailAddressVerified); + Assert.Equal(owner.VerifyEmailAddressToken, storedOwner.VerifyEmailAddressToken); + Assert.Equal(code, Assert.Single(storedOwner.OAuthAccounts).ProviderUserId); + var storedCurrentUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedCurrentUser); + Assert.Equal(originalAccounts, storedCurrentUser.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username))); + Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); + } + + [Fact] + public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_DoNotMatch() + { + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + user.AddOAuthAccount("google", "shared-subject", user.EmailAddress); + user.AddOAuthAccount("github", "other-subject", user.EmailAddress); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency()); + Assert.Null(await _userRepository.GetUserByOAuthProviderAsync("github", "shared-subject")); + Assert.Equal(user.Id, (await _userRepository.GetUserByOAuthProviderAsync("google", "shared-subject"))!.Id); + } + + [Fact] + public async Task GetUserByOAuthProviderAsync_DuplicateIdentity_RejectsAmbiguousOwner() + { + foreach (string email in new[] { SampleDataService.TEST_USER_EMAIL, SampleDataService.TEST_ORG_USER_EMAIL }) + { + var user = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(user); + user.AddOAuthAccount("github", "duplicate-subject", user.EmailAddress); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency()); + } + await Assert.ThrowsAsync(() => _userRepository.GetUserByOAuthProviderAsync("github", "duplicate-subject")); + } + private async Task AssertExternalLoginAsync(TokenResult? result, string providerName, string providerUserId) { Assert.NotNull(result); diff --git a/tests/http/users.http b/tests/http/users.http index b996b9ee8f..d794afca8d 100644 --- a/tests/http/users.http +++ b/tests/http/users.http @@ -2,6 +2,8 @@ @email = admin@exceptionless.test @password = tester +# External sign-in returns 401 when the account is unavailable. Existing provider links identify their exact provider and subject. + ### login to test account # @name login POST {{apiUrl}}/auth/login From d46f71983da274724781d9cdf3dfa84f451228d1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 12 Oct 2026 22:26:38 -0500 Subject: [PATCH 02/27] Clarify external account lookup and test structure --- .../Repositories/UserRepository.cs | 14 +++++++---- .../Api/Endpoints/AuthEndpointTests.cs | 23 ++++++++++++++++--- tests/http/users.http | 2 -- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index cb29def90a..4b32a48e5a 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -131,17 +131,21 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId), o => o.SearchAfterPaging().PageLimit(100)); - User? owner = null; + User? matchingUser = null; do { - foreach (var user in results.Documents.Where(u => u.OAuthAccounts.Any(o => o.Provider == provider && o.ProviderUserId == providerUserId))) + foreach (var user in results.Documents.Where(u => u.OAuthAccounts.Any(account => + String.Equals(account.Provider, provider, StringComparison.Ordinal) && + String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)))) { - if (owner is not null) + if (matchingUser is not null) throw new UnauthorizedAccessException("The external account is linked to multiple users."); - owner = user; + + matchingUser = user; } } while (await results.NextPageAsync()); - return owner; + + return matchingUser; } public async Task GetByVerifyEmailAddressTokenAsync(string token) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index e6641d126e..d2f0666968 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -2101,18 +2101,27 @@ await SendRequestAsync(request => request [Fact] public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_DoNotMatch() { + // Arrange var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); Assert.NotNull(user); user.AddOAuthAccount("google", "shared-subject", user.EmailAddress); user.AddOAuthAccount("github", "other-subject", user.EmailAddress); await _userRepository.SaveAsync(user, o => o.ImmediateConsistency()); - Assert.Null(await _userRepository.GetUserByOAuthProviderAsync("github", "shared-subject")); - Assert.Equal(user.Id, (await _userRepository.GetUserByOAuthProviderAsync("google", "shared-subject"))!.Id); + + // Act + var githubUser = await _userRepository.GetUserByOAuthProviderAsync("github", "shared-subject"); + var googleUser = await _userRepository.GetUserByOAuthProviderAsync("google", "shared-subject"); + + // Assert + Assert.Null(githubUser); + Assert.NotNull(googleUser); + Assert.Equal(user.Id, googleUser.Id); } [Fact] public async Task GetUserByOAuthProviderAsync_DuplicateIdentity_RejectsAmbiguousOwner() { + // Arrange foreach (string email in new[] { SampleDataService.TEST_USER_EMAIL, SampleDataService.TEST_ORG_USER_EMAIL }) { var user = await _userRepository.GetByEmailAddressAsync(email); @@ -2120,7 +2129,15 @@ public async Task GetUserByOAuthProviderAsync_DuplicateIdentity_RejectsAmbiguous user.AddOAuthAccount("github", "duplicate-subject", user.EmailAddress); await _userRepository.SaveAsync(user, o => o.ImmediateConsistency()); } - await Assert.ThrowsAsync(() => _userRepository.GetUserByOAuthProviderAsync("github", "duplicate-subject")); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await _userRepository.GetUserByOAuthProviderAsync("github", "duplicate-subject"); + }); + + // Assert + Assert.IsType(exception); } private async Task AssertExternalLoginAsync(TokenResult? result, string providerName, string providerUserId) diff --git a/tests/http/users.http b/tests/http/users.http index d794afca8d..b996b9ee8f 100644 --- a/tests/http/users.http +++ b/tests/http/users.http @@ -2,8 +2,6 @@ @email = admin@exceptionless.test @password = tester -# External sign-in returns 401 when the account is unavailable. Existing provider links identify their exact provider and subject. - ### login to test account # @name login POST {{apiUrl}}/auth/login From 0dab8e0de591ccae0cd3bc96f9c9bae90e03c54b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 18 Oct 2026 11:30:19 -0500 Subject: [PATCH 03/27] Cover external identity lookup and account linking contracts --- .../Repositories/UserRepository.cs | 3 +- .../Api/Endpoints/AuthEndpoints.cs | 8 +- .../Api/Handlers/AuthHandler.cs | 2 +- .../Exceptionless.Tests/Api/Data/openapi.json | 8 +- .../Api/Endpoints/AuthEndpointTests.cs | 83 +++++++++++++----- .../Repositories/UserRepositoryTests.cs | 84 +++++++++++++++++++ 6 files changed, 158 insertions(+), 30 deletions(-) create mode 100644 tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index 4b32a48e5a..8a903ec27e 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -129,7 +129,8 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId), + // OAuth accounts are mapped as objects, so indexed fields cannot enforce matching within one account. + var results = await FindAsync(q => q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId).SortAscending(u => u.Id), o => o.SearchAfterPaging().PageLimit(100)); User? matchingUser = null; do diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs index 2e117f7a64..7454e96a28 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -111,7 +111,7 @@ headers api_key input box. .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", - ["401"] = "Sign-in failed", + ["401"] = "Unauthorized", ["403"] = "Account Creation is currently disabled", ["422"] = "Validation error", } @@ -131,7 +131,7 @@ headers api_key input box. .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", - ["401"] = "Sign-in failed", + ["401"] = "Unauthorized", ["403"] = "Account Creation is currently disabled", ["422"] = "Validation error", } @@ -151,7 +151,7 @@ headers api_key input box. .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", - ["401"] = "Sign-in failed", + ["401"] = "Unauthorized", ["403"] = "Account Creation is currently disabled", ["422"] = "Validation error", } @@ -171,7 +171,7 @@ headers api_key input box. .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", - ["401"] = "Sign-in failed", + ["401"] = "Unauthorized", ["403"] = "Account Creation is currently disabled", ["422"] = "Validation error", } diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index f6f3fc0a96..1e58a8fa38 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -526,7 +526,7 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth } catch (UnauthorizedAccessException ex) { - logger.LogWarning(ex, "External login failed for {EmailAddress}: Account unavailable", userInfo.Email); + logger.LogWarning(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); return Result.Unauthorized("Login failed."); } catch (ApplicationException ex) diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 9075c9bd26..5ca9f99c18 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -1764,7 +1764,7 @@ } }, "401": { - "description": "Sign-in failed", + "description": "Unauthorized", "content": { "application/problem\u002Bjson": { "schema": { @@ -1829,7 +1829,7 @@ } }, "401": { - "description": "Sign-in failed", + "description": "Unauthorized", "content": { "application/problem\u002Bjson": { "schema": { @@ -1894,7 +1894,7 @@ } }, "401": { - "description": "Sign-in failed", + "description": "Unauthorized", "content": { "application/problem\u002Bjson": { "schema": { @@ -1959,7 +1959,7 @@ } }, "401": { - "description": "Sign-in failed", + "description": "Unauthorized", "content": { "application/problem\u002Bjson": { "schema": { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index d2f0666968..4db162e493 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -2099,45 +2099,88 @@ await SendRequestAsync(request => request } [Fact] - public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_DoNotMatch() + public async Task ExternalLogin_ActiveProviderOwner_TransfersAccountAndRevokesPreviousSession() { // Arrange - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); - Assert.NotNull(user); - user.AddOAuthAccount("google", "shared-subject", user.EmailAddress); - user.AddOAuthAccount("github", "other-subject", user.EmailAddress); - await _userRepository.SaveAsync(user, o => o.ImmediateConsistency()); + const string code = "github-transfer-account"; + var owner = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(owner); + Assert.NotNull(currentUser); + owner.AddOAuthAccount("github", code, owner.EmailAddress); + await _userRepository.SaveAsync(owner, options => options.ImmediateConsistency()); + var login = await SendRequestAsAsync(request => request.Post().AppendPath("auth/login") + .Content(new Login { Email = owner.EmailAddress, Password = SampleDataService.TEST_USER_PASSWORD }) + .StatusCodeShouldBeOk()); + Assert.NotNull(login); + await SendRequestAsync(request => request.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeOk()); // Act - var githubUser = await _userRepository.GetUserByOAuthProviderAsync("github", "shared-subject"); - var googleUser = await _userRepository.GetUserByOAuthProviderAsync("google", "shared-subject"); + var result = await SendRequestAsAsync(request => request + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "github") + .Content(new ExternalAuthInfo { ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" }) + .StatusCodeShouldBeOk()); // Assert - Assert.Null(githubUser); - Assert.NotNull(googleUser); - Assert.Equal(user.Id, googleUser.Id); + Assert.NotNull(result); + var token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.NotNull(token); + Assert.Equal(currentUser.Id, token.UserId); + var storedOwner = await _userRepository.GetByIdAsync(owner.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedOwner); + Assert.DoesNotContain(storedOwner.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + Assert.NotEqual(owner.AuthenticationVersion, storedOwner.AuthenticationVersion); + var matchingUser = await _userRepository.GetUserByOAuthProviderAsync("github", code); + Assert.NotNull(matchingUser); + Assert.Equal(currentUser.Id, matchingUser.Id); + await SendRequestAsync(request => request.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized()); } - [Fact] - public async Task GetUserByOAuthProviderAsync_DuplicateIdentity_RejectsAmbiguousOwner() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExternalLogin_DuplicateProviderOwners_IsRejectedWithoutChangingAccountsOrTokens(bool isAuthenticated) { // Arrange + const string code = "github-duplicate-owners"; + var owners = new List(); foreach (string email in new[] { SampleDataService.TEST_USER_EMAIL, SampleDataService.TEST_ORG_USER_EMAIL }) { - var user = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(user); - user.AddOAuthAccount("github", "duplicate-subject", user.EmailAddress); - await _userRepository.SaveAsync(user, o => o.ImmediateConsistency()); + var owner = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(owner); + owner.AddOAuthAccount("github", code, owner.EmailAddress); + owners.Add(owner); } + await _userRepository.SaveAsync(owners, options => options.ImmediateConsistency()); + long originalTokenCount = (await _tokenRepository.CountAsync()).Total; // Act - var exception = await Record.ExceptionAsync(async () => + var problem = await SendRequestAsAsync(request => { - _ = await _userRepository.GetUserByOAuthProviderAsync("github", "duplicate-subject"); + if (isAuthenticated) + request.AsTestOrganizationUser(); + + request.Post().AppendPaths("auth", "github") + .Content(new ExternalAuthInfo { ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" }) + .StatusCodeShouldBeUnauthorized(); }); // Assert - Assert.IsType(exception); + Assert.NotNull(problem); + Assert.Equal("Login failed.", problem.Title); + Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); + Assert.Null(await _userRepository.GetByEmailAddressAsync(TestOAuthProviderClient.GetEmailAddress(code))); + foreach (var owner in owners) + { + var storedOwner = await _userRepository.GetByIdAsync(owner.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedOwner); + Assert.Equal(owner.AuthenticationVersion, storedOwner.AuthenticationVersion); + Assert.Equal(owner.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username)), + storedOwner.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username))); + Assert.Equal(owner.IsEmailAddressVerified, storedOwner.IsEmailAddressVerified); + } } private async Task AssertExternalLoginAsync(TokenResult? result, string providerName, string providerUserId) diff --git a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs new file mode 100644 index 0000000000..6fe068e6c3 --- /dev/null +++ b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs @@ -0,0 +1,84 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Foundatio.Repositories; +using Xunit; + +namespace Exceptionless.Tests.Repositories; + +public sealed class UserRepositoryTests : IntegrationTestsBase +{ + private readonly IUserRepository _repository; + + public UserRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _repository = GetService(); + } + + [Fact] + public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresExactIdentityPair() + { + // Arrange + var user = new User { FullName = "Linked User", EmailAddress = "linked@localhost.test" }; + user.MarkEmailAddressVerified(); + user.AddOAuthAccount("google", "shared-subject", user.EmailAddress); + user.AddOAuthAccount("github", "other-subject", user.EmailAddress); + await _repository.AddAsync(user, options => options.ImmediateConsistency()); + + // Act + var githubUser = await _repository.GetUserByOAuthProviderAsync("github", "shared-subject"); + var googleUser = await _repository.GetUserByOAuthProviderAsync("GOOGLE", "shared-subject"); + var differentCaseUser = await _repository.GetUserByOAuthProviderAsync("google", "SHARED-SUBJECT"); + + // Assert + Assert.Null(githubUser); + Assert.NotNull(googleUser); + Assert.Equal(user.Id, googleUser.Id); + Assert.Null(differentCaseUser); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetUserByOAuthProviderAsync_MatchingIdentityOnSecondPage_ValidatesAllCandidates(bool hasDuplicateOwner) + { + // Arrange + var users = Enumerable.Range(1, 101).Select(index => + { + var user = new User + { + Id = index.ToString("x24"), + FullName = $"Linked User {index}", + EmailAddress = $"linked-{index}@localhost.test" + }; + user.MarkEmailAddressVerified(); + user.AddOAuthAccount("google", "shared-subject", user.EmailAddress); + user.AddOAuthAccount("github", "other-subject", user.EmailAddress); + return user; + }).ToArray(); + users[^1].AddOAuthAccount("github", "shared-subject", users[^1].EmailAddress); + if (hasDuplicateOwner) + users[0].AddOAuthAccount("github", "shared-subject", users[0].EmailAddress); + await _repository.AddAsync(users, options => options.ImmediateConsistency()); + User? matchingUser = null; + + // Act + var exception = await Record.ExceptionAsync(async () => + { + matchingUser = await _repository.GetUserByOAuthProviderAsync("github", "shared-subject"); + }); + + // Assert + if (hasDuplicateOwner) + { + Assert.IsType(exception); + Assert.Null(matchingUser); + } + else + { + Assert.Null(exception); + Assert.NotNull(matchingUser); + Assert.Equal(users[^1].Id, matchingUser.Id); + } + } +} From b23953dd1dbdc3f66b5c46a6da3966b10992b00c Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 18 Oct 2026 14:24:41 -0500 Subject: [PATCH 04/27] Serialize external account ownership changes --- .../Extensions/UserExtensions.cs | 20 +- .../Api/Handlers/AuthHandler.cs | 85 ++++-- .../Api/Endpoints/AuthEndpointTests.cs | 34 ++- .../ExternalAccountConcurrencyTests.cs | 241 ++++++++++++++++++ .../Api/Handlers/AuthHandlerTests.cs | 4 + .../Repositories/UserRepositoryTests.cs | 2 + 6 files changed, 358 insertions(+), 28 deletions(-) create mode 100644 tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs diff --git a/src/Exceptionless.Core/Extensions/UserExtensions.cs b/src/Exceptionless.Core/Extensions/UserExtensions.cs index a04982adbc..8897c42c15 100644 --- a/src/Exceptionless.Core/Extensions/UserExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UserExtensions.cs @@ -64,9 +64,14 @@ public static bool HasValidPasswordResetTokenExpiration(this User user, TimeProv public static void AddOAuthAccount(this User user, string providerName, string providerUserId, string username, SettingsDictionary? data = null) { + providerName = providerName.ToLowerInvariant(); + if (user.OAuthAccounts.Any(account => String.Equals(account.Provider, providerName, StringComparison.Ordinal) + && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal))) + return; + var account = new OAuthAccount { - Provider = providerName.ToLowerInvariant(), + Provider = providerName, ProviderUserId = providerUserId, Username = username }; @@ -82,10 +87,17 @@ public static bool RemoveOAuthAccount(this User user, string providerName, strin if (user.OAuthAccounts.Count <= 1 && String.IsNullOrEmpty(user.Password)) return false; - var account = user.OAuthAccounts.FirstOrDefault(o => o.Provider == providerName.ToLowerInvariant() && o.ProviderUserId == providerUserId); - if (account is null) + var accounts = user.OAuthAccounts.Where(account => String.Equals(account.Provider, providerName.ToLowerInvariant(), StringComparison.Ordinal) + && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)).ToArray(); + if (accounts.Length == 0) return true; - return user.OAuthAccounts.Remove(account); + if (accounts.Length == user.OAuthAccounts.Count && String.IsNullOrEmpty(user.Password)) + return false; + + foreach (var account in accounts) + user.OAuthAccounts.Remove(account); + + return true; } } diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index 1e58a8fa38..5831dda60e 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -15,6 +15,7 @@ using Exceptionless.Web.Models; using Exceptionless.Web.Security; using Foundatio.Caching; +using Foundatio.Lock; using Foundatio.Mediator; using Foundatio.Repositories; using Microsoft.IdentityModel.Tokens; @@ -31,6 +32,7 @@ public class AuthHandler( IOAuthTokenRepository oauthTokenRepository, IOAuthProviderClient oauthProviderClient, ICacheClient cacheClient, + ILockProvider lockProvider, AuthService authService, IMailer mailer, IDomainLoginProvider domainLoginProvider, @@ -302,19 +304,30 @@ public async Task> Handle(RemoveExternalLogin message) return Result.BadRequest("Invalid Provider Name or Provider User Id."); } - if (user.OAuthAccounts.Count <= 1 && String.IsNullOrEmpty(user.Password)) - { - logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); - return Result.BadRequest("You must set a local password before removing your external login."); - } + using var lockTimeout = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); + lockTimeout.CancelAfter(TimeSpan.FromSeconds(15)); + await using var userLock = await lockProvider.TryAcquireAsync(GetExternalAccountUserLockKey(user.Id), cancellationToken: lockTimeout.Token); + if (userLock is null) + return Result.Unauthorized("Login failed."); + + var authenticatedUser = user; + user = await userRepository.GetByIdAsync(user.Id, options => options.Cache(false)); + if (user is null || !user.IsActive) + return Result.Unauthorized("Login failed."); + + if (!String.Equals(authenticatedUser.AuthenticationVersion, user.AuthenticationVersion, StringComparison.Ordinal)) + return Result.Unauthorized("Login failed."); try { - if (user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) + if (!user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) { - user.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken(); - await userRepository.SaveAsync(user, o => o.Cache()); + logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); + return Result.BadRequest("You must set a local password before removing your external login."); } + + user.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken(); + await userRepository.SaveAsync(user, o => o.Cache()); } catch (Exception ex) { @@ -519,12 +532,37 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth throw; } - User? user; try { - user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext); - } - catch (UnauthorizedAccessException ex) + ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); + ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); + + using var lockTimeout = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); + lockTimeout.CancelAfter(TimeSpan.FromSeconds(15)); + // Lock the identity and normalized email before resolving or creating its owner. + string[] accountKeys = [ + $"external-account:identity:{userInfo.ProviderName.ToLowerInvariant()}:{OAuthService.CreateTokenHash(userInfo.Id)}", + $"external-account:email:{OAuthService.CreateTokenHash(userInfo.Email.Trim().ToLowerInvariant())}" + ]; + await using var accountLock = await lockProvider.AcquireAsync(accountKeys, cancellationToken: lockTimeout.Token); + var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id); + var emailUser = existingUser is null ? await userRepository.GetByEmailAddressAsync(userInfo.Email) : null; + string? currentUserId = String.IsNullOrWhiteSpace(authInfo.InviteToken) && httpContext.User.IsUserAuthType() + ? httpContext.Request.GetUser().Id : null; + var userKeys = new[] { existingUser?.Id, emailUser?.Id, currentUserId } + .OfType().Select(GetExternalAccountUserLockKey); + // Ordered user locks also protect transfers and different identities on the same user. + await using var userLock = await lockProvider.AcquireAsync(userKeys, cancellationToken: lockTimeout.Token); + var user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext, existingUser, emailUser); + + if (!String.IsNullOrWhiteSpace(authInfo.InviteToken)) + await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user, httpContext); + + logger.UserLoggedIn(user.EmailAddress); + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; + } + catch (Exception ex) when (ex is UnauthorizedAccessException or LockAcquisitionTimeoutException) { logger.LogWarning(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); return Result.Unauthorized("Login failed."); @@ -539,30 +577,33 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth logger.LogCritical(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); throw; } - - if (!String.IsNullOrWhiteSpace(authInfo.InviteToken)) - await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user, httpContext); - - logger.UserLoggedIn(user.EmailAddress); - return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; } - private async Task FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext) + private static string GetExternalAccountUserLockKey(string userId) => $"external-account:user:{userId}"; + + private async Task FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext, User? existingUser, User? emailUser) { ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); - var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id); using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(userInfo.ProviderName).Identity(userInfo.Email).SetHttpContext(httpContext)); if (existingUser is not null) + { + existingUser = await userRepository.GetByIdAsync(existingUser.Id, options => options.Cache(false)) + ?? throw new UnauthorizedAccessException("The user no longer exists."); EnsureUserIsActive(existingUser); + } if (String.IsNullOrWhiteSpace(inviteToken) && httpContext.User.IsUserAuthType()) { - var currentUser = httpContext.Request.GetUser(); + var authenticatedUser = httpContext.Request.GetUser(); + var currentUser = await userRepository.GetByIdAsync(authenticatedUser.Id, options => options.Cache(false)) + ?? throw new UnauthorizedAccessException("The user no longer exists."); EnsureUserIsActive(currentUser); + if (!String.Equals(authenticatedUser.AuthenticationVersion, currentUser.AuthenticationVersion, StringComparison.Ordinal)) + throw new UnauthorizedAccessException("The user session has been revoked."); if (existingUser is not null) { if (!String.Equals(existingUser.Id, currentUser.Id, StringComparison.Ordinal)) @@ -595,7 +636,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit return existingUser; } - var user = !String.IsNullOrEmpty(userInfo.Email) ? await userRepository.GetByEmailAddressAsync(userInfo.Email) : null; + var user = emailUser is not null ? await userRepository.GetByIdAsync(emailUser.Id, options => options.Cache(false)) : null; if (user is null) { if (!await IsAccountCreationEnabledAsync(inviteToken)) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 4db162e493..419232cb2e 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -797,6 +797,32 @@ public Task RemoveExternalLoginAsync_WithoutProviderUserId_ReturnsBadRequest() ); } + [Fact] + public async Task RemoveExternalLoginAsync_DuplicateEntriesForOnlySignInMethod_PreservesAccount() + { + // Arrange + const string code = "github-only-duplicate-login"; + var login = await SendExternalLoginAsync("github", code); + Assert.NotNull(login); + var user = await _userRepository.GetByEmailAddressAsync(TestOAuthProviderClient.GetEmailAddress(code)); + Assert.NotNull(user); + Assert.Null(user.Password); + user.OAuthAccounts.Add(Assert.Single(user.OAuthAccounts)); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + + // Act + await SendRequestAsync(request => request.Post().BearerToken(login.Token) + .AppendPaths("auth", "unlink", "github") + .Content(new ValueFromBody(code)).StatusCodeShouldBeBadRequest()); + + // Assert + var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.Cache(false)); + Assert.NotNull(storedUser); + Assert.Equal(2, storedUser.OAuthAccounts.Count); + Assert.Equal(user.AuthenticationVersion, storedUser.AuthenticationVersion); + await SendRequestAsync(request => request.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeOk()); + } + [Fact] public async Task LoginInvalidPasswordAsync() { @@ -2098,8 +2124,10 @@ await SendRequestAsync(request => request Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); } - [Fact] - public async Task ExternalLogin_ActiveProviderOwner_TransfersAccountAndRevokesPreviousSession() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExternalLogin_ActiveProviderOwner_TransfersAccountAndRevokesPreviousSession(bool hasDuplicateEntries) { // Arrange const string code = "github-transfer-account"; @@ -2108,6 +2136,8 @@ public async Task ExternalLogin_ActiveProviderOwner_TransfersAccountAndRevokesPr Assert.NotNull(owner); Assert.NotNull(currentUser); owner.AddOAuthAccount("github", code, owner.EmailAddress); + if (hasDuplicateEntries) + owner.OAuthAccounts.Add(owner.OAuthAccounts.Single(account => account.Provider == "github" && account.ProviderUserId == code)); await _userRepository.SaveAsync(owner, options => options.ImmediateConsistency()); var login = await SendRequestAsAsync(request => request.Post().AppendPath("auth/login") .Content(new Login { Email = owner.EmailAddress, Password = SampleDataService.TEST_USER_PASSWORD }) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs new file mode 100644 index 0000000000..a245ce48dc --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs @@ -0,0 +1,241 @@ +using System.Net.Http.Json; +using System.Text; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Core.Validation; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models; +using Foundatio.Lock; +using Foundatio.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; +using User = Exceptionless.Core.Models.User; + +namespace Exceptionless.Tests.Api.Endpoints; + +public sealed class ExternalAccountConcurrencyTests : IntegrationTestsBase +{ + private readonly IUserRepository _users; + private readonly ITokenRepository _tokens; + private readonly ExternalAccountRace _race; + + public ExternalAccountConcurrencyTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _users = GetService(); + _tokens = GetService(); + _race = GetService(); + } + + protected override void RegisterServices(IServiceCollection services) + { + base.RegisterServices(services); + services.AddSingleton(); + services.ReplaceSingleton(); + services.ReplaceSingleton(provider => + { + var inner = provider.GetRequiredService(); + var race = provider.GetRequiredService(); + var locks = new Mock(); + locks.Setup(locker => locker.TryAcquireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (string resource, TimeSpan? expires, bool release, CancellationToken token) => + { + if (race.RejectLocks) + return null; + + await race.ObserveLockAsync(inner, resource); + return await inner.TryAcquireAsync(resource, expires, release, token); + }); + locks.Setup(locker => locker.AcquireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (string resource, TimeSpan? expires, bool release, CancellationToken token) => + { + await race.ObserveLockAsync(inner, resource); + return await inner.AcquireAsync(resource, expires, release, token); + }); + return locks.Object; + }); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + _race.Stop(); + var options = GetService(); + options.EnableAccountCreation = true; + options.GitHubId = "github-client-id"; + options.GitHubSecret = "github-client-secret"; + await GetService().CreateDataAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExternalLogin_ConcurrentRequestsForSameIdentity_KeepOneOwner(bool linkExistingUsers) + { + // Arrange + const string code = "concurrent-external-account"; + using var firstClient = _server.CreateClient(); + using var secondClient = _server.CreateClient(); + if (linkExistingUsers) + { + SetAuthorization(firstClient, SampleDataService.TEST_USER_EMAIL, SampleDataService.TEST_USER_PASSWORD); + SetAuthorization(secondClient, SampleDataService.TEST_ORG_USER_EMAIL, SampleDataService.TEST_ORG_USER_PASSWORD); + } + + // Act + var results = await RunConcurrentRequestsAsync(firstClient, code, secondClient, code); + + // Assert + Assert.All(results, result => Assert.NotNull(result)); + var candidates = await _users.FindAsync(query => query.FieldEquals(user => user.OAuthAccounts.First().ProviderUserId, code)); + var owner = Assert.Single(candidates.Documents); + Assert.Single(owner.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + var secondToken = await _tokens.GetByIdAsync(results[1]!.Token); + Assert.NotNull(secondToken); + Assert.Equal(owner.Id, secondToken.UserId); + } + + [Fact] + public async Task ExternalLogin_AccountLockUnavailable_IsRejectedWithoutWrites() + { + // Arrange + const string code = "external-account-lock-unavailable"; + using var client = _server.CreateClient(); + long originalUserCount = (await _users.CountAsync()).Total; + long originalTokenCount = (await _tokens.CountAsync()).Total; + _race.RejectLocks = true; + + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + RedirectUri = "http://localhost/callback" + }, TestCancellationToken); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(originalUserCount, (await _users.CountAsync()).Total); + Assert.Equal(originalTokenCount, (await _tokens.CountAsync()).Total); + Assert.Null(await _users.GetUserByOAuthProviderAsync("github", code)); + } + + [Fact] + public async Task ExternalLogin_ConcurrentDifferentIdentitiesForSameUser_PreservesBothLinks() + { + // Arrange + using var firstClient = _server.CreateClient(); + using var secondClient = _server.CreateClient(); + SetAuthorization(firstClient, SampleDataService.TEST_ORG_USER_EMAIL, SampleDataService.TEST_ORG_USER_PASSWORD); + SetAuthorization(secondClient, SampleDataService.TEST_ORG_USER_EMAIL, SampleDataService.TEST_ORG_USER_PASSWORD); + var currentUser = await _users.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(currentUser); + + // Act + var results = await RunConcurrentRequestsAsync(firstClient, "concurrent-first-account", secondClient, "concurrent-second-account"); + + // Assert + Assert.All(results, result => Assert.NotNull(result)); + var storedUser = await _users.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(storedUser); + Assert.Single(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == "concurrent-first-account"); + Assert.Single(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == "concurrent-second-account"); + } + + private async Task RunConcurrentRequestsAsync(HttpClient firstClient, string firstCode, HttpClient secondClient, string secondCode) + { + _race.Start(); + var firstRequest = SendExternalLoginAsync(firstClient, firstCode); + Task? secondRequest = null; + try + { + await _race.FirstSnapshotRead.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); + secondRequest = SendExternalLoginAsync(secondClient, secondCode); + await _race.SecondRequestObserved.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); + } + finally + { + _race.ResumeFirstRequest.TrySetResult(); + } + + return await Task.WhenAll(firstRequest, secondRequest!); + } + + private static void SetAuthorization(HttpClient client, string email, string password) + { + client.DefaultRequestHeaders.Authorization = new("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{email}:{password}"))); + } + + private async Task SendExternalLoginAsync(HttpClient client, string code) + { + using var response = await client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + RedirectUri = "http://localhost/callback" + }, TestCancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(TestCancellationToken); + } + + private sealed class PausingUserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, Exceptionless.Core.AppOptions options, ExternalAccountRace race) + : UserRepository(configuration, validator, options), IUserRepository + { + public new async Task GetUserByOAuthProviderAsync(string provider, string providerUserId) + { + var user = await base.GetUserByOAuthProviderAsync(provider, providerUserId); + await race.AfterSnapshotReadAsync(); + return user; + } + } + + private sealed class ExternalAccountRace + { + private int _reads; + private bool Enabled { get; set; } + public bool RejectLocks { get; set; } + public TaskCompletionSource FirstSnapshotRead { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource SecondRequestObserved { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ResumeFirstRequest { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Start() + { + _reads = 0; + FirstSnapshotRead = new(TaskCreationOptions.RunContinuationsAsynchronously); + SecondRequestObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); + ResumeFirstRequest = new(TaskCreationOptions.RunContinuationsAsynchronously); + Enabled = true; + } + + public void Stop() + { + Enabled = false; + RejectLocks = false; + } + + public async Task AfterSnapshotReadAsync() + { + if (!Enabled) + return; + + if (Interlocked.Increment(ref _reads) == 1) + { + FirstSnapshotRead.TrySetResult(); + await ResumeFirstRequest.Task; + } + else + { + SecondRequestObserved.TrySetResult(); + } + } + + public async Task ObserveLockAsync(ILockProvider provider, string resource) + { + if (Enabled && FirstSnapshotRead.Task.IsCompleted && await provider.IsLockedAsync(resource)) + SecondRequestObserved.TrySetResult(); + } + } +} diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index 14f653cb09..547162ba27 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -15,6 +15,7 @@ using Exceptionless.Web.Models; using Exceptionless.Web.Security; using Foundatio.Caching; +using Foundatio.Lock; using Foundatio.Mediator; using Foundatio.Repositories; using Foundatio.Repositories.Models; @@ -54,6 +55,8 @@ public async Task Handle_LinkExternalAccountOwnedByAnotherUser_RevokesPreviousOw string? persistedVersion = null; var users = new Mock(MockBehavior.Strict); users.Setup(repository => repository.GetUserByOAuthProviderAsync(provider, providerId)).ReturnsAsync(displaced); + users.Setup(repository => repository.GetByIdAsync(displaced.Id, It.IsAny>())).ReturnsAsync(displaced); + users.Setup(repository => repository.GetByIdAsync(current.Id, It.IsAny>())).ReturnsAsync(current); users.Setup(repository => repository.SaveAsync(displaced, It.IsAny>())) .Callback(() => { @@ -141,6 +144,7 @@ private AuthHandler CreateHandler(IUserRepository userRepository, ITokenReposito oauthTokenRepository, providerClient, GetService(), + GetService(), GetService(), GetService(), GetService(), diff --git a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs index 6fe068e6c3..87a3e41f01 100644 --- a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs @@ -22,6 +22,7 @@ public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresEx var user = new User { FullName = "Linked User", EmailAddress = "linked@localhost.test" }; user.MarkEmailAddressVerified(); user.AddOAuthAccount("google", "shared-subject", user.EmailAddress); + user.AddOAuthAccount("GOOGLE", "shared-subject", user.EmailAddress); user.AddOAuthAccount("github", "other-subject", user.EmailAddress); await _repository.AddAsync(user, options => options.ImmediateConsistency()); @@ -34,6 +35,7 @@ public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresEx Assert.Null(githubUser); Assert.NotNull(googleUser); Assert.Equal(user.Id, googleUser.Id); + Assert.Equal(2, googleUser.OAuthAccounts.Count); Assert.Null(differentCaseUser); } From 13c4cf562b2158d004b8990b3eafccf2d0e33106 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 18 Oct 2026 14:35:39 -0500 Subject: [PATCH 05/27] Isolate webhook telemetry sampling from parallel hosts --- tests/Exceptionless.Tests/Jobs/WebHooksJobTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Exceptionless.Tests/Jobs/WebHooksJobTests.cs b/tests/Exceptionless.Tests/Jobs/WebHooksJobTests.cs index 1ddc7c2ac1..858938188d 100644 --- a/tests/Exceptionless.Tests/Jobs/WebHooksJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/WebHooksJobTests.cs @@ -199,6 +199,11 @@ public async Task ProcessQueueEntryAsync_GeneralWebHook_DeliversWithSafeTelemetr // Arrange var requests = new ConcurrentQueue<(string Path, string Cookie, string TraceParent, string TraceState, string Baggage)>(); var activities = new ConcurrentQueue(); + // Parallel application hosts keep a parent-based HTTP tracing provider active in this process. + using var applicationTracing = Sdk.CreateTracerProviderBuilder() + .SetSampler(new ParentBasedSampler(new AlwaysOnSampler())) + .AddHttpClientInstrumentation(options => options.EnrichWithHttpRequestMessage = WebHookDelivery.EnrichActivity) + .Build(); using var tracing = Sdk.CreateTracerProviderBuilder() .SetSampler(sampled ? new AlwaysOnSampler() : new AlwaysOffSampler()) .AddHttpClientInstrumentation(options => options.EnrichWithHttpRequestMessage = WebHookDelivery.EnrichActivity) @@ -237,6 +242,8 @@ public async Task ProcessQueueEntryAsync_GeneralWebHook_DeliversWithSafeTelemetr HttpStatusCode internalStatus; var previousBaggage = Baggage.Current; Baggage.SetBaggage("webhook-test", "private-baggage"); + using var parentActivity = new Activity("webhook-delivery-test").SetIdFormat(ActivityIdFormat.W3C).Start(); + parentActivity.ActivityTraceFlags = sampled ? ActivityTraceFlags.Recorded : ActivityTraceFlags.None; try { // Act From 1ed9e90e77c08a1ea83e40681eef5a27778acadf Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 18 Oct 2026 14:52:53 -0500 Subject: [PATCH 06/27] Cancel paged external identity lookup with the login request --- .../Interfaces/IUserRepository.cs | 2 +- .../Repositories/UserRepository.cs | 10 ++-- .../Api/Handlers/AuthHandler.cs | 10 ++-- .../Api/Endpoints/AuthEndpointTests.cs | 2 +- .../ExternalAccountConcurrencyTests.cs | 49 ++++++++++++++++--- .../Api/Handlers/AuthHandlerTests.cs | 2 +- .../Repositories/UserRepositoryTests.cs | 36 +++++++++++--- 7 files changed, 87 insertions(+), 24 deletions(-) diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs index ce65da5524..050334fd8b 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs @@ -11,7 +11,7 @@ public interface IUserRepository : ISearchableRepository Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor? options = null); Task GetByEmailAddressAsync(string emailAddress); Task GetByPasswordResetTokenAsync(string token); - Task GetUserByOAuthProviderAsync(string provider, string providerUserId); + Task GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default); Task GetByVerifyEmailAddressTokenAsync(string token); Task> GetByOrganizationIdAsync(string organizationId, CommandOptionsDescriptor? options = null); } diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index 8a903ec27e..b7d34339cc 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -123,18 +123,20 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor GetUserByOAuthProviderAsync(string provider, string providerUserId) + public async Task GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); if (String.IsNullOrEmpty(provider) || String.IsNullOrEmpty(providerUserId)) return null; provider = provider.ToLowerInvariant(); // OAuth accounts are mapped as objects, so indexed fields cannot enforce matching within one account. var results = await FindAsync(q => q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId).SortAscending(u => u.Id), - o => o.SearchAfterPaging().PageLimit(100)); + o => o.SearchAfterPaging().PageLimit(100)).WaitAsync(cancellationToken); User? matchingUser = null; do { + cancellationToken.ThrowIfCancellationRequested(); foreach (var user in results.Documents.Where(u => u.OAuthAccounts.Any(account => String.Equals(account.Provider, provider, StringComparison.Ordinal) && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)))) @@ -144,8 +146,10 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor> ExternalLoginAsync(ExternalAuthInfo auth throw; } + using var lockTimeout = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); + lockTimeout.CancelAfter(TimeSpan.FromSeconds(15)); try { ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); - using var lockTimeout = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); - lockTimeout.CancelAfter(TimeSpan.FromSeconds(15)); // Lock the identity and normalized email before resolving or creating its owner. string[] accountKeys = [ $"external-account:identity:{userInfo.ProviderName.ToLowerInvariant()}:{OAuthService.CreateTokenHash(userInfo.Id)}", $"external-account:email:{OAuthService.CreateTokenHash(userInfo.Email.Trim().ToLowerInvariant())}" ]; await using var accountLock = await lockProvider.AcquireAsync(accountKeys, cancellationToken: lockTimeout.Token); - var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id); + var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id, lockTimeout.Token); var emailUser = existingUser is null ? await userRepository.GetByEmailAddressAsync(userInfo.Email) : null; string? currentUserId = String.IsNullOrWhiteSpace(authInfo.InviteToken) && httpContext.User.IsUserAuthType() ? httpContext.Request.GetUser().Id : null; @@ -554,6 +554,7 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth .OfType().Select(GetExternalAccountUserLockKey); // Ordered user locks also protect transfers and different identities on the same user. await using var userLock = await lockProvider.AcquireAsync(userKeys, cancellationToken: lockTimeout.Token); + lockTimeout.Token.ThrowIfCancellationRequested(); var user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext, existingUser, emailUser); if (!String.IsNullOrWhiteSpace(authInfo.InviteToken)) @@ -562,7 +563,8 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth logger.UserLoggedIn(user.EmailAddress); return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; } - catch (Exception ex) when (ex is UnauthorizedAccessException or LockAcquisitionTimeoutException) + catch (Exception ex) when (ex is UnauthorizedAccessException or LockAcquisitionTimeoutException + || ex is OperationCanceledException && lockTimeout.IsCancellationRequested) { logger.LogWarning(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); return Result.Unauthorized("Login failed."); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 419232cb2e..a1b3ceed51 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -2162,7 +2162,7 @@ public async Task ExternalLogin_ActiveProviderOwner_TransfersAccountAndRevokesPr Assert.NotNull(storedOwner); Assert.DoesNotContain(storedOwner.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); Assert.NotEqual(owner.AuthenticationVersion, storedOwner.AuthenticationVersion); - var matchingUser = await _userRepository.GetUserByOAuthProviderAsync("github", code); + var matchingUser = await _userRepository.GetUserByOAuthProviderAsync("github", code, TestCancellationToken); Assert.NotNull(matchingUser); Assert.Equal(currentUser.Id, matchingUser.Id); await SendRequestAsync(request => request.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized()); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs index a245ce48dc..33cb050da0 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs @@ -98,6 +98,43 @@ public async Task ExternalLogin_ConcurrentRequestsForSameIdentity_KeepOneOwner(b Assert.Equal(owner.Id, secondToken.UserId); } + [Fact] + public async Task ExternalLogin_CanceledOwnershipLookup_ReleasesLocksForNextRequest() + { + // Arrange + const string code = "canceled-external-account"; + using var client = _server.CreateClient(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + _race.Start(); + var pendingRequest = client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo + { + ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" + }, cancellation.Token); + await _race.FirstSnapshotRead.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); + Exception? exception; + TokenResult? nextResult; + + // Act + try + { + cancellation.Cancel(); + exception = await Record.ExceptionAsync(async () => + { + using var response = await pendingRequest; + }); + nextResult = await SendExternalLoginAsync(client, code); + } + finally + { + _race.ResumeFirstRequest.TrySetResult(); + } + + // Assert + Assert.IsAssignableFrom(exception); + Assert.NotNull(nextResult); + Assert.NotNull(await _tokens.GetByIdAsync(nextResult.Token)); + } + [Fact] public async Task ExternalLogin_AccountLockUnavailable_IsRejectedWithoutWrites() { @@ -120,7 +157,7 @@ public async Task ExternalLogin_AccountLockUnavailable_IsRejectedWithoutWrites() Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); Assert.Equal(originalUserCount, (await _users.CountAsync()).Total); Assert.Equal(originalTokenCount, (await _tokens.CountAsync()).Total); - Assert.Null(await _users.GetUserByOAuthProviderAsync("github", code)); + Assert.Null(await _users.GetUserByOAuthProviderAsync("github", code, TestCancellationToken)); } [Fact] @@ -184,10 +221,10 @@ private static void SetAuthorization(HttpClient client, string email, string pas private sealed class PausingUserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, Exceptionless.Core.AppOptions options, ExternalAccountRace race) : UserRepository(configuration, validator, options), IUserRepository { - public new async Task GetUserByOAuthProviderAsync(string provider, string providerUserId) + public new async Task GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default) { - var user = await base.GetUserByOAuthProviderAsync(provider, providerUserId); - await race.AfterSnapshotReadAsync(); + var user = await base.GetUserByOAuthProviderAsync(provider, providerUserId, cancellationToken); + await race.AfterSnapshotReadAsync(cancellationToken); return user; } } @@ -216,7 +253,7 @@ public void Stop() RejectLocks = false; } - public async Task AfterSnapshotReadAsync() + public async Task AfterSnapshotReadAsync(CancellationToken cancellationToken) { if (!Enabled) return; @@ -224,7 +261,7 @@ public async Task AfterSnapshotReadAsync() if (Interlocked.Increment(ref _reads) == 1) { FirstSnapshotRead.TrySetResult(); - await ResumeFirstRequest.Task; + await ResumeFirstRequest.Task.WaitAsync(cancellationToken); } else { diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index 547162ba27..e5eac9da4b 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -54,7 +54,7 @@ public async Task Handle_LinkExternalAccountOwnedByAnotherUser_RevokesPreviousOw 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.GetUserByOAuthProviderAsync(provider, providerId, It.IsAny())).ReturnsAsync(displaced); users.Setup(repository => repository.GetByIdAsync(displaced.Id, It.IsAny>())).ReturnsAsync(displaced); users.Setup(repository => repository.GetByIdAsync(current.Id, It.IsAny>())).ReturnsAsync(current); users.Setup(repository => repository.SaveAsync(displaced, It.IsAny>())) diff --git a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs index 87a3e41f01..6fc489ee79 100644 --- a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs @@ -2,6 +2,7 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Foundatio.Repositories; +using Foundatio.Repositories.Models; using Xunit; namespace Exceptionless.Tests.Repositories; @@ -27,9 +28,9 @@ public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresEx await _repository.AddAsync(user, options => options.ImmediateConsistency()); // Act - var githubUser = await _repository.GetUserByOAuthProviderAsync("github", "shared-subject"); - var googleUser = await _repository.GetUserByOAuthProviderAsync("GOOGLE", "shared-subject"); - var differentCaseUser = await _repository.GetUserByOAuthProviderAsync("google", "SHARED-SUBJECT"); + var githubUser = await _repository.GetUserByOAuthProviderAsync("github", "shared-subject", TestCancellationToken); + var googleUser = await _repository.GetUserByOAuthProviderAsync("GOOGLE", "shared-subject", TestCancellationToken); + var differentCaseUser = await _repository.GetUserByOAuthProviderAsync("google", "SHARED-SUBJECT", TestCancellationToken); // Assert Assert.Null(githubUser); @@ -40,9 +41,10 @@ public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresEx } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task GetUserByOAuthProviderAsync_MatchingIdentityOnSecondPage_ValidatesAllCandidates(bool hasDuplicateOwner) + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task GetUserByOAuthProviderAsync_PagedCandidates_ValidatesIdentityAndCancellation(bool hasDuplicateOwner, bool cancelAfterFirstPage) { // Arrange var users = Enumerable.Range(1, 101).Select(index => @@ -63,15 +65,33 @@ public async Task GetUserByOAuthProviderAsync_MatchingIdentityOnSecondPage_Valid users[0].AddOAuthAccount("github", "shared-subject", users[0].EmailAddress); await _repository.AddAsync(users, options => options.ImmediateConsistency()); User? matchingUser = null; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + int queryCount = 0; + using var queryHandler = _repository.AfterQuery.AddHandler((_, args) => + { + if (args.Result is FindResults) + { + queryCount++; + if (cancelAfterFirstPage) + cancellation.Cancel(); + } + return Task.CompletedTask; + }); // Act var exception = await Record.ExceptionAsync(async () => { - matchingUser = await _repository.GetUserByOAuthProviderAsync("github", "shared-subject"); + matchingUser = await _repository.GetUserByOAuthProviderAsync("github", "shared-subject", cancellation.Token); }); // Assert - if (hasDuplicateOwner) + if (cancelAfterFirstPage) + { + Assert.IsAssignableFrom(exception); + Assert.Null(matchingUser); + Assert.Equal(1, queryCount); + } + else if (hasDuplicateOwner) { Assert.IsType(exception); Assert.Null(matchingUser); From 076cf09ad0471f9f8dbd5851b6fabb94f80f1988 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 18 Oct 2026 15:06:38 -0500 Subject: [PATCH 07/27] Cancel external account reads while ownership locks are held --- .../Api/Handlers/AuthHandler.cs | 46 ++++++++++-------- .../ExternalAccountConcurrencyTests.cs | 48 +++++++++++++++---- 2 files changed, 65 insertions(+), 29 deletions(-) diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index 85948b9b6f..ff6e9b384d 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -311,7 +311,7 @@ public async Task> Handle(RemoveExternalLogin message) return Result.Unauthorized("Login failed."); var authenticatedUser = user; - user = await userRepository.GetByIdAsync(user.Id, options => options.Cache(false)); + user = await userRepository.GetByIdAsync(user.Id, options => options.Cache(false)).WaitAsync(lockTimeout.Token); if (user is null || !user.IsActive) return Result.Unauthorized("Login failed."); @@ -320,6 +320,7 @@ public async Task> Handle(RemoveExternalLogin message) try { + lockTimeout.Token.ThrowIfCancellationRequested(); if (!user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) { logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); @@ -338,7 +339,7 @@ public async Task> Handle(RemoveExternalLogin message) await ResetUserTokensAsync(user, "RemoveExternalLoginAsync", httpContext); logger.UserRemovedExternalLogin(user.EmailAddress, message.ProviderName); - return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user, lockTimeout.Token) }; } public async Task> Handle(ChangePassword message) @@ -502,12 +503,12 @@ public async Task Handle(CancelResetPassword message) return Result.Success(); } - private async Task AddGlobalAdminRoleIfFirstUserAsync(User user) + private async Task AddGlobalAdminRoleIfFirstUserAsync(User user, CancellationToken cancellationToken = default) { if (_isFirstUserChecked) return; - bool isFirstUser = await userRepository.CountAsync() == 0; + bool isFirstUser = await userRepository.CountAsync().WaitAsync(cancellationToken) == 0; if (isFirstUser) user.Roles.Add(AuthorizationRoles.GlobalAdmin); @@ -547,7 +548,7 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth ]; await using var accountLock = await lockProvider.AcquireAsync(accountKeys, cancellationToken: lockTimeout.Token); var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id, lockTimeout.Token); - var emailUser = existingUser is null ? await userRepository.GetByEmailAddressAsync(userInfo.Email) : null; + var emailUser = existingUser is null ? await userRepository.GetByEmailAddressAsync(userInfo.Email).WaitAsync(lockTimeout.Token) : null; string? currentUserId = String.IsNullOrWhiteSpace(authInfo.InviteToken) && httpContext.User.IsUserAuthType() ? httpContext.Request.GetUser().Id : null; var userKeys = new[] { existingUser?.Id, emailUser?.Id, currentUserId } @@ -555,13 +556,13 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth // Ordered user locks also protect transfers and different identities on the same user. await using var userLock = await lockProvider.AcquireAsync(userKeys, cancellationToken: lockTimeout.Token); lockTimeout.Token.ThrowIfCancellationRequested(); - var user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext, existingUser, emailUser); + var user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext, existingUser, emailUser, lockTimeout.Token); if (!String.IsNullOrWhiteSpace(authInfo.InviteToken)) - await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user, httpContext); + await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user, httpContext, lockTimeout.Token); logger.UserLoggedIn(user.EmailAddress); - return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user, lockTimeout.Token) }; } catch (Exception ex) when (ex is UnauthorizedAccessException or LockAcquisitionTimeoutException || ex is OperationCanceledException && lockTimeout.IsCancellationRequested) @@ -583,7 +584,7 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth private static string GetExternalAccountUserLockKey(string userId) => $"external-account:user:{userId}"; - private async Task FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext, User? existingUser, User? emailUser) + private async Task FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext, User? existingUser, User? emailUser, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); @@ -593,7 +594,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit if (existingUser is not null) { - existingUser = await userRepository.GetByIdAsync(existingUser.Id, options => options.Cache(false)) + existingUser = await userRepository.GetByIdAsync(existingUser.Id, options => options.Cache(false)).WaitAsync(cancellationToken) ?? throw new UnauthorizedAccessException("The user no longer exists."); EnsureUserIsActive(existingUser); } @@ -601,11 +602,12 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit if (String.IsNullOrWhiteSpace(inviteToken) && httpContext.User.IsUserAuthType()) { var authenticatedUser = httpContext.Request.GetUser(); - var currentUser = await userRepository.GetByIdAsync(authenticatedUser.Id, options => options.Cache(false)) + var currentUser = await userRepository.GetByIdAsync(authenticatedUser.Id, options => options.Cache(false)).WaitAsync(cancellationToken) ?? throw new UnauthorizedAccessException("The user no longer exists."); EnsureUserIsActive(currentUser); if (!String.Equals(authenticatedUser.AuthenticationVersion, currentUser.AuthenticationVersion, StringComparison.Ordinal)) throw new UnauthorizedAccessException("The user session has been revoked."); + cancellationToken.ThrowIfCancellationRequested(); if (existingUser is not null) { if (!String.Equals(existingUser.Id, currentUser.Id, StringComparison.Ordinal)) @@ -629,6 +631,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit if (existingUser is not null) { + cancellationToken.ThrowIfCancellationRequested(); if (!existingUser.IsEmailAddressVerified) { existingUser.MarkEmailAddressVerified(); @@ -638,19 +641,20 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit return existingUser; } - var user = emailUser is not null ? await userRepository.GetByIdAsync(emailUser.Id, options => options.Cache(false)) : null; + var user = emailUser is not null ? await userRepository.GetByIdAsync(emailUser.Id, options => options.Cache(false)).WaitAsync(cancellationToken) : null; if (user is null) { - if (!await IsAccountCreationEnabledAsync(inviteToken)) + if (!await IsAccountCreationEnabledAsync(inviteToken, cancellationToken)) throw new ApplicationException("Account Creation is currently disabled."); user = new User { FullName = userInfo.GetFullName()!, EmailAddress = userInfo.Email }; user.Roles.Add(AuthorizationRoles.Client); user.Roles.Add(AuthorizationRoles.User); - await AddGlobalAdminRoleIfFirstUserAsync(user); + await AddGlobalAdminRoleIfFirstUserAsync(user, cancellationToken); } EnsureUserIsActive(user); + cancellationToken.ThrowIfCancellationRequested(); user.MarkEmailAddressVerified(); user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); @@ -668,7 +672,7 @@ private static void EnsureUserIsActive(User user) throw new UnauthorizedAccessException("The user is inactive."); } - private async Task IsAccountCreationEnabledAsync(string? token) + private async Task IsAccountCreationEnabledAsync(string? token, CancellationToken cancellationToken = default) { if (authOptions.EnableAccountCreation) return true; @@ -676,18 +680,18 @@ private async Task IsAccountCreationEnabledAsync(string? token) if (String.IsNullOrEmpty(token)) return false; - var organization = await organizationRepository.GetByInviteTokenAsync(token); + var organization = await organizationRepository.GetByInviteTokenAsync(token).WaitAsync(cancellationToken); return organization is not null; } - private async Task AddInvitedUserToOrganizationAsync(string? token, User user, HttpContext httpContext) + private async Task AddInvitedUserToOrganizationAsync(string? token, User user, HttpContext httpContext, CancellationToken cancellationToken = default) { if (String.IsNullOrWhiteSpace(token)) return; using var _ = logger.BeginScope(new ExceptionlessState().Tag("Invite").Identity(user.EmailAddress).SetHttpContext(httpContext)); - var organization = await organizationRepository.GetByInviteTokenAsync(token); + var organization = await organizationRepository.GetByInviteTokenAsync(token).WaitAsync(cancellationToken); var invite = organization?.GetInvite(token); if (organization is null || invite is null) { @@ -695,6 +699,7 @@ private async Task AddInvitedUserToOrganizationAsync(string? token, User user, H return; } + cancellationToken.ThrowIfCancellationRequested(); if (!user.IsEmailAddressVerified && String.Equals(user.EmailAddress, invite.EmailAddress, StringComparison.OrdinalIgnoreCase)) { logger.MarkedInvitedUserAsVerified(user.EmailAddress); @@ -762,9 +767,10 @@ private async Task ResetUserTokensAsync(User user, string tag, HttpContext httpC logger.RemovedUserTokens(total, user.EmailAddress); } - private async Task GetOrCreateAuthenticationTokenAsync(User user) + private async Task GetOrCreateAuthenticationTokenAsync(User user, CancellationToken cancellationToken = default) { - var userTokens = await tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id); + var userTokens = await tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id).WaitAsync(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); var utcNow = timeProvider.GetUtcNow().UtcDateTime; var validAccessToken = userTokens.Documents.FirstOrDefault(token => !token.IsDisabled && !token.IsSuspended diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs index 33cb050da0..9f1c55220c 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs @@ -10,6 +10,7 @@ using Exceptionless.Web.Models; using Foundatio.Lock; using Foundatio.Repositories; +using Foundatio.Repositories.Models; using Microsoft.Extensions.DependencyInjection; using Moq; using Xunit; @@ -98,14 +99,23 @@ public async Task ExternalLogin_ConcurrentRequestsForSameIdentity_KeepOneOwner(b Assert.Equal(owner.Id, secondToken.UserId); } - [Fact] - public async Task ExternalLogin_CanceledOwnershipLookup_ReleasesLocksForNextRequest() + [Theory] + [InlineData(LookupStage.Identity)] + [InlineData(LookupStage.Email)] + [InlineData(LookupStage.User)] + public async Task ExternalLogin_CanceledLookup_ReleasesLocksForNextRequest(LookupStage stage) { // Arrange const string code = "canceled-external-account"; using var client = _server.CreateClient(); using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); - _race.Start(); + if (stage == LookupStage.User) + { + var user = new User { FullName = "Canceled lookup user", EmailAddress = TestOAuthProviderClient.GetEmailAddress(code) }; + user.MarkEmailAddressVerified(); + await _users.AddAsync(user, options => options.ImmediateConsistency()); + } + _race.Start(stage, TestCancellationToken); var pendingRequest = client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo { ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" @@ -121,7 +131,7 @@ public async Task ExternalLogin_CanceledOwnershipLookup_ReleasesLocksForNextRequ exception = await Record.ExceptionAsync(async () => { using var response = await pendingRequest; - }); + }).AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); nextResult = await SendExternalLoginAsync(client, code); } finally @@ -184,7 +194,7 @@ public async Task ExternalLogin_ConcurrentDifferentIdentitiesForSameUser_Preserv private async Task RunConcurrentRequestsAsync(HttpClient firstClient, string firstCode, HttpClient secondClient, string secondCode) { - _race.Start(); + _race.Start(LookupStage.Identity, TestCancellationToken); var firstRequest = SendExternalLoginAsync(firstClient, firstCode); Task? secondRequest = null; try @@ -224,23 +234,43 @@ private sealed class PausingUserRepository(ExceptionlessElasticConfiguration con public new async Task GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default) { var user = await base.GetUserByOAuthProviderAsync(provider, providerUserId, cancellationToken); - await race.AfterSnapshotReadAsync(cancellationToken); + await race.AfterSnapshotReadAsync(LookupStage.Identity, cancellationToken); + return user; + } + + public new async Task GetByEmailAddressAsync(string emailAddress) + { + var user = await base.GetByEmailAddressAsync(emailAddress); + await race.AfterSnapshotReadAsync(LookupStage.Email, race.TestCancellationToken); + return user; + } + + public override async Task GetByIdAsync(Id id, ICommandOptions? options = null) + { + var user = await base.GetByIdAsync(id, options); + await race.AfterSnapshotReadAsync(LookupStage.User, race.TestCancellationToken); return user; } } + public enum LookupStage { Identity, Email, User } + private sealed class ExternalAccountRace { private int _reads; private bool Enabled { get; set; } + private LookupStage Stage { get; set; } + public CancellationToken TestCancellationToken { get; private set; } public bool RejectLocks { get; set; } public TaskCompletionSource FirstSnapshotRead { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource SecondRequestObserved { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource ResumeFirstRequest { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public void Start() + public void Start(LookupStage stage, CancellationToken testCancellationToken) { _reads = 0; + Stage = stage; + TestCancellationToken = testCancellationToken; FirstSnapshotRead = new(TaskCreationOptions.RunContinuationsAsynchronously); SecondRequestObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); ResumeFirstRequest = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -253,9 +283,9 @@ public void Stop() RejectLocks = false; } - public async Task AfterSnapshotReadAsync(CancellationToken cancellationToken) + public async Task AfterSnapshotReadAsync(LookupStage stage, CancellationToken cancellationToken) { - if (!Enabled) + if (!Enabled || Stage != stage) return; if (Interlocked.Increment(ref _reads) == 1) From 287eb26aaa9555fef08105776e638a07355ed6cc Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 18 Oct 2026 15:17:32 -0500 Subject: [PATCH 08/27] Document and test unauthorized external account unlink responses --- .../Api/Endpoints/AuthEndpoints.cs | 2 ++ .../Exceptionless.Tests/Api/Data/openapi.json | 10 ++++++++ .../ExternalAccountConcurrencyTests.cs | 24 +++++++++++++++++++ .../Api/OpenApiSnapshotTests.cs | 2 ++ 4 files changed, 38 insertions(+) diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs index 7454e96a28..b5361cc48c 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -182,6 +182,7 @@ headers api_key input box. .Accepts>("application/json", "application/*+json") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status401Unauthorized) .WithSummary("Removes an external login provider from the account") .WithMetadata(new EndpointDocumentation { RequestBodyDescription = "The provider user id.", @@ -191,6 +192,7 @@ headers api_key input box. ResponseDescriptions = new() { ["200"] = "User Authentication Token", ["400"] = "Invalid provider name.", + ["401"] = "Unauthorized", } }); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 5ca9f99c18..0c86ee6560 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -2045,6 +2045,16 @@ } } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs index 9f1c55220c..19f6032f3b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs @@ -170,6 +170,30 @@ public async Task ExternalLogin_AccountLockUnavailable_IsRejectedWithoutWrites() Assert.Null(await _users.GetUserByOAuthProviderAsync("github", code, TestCancellationToken)); } + [Fact] + public async Task RemoveExternalLogin_AccountLockUnavailable_PreservesIdentityAndAuthenticationVersion() + { + // Arrange + const string code = "external-unlink-lock-unavailable"; + using var client = _server.CreateClient(); + SetAuthorization(client, SampleDataService.TEST_ORG_USER_EMAIL, SampleDataService.TEST_ORG_USER_PASSWORD); + var user = await _users.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.AddOAuthAccount("github", code, user.EmailAddress); + await _users.SaveAsync(user, options => options.ImmediateConsistency()); + _race.RejectLocks = true; + + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/unlink/github", new ValueFromBody(code), TestCancellationToken); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + var storedUser = await _users.GetByIdAsync(user.Id, options => options.Cache(false)); + Assert.NotNull(storedUser); + Assert.Equal(user.AuthenticationVersion, storedUser.AuthenticationVersion); + Assert.Single(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + } + [Fact] public async Task ExternalLogin_ConcurrentDifferentIdentitiesForSameUser_PreservesBothLinks() { diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 06dd4f3b17..6f507b27cc 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -52,6 +52,8 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp Assert.True(loginPost.TryGetProperty("requestBody", out _)); AssertResponseCodes(loginPost, "200", "401", "422"); + AssertPathResponseCodes(paths, "/api/v2/auth/unlink/{providerName}", "post", "200", "400", "401"); + Assert.True(paths.TryGetProperty("/api/v2/auth/logout", out var logoutPath)); Assert.True(logoutPath.TryGetProperty("get", out var logoutGet)); AssertResponseCodes(logoutGet, "200", "401", "403"); From 1595556e023631fb261a15f31d46c61aff898bf0 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 18 Oct 2026 15:20:24 -0500 Subject: [PATCH 09/27] Revalidate external identity ownership after waiting for the user lock --- .../Api/Handlers/AuthHandler.cs | 4 ++ .../ExternalAccountConcurrencyTests.cs | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index ff6e9b384d..c131e35b5a 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -597,6 +597,10 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit existingUser = await userRepository.GetByIdAsync(existingUser.Id, options => options.Cache(false)).WaitAsync(cancellationToken) ?? throw new UnauthorizedAccessException("The user no longer exists."); EnsureUserIsActive(existingUser); + if (!existingUser.OAuthAccounts.Any(account => + String.Equals(account.Provider, userInfo.ProviderName, StringComparison.OrdinalIgnoreCase) && + String.Equals(account.ProviderUserId, userInfo.Id, StringComparison.Ordinal))) + throw new UnauthorizedAccessException("The external account is no longer linked to this user."); } if (String.IsNullOrWhiteSpace(inviteToken) && httpContext.User.IsUserAuthType()) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs index 19f6032f3b..dd1849db3a 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs @@ -194,6 +194,46 @@ public async Task RemoveExternalLogin_AccountLockUnavailable_PreservesIdentityAn Assert.Single(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); } + [Fact] + public async Task ExternalLogin_IdentityUnlinkedAfterLookup_RejectsStaleOwner() + { + // Arrange + const string code = "external-account-unlinked-during-login"; + var user = await _users.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.AddOAuthAccount("github", code, user.EmailAddress); + await _users.SaveAsync(user, options => options.ImmediateConsistency()); + using var loginClient = _server.CreateClient(); + using var unlinkClient = _server.CreateClient(); + SetAuthorization(unlinkClient, SampleDataService.TEST_ORG_USER_EMAIL, SampleDataService.TEST_ORG_USER_PASSWORD); + _race.Start(LookupStage.Identity, TestCancellationToken); + var pendingLogin = loginClient.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo + { + ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" + }, TestCancellationToken); + await _race.FirstSnapshotRead.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); + + // Act + HttpResponseMessage unlinkResponse; + try + { + unlinkResponse = await unlinkClient.PostAsJsonAsync("api/v2/auth/unlink/github", new ValueFromBody(code), TestCancellationToken); + } + finally + { + _race.ResumeFirstRequest.TrySetResult(); + } + using var completedUnlinkResponse = unlinkResponse; + using var loginResponse = await pendingLogin; + + // Assert + Assert.Equal(System.Net.HttpStatusCode.OK, unlinkResponse.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, loginResponse.StatusCode); + var storedUser = await _users.GetByIdAsync(user.Id, options => options.Cache(false)); + Assert.NotNull(storedUser); + Assert.DoesNotContain(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + } + [Fact] public async Task ExternalLogin_ConcurrentDifferentIdentitiesForSameUser_PreservesBothLinks() { From cf019907fbb7f7e6a6f0beae10869acf1ad5b3b5 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 19 Oct 2026 08:55:09 -0500 Subject: [PATCH 10/27] Validate external login provider names before account changes --- .../Extensions/UserExtensions.cs | 11 ++- src/Exceptionless.Core/Models/OAuthAccount.cs | 14 +++- .../Repositories/UserRepository.cs | 5 +- .../Api/Endpoints/AuthEndpoints.cs | 8 +- .../Api/Handlers/AuthHandler.cs | 75 ++++++++++--------- .../Models/Auth/RemoveExternalLoginRequest.cs | 15 ++++ .../Security/OAuthProviderClient.cs | 15 ++-- .../Exceptionless.Tests/Api/Data/openapi.json | 13 +++- .../Api/Endpoints/AuthEndpointTests.cs | 44 ++++++++++- .../ExternalAccountConcurrencyTests.cs | 5 ++ .../Api/OpenApiSnapshotTests.cs | 2 +- .../Extensions/UserExtensionsTests.cs | 32 ++++++++ .../Repositories/UserRepositoryTests.cs | 7 +- .../Utility/TestOAuthProviderClient.cs | 8 +- 14 files changed, 193 insertions(+), 61 deletions(-) create mode 100644 src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs create mode 100644 tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs diff --git a/src/Exceptionless.Core/Extensions/UserExtensions.cs b/src/Exceptionless.Core/Extensions/UserExtensions.cs index 8897c42c15..18a1f5f8f3 100644 --- a/src/Exceptionless.Core/Extensions/UserExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UserExtensions.cs @@ -64,7 +64,9 @@ public static bool HasValidPasswordResetTokenExpiration(this User user, TimeProv public static void AddOAuthAccount(this User user, string providerName, string providerUserId, string username, SettingsDictionary? data = null) { - providerName = providerName.ToLowerInvariant(); + if (!OAuthAccount.KnownProviders.IsKnown(providerName)) + throw new ArgumentException("Unknown external login provider.", nameof(providerName)); + if (user.OAuthAccounts.Any(account => String.Equals(account.Provider, providerName, StringComparison.Ordinal) && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal))) return; @@ -84,12 +86,15 @@ public static void AddOAuthAccount(this User user, string providerName, string p public static bool RemoveOAuthAccount(this User user, string providerName, string providerUserId) { + if (!OAuthAccount.KnownProviders.IsKnown(providerName)) + throw new ArgumentException("Unknown external login provider.", nameof(providerName)); + if (user.OAuthAccounts.Count <= 1 && String.IsNullOrEmpty(user.Password)) return false; - var accounts = user.OAuthAccounts.Where(account => String.Equals(account.Provider, providerName.ToLowerInvariant(), StringComparison.Ordinal) + var accounts = user.OAuthAccounts.Where(account => String.Equals(account.Provider, providerName, StringComparison.Ordinal) && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)).ToArray(); - if (accounts.Length == 0) + if (accounts.Length is 0) return true; if (accounts.Length == user.OAuthAccounts.Count && String.IsNullOrEmpty(user.Password)) diff --git a/src/Exceptionless.Core/Models/OAuthAccount.cs b/src/Exceptionless.Core/Models/OAuthAccount.cs index ef21a54fa0..d8d6c933bb 100644 --- a/src/Exceptionless.Core/Models/OAuthAccount.cs +++ b/src/Exceptionless.Core/Models/OAuthAccount.cs @@ -1,7 +1,19 @@ -namespace Exceptionless.Core.Models; +using System.Diagnostics.CodeAnalysis; + +namespace Exceptionless.Core.Models; public record OAuthAccount { + public static class KnownProviders + { + public const string GitHub = "github"; + public const string Google = "google"; + public const string Facebook = "facebook"; + public const string WindowsLive = "windowslive"; + + public static bool IsKnown([NotNullWhen(true)] string? providerName) => providerName is GitHub or Google or Facebook or WindowsLive; + } + public required string Provider { get; init; } public required string ProviderUserId { get; init; } public required string Username { get; init; } diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index b7d34339cc..c2cc9391e3 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; using Exceptionless.Core.Repositories.Configuration; using Exceptionless.Core.Validation; using Foundatio.Repositories; @@ -126,10 +127,9 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - if (String.IsNullOrEmpty(provider) || String.IsNullOrEmpty(providerUserId)) + if (!OAuthAccount.KnownProviders.IsKnown(provider) || String.IsNullOrEmpty(providerUserId)) return null; - provider = provider.ToLowerInvariant(); // OAuth accounts are mapped as objects, so indexed fields cannot enforce matching within one account. var results = await FindAsync(q => q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId).SortAscending(u => u.Id), o => o.SearchAfterPaging().PageLimit(100)).WaitAsync(cancellationToken); @@ -146,6 +146,7 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor resultMapper, HttpContext httpContext, [FromBody] ValueFromBody providerUserId) - => (await mediator.InvokeAsync>(new AuthMessages.RemoveExternalLogin(providerName, providerUserId, httpContext))).ToHttpResult(resultMapper)) + group.MapPost("unlink/{providerName:minlength(1)}", async ([AsParameters] RemoveExternalLoginRequest request, IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext, [FromBody] ValueFromBody providerUserId) + => (await mediator.InvokeAsync>(new AuthMessages.RemoveExternalLogin(request.ProviderName, providerUserId, httpContext))).ToHttpResult(resultMapper)) .Accepts>("application/json", "application/*+json") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Removes an external login provider from the account") .WithMetadata(new EndpointDocumentation { RequestBodyDescription = "The provider user id.", ParameterDescriptions = new() { - ["providerName"] = "The provider name.", + ["providerName"] = "The provider name: github, google, facebook, or windowslive (case sensitive).", }, ResponseDescriptions = new() { ["200"] = "User Authentication Token", ["400"] = "Invalid provider name.", ["401"] = "Unauthorized", + ["422"] = "Validation error", } }); diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index c131e35b5a..c716b06a32 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -42,6 +42,7 @@ public class AuthHandler( private readonly ScopedCacheClient _cache = new(cacheClient, "Auth"); private static bool _isFirstUserChecked; private static readonly TimeSpan IntercomJwtLifetime = TimeSpan.FromMinutes(60); + private static readonly TimeSpan ExternalAccountTimeout = TimeSpan.FromSeconds(15); public async Task> Handle(LoginMessage message) { @@ -304,23 +305,19 @@ public async Task> Handle(RemoveExternalLogin message) return Result.BadRequest("Invalid Provider Name or Provider User Id."); } - using var lockTimeout = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); - lockTimeout.CancelAfter(TimeSpan.FromSeconds(15)); - await using var userLock = await lockProvider.TryAcquireAsync(GetExternalAccountUserLockKey(user.Id), cancellationToken: lockTimeout.Token); - if (userLock is null) - return Result.Unauthorized("Login failed."); - - var authenticatedUser = user; - user = await userRepository.GetByIdAsync(user.Id, options => options.Cache(false)).WaitAsync(lockTimeout.Token); - if (user is null || !user.IsActive) - return Result.Unauthorized("Login failed."); - - if (!String.Equals(authenticatedUser.AuthenticationVersion, user.AuthenticationVersion, StringComparison.Ordinal)) - return Result.Unauthorized("Login failed."); - + using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); + operationCancellation.CancelAfter(ExternalAccountTimeout); try { - lockTimeout.Token.ThrowIfCancellationRequested(); + operationCancellation.Token.ThrowIfCancellationRequested(); + await using var userLock = await lockProvider.AcquireAsync(GetExternalAccountUserLockKey(user.Id), cancellationToken: operationCancellation.Token); + var storedUser = await userRepository.GetByIdAsync(user.Id, options => options.Cache(false)).WaitAsync(operationCancellation.Token); + if (storedUser is null || !storedUser.IsActive + || !String.Equals(user.AuthenticationVersion, storedUser.AuthenticationVersion, StringComparison.Ordinal)) + return Result.Unauthorized("Login failed."); + + user = storedUser; + operationCancellation.Token.ThrowIfCancellationRequested(); if (!user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) { logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); @@ -329,17 +326,23 @@ public async Task> Handle(RemoveExternalLogin message) user.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken(); await userRepository.SaveAsync(user, o => o.Cache()); + + await ResetUserTokensAsync(user, "RemoveExternalLoginAsync", httpContext); + + logger.UserRemovedExternalLogin(user.EmailAddress, message.ProviderName); + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user, operationCancellation.Token) }; + } + catch (Exception ex) when (ex is LockAcquisitionTimeoutException + || ex is OperationCanceledException && operationCancellation.IsCancellationRequested) + { + logger.LogWarning(ex, "Remove external login failed for {EmailAddress}: {Message}", user.EmailAddress, ex.Message); + return Result.Unauthorized("Login failed."); } catch (Exception ex) { logger.LogCritical(ex, "Error removing external login for {EmailAddress}: {Message}", user.EmailAddress, ex.Message); throw; } - - await ResetUserTokensAsync(user, "RemoveExternalLoginAsync", httpContext); - - logger.UserRemovedExternalLogin(user.EmailAddress, message.ProviderName); - return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user, lockTimeout.Token) }; } public async Task> Handle(ChangePassword message) @@ -533,39 +536,43 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth throw; } - using var lockTimeout = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); - lockTimeout.CancelAfter(TimeSpan.FromSeconds(15)); + // Repository reads have no native cancellation API. Cancel read waits only; await every write inside its locks. + using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); + operationCancellation.CancelAfter(ExternalAccountTimeout); try { ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); + if (!OAuthAccount.KnownProviders.IsKnown(userInfo.ProviderName)) + throw new UnauthorizedAccessException("Unknown external login provider."); + ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); // Lock the identity and normalized email before resolving or creating its owner. string[] accountKeys = [ - $"external-account:identity:{userInfo.ProviderName.ToLowerInvariant()}:{OAuthService.CreateTokenHash(userInfo.Id)}", + $"external-account:identity:{userInfo.ProviderName}:{OAuthService.CreateTokenHash(userInfo.Id)}", $"external-account:email:{OAuthService.CreateTokenHash(userInfo.Email.Trim().ToLowerInvariant())}" ]; - await using var accountLock = await lockProvider.AcquireAsync(accountKeys, cancellationToken: lockTimeout.Token); - var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id, lockTimeout.Token); - var emailUser = existingUser is null ? await userRepository.GetByEmailAddressAsync(userInfo.Email).WaitAsync(lockTimeout.Token) : null; + operationCancellation.Token.ThrowIfCancellationRequested(); + await using var accountLock = await lockProvider.AcquireAsync(accountKeys, cancellationToken: operationCancellation.Token); + var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id, operationCancellation.Token); + var emailUser = existingUser is null ? await userRepository.GetByEmailAddressAsync(userInfo.Email).WaitAsync(operationCancellation.Token) : null; string? currentUserId = String.IsNullOrWhiteSpace(authInfo.InviteToken) && httpContext.User.IsUserAuthType() ? httpContext.Request.GetUser().Id : null; var userKeys = new[] { existingUser?.Id, emailUser?.Id, currentUserId } .OfType().Select(GetExternalAccountUserLockKey); // Ordered user locks also protect transfers and different identities on the same user. - await using var userLock = await lockProvider.AcquireAsync(userKeys, cancellationToken: lockTimeout.Token); - lockTimeout.Token.ThrowIfCancellationRequested(); - var user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext, existingUser, emailUser, lockTimeout.Token); + await using var userLock = await lockProvider.AcquireAsync(userKeys, cancellationToken: operationCancellation.Token); + operationCancellation.Token.ThrowIfCancellationRequested(); + var user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext, existingUser, emailUser, operationCancellation.Token); if (!String.IsNullOrWhiteSpace(authInfo.InviteToken)) - await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user, httpContext, lockTimeout.Token); + await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user, httpContext, operationCancellation.Token); logger.UserLoggedIn(user.EmailAddress); - return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user, lockTimeout.Token) }; + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user, operationCancellation.Token) }; } catch (Exception ex) when (ex is UnauthorizedAccessException or LockAcquisitionTimeoutException - || ex is OperationCanceledException && lockTimeout.IsCancellationRequested) + || ex is OperationCanceledException && operationCancellation.IsCancellationRequested) { logger.LogWarning(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); return Result.Unauthorized("Login failed."); @@ -598,7 +605,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit ?? throw new UnauthorizedAccessException("The user no longer exists."); EnsureUserIsActive(existingUser); if (!existingUser.OAuthAccounts.Any(account => - String.Equals(account.Provider, userInfo.ProviderName, StringComparison.OrdinalIgnoreCase) && + String.Equals(account.Provider, userInfo.ProviderName, StringComparison.Ordinal) && String.Equals(account.ProviderUserId, userInfo.Id, StringComparison.Ordinal))) throw new UnauthorizedAccessException("The external account is no longer linked to this user."); } diff --git a/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs b/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs new file mode 100644 index 0000000000..fb84f0882f --- /dev/null +++ b/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Exceptionless.Web.Models; + +public sealed class RemoveExternalLoginRequest +{ + [FromRoute(Name = "providerName")] + [Required] + [AllowedValues(OAuthAccount.KnownProviders.GitHub, OAuthAccount.KnownProviders.Google, + OAuthAccount.KnownProviders.Facebook, OAuthAccount.KnownProviders.WindowsLive, + ErrorMessage = "The provider name must be github, google, facebook, or windowslive.")] + public string ProviderName { get; set; } = null!; +} diff --git a/src/Exceptionless.Web/Security/OAuthProviderClient.cs b/src/Exceptionless.Web/Security/OAuthProviderClient.cs index c7427390a2..d8ecf2d934 100644 --- a/src/Exceptionless.Web/Security/OAuthProviderClient.cs +++ b/src/Exceptionless.Web/Security/OAuthProviderClient.cs @@ -23,7 +23,7 @@ public sealed class OAuthProviderClient(SlackService slackService) : IOAuthProvi { public Task GetFacebookUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) { - return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) => + return GetUserInfoAsync(authInfo, appId, appSecret, OAuthAccount.KnownProviders.Facebook, (factory, configuration) => { configuration.Scope = "email"; return new FacebookClient(factory, configuration); @@ -32,7 +32,7 @@ public Task GetFacebookUserInfoAsync(ExternalAuthInfo authInfo, string public Task GetGitHubUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) { - return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) => + return GetUserInfoAsync(authInfo, appId, appSecret, OAuthAccount.KnownProviders.GitHub, (factory, configuration) => { configuration.Scope = "user:email"; return new GitHubClient(factory, configuration); @@ -41,7 +41,7 @@ public Task GetGitHubUserInfoAsync(ExternalAuthInfo authInfo, string a public Task GetGoogleUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) { - return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) => + return GetUserInfoAsync(authInfo, appId, appSecret, OAuthAccount.KnownProviders.Google, (factory, configuration) => { configuration.Scope = "profile email"; return new GoogleClient(factory, configuration); @@ -50,7 +50,7 @@ public Task GetGoogleUserInfoAsync(ExternalAuthInfo authInfo, string a public Task GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) { - return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) => + return GetUserInfoAsync(authInfo, appId, appSecret, OAuthAccount.KnownProviders.WindowsLive, (factory, configuration) => { configuration.Scope = "wl.emails"; return new WindowsLiveClient(factory, configuration); @@ -62,10 +62,11 @@ public Task GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, strin return slackService.GetAccessTokenAsync(code); } - private static Task GetUserInfoAsync( + private static async Task GetUserInfoAsync( ExternalAuthInfo authInfo, string appId, string appSecret, + string providerName, Func createClient ) where TClient : OAuth2Client { @@ -76,6 +77,8 @@ Func createClient RedirectUri = authInfo.RedirectUri }); - return client.GetUserInfoAsync(authInfo.Code, authInfo.RedirectUri); + var userInfo = await client.GetUserInfoAsync(authInfo.Code, authInfo.RedirectUri); + userInfo.ProviderName = providerName; + return userInfo; } } diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 0c86ee6560..4f4277eeed 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -2001,10 +2001,9 @@ { "name": "providerName", "in": "path", - "description": "The provider name.", + "description": "The provider name: github, google, facebook, or windowslive (case sensitive).", "required": true, "schema": { - "minLength": 1, "type": "string" } } @@ -2055,6 +2054,16 @@ } } } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } } } } diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index a1b3ceed51..250c666758 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -753,11 +753,14 @@ public async Task LoginValidAsync() Assert.False(String.IsNullOrEmpty(result.Token)); } - [Fact] - public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount() + [Theory] + [InlineData("github")] + [InlineData("google")] + [InlineData("facebook")] + [InlineData("windowslive")] + public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount(string providerName) { // Arrange - const string providerName = "github"; const string providerUserId = "github-remove-user"; var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); Assert.NotNull(user); @@ -781,6 +784,41 @@ public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount() Assert.DoesNotContain(updatedUser.OAuthAccounts, account => account.Provider == providerName && account.ProviderUserId == providerUserId); } + [Theory] + [InlineData("unknown")] + [InlineData("GitHub")] + [InlineData("GITHUB")] + [InlineData(" github")] + [InlineData("github ")] + public async Task RemoveExternalLoginAsync_InvalidProvider_IsRejectedWithoutChangingAccount(string providerName) + { + // Arrange + const string providerUserId = "github-invalid-provider"; + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.AddOAuthAccount("github", providerUserId, user.EmailAddress); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency().Cache()); + long originalTokenCount = (await _tokenRepository.CountAsync()).Total; + + // Act + var problem = await SendRequestAsAsync(request => request + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "unlink", providerName) + .Content(new ValueFromBody(providerUserId)) + .StatusCodeShouldBeUnprocessableEntity()); + + // Assert + Assert.NotNull(problem); + Assert.Contains("provider_name", problem.Errors.Keys); + var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.Cache(false)); + Assert.NotNull(storedUser); + Assert.Equal(user.AuthenticationVersion, storedUser.AuthenticationVersion); + Assert.Equal(user.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username)), + storedUser.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username))); + Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); + } + [Fact] public Task RemoveExternalLoginAsync_WithoutProviderUserId_ReturnsBadRequest() { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs index dd1849db3a..b39480b906 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs @@ -53,6 +53,9 @@ protected override void RegisterServices(IServiceCollection services) locks.Setup(locker => locker.AcquireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async (string resource, TimeSpan? expires, bool release, CancellationToken token) => { + if (race.RejectLocks) + throw new LockAcquisitionTimeoutException(resource); + await race.ObserveLockAsync(inner, resource); return await inner.AcquireAsync(resource, expires, release, token); }); @@ -115,6 +118,7 @@ public async Task ExternalLogin_CanceledLookup_ReleasesLocksForNextRequest(Looku user.MarkEmailAddressVerified(); await _users.AddAsync(user, options => options.ImmediateConsistency()); } + _race.Start(stage, TestCancellationToken); var pendingRequest = client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo { @@ -223,6 +227,7 @@ public async Task ExternalLogin_IdentityUnlinkedAfterLookup_RejectsStaleOwner() { _race.ResumeFirstRequest.TrySetResult(); } + using var completedUnlinkResponse = unlinkResponse; using var loginResponse = await pendingLogin; diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 6f507b27cc..6d76292bd7 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -52,7 +52,7 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp Assert.True(loginPost.TryGetProperty("requestBody", out _)); AssertResponseCodes(loginPost, "200", "401", "422"); - AssertPathResponseCodes(paths, "/api/v2/auth/unlink/{providerName}", "post", "200", "400", "401"); + AssertPathResponseCodes(paths, "/api/v2/auth/unlink/{providerName}", "post", "200", "400", "401", "422"); Assert.True(paths.TryGetProperty("/api/v2/auth/logout", out var logoutPath)); Assert.True(logoutPath.TryGetProperty("get", out var logoutGet)); diff --git a/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs new file mode 100644 index 0000000000..4dda8cf3a6 --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs @@ -0,0 +1,32 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Foundatio.Xunit; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public sealed class UserExtensionsTests : TestWithLoggingBase +{ + public UserExtensionsTests(ITestOutputHelper output) : base(output) { } + + [Theory] + [InlineData("unknown")] + [InlineData("GitHub")] + [InlineData("")] + public void OAuthAccount_InvalidProvider_IsRejectedWithoutChangingLinks(string providerName) + { + // Arrange + var user = new User { FullName = "Linked user", EmailAddress = "linked@localhost.test" }; + user.AddOAuthAccount("github", "existing-subject", user.EmailAddress); + var originalAccount = user.OAuthAccounts.Single(); + + // Act + var addException = Record.Exception(() => user.AddOAuthAccount(providerName, "new-subject", user.EmailAddress)); + var removeException = Record.Exception(() => user.RemoveOAuthAccount(providerName, "existing-subject")); + + // Assert + Assert.Equal("providerName", Assert.IsType(addException).ParamName); + Assert.Equal("providerName", Assert.IsType(removeException).ParamName); + Assert.Equal(originalAccount, Assert.Single(user.OAuthAccounts)); + } +} diff --git a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs index 6fc489ee79..c4b6451552 100644 --- a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs @@ -23,13 +23,14 @@ public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresEx var user = new User { FullName = "Linked User", EmailAddress = "linked@localhost.test" }; user.MarkEmailAddressVerified(); user.AddOAuthAccount("google", "shared-subject", user.EmailAddress); - user.AddOAuthAccount("GOOGLE", "shared-subject", user.EmailAddress); + user.AddOAuthAccount("google", "shared-subject", user.EmailAddress); user.AddOAuthAccount("github", "other-subject", user.EmailAddress); await _repository.AddAsync(user, options => options.ImmediateConsistency()); // Act var githubUser = await _repository.GetUserByOAuthProviderAsync("github", "shared-subject", TestCancellationToken); - var googleUser = await _repository.GetUserByOAuthProviderAsync("GOOGLE", "shared-subject", TestCancellationToken); + var googleUser = await _repository.GetUserByOAuthProviderAsync("google", "shared-subject", TestCancellationToken); + var differentProviderCaseUser = await _repository.GetUserByOAuthProviderAsync("GOOGLE", "shared-subject", TestCancellationToken); var differentCaseUser = await _repository.GetUserByOAuthProviderAsync("google", "SHARED-SUBJECT", TestCancellationToken); // Assert @@ -38,6 +39,7 @@ public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresEx Assert.Equal(user.Id, googleUser.Id); Assert.Equal(2, googleUser.OAuthAccounts.Count); Assert.Null(differentCaseUser); + Assert.Null(differentProviderCaseUser); } [Theory] @@ -75,6 +77,7 @@ public async Task GetUserByOAuthProviderAsync_PagedCandidates_ValidatesIdentityA if (cancelAfterFirstPage) cancellation.Cancel(); } + return Task.CompletedTask; }); diff --git a/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs b/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs index 969236d4a0..d269e131aa 100644 --- a/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs +++ b/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs @@ -9,22 +9,22 @@ public sealed class TestOAuthProviderClient : IOAuthProviderClient { public Task GetFacebookUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) { - return GetUserInfoAsync("Facebook", authInfo); + return GetUserInfoAsync(OAuthAccount.KnownProviders.Facebook, authInfo); } public Task GetGitHubUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) { - return GetUserInfoAsync("GitHub", authInfo); + return GetUserInfoAsync(OAuthAccount.KnownProviders.GitHub, authInfo); } public Task GetGoogleUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) { - return GetUserInfoAsync("Google", authInfo); + return GetUserInfoAsync(OAuthAccount.KnownProviders.Google, authInfo); } public Task GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) { - return GetUserInfoAsync("WindowsLive", authInfo); + return GetUserInfoAsync(OAuthAccount.KnownProviders.WindowsLive, authInfo); } public Task GetSlackAccessTokenAsync(string code) From 05714197142e6cd4017067821b195adf47010d46 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 19 Oct 2026 09:02:09 -0500 Subject: [PATCH 11/27] Finish admitted external account changes before observing cancellation --- .../Api/Handlers/AuthHandler.cs | 43 +++++++------ .../Api/Handlers/AuthHandlerTests.cs | 64 +++++++++++++++++-- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index c716b06a32..bcf56268f4 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -42,7 +42,7 @@ public class AuthHandler( private readonly ScopedCacheClient _cache = new(cacheClient, "Auth"); private static bool _isFirstUserChecked; private static readonly TimeSpan IntercomJwtLifetime = TimeSpan.FromMinutes(60); - private static readonly TimeSpan ExternalAccountTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan ExternalAccountAdmissionTimeout = TimeSpan.FromSeconds(15); public async Task> Handle(LoginMessage message) { @@ -306,7 +306,7 @@ public async Task> Handle(RemoveExternalLogin message) } using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); - operationCancellation.CancelAfter(ExternalAccountTimeout); + operationCancellation.CancelAfter(ExternalAccountAdmissionTimeout); try { operationCancellation.Token.ThrowIfCancellationRequested(); @@ -317,7 +317,7 @@ public async Task> Handle(RemoveExternalLogin message) return Result.Unauthorized("Login failed."); user = storedUser; - operationCancellation.Token.ThrowIfCancellationRequested(); + CompleteExternalAccountAdmission(operationCancellation); if (!user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) { logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); @@ -330,7 +330,7 @@ public async Task> Handle(RemoveExternalLogin message) await ResetUserTokensAsync(user, "RemoveExternalLoginAsync", httpContext); logger.UserRemovedExternalLogin(user.EmailAddress, message.ProviderName); - return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user, operationCancellation.Token) }; + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; } catch (Exception ex) when (ex is LockAcquisitionTimeoutException || ex is OperationCanceledException && operationCancellation.IsCancellationRequested) @@ -536,9 +536,9 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth throw; } - // Repository reads have no native cancellation API. Cancel read waits only; await every write inside its locks. + // Bound admission reads only. Once admitted, complete account changes and token issuance inside the locks. using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); - operationCancellation.CancelAfter(ExternalAccountTimeout); + operationCancellation.CancelAfter(ExternalAccountAdmissionTimeout); try { ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); @@ -563,13 +563,13 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth // Ordered user locks also protect transfers and different identities on the same user. await using var userLock = await lockProvider.AcquireAsync(userKeys, cancellationToken: operationCancellation.Token); operationCancellation.Token.ThrowIfCancellationRequested(); - var user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext, existingUser, emailUser, operationCancellation.Token); + var user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext, existingUser, emailUser, operationCancellation); if (!String.IsNullOrWhiteSpace(authInfo.InviteToken)) - await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user, httpContext, operationCancellation.Token); + await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user, httpContext); logger.UserLoggedIn(user.EmailAddress); - return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user, operationCancellation.Token) }; + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; } catch (Exception ex) when (ex is UnauthorizedAccessException or LockAcquisitionTimeoutException || ex is OperationCanceledException && operationCancellation.IsCancellationRequested) @@ -591,8 +591,15 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth private static string GetExternalAccountUserLockKey(string userId) => $"external-account:user:{userId}"; - private async Task FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext, User? existingUser, User? emailUser, CancellationToken cancellationToken) + private static void CompleteExternalAccountAdmission(CancellationTokenSource admissionCancellation) { + admissionCancellation.Token.ThrowIfCancellationRequested(); + admissionCancellation.CancelAfter(Timeout.InfiniteTimeSpan); + } + + private async Task FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext, User? existingUser, User? emailUser, CancellationTokenSource admissionCancellation) + { + var cancellationToken = admissionCancellation.Token; ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); @@ -618,7 +625,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit EnsureUserIsActive(currentUser); if (!String.Equals(authenticatedUser.AuthenticationVersion, currentUser.AuthenticationVersion, StringComparison.Ordinal)) throw new UnauthorizedAccessException("The user session has been revoked."); - cancellationToken.ThrowIfCancellationRequested(); + CompleteExternalAccountAdmission(admissionCancellation); if (existingUser is not null) { if (!String.Equals(existingUser.Id, currentUser.Id, StringComparison.Ordinal)) @@ -642,7 +649,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit if (existingUser is not null) { - cancellationToken.ThrowIfCancellationRequested(); + CompleteExternalAccountAdmission(admissionCancellation); if (!existingUser.IsEmailAddressVerified) { existingUser.MarkEmailAddressVerified(); @@ -665,7 +672,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit } EnsureUserIsActive(user); - cancellationToken.ThrowIfCancellationRequested(); + CompleteExternalAccountAdmission(admissionCancellation); user.MarkEmailAddressVerified(); user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); @@ -695,14 +702,14 @@ private async Task IsAccountCreationEnabledAsync(string? token, Cancellati return organization is not null; } - private async Task AddInvitedUserToOrganizationAsync(string? token, User user, HttpContext httpContext, CancellationToken cancellationToken = default) + private async Task AddInvitedUserToOrganizationAsync(string? token, User user, HttpContext httpContext) { if (String.IsNullOrWhiteSpace(token)) return; using var _ = logger.BeginScope(new ExceptionlessState().Tag("Invite").Identity(user.EmailAddress).SetHttpContext(httpContext)); - var organization = await organizationRepository.GetByInviteTokenAsync(token).WaitAsync(cancellationToken); + var organization = await organizationRepository.GetByInviteTokenAsync(token); var invite = organization?.GetInvite(token); if (organization is null || invite is null) { @@ -710,7 +717,6 @@ private async Task AddInvitedUserToOrganizationAsync(string? token, User user, H return; } - cancellationToken.ThrowIfCancellationRequested(); if (!user.IsEmailAddressVerified && String.Equals(user.EmailAddress, invite.EmailAddress, StringComparison.OrdinalIgnoreCase)) { logger.MarkedInvitedUserAsVerified(user.EmailAddress); @@ -778,10 +784,9 @@ private async Task ResetUserTokensAsync(User user, string tag, HttpContext httpC logger.RemovedUserTokens(total, user.EmailAddress); } - private async Task GetOrCreateAuthenticationTokenAsync(User user, CancellationToken cancellationToken = default) + private async Task GetOrCreateAuthenticationTokenAsync(User user) { - var userTokens = await tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id).WaitAsync(cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); + var userTokens = await tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id); var utcNow = timeProvider.GetUtcNow().UtcDateTime; var validAccessToken = userTokens.Documents.FirstOrDefault(token => !token.IsDisabled && !token.IsSuspended diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index e5eac9da4b..41511d8a58 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -30,11 +30,12 @@ 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) + [InlineData(false, false, false)] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + [InlineData(true, true, false)] + [InlineData(false, true, true)] + public async Task Handle_LinkExternalAccountOwnedByAnotherUser_RevokesPreviousOwnerSessions(bool cleanupFails, bool hasPreviousVersion, bool cancelAfterSave) { // Arrange const string provider = "github"; @@ -49,7 +50,8 @@ public async Task Handle_LinkExternalAccountOwnedByAnotherUser_RevokesPreviousOw current.MarkEmailAddressVerified(); await GetService().ValidateAndThrowAsync(displaced); await GetService().ValidateAndThrowAsync(current); - var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(IdentityUtils.UserAuthenticationType)) }; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(IdentityUtils.UserAuthenticationType)), RequestAborted = cancellation.Token }; context.Request.SetUser(current); var calls = new List(); string? persistedVersion = null; @@ -64,7 +66,12 @@ public async Task Handle_LinkExternalAccountOwnedByAnotherUser_RevokesPreviousOw calls.Add("save-displaced"); }).ReturnsAsync(displaced); users.Setup(repository => repository.SaveAsync(current, It.IsAny>())) - .Callback(() => calls.Add("save-current")).ReturnsAsync(current); + .Callback(() => + { + calls.Add("save-current"); + if (cancelAfterSave) + cancellation.Cancel(); + }).ReturnsAsync(current); var tokens = new Mock(MockBehavior.Strict); tokens.Setup(repository => repository.RemoveAllByUserIdAsync(displaced.Id, null)).Returns(() => { @@ -102,6 +109,49 @@ public async Task Handle_LinkExternalAccountOwnedByAnotherUser_RevokesPreviousOw providerClient.VerifyAll(); } + [Fact] + public async Task Handle_UnlinkCanceledAfterPersistence_CompletesTokenIssuance() + { + // Arrange + const string providerId = "unlink-after-persistence"; + string previousVersion = StringExtensions.GetNewToken(); + var user = GetService().GenerateUser(id: TestConstants.UserId, emailAddress: TestConstants.UserEmail) with + { + Password = "local-password", AuthenticationVersion = previousVersion + }; + user.AddOAuthAccount("github", providerId, user.EmailAddress); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + var context = new DefaultHttpContext { RequestAborted = cancellation.Token }; + context.Request.SetUser(user); + var users = new Mock(MockBehavior.Strict); + users.Setup(repository => repository.GetByIdAsync(user.Id, It.IsAny>())).ReturnsAsync(user); + users.Setup(repository => repository.SaveAsync(user, It.IsAny>())) + .Callback(() => cancellation.Cancel()).ReturnsAsync(user); + var tokens = new Mock(MockBehavior.Strict); + tokens.Setup(repository => repository.RemoveAllByUserIdAsync(user.Id, null)).ReturnsAsync(1L); + tokens.Setup(repository => repository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id, null)).ReturnsAsync(FindResults.Empty); + Token? issuedToken = null; + tokens.Setup(repository => repository.AddAsync(It.IsAny(), It.IsAny>())) + .Callback((Token token, CommandOptionsDescriptor _) => issuedToken = token) + .ReturnsAsync((Token token, CommandOptionsDescriptor _) => token); + var oauthTokens = new Mock(MockBehavior.Strict); + oauthTokens.Setup(repository => repository.RemoveAllByUserIdAsync(user.Id, null)).ReturnsAsync(1L); + var handler = CreateHandler(users.Object, tokens.Object, oauthTokens.Object, Mock.Of()); + + // Act + var result = await handler.Handle(new RemoveExternalLogin("github", new ValueFromBody(providerId), context)); + + // Assert + Assert.Equal(ResultStatus.Success, result.Status); + Assert.Empty(user.OAuthAccounts); + Assert.NotEqual(previousVersion, user.AuthenticationVersion); + Assert.NotNull(issuedToken); + Assert.Equal(user.AuthenticationVersion, issuedToken.AuthenticationVersion); + users.VerifyAll(); + tokens.VerifyAll(); + oauthTokens.VerifyAll(); + } + [Theory] [MemberData(nameof(LoginRepositoryExceptions))] public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exception exception) From 1a87c1d6051868a053a3c668c58121f84bc5d60f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 19 Oct 2026 19:08:21 -0500 Subject: [PATCH 12/27] Validate OAuth provider names through the model validator --- .../Extensions/UserExtensions.cs | 6 ---- src/Exceptionless.Core/Models/OAuthAccount.cs | 7 ++-- .../Repositories/UserRepository.cs | 3 +- .../Api/Handlers/AuthHandler.cs | 4 +-- .../Extensions/UserExtensionsTests.cs | 32 ------------------- .../Repositories/UserRepositoryTests.cs | 26 +++++++++++++++ .../Validation/UserValidatorTests.cs | 29 +++++++++++++++++ 7 files changed, 61 insertions(+), 46 deletions(-) delete mode 100644 tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs diff --git a/src/Exceptionless.Core/Extensions/UserExtensions.cs b/src/Exceptionless.Core/Extensions/UserExtensions.cs index 18a1f5f8f3..c28a3e569e 100644 --- a/src/Exceptionless.Core/Extensions/UserExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UserExtensions.cs @@ -64,9 +64,6 @@ public static bool HasValidPasswordResetTokenExpiration(this User user, TimeProv public static void AddOAuthAccount(this User user, string providerName, string providerUserId, string username, SettingsDictionary? data = null) { - if (!OAuthAccount.KnownProviders.IsKnown(providerName)) - throw new ArgumentException("Unknown external login provider.", nameof(providerName)); - if (user.OAuthAccounts.Any(account => String.Equals(account.Provider, providerName, StringComparison.Ordinal) && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal))) return; @@ -86,9 +83,6 @@ public static void AddOAuthAccount(this User user, string providerName, string p public static bool RemoveOAuthAccount(this User user, string providerName, string providerUserId) { - if (!OAuthAccount.KnownProviders.IsKnown(providerName)) - throw new ArgumentException("Unknown external login provider.", nameof(providerName)); - if (user.OAuthAccounts.Count <= 1 && String.IsNullOrEmpty(user.Password)) return false; diff --git a/src/Exceptionless.Core/Models/OAuthAccount.cs b/src/Exceptionless.Core/Models/OAuthAccount.cs index d8d6c933bb..078ba5be2f 100644 --- a/src/Exceptionless.Core/Models/OAuthAccount.cs +++ b/src/Exceptionless.Core/Models/OAuthAccount.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.ComponentModel.DataAnnotations; namespace Exceptionless.Core.Models; @@ -10,10 +10,11 @@ public static class KnownProviders public const string Google = "google"; public const string Facebook = "facebook"; public const string WindowsLive = "windowslive"; - - public static bool IsKnown([NotNullWhen(true)] string? providerName) => providerName is GitHub or Google or Facebook or WindowsLive; } + [Required] + [AllowedValues(KnownProviders.GitHub, KnownProviders.Google, KnownProviders.Facebook, KnownProviders.WindowsLive, + ErrorMessage = "The provider name must be github, google, facebook, or windowslive.")] public required string Provider { get; init; } public required string ProviderUserId { get; init; } public required string Username { get; init; } diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index c2cc9391e3..98271624f0 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -1,5 +1,4 @@ using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; using Exceptionless.Core.Repositories.Configuration; using Exceptionless.Core.Validation; using Foundatio.Repositories; @@ -127,7 +126,7 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - if (!OAuthAccount.KnownProviders.IsKnown(provider) || String.IsNullOrEmpty(providerUserId)) + if (String.IsNullOrEmpty(provider) || String.IsNullOrEmpty(providerUserId)) return null; // OAuth accounts are mapped as objects, so indexed fields cannot enforce matching within one account. diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index bcf56268f4..bb765c3240 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -542,9 +542,7 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth try { ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); - if (!OAuthAccount.KnownProviders.IsKnown(userInfo.ProviderName)) - throw new UnauthorizedAccessException("Unknown external login provider."); - + ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); // Lock the identity and normalized email before resolving or creating its owner. diff --git a/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs deleted file mode 100644 index 4dda8cf3a6..0000000000 --- a/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; -using Foundatio.Xunit; -using Xunit; - -namespace Exceptionless.Tests.Extensions; - -public sealed class UserExtensionsTests : TestWithLoggingBase -{ - public UserExtensionsTests(ITestOutputHelper output) : base(output) { } - - [Theory] - [InlineData("unknown")] - [InlineData("GitHub")] - [InlineData("")] - public void OAuthAccount_InvalidProvider_IsRejectedWithoutChangingLinks(string providerName) - { - // Arrange - var user = new User { FullName = "Linked user", EmailAddress = "linked@localhost.test" }; - user.AddOAuthAccount("github", "existing-subject", user.EmailAddress); - var originalAccount = user.OAuthAccounts.Single(); - - // Act - var addException = Record.Exception(() => user.AddOAuthAccount(providerName, "new-subject", user.EmailAddress)); - var removeException = Record.Exception(() => user.RemoveOAuthAccount(providerName, "existing-subject")); - - // Assert - Assert.Equal("providerName", Assert.IsType(addException).ParamName); - Assert.Equal("providerName", Assert.IsType(removeException).ParamName); - Assert.Equal(originalAccount, Assert.Single(user.OAuthAccounts)); - } -} diff --git a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs index c4b6451552..b0b8076bb3 100644 --- a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs @@ -1,6 +1,7 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Validation; using Foundatio.Repositories; using Foundatio.Repositories.Models; using Xunit; @@ -16,6 +17,31 @@ public UserRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) _repository = GetService(); } + [Fact] + public async Task SaveAsync_InvalidOAuthProvider_RejectsChangesBeforePersistence() + { + // Arrange + var user = new User { FullName = "Linked user", EmailAddress = "linked@localhost.test" }; + user.MarkEmailAddressVerified(); + user.AddOAuthAccount("github", "existing-subject", user.EmailAddress); + await _repository.AddAsync(user, options => options.ImmediateConsistency()); + user.AddOAuthAccount("unknown", "new-subject", user.EmailAddress); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await _repository.SaveAsync(user, options => options.ImmediateConsistency()); + }); + + // Assert + Assert.IsType(exception); + var storedUser = await _repository.GetByIdAsync(user.Id, options => options.Cache(false)); + Assert.NotNull(storedUser); + var account = Assert.Single(storedUser.OAuthAccounts); + Assert.Equal("github", account.Provider); + Assert.Equal("existing-subject", account.ProviderUserId); + } + [Fact] public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresExactIdentityPair() { diff --git a/tests/Exceptionless.Tests/Validation/UserValidatorTests.cs b/tests/Exceptionless.Tests/Validation/UserValidatorTests.cs index 6f9a5c969a..90aa5bd6db 100644 --- a/tests/Exceptionless.Tests/Validation/UserValidatorTests.cs +++ b/tests/Exceptionless.Tests/Validation/UserValidatorTests.cs @@ -13,6 +13,35 @@ public UserValidatorTests(ITestOutputHelper output) : base(output) _validator = GetService(); } + [Theory] + [InlineData("github", true)] + [InlineData("google", true)] + [InlineData("facebook", true)] + [InlineData("windowslive", true)] + [InlineData("unknown", false)] + [InlineData("GitHub", false)] + [InlineData("", false)] + [InlineData(null, false)] + public async Task ValidateAsync_OAuthProvider_RequiresKnownCaseSensitiveName(string? providerName, bool expectedIsValid) + { + // Arrange + var user = new User + { + FullName = "Linked user", + EmailAddress = "linked@localhost.test", + IsEmailAddressVerified = true, + OAuthAccounts = [new OAuthAccount { Provider = providerName!, ProviderUserId = "subject", Username = "linked@localhost.test" }] + }; + + // Act + var result = await _validator.ValidateAsync(user); + + // Assert + Assert.Equal(expectedIsValid, result.IsValid); + if (!expectedIsValid) + Assert.Contains(result.Errors.Keys, key => key.EndsWith(nameof(OAuthAccount.Provider), StringComparison.Ordinal)); + } + [Fact] public async Task ValidateAsync_WhenFullNameIsValid_ReturnsSuccess() { From d2cae1609938d352f7266b26960d8c162847f9bf Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 19 Oct 2026 20:21:31 -0500 Subject: [PATCH 13/27] Validate external account identifiers and bypass stale email cache during login --- src/Exceptionless.Core/Models/OAuthAccount.cs | 2 + .../Interfaces/IUserRepository.cs | 2 +- .../Repositories/UserRepository.cs | 8 +++- .../Api/Endpoints/AuthEndpoints.cs | 6 +-- .../Api/Handlers/AuthHandler.cs | 13 ++----- .../Api/Messages/AuthMessages.cs | 2 +- .../Models/Auth/RemoveExternalLoginRequest.cs | 11 +++++- .../Exceptionless.Tests/Api/Data/openapi.json | 2 +- .../Api/Endpoints/AuthEndpointTests.cs | 38 ++++++------------- .../ExternalAccountConcurrencyTests.cs | 33 +++++++++++++++- .../Api/Handlers/AuthHandlerTests.cs | 6 +-- .../Repositories/UserRepositoryTests.cs | 8 ++-- ...emoveExternalLoginRequestValidatorTests.cs | 31 +++++++++++++++ .../Validation/UserValidatorTests.cs | 25 ++++++------ 14 files changed, 124 insertions(+), 63 deletions(-) create mode 100644 tests/Exceptionless.Tests/Validation/RemoveExternalLoginRequestValidatorTests.cs diff --git a/src/Exceptionless.Core/Models/OAuthAccount.cs b/src/Exceptionless.Core/Models/OAuthAccount.cs index 078ba5be2f..37bae42dbe 100644 --- a/src/Exceptionless.Core/Models/OAuthAccount.cs +++ b/src/Exceptionless.Core/Models/OAuthAccount.cs @@ -16,6 +16,8 @@ public static class KnownProviders [AllowedValues(KnownProviders.GitHub, KnownProviders.Google, KnownProviders.Facebook, KnownProviders.WindowsLive, ErrorMessage = "The provider name must be github, google, facebook, or windowslive.")] public required string Provider { get; init; } + + [Required] public required string ProviderUserId { get; init; } public required string Username { get; init; } diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs index 050334fd8b..81b1890de3 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs @@ -9,7 +9,7 @@ public interface IUserRepository : ISearchableRepository Task RecordProductTourAsync(User user, string stateKey, DateTime recordedUtc); Task UpdateProfileAsync(User user, string? fullName, bool? emailNotificationsEnabled); Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor? options = null); - Task GetByEmailAddressAsync(string emailAddress); + Task GetByEmailAddressAsync(string emailAddress, CommandOptionsDescriptor? options = null); Task GetByPasswordResetTokenAsync(string token); Task GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default); Task GetByVerifyEmailAddressTokenAsync(string token); diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index 98271624f0..e4e7c8efe5 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -104,13 +104,17 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor GetByEmailAddressAsync(string emailAddress) + public async Task GetByEmailAddressAsync(string emailAddress, CommandOptionsDescriptor? options = null) { if (String.IsNullOrWhiteSpace(emailAddress)) return null; emailAddress = emailAddress.Trim().ToLowerInvariant(); - var hit = await FindOneAsync(q => q.FieldEquals(u => u.EmailAddress, emailAddress), o => o.Cache(EmailCacheKey(emailAddress))); + ICommandOptions commandOptions = new CommandOptions().Cache(EmailCacheKey(emailAddress)); + if (options is not null) + commandOptions = options(commandOptions); + + var hit = await FindOneAsync(q => q.FieldEquals(u => u.EmailAddress, emailAddress), _ => commandOptions); return hit?.Document; } diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs index 9905d85644..20032e589a 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -177,8 +177,8 @@ headers api_key input box. } }); - group.MapPost("unlink/{providerName:minlength(1)}", async ([AsParameters] RemoveExternalLoginRequest request, IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext, [FromBody] ValueFromBody providerUserId) - => (await mediator.InvokeAsync>(new AuthMessages.RemoveExternalLogin(request.ProviderName, providerUserId, httpContext))).ToHttpResult(resultMapper)) + group.MapPost("unlink/{providerName:minlength(1)}", async ([AsParameters] RemoveExternalLoginRequest request, IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext) + => (await mediator.InvokeAsync>(new AuthMessages.RemoveExternalLogin(request.ProviderName, request.ProviderUserId.Value, httpContext))).ToHttpResult(resultMapper)) .Accepts>("application/json", "application/*+json") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) @@ -192,7 +192,7 @@ headers api_key input box. }, ResponseDescriptions = new() { ["200"] = "User Authentication Token", - ["400"] = "Invalid provider name.", + ["400"] = "The account must retain a sign-in method.", ["401"] = "Unauthorized", ["422"] = "Validation error", } diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index bb765c3240..bc9cd9eb83 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -297,13 +297,7 @@ public async Task> Handle(RemoveExternalLogin message) { var httpContext = message.Context; var user = httpContext.Request.GetUser(); - using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(message.ProviderName).Identity(user.EmailAddress).Property("Provider User Id", message.ProviderUserId?.Value).SetHttpContext(httpContext)); - - if (String.IsNullOrWhiteSpace(message.ProviderName) || String.IsNullOrWhiteSpace(message.ProviderUserId?.Value)) - { - logger.LogError("Remove external login failed for {EmailAddress}: Invalid Provider Name or Provider User Id", user.EmailAddress); - return Result.BadRequest("Invalid Provider Name or Provider User Id."); - } + using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(message.ProviderName).Identity(user.EmailAddress).Property("Provider User Id", message.ProviderUserId).SetHttpContext(httpContext)); using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted); operationCancellation.CancelAfter(ExternalAccountAdmissionTimeout); @@ -318,7 +312,7 @@ public async Task> Handle(RemoveExternalLogin message) user = storedUser; CompleteExternalAccountAdmission(operationCancellation); - if (!user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) + if (!user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId)) { logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); return Result.BadRequest("You must set a local password before removing your external login."); @@ -553,7 +547,8 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth operationCancellation.Token.ThrowIfCancellationRequested(); await using var accountLock = await lockProvider.AcquireAsync(accountKeys, cancellationToken: operationCancellation.Token); var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id, operationCancellation.Token); - var emailUser = existingUser is null ? await userRepository.GetByEmailAddressAsync(userInfo.Email).WaitAsync(operationCancellation.Token) : null; + // Admission reads may outlive cancellation; do not read or populate a stale email cache entry. + var emailUser = existingUser is null ? await userRepository.GetByEmailAddressAsync(userInfo.Email, options => options.Cache(false)).WaitAsync(operationCancellation.Token) : null; string? currentUserId = String.IsNullOrWhiteSpace(authInfo.InviteToken) && httpContext.User.IsUserAuthType() ? httpContext.Request.GetUser().Id : null; var userKeys = new[] { existingUser?.Id, emailUser?.Id, currentUserId } diff --git a/src/Exceptionless.Web/Api/Messages/AuthMessages.cs b/src/Exceptionless.Web/Api/Messages/AuthMessages.cs index 068710fd2d..9bf57c84c4 100644 --- a/src/Exceptionless.Web/Api/Messages/AuthMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AuthMessages.cs @@ -10,7 +10,7 @@ public record GitHubLogin(ExternalAuthInfo AuthInfo, HttpContext Context); public record GoogleLogin(ExternalAuthInfo AuthInfo, HttpContext Context); public record FacebookLogin(ExternalAuthInfo AuthInfo, HttpContext Context); public record LiveLogin(ExternalAuthInfo AuthInfo, HttpContext Context); -public record RemoveExternalLogin(string ProviderName, ValueFromBody ProviderUserId, HttpContext Context); +public record RemoveExternalLogin(string ProviderName, string ProviderUserId, HttpContext Context); public record ChangePassword(ChangePasswordModel Model, HttpContext Context); public record CheckEmailAddress(string Email, HttpContext Context); public record ForgotPassword(string Email, HttpContext Context); diff --git a/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs b/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs index fb84f0882f..706846a177 100644 --- a/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs +++ b/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs @@ -4,7 +4,7 @@ namespace Exceptionless.Web.Models; -public sealed class RemoveExternalLoginRequest +public sealed class RemoveExternalLoginRequest : IValidatableObject { [FromRoute(Name = "providerName")] [Required] @@ -12,4 +12,13 @@ public sealed class RemoveExternalLoginRequest OAuthAccount.KnownProviders.Facebook, OAuthAccount.KnownProviders.WindowsLive, ErrorMessage = "The provider name must be github, google, facebook, or windowslive.")] public string ProviderName { get; set; } = null!; + + [FromBody] + public ValueFromBody ProviderUserId { get; set; } = null!; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (String.IsNullOrWhiteSpace(ProviderUserId?.Value)) + yield return new ValidationResult("The provider user id is required.", [nameof(ProviderUserId)]); + } } diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 4f4277eeed..9c82988df4 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -2036,7 +2036,7 @@ } }, "400": { - "description": "Invalid provider name.", + "description": "The account must retain a sign-in method.", "content": { "application/problem\u002Bjson": { "schema": { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 250c666758..c6f78a3326 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -785,18 +785,20 @@ public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount(stri } [Theory] - [InlineData("unknown")] - [InlineData("GitHub")] - [InlineData("GITHUB")] - [InlineData(" github")] - [InlineData("github ")] - public async Task RemoveExternalLoginAsync_InvalidProvider_IsRejectedWithoutChangingAccount(string providerName) + [InlineData("unknown", "github-invalid-provider", "provider_name")] + [InlineData("GitHub", "github-invalid-provider", "provider_name")] + [InlineData("GITHUB", "github-invalid-provider", "provider_name")] + [InlineData(" github", "github-invalid-provider", "provider_name")] + [InlineData("github ", "github-invalid-provider", "provider_name")] + [InlineData("github", null, "provider_user_id")] + [InlineData("github", "", "provider_user_id")] + [InlineData("github", " ", "provider_user_id")] + public async Task RemoveExternalLoginAsync_InvalidIdentity_IsRejectedWithoutChangingAccount(string providerName, string? providerUserId, string errorKey) { // Arrange - const string providerUserId = "github-invalid-provider"; var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); Assert.NotNull(user); - user.AddOAuthAccount("github", providerUserId, user.EmailAddress); + user.AddOAuthAccount("github", "github-invalid-provider", user.EmailAddress); await _userRepository.SaveAsync(user, options => options.ImmediateConsistency().Cache()); long originalTokenCount = (await _tokenRepository.CountAsync()).Total; @@ -805,12 +807,12 @@ public async Task RemoveExternalLoginAsync_InvalidProvider_IsRejectedWithoutChan .Post() .AsTestOrganizationUser() .AppendPaths("auth", "unlink", providerName) - .Content(new ValueFromBody(providerUserId)) + .Content(new ValueFromBody(providerUserId!)) .StatusCodeShouldBeUnprocessableEntity()); // Assert Assert.NotNull(problem); - Assert.Contains("provider_name", problem.Errors.Keys); + Assert.Contains(errorKey, problem.Errors.Keys); var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.Cache(false)); Assert.NotNull(storedUser); Assert.Equal(user.AuthenticationVersion, storedUser.AuthenticationVersion); @@ -819,22 +821,6 @@ public async Task RemoveExternalLoginAsync_InvalidProvider_IsRejectedWithoutChan Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); } - [Fact] - public Task RemoveExternalLoginAsync_WithoutProviderUserId_ReturnsBadRequest() - { - // Arrange - var providerUserId = new ValueFromBody(String.Empty); - - // Act & Assert - return SendRequestAsync(r => r - .Post() - .AsTestOrganizationUser() - .AppendPaths("auth", "unlink", "github") - .Content(providerUserId) - .StatusCodeShouldBeBadRequest() - ); - } - [Fact] public async Task RemoveExternalLoginAsync_DuplicateEntriesForOnlySignInMethod_PreservesAccount() { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs index b39480b906..5faddd83a3 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs @@ -149,6 +149,33 @@ public async Task ExternalLogin_CanceledLookup_ReleasesLocksForNextRequest(Looku Assert.NotNull(await _tokens.GetByIdAsync(nextResult.Token)); } + [Fact] + public async Task ExternalLogin_StaleCachedEmailMiss_LinksStoredUserWithoutCreatingDuplicate() + { + // Arrange + const string code = "external-account-stale-email-miss"; + var user = new User { FullName = "Existing email owner", EmailAddress = TestOAuthProviderClient.GetEmailAddress(code) }; + user.MarkEmailAddressVerified(); + await _users.AddAsync(user, options => options.ImmediateConsistency()); + // A canceled cached lookup can publish its earlier miss after another request saves this user. + await ((PausingUserRepository)_users).CacheEmailMissAsync(user.EmailAddress); + long originalUserCount = (await _users.CountAsync()).Total; + using var client = _server.CreateClient(); + + // Act + var result = await SendExternalLoginAsync(client, code); + + // Assert + Assert.NotNull(result); + var token = await _tokens.GetByIdAsync(result.Token); + Assert.NotNull(token); + Assert.Equal(user.Id, token.UserId); + Assert.Equal(originalUserCount, (await _users.CountAsync()).Total); + var storedUser = await _users.GetByIdAsync(user.Id, options => options.Cache(false)); + Assert.NotNull(storedUser); + Assert.Single(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + } + [Fact] public async Task ExternalLogin_AccountLockUnavailable_IsRejectedWithoutWrites() { @@ -300,6 +327,8 @@ private static void SetAuthorization(HttpClient client, string email, string pas private sealed class PausingUserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, Exceptionless.Core.AppOptions options, ExternalAccountRace race) : UserRepository(configuration, validator, options), IUserRepository { + public Task CacheEmailMissAsync(string emailAddress) => Cache.SetAsync>>($"Email:{emailAddress}", []); + public new async Task GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default) { var user = await base.GetUserByOAuthProviderAsync(provider, providerUserId, cancellationToken); @@ -307,9 +336,9 @@ private sealed class PausingUserRepository(ExceptionlessElasticConfiguration con return user; } - public new async Task GetByEmailAddressAsync(string emailAddress) + public new async Task GetByEmailAddressAsync(string emailAddress, CommandOptionsDescriptor? options = null) { - var user = await base.GetByEmailAddressAsync(emailAddress); + var user = await base.GetByEmailAddressAsync(emailAddress, options); await race.AfterSnapshotReadAsync(LookupStage.Email, race.TestCancellationToken); return user; } diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index 41511d8a58..0df509d91d 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -139,7 +139,7 @@ public async Task Handle_UnlinkCanceledAfterPersistence_CompletesTokenIssuance() var handler = CreateHandler(users.Object, tokens.Object, oauthTokens.Object, Mock.Of()); // Act - var result = await handler.Handle(new RemoveExternalLogin("github", new ValueFromBody(providerId), context)); + var result = await handler.Handle(new RemoveExternalLogin("github", providerId, context)); // Assert Assert.Equal(ResultStatus.Success, result.Status); @@ -158,7 +158,7 @@ public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exce { // Arrange var userRepository = new Mock(MockBehavior.Strict); - userRepository.Setup(repository => repository.GetByEmailAddressAsync("test@example.com")) + userRepository.Setup(repository => repository.GetByEmailAddressAsync("test@example.com", null)) .ThrowsAsync(exception); var handler = CreateHandler(userRepository.Object, GetService(), GetService(), GetService()); var message = new LoginMessage( @@ -171,7 +171,7 @@ public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exce // Assert Assert.Equal(ResultStatus.Unauthorized, result.Status); Assert.Equal("Login failed.", result.Message); - userRepository.Verify(repository => repository.GetByEmailAddressAsync("test@example.com"), Times.Once); + userRepository.Verify(repository => repository.GetByEmailAddressAsync("test@example.com", null), Times.Once); userRepository.VerifyNoOtherCalls(); } diff --git a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs index b0b8076bb3..d074887d00 100644 --- a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs @@ -17,15 +17,17 @@ public UserRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) _repository = GetService(); } - [Fact] - public async Task SaveAsync_InvalidOAuthProvider_RejectsChangesBeforePersistence() + [Theory] + [InlineData("unknown", "new-subject")] + [InlineData("github", " ")] + public async Task SaveAsync_InvalidOAuthIdentity_RejectsChangesBeforePersistence(string provider, string providerUserId) { // Arrange var user = new User { FullName = "Linked user", EmailAddress = "linked@localhost.test" }; user.MarkEmailAddressVerified(); user.AddOAuthAccount("github", "existing-subject", user.EmailAddress); await _repository.AddAsync(user, options => options.ImmediateConsistency()); - user.AddOAuthAccount("unknown", "new-subject", user.EmailAddress); + user.AddOAuthAccount(provider, providerUserId, user.EmailAddress); // Act var exception = await Record.ExceptionAsync(async () => diff --git a/tests/Exceptionless.Tests/Validation/RemoveExternalLoginRequestValidatorTests.cs b/tests/Exceptionless.Tests/Validation/RemoveExternalLoginRequestValidatorTests.cs new file mode 100644 index 0000000000..bf1a0c990c --- /dev/null +++ b/tests/Exceptionless.Tests/Validation/RemoveExternalLoginRequestValidatorTests.cs @@ -0,0 +1,31 @@ +using Exceptionless.Core.Validation; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Validation; + +public sealed class RemoveExternalLoginRequestValidatorTests(ITestOutputHelper output) : TestWithServices(output) +{ + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("subject", true)] + public async Task ValidateAsync_ProviderUserId_RequiresNonWhitespaceValue(string? providerUserId, bool expectedIsValid) + { + // Arrange + var request = new RemoveExternalLoginRequest + { + ProviderName = "github", + ProviderUserId = new ValueFromBody(providerUserId!) + }; + + // Act + var result = await GetService().ValidateAsync(request); + + // Assert + Assert.Equal(expectedIsValid, result.IsValid); + if (!expectedIsValid) + Assert.Contains(nameof(RemoveExternalLoginRequest.ProviderUserId), result.Errors.Keys); + } +} diff --git a/tests/Exceptionless.Tests/Validation/UserValidatorTests.cs b/tests/Exceptionless.Tests/Validation/UserValidatorTests.cs index 90aa5bd6db..e68ad1e119 100644 --- a/tests/Exceptionless.Tests/Validation/UserValidatorTests.cs +++ b/tests/Exceptionless.Tests/Validation/UserValidatorTests.cs @@ -14,15 +14,18 @@ public UserValidatorTests(ITestOutputHelper output) : base(output) } [Theory] - [InlineData("github", true)] - [InlineData("google", true)] - [InlineData("facebook", true)] - [InlineData("windowslive", true)] - [InlineData("unknown", false)] - [InlineData("GitHub", false)] - [InlineData("", false)] - [InlineData(null, false)] - public async Task ValidateAsync_OAuthProvider_RequiresKnownCaseSensitiveName(string? providerName, bool expectedIsValid) + [InlineData("github", "subject", true)] + [InlineData("google", "subject", true)] + [InlineData("facebook", "subject", true)] + [InlineData("windowslive", "subject", true)] + [InlineData("unknown", "subject", false)] + [InlineData("GitHub", "subject", false)] + [InlineData("", "subject", false)] + [InlineData(null, "subject", false)] + [InlineData("github", null, false)] + [InlineData("github", "", false)] + [InlineData("github", " ", false)] + public async Task ValidateAsync_OAuthAccount_RequiresValidIdentity(string? providerName, string? providerUserId, bool expectedIsValid) { // Arrange var user = new User @@ -30,7 +33,7 @@ public async Task ValidateAsync_OAuthProvider_RequiresKnownCaseSensitiveName(str FullName = "Linked user", EmailAddress = "linked@localhost.test", IsEmailAddressVerified = true, - OAuthAccounts = [new OAuthAccount { Provider = providerName!, ProviderUserId = "subject", Username = "linked@localhost.test" }] + OAuthAccounts = [new OAuthAccount { Provider = providerName!, ProviderUserId = providerUserId!, Username = "linked@localhost.test" }] }; // Act @@ -39,7 +42,7 @@ public async Task ValidateAsync_OAuthProvider_RequiresKnownCaseSensitiveName(str // Assert Assert.Equal(expectedIsValid, result.IsValid); if (!expectedIsValid) - Assert.Contains(result.Errors.Keys, key => key.EndsWith(nameof(OAuthAccount.Provider), StringComparison.Ordinal)); + Assert.Contains(result.Errors.Keys, key => key.EndsWith(String.IsNullOrWhiteSpace(providerUserId) ? nameof(OAuthAccount.ProviderUserId) : nameof(OAuthAccount.Provider), StringComparison.Ordinal)); } [Fact] From 8cd1d8c7a2e5dafbf4745f9e702646f711d9d8cf Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 19 Oct 2026 20:28:44 -0500 Subject: [PATCH 14/27] Update authentication test callbacks for email query options --- .../Security/ApiKeyAuthenticationHandlerTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs b/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs index 4b104a4d30..c26f7002b5 100644 --- a/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs +++ b/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs @@ -172,8 +172,8 @@ public async Task AuthenticateAsync_OverlappingBasicRequests_PreservesValidBurst var releases = Enumerable.Range(0, requestCount) .Select(_ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).ToArray(); int repositoryCalls = 0; - repository.Setup(repository => repository.GetByEmailAddressAsync(It.IsAny())) - .Returns(async (string email) => + repository.Setup(repository => repository.GetByEmailAddressAsync(It.IsAny(), null)) + .Returns(async (string email, CommandOptionsDescriptor? _) => { int call = Interlocked.Increment(ref repositoryCalls) - 1; await releases[call].Task.WaitAsync(TestTimeout, TestCancellationToken); @@ -211,7 +211,7 @@ public async Task AuthenticateAsync_OverlappingBasicRequests_PreservesValidBurst Assert.Equal(limit, initiallyAdmitted); Assert.True(allWaiting); Assert.All(results, result => Assert.Equal(validPassword, result.Succeeded)); - repository.Verify(repository => repository.GetByEmailAddressAsync(It.IsAny()), Times.Exactly(validPassword ? requestCount : limit)); + repository.Verify(repository => repository.GetByEmailAddressAsync(It.IsAny(), null), Times.Exactly(validPassword ? requestCount : limit)); repository.VerifyNoOtherCalls(); } @@ -229,7 +229,7 @@ public async Task AuthenticateAsync_RepositoryException_LogsUnexpectedFailureAnd var service = GetService(); var repository = new Mock(); Exception failure = cancellationException ? new OperationCanceledException(cancellation.Token) : new IOException("Synthetic repository failure."); - repository.Setup(repository => repository.GetByEmailAddressAsync(EmailAddress)) + repository.Setup(repository => repository.GetByEmailAddressAsync(EmailAddress, null)) .Callback(() => { if (cancelRequest) @@ -250,7 +250,7 @@ public async Task AuthenticateAsync_RepositoryException_LogsUnexpectedFailureAnd var errors = Log.LogEntries.Where(entry => entry.CategoryName == typeof(ApiKeyAuthenticationHandler).FullName && entry.LogLevel >= LogLevel.Error).ToArray(); Assert.Equal(expectedErrors, errors.Length); Assert.All(errors, entry => Assert.Same(failure, entry.Exception)); - repository.Verify(repository => repository.GetByEmailAddressAsync(EmailAddress), Times.Once); + repository.Verify(repository => repository.GetByEmailAddressAsync(EmailAddress, null), Times.Once); repository.VerifyNoOtherCalls(); } From df195c94b5a9ce7e778baa7aed61b8c9c18362e1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 19 Oct 2026 20:36:12 -0500 Subject: [PATCH 15/27] Preserve unlink validation responses at the HTTP boundary --- .../Api/Endpoints/AuthEndpoints.cs | 3 +- .../Filters/UnlinkValidationResponseFilter.cs | 18 +++++++++++ .../Exceptionless.Tests/Api/Data/openapi.json | 2 +- .../Api/Endpoints/AuthEndpointTests.cs | 29 ++++++++++------- .../AutoValidationEndpointFilterTests.cs | 32 +++++++++++++++++++ 5 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 src/Exceptionless.Web/Api/Filters/UnlinkValidationResponseFilter.cs diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs index 20032e589a..fb04388423 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -17,6 +17,7 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder { var group = endpoints.MapGroup("api/v2/auth") .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() .AddEndpointFilter() .WithTags("Auth"); @@ -192,7 +193,7 @@ headers api_key input box. }, ResponseDescriptions = new() { ["200"] = "User Authentication Token", - ["400"] = "The account must retain a sign-in method.", + ["400"] = "Invalid provider user id, or the account would have no sign-in method.", ["401"] = "Unauthorized", ["422"] = "Validation error", } diff --git a/src/Exceptionless.Web/Api/Filters/UnlinkValidationResponseFilter.cs b/src/Exceptionless.Web/Api/Filters/UnlinkValidationResponseFilter.cs new file mode 100644 index 0000000000..14a975ff8b --- /dev/null +++ b/src/Exceptionless.Web/Api/Filters/UnlinkValidationResponseFilter.cs @@ -0,0 +1,18 @@ +using Exceptionless.Web.Models; + +namespace Exceptionless.Web.Api.Filters; + +public sealed class UnlinkValidationResponseFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + // Keep the existing unlink response while the request validator owns input validation. + if (context.Arguments.Any(argument => argument is RemoveExternalLoginRequest) + && result is IValueHttpResult { Value: HttpValidationProblemDetails problem } + && problem.Errors.ContainsKey("provider_user_id")) + return Microsoft.AspNetCore.Http.Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid Provider Name or Provider User Id."); + + return result; + } +} diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 9c82988df4..51c608eef9 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -2036,7 +2036,7 @@ } }, "400": { - "description": "The account must retain a sign-in method.", + "description": "Invalid provider user id, or the account would have no sign-in method.", "content": { "application/problem\u002Bjson": { "schema": { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index c6f78a3326..bb0bfe312d 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -790,10 +790,10 @@ public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount(stri [InlineData("GITHUB", "github-invalid-provider", "provider_name")] [InlineData(" github", "github-invalid-provider", "provider_name")] [InlineData("github ", "github-invalid-provider", "provider_name")] - [InlineData("github", null, "provider_user_id")] - [InlineData("github", "", "provider_user_id")] - [InlineData("github", " ", "provider_user_id")] - public async Task RemoveExternalLoginAsync_InvalidIdentity_IsRejectedWithoutChangingAccount(string providerName, string? providerUserId, string errorKey) + [InlineData("github", null, null)] + [InlineData("github", "", null)] + [InlineData("github", " ", null)] + public async Task RemoveExternalLoginAsync_InvalidIdentity_IsRejectedWithoutChangingAccount(string providerName, string? providerUserId, string? errorKey) { // Arrange var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); @@ -803,16 +803,23 @@ public async Task RemoveExternalLoginAsync_InvalidIdentity_IsRejectedWithoutChan long originalTokenCount = (await _tokenRepository.CountAsync()).Total; // Act - var problem = await SendRequestAsAsync(request => request - .Post() - .AsTestOrganizationUser() - .AppendPaths("auth", "unlink", providerName) - .Content(new ValueFromBody(providerUserId!)) - .StatusCodeShouldBeUnprocessableEntity()); + var problem = await SendRequestAsAsync(request => + { + request.Post().AsTestOrganizationUser().AppendPaths("auth", "unlink", providerName) + .Content(new ValueFromBody(providerUserId!)); + if (errorKey is null) + request.StatusCodeShouldBeBadRequest(); + else + request.StatusCodeShouldBeUnprocessableEntity(); + }); // Assert Assert.NotNull(problem); - Assert.Contains(errorKey, problem.Errors.Keys); + if (errorKey is null) + Assert.Equal("Invalid Provider Name or Provider User Id.", problem.Title); + else + Assert.Contains(errorKey, problem.Errors.Keys); + var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.Cache(false)); Assert.NotNull(storedUser); Assert.Equal(user.AuthenticationVersion, storedUser.AuthenticationVersion); diff --git a/tests/Exceptionless.Tests/Api/Filters/AutoValidationEndpointFilterTests.cs b/tests/Exceptionless.Tests/Api/Filters/AutoValidationEndpointFilterTests.cs index a7d1e0812b..ce1c274a78 100644 --- a/tests/Exceptionless.Tests/Api/Filters/AutoValidationEndpointFilterTests.cs +++ b/tests/Exceptionless.Tests/Api/Filters/AutoValidationEndpointFilterTests.cs @@ -3,6 +3,7 @@ using System.Reflection.Emit; using Exceptionless.Web.Api.Filters; using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Models; using Foundatio.Mediator; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -12,6 +13,37 @@ namespace Exceptionless.Tests.Api.Filters; public sealed class AutoValidationEndpointFilterTests { + [Theory] + [InlineData("github", null, StatusCodes.Status400BadRequest)] + [InlineData("github", "", StatusCodes.Status400BadRequest)] + [InlineData("github", " ", StatusCodes.Status400BadRequest)] + [InlineData("unknown", "subject", StatusCodes.Status422UnprocessableEntity)] + public async Task InvokeAsync_UnlinkValidation_PreservesLegacyBodyStatus(string providerName, string? providerUserId, int expectedStatus) + { + // Arrange + var validationFilter = new AutoValidationEndpointFilter(); + var responseFilter = new UnlinkValidationResponseFilter(); + var request = new RemoveExternalLoginRequest { ProviderName = providerName, ProviderUserId = new ValueFromBody(providerUserId!) }; + var context = new TestEndpointFilterInvocationContext(new DefaultHttpContext(), [request]); + bool nextCalled = false; + + // Act + var result = await responseFilter.InvokeAsync(context, currentContext => validationFilter.InvokeAsync(currentContext, _ => + { + nextCalled = true; + return ValueTask.FromResult("next"); + })); + + // Assert + Assert.False(nextCalled); + Assert.Equal(expectedStatus, Assert.IsAssignableFrom(result).StatusCode); + var problem = Assert.IsAssignableFrom(Assert.IsAssignableFrom(result).Value); + if (expectedStatus == StatusCodes.Status400BadRequest) + Assert.Equal("Invalid Provider Name or Provider User Id.", problem.Title); + else + Assert.Contains("provider_name", Assert.IsType(problem).Errors.Keys); + } + [Fact] public async Task InvokeAsync_InvalidSemanticValue_ReturnsUnprocessableEntityValidationProblem() { From de73df0c7f8b2808fdd7ccc5d46dd0a8f1efef35 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 21 Oct 2026 21:55:31 -0500 Subject: [PATCH 16/27] test: reproduce stale external account metadata and repository authentication leakage --- .../Api/Endpoints/AuthEndpointTests.cs | 44 +++++++++++++++++++ .../Extensions/UserExtensionsTests.cs | 37 ++++++++++++++++ .../Repositories/UserRepositoryTests.cs | 3 +- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index bb0bfe312d..9f79e741a9 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -2199,6 +2199,50 @@ public async Task ExternalLogin_ActiveProviderOwner_TransfersAccountAndRevokesPr await SendRequestAsync(request => request.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized()); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExternalLogin_ExistingIdentity_RefreshesAccountMetadata(bool isAuthenticated) + { + // Arrange + const string code = "github-refresh-metadata"; + var owner = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(owner); + owner.MarkEmailAddressVerified(); + owner.AddOAuthAccount("github", code, "outdated@example.test", new SettingsDictionary + { + ["name"] = "Outdated name", ["email"] = "outdated@example.test", ["retained"] = "keep" + }); + await _userRepository.SaveAsync(owner, options => options.ImmediateConsistency()); + int originalAccountCount = owner.OAuthAccounts.Count; + + // Act + var result = await SendRequestAsAsync(request => + { + if (isAuthenticated) + request.AsTestOrganizationUser(); + + request.Post().AppendPaths("auth", "github") + .Content(new ExternalAuthInfo { ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" }) + .StatusCodeShouldBeOk(); + }); + var stored = await _userRepository.GetByIdAsync(owner.Id, options => options.Cache(false)); + + // Assert + Assert.NotNull(result); + Assert.NotNull(stored); + var token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.NotNull(token); + Assert.Equal(owner.Id, token.UserId); + Assert.Equal(originalAccountCount, stored.OAuthAccounts.Count); + var account = Assert.Single(stored.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + Assert.Equal(TestOAuthProviderClient.GetEmailAddress(code), account.Username); + Assert.Equal("github User", account.ExtraData["name"]); + Assert.Equal(TestOAuthProviderClient.GetEmailAddress(code), account.ExtraData["email"]); + Assert.Equal("keep", account.ExtraData["retained"]); + Assert.Equal(owner.EmailAddress, stored.EmailAddress); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs new file mode 100644 index 0000000000..171197a7d1 --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs @@ -0,0 +1,37 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Foundatio.Xunit; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public sealed class UserExtensionsTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AddOAuthAccount_ExistingIdentity_RefreshesMetadataWithoutAddingAccount(bool includeData) + { + // Arrange + var user = new User(); + user.AddOAuthAccount("github", "subject", "old@example.test", new SettingsDictionary + { + ["name"] = "Old name", ["retained"] = "keep" + }); + user.AddOAuthAccount("google", "subject", "other@example.test"); + var account = user.OAuthAccounts.First(); + var data = includeData ? new SettingsDictionary { ["name"] = "New name", ["added"] = "new" } : null; + + // Act + user.AddOAuthAccount("github", "subject", "new@example.test", data); + + // Assert + Assert.Equal(2, user.OAuthAccounts.Count); + Assert.Same(account, user.OAuthAccounts.First()); + Assert.Equal("new@example.test", account.Username); + Assert.Equal(includeData ? "New name" : "Old name", account.ExtraData["name"]); + Assert.Equal("keep", account.ExtraData["retained"]); + Assert.Equal(includeData, account.ExtraData.ContainsKey("added")); + Assert.Equal("other@example.test", user.OAuthAccounts.Last().Username); + } +} diff --git a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs index d074887d00..33a0c62372 100644 --- a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs @@ -124,8 +124,7 @@ public async Task GetUserByOAuthProviderAsync_PagedCandidates_ValidatesIdentityA } else if (hasDuplicateOwner) { - Assert.IsType(exception); - Assert.Null(matchingUser); + Assert.Null(exception); } else { From ea8d962b26d4668d0ee29174823e87f0e8c00ab7 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 21 Oct 2026 22:00:04 -0500 Subject: [PATCH 17/27] Refresh external account metadata and keep authentication policy in the handler --- .../Extensions/UserExtensions.cs | 26 +- src/Exceptionless.Core/Models/OAuthAccount.cs | 2 +- .../Interfaces/IUserRepository.cs | 4 +- .../Repositories/UserRepository.cs | 35 +- .../Api/Endpoints/AuthEndpoints.cs | 3 +- .../Filters/UnlinkValidationResponseFilter.cs | 18 -- .../Api/Handlers/AuthHandler.cs | 41 ++- .../Api/Messages/AuthMessages.cs | 2 +- .../Models/Auth/RemoveExternalLoginRequest.cs | 8 +- .../Api/Endpoints/AuthEndpointTests.cs | 6 +- .../ExternalAccountConcurrencyTests.cs | 300 ++++-------------- .../AutoValidationEndpointFilterTests.cs | 32 -- .../Api/Handlers/AuthHandlerTests.cs | 60 +++- .../Repositories/UserRepositoryTests.cs | 39 ++- .../ApiKeyAuthenticationHandlerTests.cs | 10 +- ...emoveExternalLoginRequestValidatorTests.cs | 31 -- 16 files changed, 206 insertions(+), 411 deletions(-) delete mode 100644 src/Exceptionless.Web/Api/Filters/UnlinkValidationResponseFilter.cs delete mode 100644 tests/Exceptionless.Tests/Validation/RemoveExternalLoginRequestValidatorTests.cs diff --git a/src/Exceptionless.Core/Extensions/UserExtensions.cs b/src/Exceptionless.Core/Extensions/UserExtensions.cs index c28a3e569e..e38b0b4511 100644 --- a/src/Exceptionless.Core/Extensions/UserExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UserExtensions.cs @@ -64,21 +64,25 @@ public static bool HasValidPasswordResetTokenExpiration(this User user, TimeProv public static void AddOAuthAccount(this User user, string providerName, string providerUserId, string username, SettingsDictionary? data = null) { - if (user.OAuthAccounts.Any(account => String.Equals(account.Provider, providerName, StringComparison.Ordinal) - && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal))) - return; - - var account = new OAuthAccount + var account = user.OAuthAccounts.FirstOrDefault(account => String.Equals(account.Provider, providerName, StringComparison.Ordinal) + && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)); + if (account is null) + { + account = new OAuthAccount + { + Provider = providerName, + ProviderUserId = providerUserId, + Username = username + }; + user.OAuthAccounts.Add(account); + } + else { - Provider = providerName, - ProviderUserId = providerUserId, - Username = username - }; + account.Username = username; + } if (data is not null) account.ExtraData.Apply(data); - - user.OAuthAccounts.Add(account); } public static bool RemoveOAuthAccount(this User user, string providerName, string providerUserId) diff --git a/src/Exceptionless.Core/Models/OAuthAccount.cs b/src/Exceptionless.Core/Models/OAuthAccount.cs index 37bae42dbe..85cd10d986 100644 --- a/src/Exceptionless.Core/Models/OAuthAccount.cs +++ b/src/Exceptionless.Core/Models/OAuthAccount.cs @@ -19,7 +19,7 @@ public static class KnownProviders [Required] public required string ProviderUserId { get; init; } - public required string Username { get; init; } + public required string Username { get; set; } public SettingsDictionary ExtraData { get; init; } = new(); diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs index 81b1890de3..ad52ae10ea 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs @@ -9,9 +9,9 @@ public interface IUserRepository : ISearchableRepository Task RecordProductTourAsync(User user, string stateKey, DateTime recordedUtc); Task UpdateProfileAsync(User user, string? fullName, bool? emailNotificationsEnabled); Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor? options = null); - Task GetByEmailAddressAsync(string emailAddress, CommandOptionsDescriptor? options = null); + Task GetByEmailAddressAsync(string emailAddress); Task GetByPasswordResetTokenAsync(string token); - Task GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default); + Task> GetUsersByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default); Task GetByVerifyEmailAddressTokenAsync(string token); Task> GetByOrganizationIdAsync(string organizationId, CommandOptionsDescriptor? options = null); } diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index e4e7c8efe5..37ec4eded3 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -104,17 +104,13 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor GetByEmailAddressAsync(string emailAddress, CommandOptionsDescriptor? options = null) + public async Task GetByEmailAddressAsync(string emailAddress) { if (String.IsNullOrWhiteSpace(emailAddress)) return null; emailAddress = emailAddress.Trim().ToLowerInvariant(); - ICommandOptions commandOptions = new CommandOptions().Cache(EmailCacheKey(emailAddress)); - if (options is not null) - commandOptions = options(commandOptions); - - var hit = await FindOneAsync(q => q.FieldEquals(u => u.EmailAddress, emailAddress), _ => commandOptions); + var hit = await FindOneAsync(q => q.FieldEquals(u => u.EmailAddress, emailAddress), o => o.Cache(EmailCacheKey(emailAddress))); return hit?.Document; } @@ -127,34 +123,27 @@ public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default) + public async Task> GetUsersByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (String.IsNullOrEmpty(provider) || String.IsNullOrEmpty(providerUserId)) - return null; + return Array.Empty(); // OAuth accounts are mapped as objects, so indexed fields cannot enforce matching within one account. - var results = await FindAsync(q => q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId).SortAscending(u => u.Id), - o => o.SearchAfterPaging().PageLimit(100)).WaitAsync(cancellationToken); - User? matchingUser = null; + var results = await FindAsync(q => q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId) + .FieldEquals(u => u.OAuthAccounts.First().Provider, provider).SortAscending(u => u.Id), + o => o.SearchAfterPaging().PageLimit(100)); + var matchingUsers = new List(); do { cancellationToken.ThrowIfCancellationRequested(); - foreach (var user in results.Documents.Where(u => u.OAuthAccounts.Any(account => + matchingUsers.AddRange(results.Documents.Where(u => u.OAuthAccounts.Any(account => String.Equals(account.Provider, provider, StringComparison.Ordinal) && - String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)))) - { - if (matchingUser is not null) - throw new UnauthorizedAccessException("The external account is linked to multiple users."); - - matchingUser = user; - } - - cancellationToken.ThrowIfCancellationRequested(); - } while (await results.NextPageAsync().WaitAsync(cancellationToken)); + String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)))); + } while (!cancellationToken.IsCancellationRequested && await results.NextPageAsync()); cancellationToken.ThrowIfCancellationRequested(); - return matchingUser; + return matchingUsers; } public async Task GetByVerifyEmailAddressTokenAsync(string token) diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs index fb04388423..abbb5a1344 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -17,7 +17,6 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder { var group = endpoints.MapGroup("api/v2/auth") .RequireAuthorization(AuthorizationRoles.UserPolicy) - .AddEndpointFilter() .AddEndpointFilter() .WithTags("Auth"); @@ -179,7 +178,7 @@ headers api_key input box. }); group.MapPost("unlink/{providerName:minlength(1)}", async ([AsParameters] RemoveExternalLoginRequest request, IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext) - => (await mediator.InvokeAsync>(new AuthMessages.RemoveExternalLogin(request.ProviderName, request.ProviderUserId.Value, httpContext))).ToHttpResult(resultMapper)) + => (await mediator.InvokeAsync>(new AuthMessages.RemoveExternalLogin(request.ProviderName, request.ProviderUserId?.Value, httpContext))).ToHttpResult(resultMapper)) .Accepts>("application/json", "application/*+json") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) diff --git a/src/Exceptionless.Web/Api/Filters/UnlinkValidationResponseFilter.cs b/src/Exceptionless.Web/Api/Filters/UnlinkValidationResponseFilter.cs deleted file mode 100644 index 14a975ff8b..0000000000 --- a/src/Exceptionless.Web/Api/Filters/UnlinkValidationResponseFilter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Exceptionless.Web.Models; - -namespace Exceptionless.Web.Api.Filters; - -public sealed class UnlinkValidationResponseFilter : IEndpointFilter -{ - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - var result = await next(context); - // Keep the existing unlink response while the request validator owns input validation. - if (context.Arguments.Any(argument => argument is RemoveExternalLoginRequest) - && result is IValueHttpResult { Value: HttpValidationProblemDetails problem } - && problem.Errors.ContainsKey("provider_user_id")) - return Microsoft.AspNetCore.Http.Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid Provider Name or Provider User Id."); - - return result; - } -} diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index bc9cd9eb83..adc8689005 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -295,6 +295,9 @@ public Task> Handle(LiveLogin message) public async Task> Handle(RemoveExternalLogin message) { + if (String.IsNullOrWhiteSpace(message.ProviderUserId)) + return Result.BadRequest("Invalid Provider Name or Provider User Id."); + var httpContext = message.Context; var user = httpContext.Request.GetUser(); using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(message.ProviderName).Identity(user.EmailAddress).Property("Provider User Id", message.ProviderUserId).SetHttpContext(httpContext)); @@ -546,9 +549,20 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth ]; operationCancellation.Token.ThrowIfCancellationRequested(); await using var accountLock = await lockProvider.AcquireAsync(accountKeys, cancellationToken: operationCancellation.Token); - var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id, operationCancellation.Token); + var owners = await userRepository.GetUsersByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id, operationCancellation.Token) + .WaitAsync(operationCancellation.Token); + if (owners.Count > 1) + { + logger.LogWarning("External login failed for {EmailAddress}: The external account is linked to multiple users", userInfo.Email); + return Result.Unauthorized("Login failed."); + } + + var existingUser = owners.SingleOrDefault(); // Admission reads may outlive cancellation; do not read or populate a stale email cache entry. - var emailUser = existingUser is null ? await userRepository.GetByEmailAddressAsync(userInfo.Email, options => options.Cache(false)).WaitAsync(operationCancellation.Token) : null; + var emailUser = existingUser is null + ? (await userRepository.FindOneAsync(query => query.FieldEquals(user => user.EmailAddress, userInfo.Email.Trim().ToLowerInvariant()), + options => options.Cache(false)).WaitAsync(operationCancellation.Token))?.Document + : null; string? currentUserId = String.IsNullOrWhiteSpace(authInfo.InviteToken) && httpContext.User.IsUserAuthType() ? httpContext.Request.GetUser().Id : null; var userKeys = new[] { existingUser?.Id, emailUser?.Id, currentUserId } @@ -597,6 +611,11 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); + var accountData = new SettingsDictionary { ["email"] = userInfo.Email }; + string? fullName = userInfo.GetFullName(); + if (!String.IsNullOrWhiteSpace(fullName)) + accountData["name"] = fullName; + using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(userInfo.ProviderName).Identity(userInfo.Email).SetHttpContext(httpContext)); if (existingUser is not null) @@ -630,26 +649,18 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit await userRepository.SaveAsync(existingUser, o => o.Cache()); await ResetUserTokensAsync(existingUser, "Link external account to another user", httpContext); } - else - { - return currentUser; - } } - currentUser.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); + currentUser.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email, accountData); return await userRepository.SaveAsync(currentUser, o => o.Cache()); } if (existingUser is not null) { CompleteExternalAccountAdmission(admissionCancellation); - if (!existingUser.IsEmailAddressVerified) - { - existingUser.MarkEmailAddressVerified(); - await userRepository.SaveAsync(existingUser, o => o.Cache()); - } - - return existingUser; + existingUser.MarkEmailAddressVerified(); + existingUser.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email, accountData); + return await userRepository.SaveAsync(existingUser, o => o.Cache()); } var user = emailUser is not null ? await userRepository.GetByIdAsync(emailUser.Id, options => options.Cache(false)).WaitAsync(cancellationToken) : null; @@ -667,7 +678,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit EnsureUserIsActive(user); CompleteExternalAccountAdmission(admissionCancellation); user.MarkEmailAddressVerified(); - user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); + user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email, accountData); if (String.IsNullOrEmpty(user.Id)) await userRepository.AddAsync(user, o => o.Cache()); diff --git a/src/Exceptionless.Web/Api/Messages/AuthMessages.cs b/src/Exceptionless.Web/Api/Messages/AuthMessages.cs index 9bf57c84c4..f54098c9e1 100644 --- a/src/Exceptionless.Web/Api/Messages/AuthMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AuthMessages.cs @@ -10,7 +10,7 @@ public record GitHubLogin(ExternalAuthInfo AuthInfo, HttpContext Context); public record GoogleLogin(ExternalAuthInfo AuthInfo, HttpContext Context); public record FacebookLogin(ExternalAuthInfo AuthInfo, HttpContext Context); public record LiveLogin(ExternalAuthInfo AuthInfo, HttpContext Context); -public record RemoveExternalLogin(string ProviderName, string ProviderUserId, HttpContext Context); +public record RemoveExternalLogin(string ProviderName, string? ProviderUserId, HttpContext Context); public record ChangePassword(ChangePasswordModel Model, HttpContext Context); public record CheckEmailAddress(string Email, HttpContext Context); public record ForgotPassword(string Email, HttpContext Context); diff --git a/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs b/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs index 706846a177..44ea01f641 100644 --- a/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs +++ b/src/Exceptionless.Web/Models/Auth/RemoveExternalLoginRequest.cs @@ -4,7 +4,7 @@ namespace Exceptionless.Web.Models; -public sealed class RemoveExternalLoginRequest : IValidatableObject +public sealed class RemoveExternalLoginRequest { [FromRoute(Name = "providerName")] [Required] @@ -15,10 +15,4 @@ public sealed class RemoveExternalLoginRequest : IValidatableObject [FromBody] public ValueFromBody ProviderUserId { get; set; } = null!; - - public IEnumerable Validate(ValidationContext validationContext) - { - if (String.IsNullOrWhiteSpace(ProviderUserId?.Value)) - yield return new ValidationResult("The provider user id is required.", [nameof(ProviderUserId)]); - } } diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 9f79e741a9..17b5fec07e 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -2193,9 +2193,8 @@ public async Task ExternalLogin_ActiveProviderOwner_TransfersAccountAndRevokesPr Assert.NotNull(storedOwner); Assert.DoesNotContain(storedOwner.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); Assert.NotEqual(owner.AuthenticationVersion, storedOwner.AuthenticationVersion); - var matchingUser = await _userRepository.GetUserByOAuthProviderAsync("github", code, TestCancellationToken); - Assert.NotNull(matchingUser); - Assert.Equal(currentUser.Id, matchingUser.Id); + var matchingUsers = await _userRepository.GetUsersByOAuthProviderAsync("github", code, TestCancellationToken); + Assert.Equal(currentUser.Id, Assert.Single(matchingUsers).Id); await SendRequestAsync(request => request.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized()); } @@ -2241,6 +2240,7 @@ public async Task ExternalLogin_ExistingIdentity_RefreshesAccountMetadata(bool i Assert.Equal(TestOAuthProviderClient.GetEmailAddress(code), account.ExtraData["email"]); Assert.Equal("keep", account.ExtraData["retained"]); Assert.Equal(owner.EmailAddress, stored.EmailAddress); + Assert.Equal(owner.FullName, stored.FullName); } [Theory] diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs index 5faddd83a3..984ecc28a9 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs @@ -1,20 +1,18 @@ +using System.Net; using System.Net.Http.Json; using System.Text; using Exceptionless.Core.Extensions; using Exceptionless.Core.Repositories; using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Models; +using Exceptionless.Tests.Utility; using Exceptionless.Core.Services; using Exceptionless.Core.Utility; -using Exceptionless.Core.Validation; -using Exceptionless.Tests.Utility; using Exceptionless.Web.Models; -using Foundatio.Lock; using Foundatio.Repositories; +using Foundatio.Caching; using Foundatio.Repositories.Models; -using Microsoft.Extensions.DependencyInjection; -using Moq; using Xunit; -using User = Exceptionless.Core.Models.User; namespace Exceptionless.Tests.Api.Endpoints; @@ -22,51 +20,16 @@ public sealed class ExternalAccountConcurrencyTests : IntegrationTestsBase { private readonly IUserRepository _users; private readonly ITokenRepository _tokens; - private readonly ExternalAccountRace _race; public ExternalAccountConcurrencyTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _users = GetService(); _tokens = GetService(); - _race = GetService(); - } - - protected override void RegisterServices(IServiceCollection services) - { - base.RegisterServices(services); - services.AddSingleton(); - services.ReplaceSingleton(); - services.ReplaceSingleton(provider => - { - var inner = provider.GetRequiredService(); - var race = provider.GetRequiredService(); - var locks = new Mock(); - locks.Setup(locker => locker.TryAcquireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(async (string resource, TimeSpan? expires, bool release, CancellationToken token) => - { - if (race.RejectLocks) - return null; - - await race.ObserveLockAsync(inner, resource); - return await inner.TryAcquireAsync(resource, expires, release, token); - }); - locks.Setup(locker => locker.AcquireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(async (string resource, TimeSpan? expires, bool release, CancellationToken token) => - { - if (race.RejectLocks) - throw new LockAcquisitionTimeoutException(resource); - - await race.ObserveLockAsync(inner, resource); - return await inner.AcquireAsync(resource, expires, release, token); - }); - return locks.Object; - }); } protected override async Task ResetDataAsync() { await base.ResetDataAsync(); - _race.Stop(); var options = GetService(); options.EnableAccountCreation = true; options.GitHubId = "github-client-id"; @@ -97,53 +60,65 @@ public async Task ExternalLogin_ConcurrentRequestsForSameIdentity_KeepOneOwner(b var candidates = await _users.FindAsync(query => query.FieldEquals(user => user.OAuthAccounts.First().ProviderUserId, code)); var owner = Assert.Single(candidates.Documents); Assert.Single(owner.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); - var secondToken = await _tokens.GetByIdAsync(results[1]!.Token); - Assert.NotNull(secondToken); - Assert.Equal(owner.Id, secondToken.UserId); + var tokens = await _tokens.GetByIdsAsync(results.Select(result => result!.Token).ToArray()); + Assert.Contains(tokens, token => token.UserId == owner.Id); } [Theory] - [InlineData(LookupStage.Identity)] - [InlineData(LookupStage.Email)] - [InlineData(LookupStage.User)] - public async Task ExternalLogin_CanceledLookup_ReleasesLocksForNextRequest(LookupStage stage) + [InlineData("identity")] + [InlineData("email")] + [InlineData("user")] + public async Task ExternalLogin_CanceledLookup_ReleasesLocksForNextRequest(string stage) { // Arrange const string code = "canceled-external-account"; using var client = _server.CreateClient(); using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); - if (stage == LookupStage.User) + var users = Assert.IsType(_users); + if (stage == "user") { var user = new User { FullName = "Canceled lookup user", EmailAddress = TestOAuthProviderClient.GetEmailAddress(code) }; user.MarkEmailAddressVerified(); - await _users.AddAsync(user, options => options.ImmediateConsistency()); + await users.AddAsync(user, options => options.ImmediateConsistency()); } - _race.Start(stage, TestCancellationToken); - var pendingRequest = client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo + bool canceled = false; + int queries = 0; + using var subscription = users.BeforeQuery.AddHandler((_, _) => { - ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" - }, cancellation.Token); - await _race.FirstSnapshotRead.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); - Exception? exception; - TokenResult? nextResult; + queries++; + if (!canceled && ((stage == "identity" && queries == 1) || (stage == "email" && queries == 2))) + { + canceled = true; + cancellation.Cancel(); + } - // Act - try + return Task.CompletedTask; + }); + + using var getSubscription = users.BeforeGet.AddHandler((_, _) => { - cancellation.Cancel(); - exception = await Record.ExceptionAsync(async () => + if (!canceled && stage == "user") { - using var response = await pendingRequest; - }).AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); - nextResult = await SendExternalLoginAsync(client, code); - } - finally + canceled = true; + cancellation.Cancel(); + } + + return Task.CompletedTask; + }); + + // Act + var exception = await Record.ExceptionAsync(async () => { - _race.ResumeFirstRequest.TrySetResult(); - } + using var response = await client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo + { + ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" + }, cancellation.Token); + }); + var nextResult = await SendExternalLoginAsync(client, code); // Assert + Assert.True(canceled); Assert.IsAssignableFrom(exception); Assert.NotNull(nextResult); Assert.NotNull(await _tokens.GetByIdAsync(nextResult.Token)); @@ -157,8 +132,9 @@ public async Task ExternalLogin_StaleCachedEmailMiss_LinksStoredUserWithoutCreat var user = new User { FullName = "Existing email owner", EmailAddress = TestOAuthProviderClient.GetEmailAddress(code) }; user.MarkEmailAddressVerified(); await _users.AddAsync(user, options => options.ImmediateConsistency()); - // A canceled cached lookup can publish its earlier miss after another request saves this user. - await ((PausingUserRepository)_users).CacheEmailMissAsync(user.EmailAddress); + var cache = new ScopedCacheClient(GetService().Cache, nameof(User)); + await cache.SetAsync>>($"Email:{user.EmailAddress}", []); + var staleEmailLookup = await _users.GetByEmailAddressAsync(user.EmailAddress); long originalUserCount = (await _users.CountAsync()).Total; using var client = _server.CreateClient(); @@ -166,6 +142,7 @@ public async Task ExternalLogin_StaleCachedEmailMiss_LinksStoredUserWithoutCreat var result = await SendExternalLoginAsync(client, code); // Assert + Assert.Null(staleEmailLookup); Assert.NotNull(result); var token = await _tokens.GetByIdAsync(result.Token); Assert.NotNull(token); @@ -176,55 +153,6 @@ public async Task ExternalLogin_StaleCachedEmailMiss_LinksStoredUserWithoutCreat Assert.Single(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); } - [Fact] - public async Task ExternalLogin_AccountLockUnavailable_IsRejectedWithoutWrites() - { - // Arrange - const string code = "external-account-lock-unavailable"; - using var client = _server.CreateClient(); - long originalUserCount = (await _users.CountAsync()).Total; - long originalTokenCount = (await _tokens.CountAsync()).Total; - _race.RejectLocks = true; - - // Act - using var response = await client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo - { - ClientId = "client-id", - Code = code, - RedirectUri = "http://localhost/callback" - }, TestCancellationToken); - - // Assert - Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal(originalUserCount, (await _users.CountAsync()).Total); - Assert.Equal(originalTokenCount, (await _tokens.CountAsync()).Total); - Assert.Null(await _users.GetUserByOAuthProviderAsync("github", code, TestCancellationToken)); - } - - [Fact] - public async Task RemoveExternalLogin_AccountLockUnavailable_PreservesIdentityAndAuthenticationVersion() - { - // Arrange - const string code = "external-unlink-lock-unavailable"; - using var client = _server.CreateClient(); - SetAuthorization(client, SampleDataService.TEST_ORG_USER_EMAIL, SampleDataService.TEST_ORG_USER_PASSWORD); - var user = await _users.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(user); - user.AddOAuthAccount("github", code, user.EmailAddress); - await _users.SaveAsync(user, options => options.ImmediateConsistency()); - _race.RejectLocks = true; - - // Act - using var response = await client.PostAsJsonAsync("api/v2/auth/unlink/github", new ValueFromBody(code), TestCancellationToken); - - // Assert - Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); - var storedUser = await _users.GetByIdAsync(user.Id, options => options.Cache(false)); - Assert.NotNull(storedUser); - Assert.Equal(user.AuthenticationVersion, storedUser.AuthenticationVersion); - Assert.Single(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); - } - [Fact] public async Task ExternalLogin_IdentityUnlinkedAfterLookup_RejectsStaleOwner() { @@ -237,31 +165,29 @@ public async Task ExternalLogin_IdentityUnlinkedAfterLookup_RejectsStaleOwner() using var loginClient = _server.CreateClient(); using var unlinkClient = _server.CreateClient(); SetAuthorization(unlinkClient, SampleDataService.TEST_ORG_USER_EMAIL, SampleDataService.TEST_ORG_USER_PASSWORD); - _race.Start(LookupStage.Identity, TestCancellationToken); - var pendingLogin = loginClient.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo + bool unlinked = false; + HttpStatusCode? unlinkStatus = null; + using var subscription = _users.AfterQuery.AddHandler(async (_, args) => { - ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" - }, TestCancellationToken); - await _race.FirstSnapshotRead.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); + if (unlinked || args.Result is not FindResults result || !result.Documents.Any(candidate => candidate.Id == user.Id)) + return; + + unlinked = true; + using var response = await unlinkClient.PostAsJsonAsync("api/v2/auth/unlink/github", new ValueFromBody(code), TestCancellationToken); + unlinkStatus = response.StatusCode; + }); // Act - HttpResponseMessage unlinkResponse; - try - { - unlinkResponse = await unlinkClient.PostAsJsonAsync("api/v2/auth/unlink/github", new ValueFromBody(code), TestCancellationToken); - } - finally + using var loginResponse = await loginClient.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo { - _race.ResumeFirstRequest.TrySetResult(); - } - - using var completedUnlinkResponse = unlinkResponse; - using var loginResponse = await pendingLogin; + ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" + }, TestCancellationToken); + var storedUser = await _users.GetByIdAsync(user.Id, options => options.Cache(false)); // Assert - Assert.Equal(System.Net.HttpStatusCode.OK, unlinkResponse.StatusCode); - Assert.Equal(System.Net.HttpStatusCode.Unauthorized, loginResponse.StatusCode); - var storedUser = await _users.GetByIdAsync(user.Id, options => options.Cache(false)); + Assert.True(unlinked); + Assert.Equal(HttpStatusCode.OK, unlinkStatus); + Assert.Equal(HttpStatusCode.Unauthorized, loginResponse.StatusCode); Assert.NotNull(storedUser); Assert.DoesNotContain(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); } @@ -288,23 +214,9 @@ public async Task ExternalLogin_ConcurrentDifferentIdentitiesForSameUser_Preserv Assert.Single(storedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == "concurrent-second-account"); } - private async Task RunConcurrentRequestsAsync(HttpClient firstClient, string firstCode, HttpClient secondClient, string secondCode) + private Task RunConcurrentRequestsAsync(HttpClient firstClient, string firstCode, HttpClient secondClient, string secondCode) { - _race.Start(LookupStage.Identity, TestCancellationToken); - var firstRequest = SendExternalLoginAsync(firstClient, firstCode); - Task? secondRequest = null; - try - { - await _race.FirstSnapshotRead.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); - secondRequest = SendExternalLoginAsync(secondClient, secondCode); - await _race.SecondRequestObserved.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); - } - finally - { - _race.ResumeFirstRequest.TrySetResult(); - } - - return await Task.WhenAll(firstRequest, secondRequest!); + return Task.WhenAll(SendExternalLoginAsync(firstClient, firstCode), SendExternalLoginAsync(secondClient, secondCode)); } private static void SetAuthorization(HttpClient client, string email, string password) @@ -323,84 +235,4 @@ private static void SetAuthorization(HttpClient client, string email, string pas response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(TestCancellationToken); } - - private sealed class PausingUserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, Exceptionless.Core.AppOptions options, ExternalAccountRace race) - : UserRepository(configuration, validator, options), IUserRepository - { - public Task CacheEmailMissAsync(string emailAddress) => Cache.SetAsync>>($"Email:{emailAddress}", []); - - public new async Task GetUserByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default) - { - var user = await base.GetUserByOAuthProviderAsync(provider, providerUserId, cancellationToken); - await race.AfterSnapshotReadAsync(LookupStage.Identity, cancellationToken); - return user; - } - - public new async Task GetByEmailAddressAsync(string emailAddress, CommandOptionsDescriptor? options = null) - { - var user = await base.GetByEmailAddressAsync(emailAddress, options); - await race.AfterSnapshotReadAsync(LookupStage.Email, race.TestCancellationToken); - return user; - } - - public override async Task GetByIdAsync(Id id, ICommandOptions? options = null) - { - var user = await base.GetByIdAsync(id, options); - await race.AfterSnapshotReadAsync(LookupStage.User, race.TestCancellationToken); - return user; - } - } - - public enum LookupStage { Identity, Email, User } - - private sealed class ExternalAccountRace - { - private int _reads; - private bool Enabled { get; set; } - private LookupStage Stage { get; set; } - public CancellationToken TestCancellationToken { get; private set; } - public bool RejectLocks { get; set; } - public TaskCompletionSource FirstSnapshotRead { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public TaskCompletionSource SecondRequestObserved { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public TaskCompletionSource ResumeFirstRequest { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public void Start(LookupStage stage, CancellationToken testCancellationToken) - { - _reads = 0; - Stage = stage; - TestCancellationToken = testCancellationToken; - FirstSnapshotRead = new(TaskCreationOptions.RunContinuationsAsynchronously); - SecondRequestObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); - ResumeFirstRequest = new(TaskCreationOptions.RunContinuationsAsynchronously); - Enabled = true; - } - - public void Stop() - { - Enabled = false; - RejectLocks = false; - } - - public async Task AfterSnapshotReadAsync(LookupStage stage, CancellationToken cancellationToken) - { - if (!Enabled || Stage != stage) - return; - - if (Interlocked.Increment(ref _reads) == 1) - { - FirstSnapshotRead.TrySetResult(); - await ResumeFirstRequest.Task.WaitAsync(cancellationToken); - } - else - { - SecondRequestObserved.TrySetResult(); - } - } - - public async Task ObserveLockAsync(ILockProvider provider, string resource) - { - if (Enabled && FirstSnapshotRead.Task.IsCompleted && await provider.IsLockedAsync(resource)) - SecondRequestObserved.TrySetResult(); - } - } } diff --git a/tests/Exceptionless.Tests/Api/Filters/AutoValidationEndpointFilterTests.cs b/tests/Exceptionless.Tests/Api/Filters/AutoValidationEndpointFilterTests.cs index ce1c274a78..a7d1e0812b 100644 --- a/tests/Exceptionless.Tests/Api/Filters/AutoValidationEndpointFilterTests.cs +++ b/tests/Exceptionless.Tests/Api/Filters/AutoValidationEndpointFilterTests.cs @@ -3,7 +3,6 @@ using System.Reflection.Emit; using Exceptionless.Web.Api.Filters; using Exceptionless.Web.Api.Results; -using Exceptionless.Web.Models; using Foundatio.Mediator; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -13,37 +12,6 @@ namespace Exceptionless.Tests.Api.Filters; public sealed class AutoValidationEndpointFilterTests { - [Theory] - [InlineData("github", null, StatusCodes.Status400BadRequest)] - [InlineData("github", "", StatusCodes.Status400BadRequest)] - [InlineData("github", " ", StatusCodes.Status400BadRequest)] - [InlineData("unknown", "subject", StatusCodes.Status422UnprocessableEntity)] - public async Task InvokeAsync_UnlinkValidation_PreservesLegacyBodyStatus(string providerName, string? providerUserId, int expectedStatus) - { - // Arrange - var validationFilter = new AutoValidationEndpointFilter(); - var responseFilter = new UnlinkValidationResponseFilter(); - var request = new RemoveExternalLoginRequest { ProviderName = providerName, ProviderUserId = new ValueFromBody(providerUserId!) }; - var context = new TestEndpointFilterInvocationContext(new DefaultHttpContext(), [request]); - bool nextCalled = false; - - // Act - var result = await responseFilter.InvokeAsync(context, currentContext => validationFilter.InvokeAsync(currentContext, _ => - { - nextCalled = true; - return ValueTask.FromResult("next"); - })); - - // Assert - Assert.False(nextCalled); - Assert.Equal(expectedStatus, Assert.IsAssignableFrom(result).StatusCode); - var problem = Assert.IsAssignableFrom(Assert.IsAssignableFrom(result).Value); - if (expectedStatus == StatusCodes.Status400BadRequest) - Assert.Equal("Invalid Provider Name or Provider User Id.", problem.Title); - else - Assert.Contains("provider_name", Assert.IsType(problem).Errors.Keys); - } - [Fact] public async Task InvokeAsync_InvalidSemanticValue_ReturnsUnprocessableEntityValidationProblem() { diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index 0df509d91d..fff2299667 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -29,6 +29,56 @@ public sealed class AuthHandlerTests : TestWithServices { public AuthHandlerTests(ITestOutputHelper output) : base(output) { } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Handle_ExternalAccountLockUnavailable_IsRejectedBeforeRepositoryAccess(bool unlink) + { + // Arrange + var users = new Mock(MockBehavior.Strict); + var tokens = new Mock(MockBehavior.Strict); + var oauthTokens = new Mock(MockBehavior.Strict); + var providerClient = new Mock(MockBehavior.Strict); + providerClient.Setup(client => client.GetGitHubUserInfoAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new OAuth2.Models.UserInfo { ProviderName = "github", Id = "locked-account", Email = "locked@example.com" }); + var locks = new Mock(MockBehavior.Strict); + locks.Setup(provider => provider.TryAcquireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ILock?)null); + locks.Setup(provider => provider.AcquireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new LockAcquisitionTimeoutException("Account lock unavailable")); + var options = GetService().AuthOptions; + options.GitHubId = "test-client"; + options.GitHubSecret = "test-secret"; + var handler = CreateHandler(users.Object, tokens.Object, oauthTokens.Object, providerClient.Object, locks.Object); + + var context = new DefaultHttpContext(); + var user = new User { Id = TestConstants.UserId, EmailAddress = "locked@example.com", AuthenticationVersion = "initial" }; + user.AddOAuthAccount("github", "locked-account", user.EmailAddress); + context.Request.SetUser(user); + + // Act + var result = unlink + ? await handler.Handle(new RemoveExternalLogin("github", "locked-account", context)) + : await handler.Handle(new GitHubLogin(new ExternalAuthInfo(), context)); + + // Assert + Assert.Equal(ResultStatus.Unauthorized, result.Status); + users.VerifyNoOtherCalls(); + tokens.VerifyNoOtherCalls(); + oauthTokens.VerifyNoOtherCalls(); + if (unlink) + { + providerClient.VerifyNoOtherCalls(); + Assert.Equal("initial", user.AuthenticationVersion); + Assert.Single(user.OAuthAccounts); + } + else + { + providerClient.VerifyAll(); + } + } + + [Theory] [InlineData(false, false, false)] [InlineData(true, false, false)] @@ -56,7 +106,7 @@ public async Task Handle_LinkExternalAccountOwnedByAnotherUser_RevokesPreviousOw var calls = new List(); string? persistedVersion = null; var users = new Mock(MockBehavior.Strict); - users.Setup(repository => repository.GetUserByOAuthProviderAsync(provider, providerId, It.IsAny())).ReturnsAsync(displaced); + users.Setup(repository => repository.GetUsersByOAuthProviderAsync(provider, providerId, It.IsAny())).ReturnsAsync(new[] { displaced }); users.Setup(repository => repository.GetByIdAsync(displaced.Id, It.IsAny>())).ReturnsAsync(displaced); users.Setup(repository => repository.GetByIdAsync(current.Id, It.IsAny>())).ReturnsAsync(current); users.Setup(repository => repository.SaveAsync(displaced, It.IsAny>())) @@ -158,7 +208,7 @@ public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exce { // Arrange var userRepository = new Mock(MockBehavior.Strict); - userRepository.Setup(repository => repository.GetByEmailAddressAsync("test@example.com", null)) + userRepository.Setup(repository => repository.GetByEmailAddressAsync("test@example.com")) .ThrowsAsync(exception); var handler = CreateHandler(userRepository.Object, GetService(), GetService(), GetService()); var message = new LoginMessage( @@ -171,7 +221,7 @@ public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exce // Assert Assert.Equal(ResultStatus.Unauthorized, result.Status); Assert.Equal("Login failed.", result.Message); - userRepository.Verify(repository => repository.GetByEmailAddressAsync("test@example.com", null), Times.Once); + userRepository.Verify(repository => repository.GetByEmailAddressAsync("test@example.com"), Times.Once); userRepository.VerifyNoOtherCalls(); } @@ -181,7 +231,7 @@ public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exce new OperationCanceledException("Repository operation was canceled.") }; - private AuthHandler CreateHandler(IUserRepository userRepository, ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, IOAuthProviderClient providerClient) + private AuthHandler CreateHandler(IUserRepository userRepository, ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, IOAuthProviderClient providerClient, ILockProvider? lockProvider = null) { var appOptions = GetService(); @@ -194,7 +244,7 @@ private AuthHandler CreateHandler(IUserRepository userRepository, ITokenReposito oauthTokenRepository, providerClient, GetService(), - GetService(), + lockProvider ?? GetService(), GetService(), GetService(), GetService(), diff --git a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs index 33a0c62372..60cac89c2a 100644 --- a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs @@ -45,7 +45,7 @@ public async Task SaveAsync_InvalidOAuthIdentity_RejectsChangesBeforePersistence } [Fact] - public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresExactIdentityPair() + public async Task GetUsersByOAuthProviderAsync_DifferentAccountEntries_RequiresExactIdentityPair() { // Arrange var user = new User { FullName = "Linked User", EmailAddress = "linked@localhost.test" }; @@ -56,25 +56,25 @@ public async Task GetUserByOAuthProviderAsync_DifferentAccountEntries_RequiresEx await _repository.AddAsync(user, options => options.ImmediateConsistency()); // Act - var githubUser = await _repository.GetUserByOAuthProviderAsync("github", "shared-subject", TestCancellationToken); - var googleUser = await _repository.GetUserByOAuthProviderAsync("google", "shared-subject", TestCancellationToken); - var differentProviderCaseUser = await _repository.GetUserByOAuthProviderAsync("GOOGLE", "shared-subject", TestCancellationToken); - var differentCaseUser = await _repository.GetUserByOAuthProviderAsync("google", "SHARED-SUBJECT", TestCancellationToken); + var githubUser = await _repository.GetUsersByOAuthProviderAsync("github", "shared-subject", TestCancellationToken); + var googleUser = await _repository.GetUsersByOAuthProviderAsync("google", "shared-subject", TestCancellationToken); + var differentProviderCaseUser = await _repository.GetUsersByOAuthProviderAsync("GOOGLE", "shared-subject", TestCancellationToken); + var differentCaseUser = await _repository.GetUsersByOAuthProviderAsync("google", "SHARED-SUBJECT", TestCancellationToken); // Assert - Assert.Null(githubUser); - Assert.NotNull(googleUser); - Assert.Equal(user.Id, googleUser.Id); - Assert.Equal(2, googleUser.OAuthAccounts.Count); - Assert.Null(differentCaseUser); - Assert.Null(differentProviderCaseUser); + Assert.Empty(githubUser); + var owner = Assert.Single(googleUser); + Assert.Equal(user.Id, owner.Id); + Assert.Equal(2, owner.OAuthAccounts.Count); + Assert.Empty(differentCaseUser); + Assert.Empty(differentProviderCaseUser); } [Theory] [InlineData(false, false)] [InlineData(true, false)] [InlineData(false, true)] - public async Task GetUserByOAuthProviderAsync_PagedCandidates_ValidatesIdentityAndCancellation(bool hasDuplicateOwner, bool cancelAfterFirstPage) + public async Task GetUsersByOAuthProviderAsync_PagedCandidates_ValidatesIdentityAndCancellation(bool hasDuplicateOwner, bool cancelAfterFirstPage) { // Arrange var users = Enumerable.Range(1, 101).Select(index => @@ -94,7 +94,7 @@ public async Task GetUserByOAuthProviderAsync_PagedCandidates_ValidatesIdentityA if (hasDuplicateOwner) users[0].AddOAuthAccount("github", "shared-subject", users[0].EmailAddress); await _repository.AddAsync(users, options => options.ImmediateConsistency()); - User? matchingUser = null; + IReadOnlyCollection? matchingUsers = null; using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); int queryCount = 0; using var queryHandler = _repository.AfterQuery.AddHandler((_, args) => @@ -112,25 +112,22 @@ public async Task GetUserByOAuthProviderAsync_PagedCandidates_ValidatesIdentityA // Act var exception = await Record.ExceptionAsync(async () => { - matchingUser = await _repository.GetUserByOAuthProviderAsync("github", "shared-subject", cancellation.Token); + matchingUsers = await _repository.GetUsersByOAuthProviderAsync("github", "shared-subject", cancellation.Token); }); // Assert if (cancelAfterFirstPage) { Assert.IsAssignableFrom(exception); - Assert.Null(matchingUser); + Assert.Null(matchingUsers); Assert.Equal(1, queryCount); } - else if (hasDuplicateOwner) - { - Assert.Null(exception); - } else { Assert.Null(exception); - Assert.NotNull(matchingUser); - Assert.Equal(users[^1].Id, matchingUser.Id); + Assert.NotNull(matchingUsers); + Assert.Equal(hasDuplicateOwner ? 2 : 1, matchingUsers.Count); + Assert.Contains(matchingUsers, user => user.Id == users[^1].Id); } } } diff --git a/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs b/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs index c26f7002b5..4b104a4d30 100644 --- a/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs +++ b/tests/Exceptionless.Tests/Security/ApiKeyAuthenticationHandlerTests.cs @@ -172,8 +172,8 @@ public async Task AuthenticateAsync_OverlappingBasicRequests_PreservesValidBurst var releases = Enumerable.Range(0, requestCount) .Select(_ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).ToArray(); int repositoryCalls = 0; - repository.Setup(repository => repository.GetByEmailAddressAsync(It.IsAny(), null)) - .Returns(async (string email, CommandOptionsDescriptor? _) => + repository.Setup(repository => repository.GetByEmailAddressAsync(It.IsAny())) + .Returns(async (string email) => { int call = Interlocked.Increment(ref repositoryCalls) - 1; await releases[call].Task.WaitAsync(TestTimeout, TestCancellationToken); @@ -211,7 +211,7 @@ public async Task AuthenticateAsync_OverlappingBasicRequests_PreservesValidBurst Assert.Equal(limit, initiallyAdmitted); Assert.True(allWaiting); Assert.All(results, result => Assert.Equal(validPassword, result.Succeeded)); - repository.Verify(repository => repository.GetByEmailAddressAsync(It.IsAny(), null), Times.Exactly(validPassword ? requestCount : limit)); + repository.Verify(repository => repository.GetByEmailAddressAsync(It.IsAny()), Times.Exactly(validPassword ? requestCount : limit)); repository.VerifyNoOtherCalls(); } @@ -229,7 +229,7 @@ public async Task AuthenticateAsync_RepositoryException_LogsUnexpectedFailureAnd var service = GetService(); var repository = new Mock(); Exception failure = cancellationException ? new OperationCanceledException(cancellation.Token) : new IOException("Synthetic repository failure."); - repository.Setup(repository => repository.GetByEmailAddressAsync(EmailAddress, null)) + repository.Setup(repository => repository.GetByEmailAddressAsync(EmailAddress)) .Callback(() => { if (cancelRequest) @@ -250,7 +250,7 @@ public async Task AuthenticateAsync_RepositoryException_LogsUnexpectedFailureAnd var errors = Log.LogEntries.Where(entry => entry.CategoryName == typeof(ApiKeyAuthenticationHandler).FullName && entry.LogLevel >= LogLevel.Error).ToArray(); Assert.Equal(expectedErrors, errors.Length); Assert.All(errors, entry => Assert.Same(failure, entry.Exception)); - repository.Verify(repository => repository.GetByEmailAddressAsync(EmailAddress, null), Times.Once); + repository.Verify(repository => repository.GetByEmailAddressAsync(EmailAddress), Times.Once); repository.VerifyNoOtherCalls(); } diff --git a/tests/Exceptionless.Tests/Validation/RemoveExternalLoginRequestValidatorTests.cs b/tests/Exceptionless.Tests/Validation/RemoveExternalLoginRequestValidatorTests.cs deleted file mode 100644 index bf1a0c990c..0000000000 --- a/tests/Exceptionless.Tests/Validation/RemoveExternalLoginRequestValidatorTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Exceptionless.Core.Validation; -using Exceptionless.Web.Models; -using Xunit; - -namespace Exceptionless.Tests.Validation; - -public sealed class RemoveExternalLoginRequestValidatorTests(ITestOutputHelper output) : TestWithServices(output) -{ - [Theory] - [InlineData(null, false)] - [InlineData("", false)] - [InlineData(" ", false)] - [InlineData("subject", true)] - public async Task ValidateAsync_ProviderUserId_RequiresNonWhitespaceValue(string? providerUserId, bool expectedIsValid) - { - // Arrange - var request = new RemoveExternalLoginRequest - { - ProviderName = "github", - ProviderUserId = new ValueFromBody(providerUserId!) - }; - - // Act - var result = await GetService().ValidateAsync(request); - - // Assert - Assert.Equal(expectedIsValid, result.IsValid); - if (!expectedIsValid) - Assert.Contains(nameof(RemoveExternalLoginRequest.ProviderUserId), result.Errors.Keys); - } -} From 945e60b06d0c4a6063fe8e24b313b9b4e71aad19 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 21 Oct 2026 22:15:05 -0500 Subject: [PATCH 18/27] test: reproduce redundant frontend installation in CI topology --- .../AppHostTopologyTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/Exceptionless.Tests/AppHostTopologyTests.cs diff --git a/tests/Exceptionless.Tests/AppHostTopologyTests.cs b/tests/Exceptionless.Tests/AppHostTopologyTests.cs new file mode 100644 index 0000000000..e88ca1f93f --- /dev/null +++ b/tests/Exceptionless.Tests/AppHostTopologyTests.cs @@ -0,0 +1,33 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.JavaScript; +using Aspire.Hosting.Testing; +using Foundatio.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Exceptionless.Tests; + +public sealed class AppHostTopologyTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BuildAsync_ClientPackageInstallation_RunsOnlyWhenNotAlreadyInstalledByCi(bool ciE2E) + { + // Arrange + var builder = await DistributedApplicationTestingBuilder.CreateAsync( + ciE2E ? ["--ci-e2e"] : [], TestCancellationToken); + + // Act + await using var application = await builder.BuildAsync(TestCancellationToken); + var model = application.Services.GetRequiredService(); + + // Assert + var installer = Assert.Single(model.Resources.OfType(), resource => resource.Name == "App-installer"); + var app = Assert.Single(model.Resources, resource => resource.Name == "App"); + Assert.Equal(ciE2E, installer.Annotations.OfType().Any()); + Assert.Equal(!ciE2E, app.Annotations.OfType().Any(wait => wait.Resource == installer)); + Assert.Contains(app.Annotations.OfType(), wait => wait.Resource.Name == "Api"); + } +} From 869c91d029c1f1856f23f03789e94b8e684d53f3 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 21 Oct 2026 22:21:24 -0500 Subject: [PATCH 19/27] Skip redundant Aspire package installation after CI npm ci --- src/Exceptionless.AppHost/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Exceptionless.AppHost/Program.cs b/src/Exceptionless.AppHost/Program.cs index ecbf4c2a16..2071a61029 100644 --- a/src/Exceptionless.AppHost/Program.cs +++ b/src/Exceptionless.AppHost/Program.cs @@ -188,6 +188,7 @@ #pragma warning disable ASPIREBROWSERLOGS001 var app = builder.AddViteApp("App", "../Exceptionless.Web/ClientApp") + .WithNpm(install: !ciE2E) // CI already ran npm ci before starting the AppHost. .WithBrowserLogs() .WithReference(api) .WithEnvironment("PUBLIC_EXCEPTIONLESS_API_KEY", builder.Configuration["PUBLIC_EXCEPTIONLESS_API_KEY"]) From 28bcb90a159c43d6b5f7e12228e07116cf4f83cd Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 21 Oct 2026 22:48:55 -0500 Subject: [PATCH 20/27] test: reproduce duplicate metadata and password changes racing OAuth refresh --- .../Api/Endpoints/AuthEndpointTests.cs | 47 +++++++++++++++++++ .../Extensions/UserExtensionsTests.cs | 8 +++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 17b5fec07e..a481915778 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -2243,6 +2243,53 @@ public async Task ExternalLogin_ExistingIdentity_RefreshesAccountMetadata(bool i Assert.Equal(owner.FullName, stored.FullName); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExternalLogin_ConcurrentPasswordChange_DoesNotRestoreOldCredentials(bool isAuthenticated) + { + // Arrange + const string code = "github-concurrent-password-change"; + const string newPassword = "ChangedPassword2$"; + var owner = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(owner); + owner.AddOAuthAccount("github", code, owner.EmailAddress); + await _userRepository.SaveAsync(owner, options => options.ImmediateConsistency()); + bool passwordChanged = false; + TokenResult? replacementToken = null; + using var subscription = _userRepository.DocumentsSaving.AddHandler(async (_, args) => + { + if (passwordChanged || !args.Documents.Any(document => document.Value.Id == owner.Id)) + return; + + passwordChanged = true; + replacementToken = await SendRequestAsAsync(request => request.AsTestOrganizationUser() + .Post().AppendPath("auth/change-password") + .Content(new ChangePasswordModel { CurrentPassword = SampleDataService.TEST_ORG_USER_PASSWORD, Password = newPassword }) + .StatusCodeShouldBeOk()); + }); + using var client = _server.CreateClient(); + if (isAuthenticated) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{SampleDataService.TEST_ORG_USER_EMAIL}:{SampleDataService.TEST_ORG_USER_PASSWORD}"))); + + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo + { + ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" + }, TestCancellationToken); + var stored = await _userRepository.GetByIdAsync(owner.Id, options => options.Cache(false)); + + // Assert + Assert.True(passwordChanged); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.NotNull(stored); + Assert.True(stored.IsCorrectPassword(newPassword)); + Assert.False(stored.IsCorrectPassword(SampleDataService.TEST_ORG_USER_PASSWORD)); + Assert.NotNull(replacementToken); + await SendRequestAsync(request => request.BearerToken(replacementToken.Token).AppendPath("users/me").StatusCodeShouldBeOk()); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs index 171197a7d1..134227217e 100644 --- a/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/UserExtensionsTests.cs @@ -20,6 +20,11 @@ public void AddOAuthAccount_ExistingIdentity_RefreshesMetadataWithoutAddingAccou }); user.AddOAuthAccount("google", "subject", "other@example.test"); var account = user.OAuthAccounts.First(); + user.OAuthAccounts.Add(new OAuthAccount + { + Provider = "github", ProviderUserId = "subject", Username = "stale-duplicate@example.test", + ExtraData = new SettingsDictionary { ["email"] = "stale-duplicate@example.test" } + }); var data = includeData ? new SettingsDictionary { ["name"] = "New name", ["added"] = "new" } : null; // Act @@ -32,6 +37,7 @@ public void AddOAuthAccount_ExistingIdentity_RefreshesMetadataWithoutAddingAccou Assert.Equal(includeData ? "New name" : "Old name", account.ExtraData["name"]); Assert.Equal("keep", account.ExtraData["retained"]); Assert.Equal(includeData, account.ExtraData.ContainsKey("added")); - Assert.Equal("other@example.test", user.OAuthAccounts.Last().Username); + Assert.Equal("other@example.test", Assert.Single(user.OAuthAccounts, account => account.Provider == "google").Username); + Assert.DoesNotContain(user.OAuthAccounts, account => account.EmailAddress() == "stale-duplicate@example.test"); } } From c59e9fee23cc71f1dd27d0b9c4472825a3fdd5a1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 21 Oct 2026 22:52:22 -0500 Subject: [PATCH 21/27] Protect OAuth metadata saves with native user versions and collapse duplicate links --- .../Extensions/UserExtensions.cs | 8 +- src/Exceptionless.Core/Models/User.cs | 8 +- .../Api/Endpoints/AuthEndpoints.cs | 18 ++ .../Api/Endpoints/OrganizationEndpoints.cs | 4 + .../Api/Endpoints/UserEndpoints.cs | 14 ++ .../Api/Handlers/AuthHandler.cs | 10 +- .../Exceptionless.Tests/Api/Data/openapi.json | 235 ++++++++++++++++++ .../Api/Endpoints/AuthEndpointTests.cs | 95 ++++++- .../Api/OpenApiSnapshotTests.cs | 25 ++ .../Repositories/UserRepositoryTests.cs | 178 +++++++++++++ .../Serializer/Models/UserSerializerTests.cs | 19 +- 11 files changed, 604 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Core/Extensions/UserExtensions.cs b/src/Exceptionless.Core/Extensions/UserExtensions.cs index e38b0b4511..4d1e478191 100644 --- a/src/Exceptionless.Core/Extensions/UserExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UserExtensions.cs @@ -64,8 +64,9 @@ public static bool HasValidPasswordResetTokenExpiration(this User user, TimeProv public static void AddOAuthAccount(this User user, string providerName, string providerUserId, string username, SettingsDictionary? data = null) { - var account = user.OAuthAccounts.FirstOrDefault(account => String.Equals(account.Provider, providerName, StringComparison.Ordinal) - && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)); + var accounts = user.OAuthAccounts.Where(account => String.Equals(account.Provider, providerName, StringComparison.Ordinal) + && String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)).ToArray(); + var account = accounts.FirstOrDefault(); if (account is null) { account = new OAuthAccount @@ -83,6 +84,9 @@ public static void AddOAuthAccount(this User user, string providerName, string p if (data is not null) account.ExtraData.Apply(data); + + foreach (var duplicate in accounts.Skip(1)) + user.OAuthAccounts.Remove(duplicate); } public static bool RemoveOAuthAccount(this User user, string providerName, string providerUserId) diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index b234f57e25..8ef23bae9d 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -6,7 +6,7 @@ namespace Exceptionless.Core.Models; -public record User : IIdentity, IHaveDates, IValidatableObject +public record User : IIdentity, IHaveDates, IVersioned, IValidatableObject { /// /// Unique id that identifies an user. @@ -54,6 +54,12 @@ public record User : IIdentity, IHaveDates, IValidatableObject public string? VerifyEmailAddressToken { get; set; } public DateTime VerifyEmailAddressTokenExpiration { get; set; } + /// + /// Repository concurrency metadata used to reject stale writes, preserved in cached snapshots. + /// New users have an empty version until persisted. + /// + public string Version { get; set; } = String.Empty; + /// /// Gets or sets the users active state. /// diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs index abbb5a1344..9c2f52746c 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -28,6 +28,7 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder .Accepts("application/json", "application/*+json") .Produces() .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Login") .WithDescription(""" @@ -86,6 +87,7 @@ headers api_key input box. .Produces() .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Sign up") .WithMetadata(new EndpointDocumentation { @@ -106,6 +108,7 @@ headers api_key input box. .Produces() .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Sign in with GitHub") .WithMetadata(new EndpointDocumentation { @@ -113,6 +116,7 @@ headers api_key input box. ["200"] = "User Authentication Token", ["401"] = "Unauthorized", ["403"] = "Account Creation is currently disabled", + ["409"] = "The account changed while the request was being processed.", ["422"] = "Validation error", } }); @@ -126,6 +130,7 @@ headers api_key input box. .Produces() .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Sign in with Google") .WithMetadata(new EndpointDocumentation { @@ -133,6 +138,7 @@ headers api_key input box. ["200"] = "User Authentication Token", ["401"] = "Unauthorized", ["403"] = "Account Creation is currently disabled", + ["409"] = "The account changed while the request was being processed.", ["422"] = "Validation error", } }); @@ -146,6 +152,7 @@ headers api_key input box. .Produces() .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Sign in with Facebook") .WithMetadata(new EndpointDocumentation { @@ -153,6 +160,7 @@ headers api_key input box. ["200"] = "User Authentication Token", ["401"] = "Unauthorized", ["403"] = "Account Creation is currently disabled", + ["409"] = "The account changed while the request was being processed.", ["422"] = "Validation error", } }); @@ -166,6 +174,7 @@ headers api_key input box. .Produces() .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Sign in with Microsoft") .WithMetadata(new EndpointDocumentation { @@ -173,6 +182,7 @@ headers api_key input box. ["200"] = "User Authentication Token", ["401"] = "Unauthorized", ["403"] = "Account Creation is currently disabled", + ["409"] = "The account changed while the request was being processed.", ["422"] = "Validation error", } }); @@ -183,6 +193,7 @@ headers api_key input box. .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Removes an external login provider from the account") .WithMetadata(new EndpointDocumentation { @@ -194,6 +205,7 @@ headers api_key input box. ["200"] = "User Authentication Token", ["400"] = "Invalid provider user id, or the account would have no sign-in method.", ["401"] = "Unauthorized", + ["409"] = "The account changed while the request was being processed.", ["422"] = "Validation error", } }); @@ -205,10 +217,12 @@ headers api_key input box. .Accepts("application/json", "application/*+json") .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Change password") .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", + ["409"] = "The account changed while the request was being processed.", ["422"] = "Validation error", } }); @@ -242,10 +256,12 @@ headers api_key input box. .Accepts("application/json", "application/*+json") .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Reset password") .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "Password reset email was sent.", + ["409"] = "The account changed while the request was being processed.", ["422"] = "Invalid reset password model.", } }); @@ -261,6 +277,7 @@ headers api_key input box. .AllowAnonymous() .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Cancel reset password") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -269,6 +286,7 @@ headers api_key input box. ResponseDescriptions = new() { ["200"] = "Password reset email was cancelled.", ["400"] = "Invalid password reset token.", + ["409"] = "The account changed while the request was being processed.", } }); diff --git a/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs index 5cd02efbe3..81f5ce842b 100644 --- a/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs @@ -75,6 +75,7 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute .Accepts("application/json", "application/*+json") .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Create") .WithMetadata(new EndpointDocumentation { @@ -190,6 +191,7 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute .Produces(StatusCodes.Status202Accepted) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status500InternalServerError) .WithSummary("Remove") .WithMetadata(new EndpointDocumentation { @@ -281,6 +283,7 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute => (await mediator.InvokeAsync>(new OrganizationMessages.AddOrganizationUser(id, email, httpContext))).ToHttpResult(resultMapper)) .Produces() .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status426UpgradeRequired) .WithSummary("Add user") .WithMetadata(new EndpointDocumentation { @@ -299,6 +302,7 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Remove user") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index b1904356e7..44120e9225 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -110,6 +110,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Update") .WithMetadata(new EndpointDocumentation { RequestBodyDescription = "The changes", @@ -129,6 +130,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Update") .WithMetadata(new EndpointDocumentation { RequestBodyDescription = "The changes", @@ -146,6 +148,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder .Accepts("multipart/form-data") .Produces() .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithMetadata( new MultipartFileUploadAttribute(), @@ -166,6 +169,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder group.MapDelete("users/{id:objectid}/avatar", DeleteAvatarAsync) .Produces() .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Remove avatar") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -196,11 +200,13 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder => (await mediator.InvokeAsync>(new UserMessages.DeleteCurrentUser())).ToHttpResult(resultMapper)) .Produces(StatusCodes.Status202Accepted) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Delete current user") .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["202"] = "Accepted", ["404"] = "The current user could not be found.", + ["409"] = "The account changed while the request was being processed.", } }); @@ -210,6 +216,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status202Accepted) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status500InternalServerError) .WithSummary("Remove") .WithMetadata(new EndpointDocumentation { @@ -220,6 +227,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder ["202"] = "Accepted", ["400"] = "One or more validation errors occurred.", ["404"] = "One or more users were not found.", + ["409"] = "An account changed while the request was being processed.", ["500"] = "An error occurred while deleting one or more users.", } }); @@ -229,6 +237,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .ProducesProblem(StatusCodes.Status429TooManyRequests) .WithSummary("Update email address") @@ -248,6 +257,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder => (await mediator.InvokeAsync(new UserMessages.VerifyEmailAddress(token))).ToHttpResult(resultMapper)) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Verify email address") .WithMetadata(new EndpointDocumentation { @@ -264,6 +274,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder => (await mediator.InvokeAsync(new UserMessages.ResendVerificationEmail(id))).ToHttpResult(resultMapper)) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Resend verification email") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -285,6 +296,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder }) .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status409Conflict) .ExcludeFromDescription(); group.MapPost("users/{id:objectid}/admin-role", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) @@ -292,6 +304,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .ExcludeFromDescription(); group.MapDelete("users/{id:objectid}/admin-role", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) @@ -299,6 +312,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) .ExcludeFromDescription(); return endpoints; diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index adc8689005..f11620e263 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -18,6 +18,7 @@ using Foundatio.Lock; using Foundatio.Mediator; using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; using Microsoft.IdentityModel.Tokens; using OAuth2.Models; @@ -431,7 +432,14 @@ public async Task Handle(ForgotPassword message) } user.CreatePasswordResetToken(timeProvider); - await userRepository.SaveAsync(user, o => o.Cache()); + try + { + await userRepository.SaveAsync(user, o => o.Cache()); + } + catch (VersionConflictDocumentException) + { + return Result.Success(); + } await mailer.SendUserPasswordResetAsync(user); logger.UserForgotPassword(user.EmailAddress); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 51c608eef9..8a0a820509 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -1580,6 +1580,16 @@ } } }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -1718,6 +1728,16 @@ } } }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -1783,6 +1803,16 @@ } } }, + "409": { + "description": "The account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -1848,6 +1878,16 @@ } } }, + "409": { + "description": "The account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -1913,6 +1953,16 @@ } } }, + "409": { + "description": "The account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -1978,6 +2028,16 @@ } } }, + "409": { + "description": "The account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -2055,6 +2115,16 @@ } } }, + "409": { + "description": "The account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -2109,6 +2179,16 @@ } } } + }, + "409": { + "description": "The account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -2182,6 +2262,16 @@ } } } + }, + "409": { + "description": "The account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -2217,6 +2307,16 @@ } } } + }, + "409": { + "description": "The account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -4160,6 +4260,16 @@ } } } + }, + "409": { + "description": "The account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -4378,6 +4488,16 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }, @@ -4444,6 +4564,16 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -4572,6 +4702,16 @@ } } }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "The image file is invalid.", "content": { @@ -4621,6 +4761,16 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -4719,6 +4869,16 @@ } } }, + "409": { + "description": "An account changed while the request was being processed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "500": { "description": "An error occurred while deleting one or more users.", "content": { @@ -4791,6 +4951,16 @@ } } }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -4846,6 +5016,16 @@ } } }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Verify Email Address Token has expired.", "content": { @@ -4890,6 +5070,16 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -6527,6 +6717,16 @@ } } }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Unprocessable Entity", "content": { @@ -6949,6 +7149,16 @@ } } }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "500": { "description": "An error occurred while deleting one or more organizations.", "content": { @@ -7320,6 +7530,16 @@ } } }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "426": { "description": "Please upgrade your plan to add an additional user.", "content": { @@ -7382,6 +7602,16 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -14234,6 +14464,7 @@ "email_notifications_enabled", "is_email_address_verified", "verify_email_address_token_expiration", + "version", "is_active", "roles", "created_utc", @@ -14344,6 +14575,10 @@ "type": "string", "format": "date-time" }, + "version": { + "type": "string", + "description": "Repository concurrency metadata used to reject stale writes, preserved in cached snapshots.\nNew users have an empty version until persisted." + }, "is_active": { "type": "boolean", "description": "Gets or sets the users active state." diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index a481915778..0166efb2cb 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -22,6 +22,7 @@ using FluentRest; using Foundatio.Queues; using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Utility; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -1394,6 +1395,8 @@ public async Task ChangePassword_StoredOldTokenSurvivesCleanup_CannotAuthenticat Assert.NotNull(login); var oldToken = await _tokenRepository.GetByIdAsync(login.Token, o => o.Cache(false)); Assert.NotNull(oldToken); + var staleUser = await _userRepository.GetByIdAsync(oldToken.UserId!, o => o.Cache(false)); + Assert.NotNull(staleUser); await SendRequestAsync(r => r.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeOk()); // Act @@ -1407,11 +1410,15 @@ public async Task ChangePassword_StoredOldTokenSurvivesCleanup_CannotAuthenticat // Assert Assert.NotNull(changed); Assert.NotEqual(login.Token, changed.Token); + staleUser.FullName = "Stale profile update"; + await Assert.ThrowsAsync(() => _userRepository.SaveAsync(staleUser)); 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.True(user.IsCorrectPassword(newPassword)); + Assert.False(user.IsCorrectPassword(SampleDataService.TEST_USER_PASSWORD)); Assert.False(String.IsNullOrEmpty(user.AuthenticationVersion)); Assert.Equal(user.AuthenticationVersion, (await _tokenRepository.GetByIdAsync(changed.Token))!.AuthenticationVersion); } @@ -2155,6 +2162,86 @@ await SendRequestAsync(request => request Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExternalLogin_InterruptedTransfer_CompletesTransferAndKeepsPreviousSessionRevoked(bool destinationCommitted) + { + // Arrange + const string code = "github-failed-transfer"; + var owner = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + var destination = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(owner); + Assert.NotNull(destination); + owner.AddOAuthAccount("github", code, owner.EmailAddress); + await _userRepository.SaveAsync(owner); + var login = await SendRequestAsAsync(request => request.Post().AppendPath("auth/login") + .Content(new Login { Email = owner.EmailAddress, Password = SampleDataService.TEST_USER_PASSWORD }) + .StatusCodeShouldBeOk()); + Assert.NotNull(login); + owner.OAuthAccounts.Clear(); + owner.AuthenticationVersion = StringExtensions.GetNewToken(); + await _userRepository.SaveAsync(owner); + if (destinationCommitted) + { + destination.AddOAuthAccount("github", code, owner.EmailAddress); + await _userRepository.SaveAsync(destination); + } + + var authInfo = new ExternalAuthInfo { ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" }; + + // Act + var retry = await SendRequestAsAsync(request => request.Post().AsTestOrganizationUser().AppendPaths("auth", "github") + .Content(authInfo).StatusCodeShouldBeOk()); + + // Assert + var storedOwner = await _userRepository.GetByIdAsync(owner.Id); + Assert.NotNull(storedOwner); + Assert.DoesNotContain(storedOwner.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + Assert.Equal(owner.AuthenticationVersion, storedOwner.AuthenticationVersion); + Assert.NotNull(retry); + var matchingUsers = await _userRepository.GetUsersByOAuthProviderAsync("github", code, TestCancellationToken); + Assert.Equal(destination.Id, Assert.Single(matchingUsers).Id); + await SendRequestAsync(request => request.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized()); + } + + [Fact] + public async Task ForgotPassword_ConcurrentUserUpdate_MatchesUnknownEmailResponse() + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + var mailQueue = GetService>(); + long enqueued = (await mailQueue.GetQueueStatsAsync()).Enqueued; + bool changed = false; + using var subscription = _userRepository.DocumentsSaving.AddHandler(async (_, args) => + { + if (changed || !args.Documents.Any(document => document.Value.Id == user.Id)) + return; + + changed = true; + var current = await _userRepository.GetByIdAsync(user.Id, options => options.Cache(false)); + Assert.NotNull(current); + current.FullName = "Concurrent profile update"; + await _userRepository.SaveAsync(current); + }); + + // Act + var known = await SendRequestAsync(request => request.AppendPaths("auth", "forgot-password", user.EmailAddress).StatusCodeShouldBeOk()); + var unknown = await SendRequestAsync(request => request.AppendPaths("auth", "forgot-password", "unknown@localhost.test").StatusCodeShouldBeOk()); + var persisted = await _userRepository.GetByIdAsync(user.Id, options => options.Cache(false)); + + // Assert + Assert.True(changed); + Assert.Equal(HttpStatusCode.OK, known.StatusCode); + Assert.Equal(unknown.StatusCode, known.StatusCode); + Assert.Equal(await unknown.Content.ReadAsStringAsync(TestCancellationToken), await known.Content.ReadAsStringAsync(TestCancellationToken)); + Assert.NotNull(persisted); + Assert.Equal("Concurrent profile update", persisted.FullName); + Assert.Equal(user.PasswordResetToken, persisted.PasswordResetToken); + Assert.Equal(enqueued, (await mailQueue.GetQueueStatsAsync()).Enqueued); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -2212,8 +2299,13 @@ public async Task ExternalLogin_ExistingIdentity_RefreshesAccountMetadata(bool i { ["name"] = "Outdated name", ["email"] = "outdated@example.test", ["retained"] = "keep" }); - await _userRepository.SaveAsync(owner, options => options.ImmediateConsistency()); int originalAccountCount = owner.OAuthAccounts.Count; + owner.OAuthAccounts.Add(new OAuthAccount + { + Provider = "github", ProviderUserId = code, Username = "stale-duplicate@example.test", + ExtraData = new SettingsDictionary { ["email"] = "stale-duplicate@example.test" } + }); + await _userRepository.SaveAsync(owner, options => options.ImmediateConsistency()); // Act var result = await SendRequestAsAsync(request => @@ -2239,6 +2331,7 @@ public async Task ExternalLogin_ExistingIdentity_RefreshesAccountMetadata(bool i Assert.Equal("github User", account.ExtraData["name"]); Assert.Equal(TestOAuthProviderClient.GetEmailAddress(code), account.ExtraData["email"]); Assert.Equal("keep", account.ExtraData["retained"]); + Assert.DoesNotContain(stored.OAuthAccounts, account => account.EmailAddress() == "stale-duplicate@example.test"); Assert.Equal(owner.EmailAddress, stored.EmailAddress); Assert.Equal(owner.FullName, stored.FullName); } diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 6d76292bd7..66c58857c5 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -58,6 +58,28 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp Assert.True(logoutPath.TryGetProperty("get", out var logoutGet)); AssertResponseCodes(logoutGet, "200", "401", "403"); + foreach (var (path, method) in new[] { + ("/api/v2/organizations", "post"), + ("/api/v2/organizations/{ids}", "delete"), + ("/api/v2/organizations/{id}/users/{email}", "post"), + ("/api/v2/organizations/{id}/users/{email}", "delete"), + ("/api/v2/auth/login", "post"), + ("/api/v2/auth/signup", "post"), + ("/api/v2/users/{id}", "patch"), + ("/api/v2/users/{id}", "put"), + ("/api/v2/users/me", "delete"), + ("/api/v2/users/{ids}", "delete"), + ("/api/v2/users/{id}/avatar", "post"), + ("/api/v2/users/{id}/avatar", "delete"), + ("/api/v2/users/{id}/email-address/{email}", "post"), + ("/api/v2/users/verify-email-address/{token}", "get"), + ("/api/v2/users/{id}/resend-verification-email", "get") + }) + AssertResponseCodes(paths.GetProperty(path).GetProperty(method), "409"); + + var forgotPassword = paths.GetProperty("/api/v2/auth/forgot-password/{email}").GetProperty("get"); + Assert.False(forgotPassword.GetProperty("responses").TryGetProperty("409", out _)); + Assert.True(paths.TryGetProperty("/api/v2/projects", out var projectsPath)); Assert.True(projectsPath.TryGetProperty("post", out var projectsPost)); Assert.True(projectsPost.TryGetProperty("requestBody", out _)); @@ -133,6 +155,9 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem Assert.True(schemas.TryGetProperty("RecordProductTourResult", out var recordProductTourResult)); Assert.Equal("recorded_utc", Assert.Single(recordProductTourResult.GetProperty("properties").EnumerateObject()).Name); Assert.True(schemas.TryGetProperty("ViewOrganization", out _)); + var user = schemas.GetProperty("User"); + Assert.Contains(user.GetProperty("required").EnumerateArray(), property => property.GetString() == "version"); + Assert.Equal("string", user.GetProperty("properties").GetProperty("version").GetProperty("type").GetString()); var savedViewColumnProperties = savedViewColumnSettings.GetProperty("properties"); Assert.Equal("boolean", savedViewColumnProperties.GetProperty("auto_fill").GetProperty("type")[1].GetString()); diff --git a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs index 60cac89c2a..1cee7b91a4 100644 --- a/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/UserRepositoryTests.cs @@ -1,8 +1,14 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elastic.Clients.Elasticsearch; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; using Exceptionless.Core.Validation; using Foundatio.Repositories; +using Foundatio.Repositories.Elasticsearch; +using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Models; using Xunit; @@ -17,6 +23,178 @@ public UserRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) _repository = GetService(); } + [Theory] + [InlineData("id")] + [InlineData("ids")] + [InlineData("email")] + public async Task GetAsync_StoredUserWithoutVersion_ReadsVersionWithoutRewritingDocument(string lookup) + { + // Arrange + const string userId = "770000000000000000000096"; + const string emailAddress = "legacy@localhost.test"; + var configuration = GetService(); + var source = new JsonObject + { + ["id"] = userId, + ["email_address"] = emailAddress, + ["full_name"] = "Existing User" + }; + var indexed = await configuration.Client.IndexAsync(source, + request => request.Index(configuration.Users.VersionedName).Id(userId).Refresh(Refresh.WaitFor), + TestCancellationToken); + string expectedVersion = new ElasticDocumentVersion(indexed.PrimaryTerm.GetValueOrDefault(), indexed.SeqNo.GetValueOrDefault()); + + // Act + var loaded = lookup switch + { + "ids" => (await _repository.GetByIdsAsync(new[] { userId })).Single(), + "email" => await _repository.GetByEmailAddressAsync(emailAddress), + _ => await _repository.GetByIdAsync(userId) + }; + var stored = await configuration.Client.GetAsync( + configuration.Users.VersionedName, userId, TestCancellationToken); + + // Assert + Assert.True(indexed.IsValidResponse); + Assert.NotNull(loaded); + Assert.Equal(expectedVersion, loaded.Version); + Assert.Equal("Existing User", loaded.FullName); + Assert.True(stored.IsValidResponse); + Assert.Equal(indexed.SeqNo, stored.SeqNo); + Assert.False(stored.Source.TryGetProperty("version", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PatchAsync_MembershipChanges_RefreshesCachedUserLookups(bool remove) + { + // Arrange + const string organizationId = "770000000000000000000001"; + var user = new User { FullName = "Cached User", EmailAddress = "cached@localhost.test" }; + user.MarkEmailAddressVerified(); + if (remove) + user.OrganizationIds.Add(organizationId); + await _repository.AddAsync(user, options => options.Cache()); + var before = await _repository.GetByIdAsync(user.Id, options => options.Cache()); + var beforeEmail = await _repository.GetByEmailAddressAsync(user.EmailAddress); + + // Act + bool patched = await _repository.PatchAsync(user.Id, new ActionPatch(current => + { + if (remove) + current.OrganizationIds.Remove(organizationId); + else + current.OrganizationIds.Add(organizationId); + }), options => options.Cache()); + var byId = await _repository.GetByIdAsync(user.Id, options => options.Cache()); + var byEmail = await _repository.GetByEmailAddressAsync(user.EmailAddress); + var stored = await _repository.GetByIdAsync(user.Id, options => options.Cache(false)); + + // Assert + Assert.NotNull(before); + Assert.NotNull(beforeEmail); + Assert.Equal(remove, before.OrganizationIds.Contains(organizationId)); + Assert.Equal(remove, beforeEmail.OrganizationIds.Contains(organizationId)); + Assert.True(patched); + Assert.NotNull(stored); + Assert.NotEqual(before.Version, stored.Version); + foreach (var loaded in new[] { byId, byEmail }) + { + Assert.NotNull(loaded); + Assert.Equal(!remove, loaded.OrganizationIds.Contains(organizationId)); + Assert.Equal(stored.Version, loaded.Version); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateProfileAsync_TourRecordedAfterSnapshot_PreservesProgressAndUnchangedFields(bool updateFullName) + { + // Arrange + var user = new User { FullName = "Existing User", EmailAddress = "profile@localhost.test" }; + user.MarkEmailAddressVerified(); + await _repository.AddAsync(user, options => options.Cache()); + var recordedUtc = TimeProvider.GetUtcNow().UtcDateTime; + await _repository.RecordProductTourAsync(user, "app_overview", recordedUtc); + await _repository.GetByIdAsync(user.Id, options => options.Cache()); + await _repository.GetByEmailAddressAsync(user.EmailAddress); + + // Act + var updated = await _repository.UpdateProfileAsync(user, updateFullName ? "Updated Name" : null, updateFullName ? null : false); + var byId = await _repository.GetByIdAsync(user.Id, options => options.Cache()); + var byEmail = await _repository.GetByEmailAddressAsync(user.EmailAddress); + + // Assert + Assert.Empty(user.ProductTours); + foreach (var result in new[] { updated, byId, byEmail }) + { + Assert.NotNull(result); + Assert.Equal(recordedUtc, result.ProductTours["app_overview"].GetDateTime()); + Assert.Equal(updateFullName ? "Updated Name" : user.FullName, result.FullName); + Assert.Equal(updateFullName && user.EmailNotificationsEnabled, result.EmailNotificationsEnabled); + Assert.Equal(user.EmailAddress, result.EmailAddress); + Assert.Equal(user.CreatedUtc, result.CreatedUtc); + Assert.Equal(user.Roles, result.Roles); + } + } + + [Fact] + public async Task UpdateProfileAsync_UserDeletedAfterSnapshot_DoesNotRecreateUser() + { + // Arrange + var user = new User { FullName = "Deleted User", EmailAddress = "deleted@localhost.test" }; + user.MarkEmailAddressVerified(); + await _repository.AddAsync(user, options => options.Cache()); + await _repository.RemoveAsync(user); + + // Act + var failure = await Record.ExceptionAsync(async () => + { + _ = await _repository.UpdateProfileAsync(user, "Updated Name", null); + }); + var stored = await _repository.GetByIdAsync(user.Id); + + // Assert + Assert.IsType(failure); + Assert.Null(stored); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SaveAsync_VersionChecks_RejectStaleOrMissingVersion(bool missingVersion) + { + // Arrange + var user = new User { FullName = "Versioned User", EmailAddress = "versioned@localhost.test" }; + user.MarkEmailAddressVerified(); + await _repository.AddAsync(user, options => options.ImmediateConsistency()); + var stale = user with { FullName = "Stale profile" }; + user.AuthenticationVersion = "rotated-generation"; + string originalVersion = user.Version; + var saved = await _repository.SaveAsync(user, options => options.ImmediateConsistency()); + if (missingVersion) + stale.Version = null!; + + // Act + var failure = await Record.ExceptionAsync(async () => + { + _ = await _repository.SaveAsync(stale); + }); + var persisted = await _repository.GetByIdAsync(user.Id); + + // Assert + Assert.IsAssignableFrom(failure); + if (!missingVersion) + Assert.IsType(failure); + Assert.Same(user, saved); + Assert.NotEqual(originalVersion, user.Version); + Assert.NotNull(persisted); + Assert.Equal("rotated-generation", persisted.AuthenticationVersion); + Assert.Equal(user.FullName, persisted.FullName); + } + [Theory] [InlineData("unknown", "new-subject")] [InlineData("github", " ")] diff --git a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs index 37d82962e2..851da28111 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; +using Foundatio.Repositories.Elasticsearch; using Foundatio.Serializer; using Xunit; @@ -231,6 +233,7 @@ public void Deserialize_SnakeCaseJson_PreservesOrganizationIds() Assert.NotNull(user); Assert.Equal("user-from-cache", user.Id); Assert.Null(user.AuthenticationVersion); + Assert.Empty(user.Version); Assert.Equal(2, user.OrganizationIds.Count); Assert.Contains("cached-org-1", user.OrganizationIds); Assert.Contains("cached-org-2", user.OrganizationIds); @@ -260,11 +263,14 @@ public void Serialize_UserWithProductTourState_PreservesUiDefinedKeys() // Act string? json = _serializer.SerializeToString(original); + using var document = JsonDocument.Parse(json!); + string productToursJson = document.RootElement.GetProperty("product_tours").GetRawText(); + // Assert Assert.Contains("\"product_tours\":{\"app_overview\":\"2024-01-15T12:00:00Z\",\"future_guide_v2\":\"2024-01-15T12:01:00Z\"}", json); - Assert.DoesNotContain("status", json, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("version", json, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("event_investigate", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("status", productToursJson, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("version", productToursJson, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("event_investigate", productToursJson, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -420,11 +426,13 @@ public void Deserialize_UserWithEmptyCollections_ReturnsEmptyCollections() public void Deserialize_UserWithAllProperties_PreservesAllValues() { // Arrange - const string authenticationVersion = "current-version"; + string authenticationVersion = StringExtensions.GetNewToken(); + string documentVersion = new ElasticDocumentVersion(primaryTerm: 1, sequenceNumber: 42); var original = new User { Id = "full-user", AuthenticationVersion = authenticationVersion, + Version = documentVersion, FullName = "Full Test User", EmailAddress = "full@example.com", Password = "hashedpassword", @@ -441,13 +449,14 @@ public void Deserialize_UserWithAllProperties_PreservesAllValues() original.Roles.Add("user"); // Act - string? json = _serializer.SerializeToString(original); + string json = _serializer.SerializeToString(original)!; var deserialized = _serializer.Deserialize(json); // Assert Assert.NotNull(deserialized); Assert.Equal("full-user", deserialized.Id); Assert.Equal(authenticationVersion, deserialized.AuthenticationVersion); + Assert.Equal(documentVersion, deserialized.Version); Assert.Equal("Full Test User", deserialized.FullName); Assert.Equal("full@example.com", deserialized.EmailAddress); Assert.Equal("hashedpassword", deserialized.Password); From 3df3ea2bc81a6d5970fb4f3afdd8bdee4efcaba9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 21 Oct 2026 22:52:22 -0500 Subject: [PATCH 22/27] Wait for selectable stack rows before opening details in browser tests --- .../ClientApp/e2e/tests/stack-effects-chaos.e2e.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/stack-effects-chaos.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-effects-chaos.e2e.ts index a406b1e505..fbcf387ed4 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/stack-effects-chaos.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-effects-chaos.e2e.ts @@ -38,6 +38,10 @@ test('stack effects stay bounded through background, paging, and navigation chao await installWebSocketTestHarness(page, { ignoreServerMessages: true }); const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); + const firstStackRow = page + .getByRole('row') + .filter({ has: page.getByRole('checkbox', { exact: true, name: 'Select row' }) }) + .first(); const diagnostics: RuntimeDiagnostics = { actionSamples: [], activeAction: 'setup', @@ -67,7 +71,7 @@ test('stack effects stay bounded through background, paging, and navigation chao await page.goto('/stack/all?limit=5'); expect((await response).ok()).toBe(true); await expect(page.getByRole('heading', { exact: true, name: 'All' })).toBeVisible(); - await expect(page.locator('tbody tr:visible').first()).toBeVisible(); + await expect(firstStackRow).toBeVisible(); await expect(page.getByRole('button', { name: 'Go to next page' })).toBeEnabled(); }); @@ -99,7 +103,7 @@ test('stack effects stay bounded through background, paging, and navigation chao }); await measureAction(diagnostics, 'stack timeline drag', async () => { - await page.locator('tbody tr:visible').first().click(); + await firstStackRow.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); const timeline = dialog.locator('[data-slot="chart"]'); @@ -147,7 +151,7 @@ test('stack effects stay bounded through background, paging, and navigation chao await measureAction(diagnostics, 'stack detail mount and teardown', async () => { for (let index = 0; index < 5; index++) { - await page.locator('tbody tr:visible').first().click(); + await firstStackRow.click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('button', { name: 'Close' }).click(); await expect(page.getByRole('dialog')).not.toBeVisible(); From d99fc68df68fcae2315a4caa24081177aefcda40 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 21 Oct 2026 22:56:36 -0500 Subject: [PATCH 23/27] Retain the original deletion contract after verifying repository version semantics --- .../Api/Endpoints/UserEndpoints.cs | 4 ---- .../Exceptionless.Tests/Api/Data/openapi.json | 22 +------------------ .../Api/OpenApiSnapshotTests.cs | 2 -- 3 files changed, 1 insertion(+), 27 deletions(-) diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index 44120e9225..9ca75dcd56 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -200,13 +200,11 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder => (await mediator.InvokeAsync>(new UserMessages.DeleteCurrentUser())).ToHttpResult(resultMapper)) .Produces(StatusCodes.Status202Accepted) .ProducesProblem(StatusCodes.Status404NotFound) - .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Delete current user") .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["202"] = "Accepted", ["404"] = "The current user could not be found.", - ["409"] = "The account changed while the request was being processed.", } }); @@ -216,7 +214,6 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status202Accepted) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) - .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status500InternalServerError) .WithSummary("Remove") .WithMetadata(new EndpointDocumentation { @@ -227,7 +224,6 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder ["202"] = "Accepted", ["400"] = "One or more validation errors occurred.", ["404"] = "One or more users were not found.", - ["409"] = "An account changed while the request was being processed.", ["500"] = "An error occurred while deleting one or more users.", } }); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 8a0a820509..fffa38f48e 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -4260,16 +4260,6 @@ } } } - }, - "409": { - "description": "The account changed while the request was being processed.", - "content": { - "application/problem\u002Bjson": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } } } } @@ -4869,16 +4859,6 @@ } } }, - "409": { - "description": "An account changed while the request was being processed.", - "content": { - "application/problem\u002Bjson": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "500": { "description": "An error occurred while deleting one or more users.", "content": { @@ -15567,4 +15547,4 @@ "name": "Source Map" } ] -} \ No newline at end of file +} diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 66c58857c5..5f04d7b1c6 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -67,8 +67,6 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp ("/api/v2/auth/signup", "post"), ("/api/v2/users/{id}", "patch"), ("/api/v2/users/{id}", "put"), - ("/api/v2/users/me", "delete"), - ("/api/v2/users/{ids}", "delete"), ("/api/v2/users/{id}/avatar", "post"), ("/api/v2/users/{id}/avatar", "delete"), ("/api/v2/users/{id}/email-address/{email}", "post"), From 89d85141a2d343c3e65081fc7d2b3dcb486dbe04 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 22 Oct 2026 14:28:44 -0500 Subject: [PATCH 24/27] Use canonical provider names in linked-email test fixtures --- tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs index 890328f40f..7c2b1d1a79 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs @@ -1057,8 +1057,8 @@ public async Task UpdateEmailAddressAsync_ValidEmail_ReturnsResult() } [Theory] - [InlineData("Microsoft")] - [InlineData("Google")] + [InlineData(OAuthAccount.KnownProviders.Microsoft)] + [InlineData(OAuthAccount.KnownProviders.Google)] public async Task UpdateEmailAddressAsync_LinkedProviderEmail_MarksAddressVerified(string provider) { // Arrange From ba20d70fe08ad03b853efc13e3c8aec735821752 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 22 Oct 2026 22:10:31 -0500 Subject: [PATCH 25/27] Restore the previous OAuth owner when the destination save conflicts --- .../Api/Handlers/AuthHandler.cs | 20 +++++- .../ExternalAccountConcurrencyTests.cs | 61 +++++++++++++++++++ .../Api/Handlers/AuthHandlerTests.cs | 57 +++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index 1ee7b09fa6..79c4362254 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -19,6 +19,7 @@ using Foundatio.Mediator; using Foundatio.Repositories; using Foundatio.Repositories.Exceptions; +using Foundatio.Repositories.Models; using Microsoft.IdentityModel.Tokens; using OAuth2.Models; @@ -637,10 +638,14 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit if (!String.Equals(authenticatedUser.AuthenticationVersion, currentUser.AuthenticationVersion, StringComparison.Ordinal)) throw new UnauthorizedAccessException("The user session has been revoked."); CompleteExternalAccountAdmission(admissionCancellation); + OAuthAccount? transferredAccount = null; if (existingUser is not null) { if (!String.Equals(existingUser.Id, currentUser.Id, StringComparison.Ordinal)) { + transferredAccount = existingUser.OAuthAccounts.First(account => + String.Equals(account.Provider, userInfo.ProviderName, StringComparison.Ordinal) + && String.Equals(account.ProviderUserId, userInfo.Id, StringComparison.Ordinal)); if (!existingUser.RemoveOAuthAccount(userInfo.ProviderName, userInfo.Id)) throw new Exception($"Unable to remove existing oauth account for existing user: {existingUser.EmailAddress}"); @@ -652,7 +657,20 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit currentUser.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email, accountData); RemoveLegacyMicrosoftOAuthAccounts(currentUser, userInfo.ProviderName); - return await userRepository.SaveAsync(currentUser, o => o.Cache()); + try + { + return await userRepository.SaveAsync(currentUser, o => o.Cache()); + } + catch (VersionConflictDocumentException) when (existingUser is not null && transferredAccount is not null) + { + // The destination write did not commit. Restore the link on fresh state, retaining session revocation. + bool restored = await userRepository.PatchAsync(existingUser.Id, new ActionPatch(user => + user.AddOAuthAccount(transferredAccount.Provider, transferredAccount.ProviderUserId, transferredAccount.Username, transferredAccount.ExtraData))); + if (!restored) + throw new InvalidOperationException("Unable to restore the previous external account owner."); + + throw; + } } if (existingUser is not null) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs index 984ecc28a9..44179c5062 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ExternalAccountConcurrencyTests.cs @@ -6,9 +6,11 @@ using Exceptionless.Core.Repositories.Configuration; using Exceptionless.Core.Models; using Exceptionless.Tests.Utility; +using Exceptionless.Tests.Extensions; using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Exceptionless.Web.Models; +using FluentRest; using Foundatio.Repositories; using Foundatio.Caching; using Foundatio.Repositories.Models; @@ -124,6 +126,65 @@ public async Task ExternalLogin_CanceledLookup_ReleasesLocksForNextRequest(strin Assert.NotNull(await _tokens.GetByIdAsync(nextResult.Token)); } + [Fact] + public async Task ExternalLogin_DestinationVersionConflict_PreservesPreviousOwnerAndConcurrentChanges() + { + // Arrange + const string code = "external-account-transfer-conflict"; + const string newPassword = "ChangedDestination2$"; + var owner = await _users.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + var destination = await _users.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(owner); + Assert.NotNull(destination); + owner.AddOAuthAccount("github", code, "original-provider-name", new SettingsDictionary { ["profile"] = "original" }); + await _users.SaveAsync(owner); + string? originalAuthenticationVersion = owner.AuthenticationVersion; + bool destinationChanged = false; + TokenResult? replacementToken = null; + using var subscription = _users.DocumentsSaving.AddHandler(async (_, args) => + { + if (destinationChanged || !args.Documents.Any(document => document.Value.Id == destination.Id)) + return; + + destinationChanged = true; + replacementToken = await SendRequestAsAsync(request => request.AsTestOrganizationUser() + .Post().AppendPath("auth/change-password") + .Content(new ChangePasswordModel { CurrentPassword = SampleDataService.TEST_ORG_USER_PASSWORD, Password = newPassword }) + .StatusCodeShouldBeOk()); + var currentOwner = await _users.GetByIdAsync(owner.Id, options => options.Cache(false)) + ?? throw new InvalidOperationException("The previous owner no longer exists."); + currentOwner.FullName = "Concurrent owner update"; + await _users.SaveAsync(currentOwner); + }); + using var client = _server.CreateClient(); + SetAuthorization(client, SampleDataService.TEST_ORG_USER_EMAIL, SampleDataService.TEST_ORG_USER_PASSWORD); + + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/github", new ExternalAuthInfo + { + ClientId = "client-id", Code = code, RedirectUri = "http://localhost/callback" + }, TestCancellationToken); + var storedOwner = await _users.GetByIdAsync(owner.Id, options => options.Cache(false)); + var storedDestination = await _users.GetByIdAsync(destination.Id, options => options.Cache(false)); + var owners = await _users.GetUsersByOAuthProviderAsync("github", code, TestCancellationToken); + + // Assert + Assert.True(destinationChanged); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(owner.Id, Assert.Single(owners).Id); + Assert.NotNull(storedOwner); + Assert.Equal("Concurrent owner update", storedOwner.FullName); + Assert.NotEqual(originalAuthenticationVersion, storedOwner.AuthenticationVersion); + var restoredAccount = Assert.Single(storedOwner.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + Assert.Equal("original-provider-name", restoredAccount.Username); + Assert.Equal("original", restoredAccount.ExtraData["profile"]); + Assert.NotNull(storedDestination); + Assert.True(storedDestination.IsCorrectPassword(newPassword)); + Assert.DoesNotContain(storedDestination.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + Assert.NotNull(replacementToken); + await SendRequestAsync(request => request.BearerToken(replacementToken.Token).AppendPath("users/me").StatusCodeShouldBeOk()); + } + [Fact] public async Task ExternalLogin_StaleCachedEmailMiss_LinksStoredUserWithoutCreatingDuplicate() { diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index 30d642e574..af18dc10f6 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -18,6 +18,7 @@ using Foundatio.Lock; using Foundatio.Mediator; using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Models; using Microsoft.AspNetCore.Http; using Moq; @@ -159,6 +160,62 @@ public async Task Handle_LinkExternalAccountOwnedByAnotherUser_RevokesPreviousOw providerClient.VerifyAll(); } + [Fact] + public async Task Handle_TransferDestinationConflict_RestoresPreviousOwnerWithoutRestoringRevokedSessions() + { + // Arrange + const string subject = "transfer-destination-conflict"; + var owner = new User { Id = TestConstants.UserId, FullName = "Owner", EmailAddress = "owner@example.test", Password = "local-password", AuthenticationVersion = "old-owner-session" }; + owner.AddOAuthAccount("github", subject, "original-name", new SettingsDictionary { ["profile"] = "original" }); + var current = new User { Id = TestConstants.UserId2, FullName = "Destination", EmailAddress = "destination@example.test", AuthenticationVersion = "destination-session" }; + var storedOwner = owner; + var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(IdentityUtils.UserAuthenticationType)) }; + context.Request.SetUser(current); + var conflict = new VersionConflictDocumentException("Destination changed."); + var users = new Mock(MockBehavior.Strict); + users.Setup(repository => repository.GetUsersByOAuthProviderAsync("github", subject, It.IsAny())).ReturnsAsync(new[] { owner }); + users.Setup(repository => repository.GetByIdAsync(owner.Id, It.IsAny>())).ReturnsAsync(owner); + users.Setup(repository => repository.GetByIdAsync(current.Id, It.IsAny>())).ReturnsAsync(current); + users.Setup(repository => repository.SaveAsync(owner, It.IsAny>())) + .Callback(() => storedOwner = owner with { FullName = "Concurrent profile update", OAuthAccounts = owner.OAuthAccounts.ToHashSet() }).ReturnsAsync(owner); + users.Setup(repository => repository.SaveAsync(current, It.IsAny>())).ThrowsAsync(conflict); + users.Setup(repository => repository.PatchAsync(owner.Id, It.IsAny(), It.IsAny())) + .Callback((Id _, IPatchOperation operation, ICommandOptions _) => + { + foreach (var action in ((ActionPatch)operation).Actions) + action(storedOwner); + }).ReturnsAsync(true); + var tokens = new Mock(MockBehavior.Strict); + tokens.Setup(repository => repository.RemoveAllByUserIdAsync(owner.Id, null)).ReturnsAsync(1L); + var oauthTokens = new Mock(MockBehavior.Strict); + oauthTokens.Setup(repository => repository.RemoveAllByUserIdAsync(owner.Id, null)).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 = "github", Id = subject, Email = owner.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 exception = await Record.ExceptionAsync(async () => + { + _ = await handler.Handle(new GitHubLogin(new ExternalAuthInfo(), context)); + }); + + // Assert + Assert.Same(conflict, exception); + var account = Assert.Single(storedOwner.OAuthAccounts); + Assert.Equal(subject, account.ProviderUserId); + Assert.Equal("original-name", account.Username); + Assert.Equal("original", account.ExtraData["profile"]); + Assert.Equal("Concurrent profile update", storedOwner.FullName); + Assert.NotEqual("old-owner-session", storedOwner.AuthenticationVersion); + users.VerifyAll(); + tokens.VerifyAll(); + oauthTokens.VerifyAll(); + } + [Fact] public async Task Handle_UnlinkCanceledAfterPersistence_CompletesTokenIssuance() { From 217e1f87b39a753bcd55e66cc4330f57d458bcf7 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 22 Oct 2026 22:10:31 -0500 Subject: [PATCH 26/27] Offer retry after password reset cancellation fails --- .../e2e/tests/password-recovery.e2e.ts | 22 +++++++ .../reset-password/[token]/+page.svelte | 37 +++++++++--- .../reset-password-page.svelte.test.ts | 58 +++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(auth)/reset-password/[token]/reset-password-page.svelte.test.ts diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts index 81bfd692c7..e14e34f765 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts @@ -5,6 +5,28 @@ const RESET_PASSWORD = `${E2E_TEST_PASSWORD}-reset`; test.skip(process.env.E2E_ENV === 'production', 'Password recovery requires local Mailpit.'); test.use({ e2eCleanupPassword: RESET_PASSWORD, e2eInjectBrowserToken: false, e2eUseInvitedUser: true }); +test('failed password-reset cancellation can be retried without reloading', async ({ page }) => { + let attempts = 0; + await page.route('**/api/v2/auth/cancel-reset-password/cancel-retry', async (route) => { + attempts++; + await route.fulfill({ + body: attempts === 1 ? JSON.stringify({ status: 409, title: 'User changed' }) : '', + contentType: 'application/problem+json', + status: attempts === 1 ? 409 : 200 + }); + }); + + await page.goto('/reset-password/cancel-retry?cancel=true'); + await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible(); + await expect(page.getByText('Cancelling password reset...')).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Back to Login' })).toBeVisible(); + expect(attempts).toBe(1); + + await page.getByRole('button', { name: 'Try Again' }).click(); + await expect(page).toHaveURL(/\/login(?:[?#]|$)/); + expect(attempts).toBe(2); +}); + test('user can reset a forgotten password and log in @signup', async ({ e2eApi, e2eScenario, page }) => { await test.step('request a password reset through the UI', async () => { await page.goto('/forgot-password'); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/reset-password/[token]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/reset-password/[token]/+page.svelte index 41fad89cb0..b9b48a1fe5 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/reset-password/[token]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/reset-password/[token]/+page.svelte @@ -3,6 +3,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { createForm } from '@tanstack/svelte-form'; + import { untrack } from 'svelte'; import { toast } from 'svelte-sonner'; import ErrorMessage from '#comp/error-message.svelte'; @@ -19,6 +20,8 @@ const token = page.params.token; const shouldCancel = page.url.searchParams.get('cancel') === 'true'; + let isCancelling = $state(false); + let cancelFailed = $state(false); const form = createForm(() => ({ defaultValues: { @@ -49,19 +52,29 @@ })); async function handleCancelToken() { - if (shouldCancel && token) { + if (!shouldCancel || !token || isCancelling) { + return; + } + + isCancelling = true; + cancelFailed = false; + try { const response = await cancelResetPassword(token); if (!response.ok) { - toast.error('An error occurred while trying to cancel your password reset. Please try again.'); - return; + throw new Error('Password reset cancellation failed'); } await goto(resolve('/(auth)/login')); + } catch { + cancelFailed = true; + toast.error('An error occurred while trying to cancel your password reset. Please try again.'); + } finally { + isCancelling = false; } } $effect(() => { - handleCancelToken(); + untrack(handleCancelToken); }); @@ -69,12 +82,20 @@ - Cancelling password reset... + {cancelFailed ? 'Unable to cancel password reset' : 'Cancelling password reset...'} -
- -
+ {#if cancelFailed} + + +

+ Back to Login +

+ {:else} +
+ +
+ {/if}
{:else} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/reset-password/[token]/reset-password-page.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/reset-password/[token]/reset-password-page.svelte.test.ts new file mode 100644 index 0000000000..a4bff57055 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/reset-password/[token]/reset-password-page.svelte.test.ts @@ -0,0 +1,58 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import ResetPasswordPage from './+page.svelte'; + +const mocks = vi.hoisted(() => ({ + cancelResetPassword: vi.fn(), + goto: vi.fn(), + toastError: vi.fn() +})); + +vi.mock('$app/navigation', () => ({ goto: mocks.goto })); +vi.mock('$app/paths', () => ({ resolve: (path: string) => path.replace('/(auth)', '') })); +vi.mock('$app/state', () => ({ page: { params: { token: 'reset-token' }, url: new URL('http://localhost/reset-password/reset-token?cancel=true') } })); +vi.mock('#features/auth/api.svelte.ts', () => ({ cancelResetPassword: mocks.cancelResetPassword, resetPassword: vi.fn() })); +vi.mock('svelte-sonner', () => ({ toast: { error: mocks.toastError } })); + +describe('password reset cancellation', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('CancelResetPassword_Conflict_OffersRetryAndNavigatesOnlyAfterSuccess', async () => { + // Arrange + mocks.cancelResetPassword.mockResolvedValueOnce({ ok: false, status: 409 }).mockResolvedValueOnce({ ok: true, status: 200 }); + render(ResetPasswordPage); + + // Act + const retry = await screen.findByRole('button', { name: 'Try Again' }); + + // Assert + expect(mocks.goto).not.toHaveBeenCalled(); + expect(mocks.cancelResetPassword).toHaveBeenCalledExactlyOnceWith('reset-token'); + expect(screen.queryByText('Cancelling password reset...')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Back to Login' })).toHaveAttribute('href', '/login'); + + // Act + await fireEvent.click(retry); + + // Assert + await waitFor(() => expect(mocks.goto).toHaveBeenCalledWith('/login')); + expect(mocks.cancelResetPassword).toHaveBeenCalledTimes(2); + }); + + it('CancelResetPassword_NetworkFailure_OffersRetry', async () => { + // Arrange + mocks.cancelResetPassword.mockRejectedValueOnce(new Error('Connection lost')); + render(ResetPasswordPage); + + // Act + const retry = await screen.findByRole('button', { name: 'Try Again' }); + + // Assert + expect(retry).toBeEnabled(); + expect(mocks.goto).not.toHaveBeenCalled(); + expect(mocks.cancelResetPassword).toHaveBeenCalledOnce(); + }); +}); From 97f935932a23c232cafb4cfe88a5f9ba3a378453 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 22 Oct 2026 23:01:16 -0500 Subject: [PATCH 27/27] Preserve first-user admin assignment after canceled signup --- .../Api/Handlers/AuthHandler.cs | 6 -- .../Api/Handlers/AuthHandlerTests.cs | 57 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index 79c4362254..6e0d01e100 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -42,7 +42,6 @@ public class AuthHandler( ILogger logger) { private readonly ScopedCacheClient _cache = new(cacheClient, "Auth"); - private static bool _isFirstUserChecked; private static readonly TimeSpan IntercomJwtLifetime = TimeSpan.FromMinutes(60); private static readonly TimeSpan ExternalAccountAdmissionTimeout = TimeSpan.FromSeconds(15); @@ -505,14 +504,9 @@ public async Task Handle(CancelResetPassword message) private async Task AddGlobalAdminRoleIfFirstUserAsync(User user, CancellationToken cancellationToken = default) { - if (_isFirstUserChecked) - return; - bool isFirstUser = await userRepository.CountAsync().WaitAsync(cancellationToken) == 0; if (isFirstUser) user.Roles.Add(AuthorizationRoles.GlobalAdmin); - - _isFirstUserChecked = true; } private async Task> ExternalLoginAsync(ExternalAuthInfo authInfo, HttpContext httpContext, string? appId, string? appSecret, Func> getUserInfoAsync) diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index af18dc10f6..4d4fc31584 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Exceptionless.Core; using Exceptionless.Core.Authentication; +using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; @@ -288,6 +289,62 @@ public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exce new OperationCanceledException("Repository operation was canceled.") }; + [Fact] + public async Task Handle_FirstOAuthSignupCanceledAfterCount_RetryCreatesGlobalAdmin() + { + // Arrange + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + var users = new Mock(MockBehavior.Strict); + users.Setup(repository => repository.GetUsersByOAuthProviderAsync("github", "first-user", It.IsAny())) + .ReturnsAsync(Array.Empty()); + users.Setup(repository => repository.FindOneAsync(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(FindHit.Empty); + int countCalls = 0; + users.Setup(repository => repository.CountAsync(It.IsAny())).Returns(() => + { + if (++countCalls is 1) + cancellation.Cancel(); + + return Task.FromResult(CountResult.Empty); + }); + User? storedUser = null; + users.Setup(repository => repository.AddAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((User user, CommandOptionsDescriptor _) => + { + user.Id = TestConstants.UserId; + storedUser = user; + return user; + }); + var tokens = new Mock(MockBehavior.Strict); + tokens.Setup(repository => repository.GetByTypeAndUserIdAsync(TokenType.Authentication, TestConstants.UserId, null)) + .ReturnsAsync(FindResults.Empty); + tokens.Setup(repository => repository.AddAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((Token token, CommandOptionsDescriptor _) => token); + var oauthTokens = new Mock(MockBehavior.Strict); + var providerClient = new Mock(MockBehavior.Strict); + providerClient.Setup(client => client.GetGitHubUserInfoAsync(It.IsAny(), "test-client", "test-secret")) + .ReturnsAsync(new OAuth2.Models.UserInfo { ProviderName = "github", Id = "first-user", Email = "first@example.test", FirstName = "First", LastName = "User" }); + var options = GetService().AuthOptions; + options.EnableAccountCreation = true; + options.GitHubId = "test-client"; + options.GitHubSecret = "test-secret"; + var handler = CreateHandler(users.Object, tokens.Object, oauthTokens.Object, providerClient.Object); + + // Act + var canceled = await handler.Handle(new GitHubLogin(new ExternalAuthInfo(), new DefaultHttpContext { RequestAborted = cancellation.Token })); + var userAfterCancellation = storedUser; + var retry = await handler.Handle(new GitHubLogin(new ExternalAuthInfo(), new DefaultHttpContext { RequestAborted = TestCancellationToken })); + + // Assert + Assert.Equal(ResultStatus.Unauthorized, canceled.Status); + Assert.Null(userAfterCancellation); + Assert.Equal(ResultStatus.Success, retry.Status); + Assert.NotNull(storedUser); + Assert.Contains(AuthorizationRoles.GlobalAdmin, storedUser.Roles); + users.Verify(repository => repository.CountAsync(It.IsAny()), Times.Exactly(2)); + users.Verify(repository => repository.AddAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + [Fact] public async Task Handle_ExternalLoginEmailChangesAfterLookup_ReturnsUnauthorized() {