Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ee75910
Validate external account state and identity
niemyjski Oct 13, 2026
d46f719
Clarify external account lookup and test structure
niemyjski Oct 13, 2026
0dab8e0
Cover external identity lookup and account linking contracts
niemyjski Oct 18, 2026
b23953d
Serialize external account ownership changes
niemyjski Oct 18, 2026
13c4cf5
Isolate webhook telemetry sampling from parallel hosts
niemyjski Oct 18, 2026
1ed9e90
Cancel paged external identity lookup with the login request
niemyjski Oct 18, 2026
076cf09
Cancel external account reads while ownership locks are held
niemyjski Oct 18, 2026
287eb26
Document and test unauthorized external account unlink responses
niemyjski Oct 18, 2026
1595556
Revalidate external identity ownership after waiting for the user lock
niemyjski Oct 18, 2026
cf01990
Validate external login provider names before account changes
niemyjski Oct 19, 2026
0571419
Finish admitted external account changes before observing cancellation
niemyjski Oct 19, 2026
1a87c1d
Validate OAuth provider names through the model validator
niemyjski Oct 20, 2026
d2cae16
Validate external account identifiers and bypass stale email cache du…
niemyjski Oct 20, 2026
8cd1d8c
Update authentication test callbacks for email query options
niemyjski Oct 20, 2026
df195c9
Preserve unlink validation responses at the HTTP boundary
niemyjski Oct 20, 2026
de73df0
test: reproduce stale external account metadata and repository authen…
niemyjski Oct 22, 2026
ea8d962
Refresh external account metadata and keep authentication policy in t…
niemyjski Oct 22, 2026
945e60b
test: reproduce redundant frontend installation in CI topology
niemyjski Oct 22, 2026
869c91d
Skip redundant Aspire package installation after CI npm ci
niemyjski Oct 22, 2026
28bcb90
test: reproduce duplicate metadata and password changes racing OAuth …
niemyjski Oct 22, 2026
c59e9fe
Protect OAuth metadata saves with native user versions and collapse d…
niemyjski Oct 22, 2026
3df3ea2
Wait for selectable stack rows before opening details in browser tests
niemyjski Oct 22, 2026
d99fc68
Retain the original deletion contract after verifying repository vers…
niemyjski Oct 22, 2026
96d2b97
Reconcile Microsoft authentication and revalidate external account state
niemyjski Oct 22, 2026
89d8514
Use canonical provider names in linked-email test fixtures
niemyjski Oct 22, 2026
ba20d70
Restore the previous OAuth owner when the destination save conflicts
niemyjski Oct 23, 2026
217e1f8
Offer retry after password reset cancellation fails
niemyjski Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/Exceptionless.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -216,10 +217,6 @@
.WithEnvironment("API_HTTPS", worktreePorts.ApiHttpsUrl);
}

// The e2e workflow runs npm ci before starting the AppHost, so it skips Aspire's second install.
if (ciE2E)
app.WithNpm(install: false);

if (includeDevTools)
{
#pragma warning disable ASPIREDENO001
Expand Down
42 changes: 28 additions & 14 deletions src/Exceptionless.Core/Extensions/UserExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,34 +64,48 @@ 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 = new OAuthAccount
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
{
Provider = providerName,
ProviderUserId = providerUserId,
Username = username
};
user.OAuthAccounts.Add(account);
}
else
{
Provider = providerName.ToLowerInvariant(),
ProviderUserId = providerUserId,
Username = username
};
account.Username = username;
}

if (data is not null)
account.ExtraData.Apply(data);

user.OAuthAccounts.Add(account);
foreach (var duplicate in accounts.Skip(1))
user.OAuthAccounts.Remove(duplicate);
}

public static bool RemoveOAuthAccount(this User user, string providerName, string providerUserId)
{
var account = user.OAuthAccounts.FirstOrDefault(o =>
String.Equals(o.Provider, providerName, StringComparison.OrdinalIgnoreCase)
&& String.Equals(o.ProviderUserId, providerUserId, StringComparison.Ordinal));
if (account is null)
var accounts = user.OAuthAccounts.Where(account => String.Equals(account.Provider, providerName, StringComparison.Ordinal)
&& String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal)).ToArray();
if (accounts.Length is 0)
return true;

var activeOAuthAccounts = user.OAuthAccounts
.Where(o => !String.Equals(o.Provider, "facebook", StringComparison.OrdinalIgnoreCase)
&& !String.Equals(o.Provider, "windowslive", StringComparison.OrdinalIgnoreCase))
.Where(account => !String.Equals(account.Provider, OAuthAccount.KnownProviders.Facebook, StringComparison.OrdinalIgnoreCase)
&& !String.Equals(account.Provider, OAuthAccount.KnownProviders.WindowsLive, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (String.IsNullOrEmpty(user.Password) && activeOAuthAccounts.Length <= 1 && activeOAuthAccounts.Contains(account))
if (String.IsNullOrEmpty(user.Password) && activeOAuthAccounts.Length <= accounts.Length && activeOAuthAccounts.Contains(accounts[0]))
return false;

return user.OAuthAccounts.Remove(account);
foreach (var account in accounts)
user.OAuthAccounts.Remove(account);

return true;
}
}
20 changes: 18 additions & 2 deletions src/Exceptionless.Core/Models/OAuthAccount.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
namespace Exceptionless.Core.Models;
using System.ComponentModel.DataAnnotations;

namespace Exceptionless.Core.Models;

public record OAuthAccount
{
public static class KnownProviders
{
public const string GitHub = "github";
public const string Google = "google";
public const string Microsoft = "microsoft";
public const string Facebook = "facebook";
public const string WindowsLive = "windowslive";
}

[Required]
[AllowedValues(KnownProviders.GitHub, KnownProviders.Google, KnownProviders.Microsoft, KnownProviders.Facebook, KnownProviders.WindowsLive,
ErrorMessage = "The provider name must be github, google, microsoft, facebook, or windowslive.")]
public required string Provider { get; init; }

[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();

Expand Down
8 changes: 7 additions & 1 deletion src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace Exceptionless.Core.Models;

public record User : IIdentity, IHaveDates, IValidatableObject
public record User : IIdentity, IHaveDates, IVersioned, IValidatableObject
{
/// <summary>
/// Unique id that identifies an user.
Expand Down Expand Up @@ -54,6 +54,12 @@ public record User : IIdentity, IHaveDates, IValidatableObject
public string? VerifyEmailAddressToken { get; set; }
public DateTime VerifyEmailAddressTokenExpiration { get; set; }

/// <summary>
/// Repository concurrency metadata used to reject stale writes, preserved in cached snapshots.
/// New users have an empty version until persisted.
/// </summary>
public string Version { get; set; } = String.Empty;
Comment thread
niemyjski marked this conversation as resolved.

/// <summary>
/// Gets or sets the users active state.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public interface IUserRepository : ISearchableRepository<User>
Task<bool> SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor<User>? options = null);
Task<User?> GetByEmailAddressAsync(string emailAddress);
Task<User?> GetByPasswordResetTokenAsync(string token);
Task<User?> GetUserByOAuthProviderAsync(string provider, string providerUserId);
Task<IReadOnlyCollection<User>> GetUsersByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default);
Task<User?> GetByVerifyEmailAddressTokenAsync(string token);
Task<FindResults<User>> GetByOrganizationIdAsync(string organizationId, CommandOptionsDescriptor<User>? options = null);
}
25 changes: 19 additions & 6 deletions src/Exceptionless.Core/Repositories/UserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,27 @@ public Task<bool> SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor<Us
return hit?.Document;
}

public async Task<User?> GetUserByOAuthProviderAsync(string provider, string providerUserId)
public async Task<IReadOnlyCollection<User>> GetUsersByOAuthProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (String.IsNullOrEmpty(provider) || String.IsNullOrEmpty(providerUserId))
return null;

provider = provider.ToLowerInvariant();
var results = (await FindAsync(q => q.FieldEquals(u => u.OAuthAccounts.First().ProviderUserId, providerUserId))).Documents;
return results.FirstOrDefault(u => u.OAuthAccounts.Any(o => o.Provider == provider));
return Array.Empty<User>();

// 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)
.FieldEquals(u => u.OAuthAccounts.First().Provider, provider).SortAscending(u => u.Id),
o => o.SearchAfterPaging().PageLimit(100));
var matchingUsers = new List<User>();
do
{
cancellationToken.ThrowIfCancellationRequested();
matchingUsers.AddRange(results.Documents.Where(u => u.OAuthAccounts.Any(account =>
String.Equals(account.Provider, provider, StringComparison.Ordinal) &&
String.Equals(account.ProviderUserId, providerUserId, StringComparison.Ordinal))));
} while (!cancellationToken.IsCancellationRequested && await results.NextPageAsync());

cancellationToken.ThrowIfCancellationRequested();
return matchingUsers;
}

public async Task<User?> GetByVerifyEmailAddressTokenAsync(string token)
Expand Down
34 changes: 30 additions & 4 deletions src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder
.Accepts<Login>("application/json", "application/*+json")
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Login")
.WithDescription("""
Expand Down Expand Up @@ -86,6 +87,7 @@ headers api_key input box.
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Sign up")
.WithMetadata(new EndpointDocumentation {
Expand All @@ -104,13 +106,17 @@ headers api_key input box.
.AllowAnonymous()
.Accepts<ExternalAuthInfo>("application/json", "application/*+json")
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Sign in with GitHub")
.WithMetadata(new EndpointDocumentation {
ResponseDescriptions = new() {
["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",
}
});
Expand All @@ -122,13 +128,17 @@ headers api_key input box.
.AllowAnonymous()
.Accepts<ExternalAuthInfo>("application/json", "application/*+json")
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Sign in with Google")
.WithMetadata(new EndpointDocumentation {
ResponseDescriptions = new() {
["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",
}
});
Expand All @@ -140,31 +150,41 @@ headers api_key input box.
.AllowAnonymous()
.Accepts<ExternalAuthInfo>("application/json", "application/*+json")
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Sign in with Microsoft")
.WithMetadata(new EndpointDocumentation {
ResponseDescriptions = new() {
["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",
}
});

group.MapPost("unlink/{providerName:minlength(1)}", async (string providerName, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper, HttpContext httpContext, [FromBody] ValueFromBody<string> providerUserId)
=> (await mediator.InvokeAsync<Result<TokenResult>>(new AuthMessages.RemoveExternalLogin(providerName, providerUserId, httpContext))).ToHttpResult(resultMapper))
group.MapPost("unlink/{providerName:minlength(1)}", async ([AsParameters] RemoveExternalLoginRequest request, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper, HttpContext httpContext)
=> (await mediator.InvokeAsync<Result<TokenResult>>(new AuthMessages.RemoveExternalLogin(request.ProviderName, request.ProviderUserId?.Value, httpContext))).ToHttpResult(resultMapper))
.Accepts<ValueFromBody<string>>("application/json", "application/*+json")
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status409Conflict)
.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, microsoft, facebook, or windowslive (case sensitive).",
},
ResponseDescriptions = new() {
["200"] = "User Authentication Token",
["400"] = "Invalid provider name.",
["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",
}
});

Expand All @@ -175,10 +195,12 @@ headers api_key input box.
.Accepts<ChangePasswordModel>("application/json", "application/*+json")
.Produces<TokenResult>()
.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",
}
});
Expand Down Expand Up @@ -212,10 +234,12 @@ headers api_key input box.
.Accepts<ResetPasswordModel>("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.",
}
});
Expand All @@ -231,6 +255,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() {
Expand All @@ -239,6 +264,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.",
}
});

Expand Down
4 changes: 4 additions & 0 deletions src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute
.Accepts<NewOrganization>("application/json", "application/*+json")
.Produces<ViewOrganization>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Create")
.WithMetadata(new EndpointDocumentation {
Expand Down Expand Up @@ -190,6 +191,7 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute
.Produces<WorkInProgressResult>(StatusCodes.Status202Accepted)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.WithSummary("Remove")
.WithMetadata(new EndpointDocumentation {
Expand Down Expand Up @@ -281,6 +283,7 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute
=> (await mediator.InvokeAsync<Result<User>>(new OrganizationMessages.AddOrganizationUser(id, email, httpContext))).ToHttpResult(resultMapper))
.Produces<User>()
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status426UpgradeRequired)
.WithSummary("Add user")
.WithMetadata(new EndpointDocumentation {
Expand All @@ -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() {
Expand Down
Loading
Loading