Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 29 additions & 3 deletions FULLWORTH_CONTEXT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
# FullWorth Current Context

Last updated: 2026-09-24
Last updated: 2026-09-25

## Release preparation / email delivery checkpoint — 2026-09-25

PR #351 passed exact-head FullWorth CI #996 and dependency security #105 on `d021c32f7623528dccaa75cf1b22cfca27e46fbc`, then squash-merged into development as `9a1a867ce55ce9e28532d41da833f65105f6e050`. Anonymous registration, forgot-password and confirmation resend now keep the same public response during email-provider rejection, transport failure and timeout. Generic event 4101 records delivery failure without recipient/token/provider details. Thirteen regression cases cover provider failures, state preservation, sanitized exceptions and propagation boundaries. Delivery is not guaranteed by the public acknowledgement; no retry queue was added.

Release preparation combines that development head with master `d798e29ac916a9ab7b23b1f987c6cdbc09826cd1`. A three-way merge is conflict-free; master's `SECURITY.md` is retained unchanged. The promotion needs its own exact-head CI before merging to master. This checkpoint does not claim the promotion has passed, been merged or been deployed.

The broader enumeration audit remains open for external registration/linking and timing behavior. Existing private-beta real-environment acceptance gates remain open. Production deployment must use the guarded workflow against the final approved master SHA, followed by release/readiness verification.

## Direct registration/confirmation response checkpoint — 2026-09-25

PR #350 closes the direct API duplicate-registration disclosure. A narrowly scoped endpoint filter unwraps Identity's registration result and maps duplicate-only email/username errors to the same empty 200 response as successful registration. Other validation failures remain failures. Framework registration, legal acceptance, password validation, rate limiting, and existing account state remain intact.

Seven integration cases cover case-normalized duplicate registration with a different password, preservation of the existing password/account/security stamp, invalid-password error equivalence, malformed and invalid confirmation/change-email proofs for known versus unknown user IDs, and successful valid email confirmation.

Exact head `a3813ea57c9b9aa56633a9ea8fe16ac3f7a15571` passed FullWorth CI #995 and dependency security #104 before squash merge as `1b442a00b828efd3c1ca665f627aa4686b605441`. Backend build/tests, migration verification, transaction-stream regression, API/Web production-container build/readiness/HTTP security, encrypted backup, isolated restore, and API recovery ran successfully. MAUI execution and visual acceptance were skipped by existing change detection. Production was not deployed.

Issue #291's broader enumeration review remains open. Next inspect mail-provider failure responses and external registration/linking for existence disclosure. The current email sender throws on provider rejection; determine and test the public response behavior for known versus unknown accounts before selecting a bounded fix. Timing-side-channel resistance is not established. Do not reopen the response-equivalence cases already covered by PRs #349/#350 without new evidence.

## Email/recovery response checkpoint — 2026-09-25

PR #349 adds six integration cases for anonymous email/recovery responses. Confirmation resend and forgot-password now have response-equivalence coverage for both confirmed and unconfirmed existing accounts versus unknown email addresses. Invalid password-reset proof has error-equivalence coverage for both states, plus assertions that rejected reset attempts preserve the existing password. Users are arranged directly through Identity, without relying on registration/login response behavior.

Exact head `17440d34290914a97f945e20034c2126e14f80ed` passed FullWorth CI #994 and dependency security #103, then squash-merged into development as `769cdcc0d2734d27a4d5f4d093b60b02bf6bae88`. Backend build/tests, migration/model verification, and transaction-stream regression ran successfully. Existing change detection skipped the unrelated MAUI and production-container execution steps; this is not new production/container acceptance evidence.

Issue #291's broader email/recovery enumeration item remains open. These tests cover public status/content-type/payload equivalence, not timing side channels or mail-provider failure behavior. Continue by inspecting direct API registration and invalid confirmation-link responses; the existing Web registration test alone does not establish direct API enumeration resistance. Production remains unchanged.

## Web/BFF cookie fixation checkpoint — 2026-09-25

Expand Down Expand Up @@ -92,7 +118,7 @@ Production remains unchanged by architecture/security merges unless a separate g
This checkpoint supersedes older branch/domain summaries below. Current GitHub source and exact-head CI remain authoritative.

- Repository: `Fullworth/FullWorth`; release branch: `master`; integration branch: `development`.
- Current `development`: `c8514750893204bdd585cb5feb3a766cdfefb127` after PR #339 completed the Web/BFF fixed-lifetime renewal proof.
- Latest verified code checkpoint on `development`: `1b442a00b828efd3c1ca665f627aa4686b605441` after PR #350 closed direct registration disclosure and verified confirmation responses. Later handoff-only commits may advance the branch.
- Current `master`: `a4d60bc25d680dfc3b786fb476e4e7f42f84eba1`. A GitHub branch head is not evidence of a production deployment.
- The last operator-reported live production release remains `81a74f11941f6ed67ba5de61b9ef186ef09bae3c`. No later architecture/security merge is being claimed as deployed.
- Production remains untouched unless a guarded deployment is separately and explicitly approved.
Expand Down Expand Up @@ -374,7 +400,7 @@ Before trusted external beta invitations:

1. Read current GitHub `development`, open PRs, issue #291, issue #260, and this checkpoint before making changes.
2. PRs #305–#314 are merged. External OIDC invariants are regression-locked; security-changing actions rotate revocation state; refresh tokens are single-use within bounded families; current-session logout revokes its refresh family; account-wide revocation invalidates every existing refresh token; and the Web exposes a strongly reauthenticated sign-out-everywhere control with accurate 15-minute bearer-token semantics.
3. Continue security issue #291 in small reviewable slices. The strong-reauthentication audit is complete through PR #324, and the MFA enrollment/disable/setup-reset/recovery-enumeration review is complete through PR #335. Next inspect Web/BFF cookie prefixes, Secure/HttpOnly/SameSite, lifetime, fixation, and renewal behavior. Patch only confirmed gaps; do not replace the Data Protection-protected Redis ticket-store design from PR #299 without new evidence.
3. Continue security issue #291 in small reviewable slices. The strong-reauthentication audit is complete through PR #324, and the MFA enrollment/disable/setup-reset/recovery-enumeration review is complete through PR #335. The cookie review is complete through PRs #337/#339. PR #349 adds confirmed/unconfirmed email-recovery response coverage. PR #350 closes direct registration disclosure and verifies confirmation responses. Continue with mail-provider failure behavior and external registration/linking under the still-open enumeration item; patch only confirmed gaps.
4. Do not reopen or replace the framework Identity bearer-token/bounded refresh-family design without new evidence. Preserve the completed session-revocation semantics while auditing strong reauthentication.
5. Issue #260 remains open for remaining bounded-domain ownership enforcement. Inspect current source before choosing the next domain; do not restore already-removed `BillAlerts -> BillChanges` or `BankTransactions.BillStreamId` schema coupling.
6. The last operator-reported live production release remains `81a74f11941f6ed67ba5de61b9ef186ef09bae3c`. Do not claim newer GitHub code is deployed without guarded deployment evidence.
Expand Down
8 changes: 7 additions & 1 deletion FullWorth.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,12 @@ await rotationService.RevokeFamilyAsync(

authenticationGroup
.MapIdentityApi<ApplicationUser>()
.AddEndpointFilter<
IEndpointConventionBuilder,
AnonymousIdentityEmailDeliveryEndpointFilter>()
.AddEndpointFilter<
IEndpointConventionBuilder,
RegistrationEnumerationEndpointFilter>()
.AddEndpointFilter<
IEndpointConventionBuilder,
RefreshTokenReplayEndpointFilter>();
Expand Down Expand Up @@ -1071,4 +1077,4 @@ static RateLimitPartition<string>
AutoReplenishment =
true
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Identity.Data;

namespace FullWorth.API.Services.Identity;

public sealed class IdentityEmailDeliveryException()
: Exception("Identity email delivery failed.");

public sealed class AnonymousIdentityEmailDeliveryEndpointFilter(
ILogger<AnonymousIdentityEmailDeliveryEndpointFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
try
{
return await next(context);
}
catch (IdentityEmailDeliveryException) when (
!context.HttpContext.RequestAborted.IsCancellationRequested &&
context.Arguments.Any(argument => argument is
RegisterRequest or ForgotPasswordRequest or ResendConfirmationEmailRequest))
{
// Match the existing enumeration-safe public success response.
// Do not attach the exception, recipient, action link or provider body.
logger.LogWarning(new EventId(4101, "IdentityEmailDeliveryFailed"),
"An identity email could not be delivered. Check the email provider health and configuration.");
return Results.Ok();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity.Data;

namespace FullWorth.API.Services.Identity;

public sealed class RegistrationEnumerationEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var result = await next(context);

if (!context.Arguments.OfType<RegisterRequest>().Any())
{
return result;
}

// Identity returns Results<Ok, ValidationProblem>, so inspect the
// contained result without replacing registration or its validation.
var inner = result;
while (inner is INestedHttpResult nested)
{
inner = nested.Result;
}

if (inner is not ValidationProblem problem ||
!problem.ProblemDetails.Errors.Keys.Any(IsDuplicateIdentity))
{
return result;
}

var publicErrors = problem.ProblemDetails.Errors
.Where(error => !IsDuplicateIdentity(error.Key))
.ToDictionary(error => error.Key, error => error.Value);

// Only conceal identity-existence errors. Other validation failures
// remain failures; no existing account or password is modified.
return publicErrors.Count == 0
? Results.Ok()
: Results.ValidationProblem(publicErrors);
}

private static bool IsDuplicateIdentity(string code) =>
code is "DuplicateEmail" or "DuplicateUserName";
}
26 changes: 17 additions & 9 deletions FullWorth.API/Services/Identity/ResendIdentityEmailSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,24 @@ private async Task SendSecurityEmailAsync(
html
});

using var response =
await httpClient.SendAsync(
request);

if (!response.IsSuccessStatusCode)
try
{
using var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
throw new IdentityEmailDeliveryException();
}
}
catch (HttpRequestException)
{
// Do not retain provider exceptions: they may contain sensitive URLs.
throw new IdentityEmailDeliveryException();
}
catch (OperationCanceledException)
{
throw new HttpRequestException(
"The identity email provider rejected the request.",
inner: null,
response.StatusCode);
// IEmailSender has no cancellation parameter; HttpClient timeouts
// are delivery failures. The endpoint filter preserves request aborts.
throw new IdentityEmailDeliveryException();
}
}

Expand Down
153 changes: 153 additions & 0 deletions FullWorth.Tests/Security/DirectIdentityEnumerationSecurityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FullWorth.API.Data.Entities;
using FullWorth.Core.Legal;
using FullWorth.Tests.Infrastructure;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;

namespace FullWorth.Tests.Security;

public sealed class DirectIdentityEnumerationSecurityTests
{
private const string Password = "FullWorth!Tests123";

[Fact]
public async Task DuplicateRegistration_MatchesNewRegistration_AndPreservesExistingAccount()
{
await using var factory = new FullWorthApiFactory();
using var client = factory.CreateHttpsClient();
var email = $"register-{Guid.NewGuid():N}@fullworth.local";

using var created = await RegisterAsync(client, email, Password);
Assert.Equal(HttpStatusCode.OK, created.StatusCode);
var before = await ReadStateAsync(factory, email);

// Case changes must not bypass Identity's normalized uniqueness check.
const string replacement = "FullWorth!Replacement456";
using var duplicate = await RegisterAsync(client, email.ToUpperInvariant(), replacement);
await AssertSameResponseAsync(created, duplicate);
Assert.Equal(before, await ReadStateAsync(factory, email));

await using var scope = factory.Services.CreateAsyncScope();
var manager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = await manager.FindByEmailAsync(email);
Assert.NotNull(user);
Assert.True(await manager.CheckPasswordAsync(user!, Password));
Assert.False(await manager.CheckPasswordAsync(user!, replacement));
Assert.Single(manager.Users.Where(candidate => candidate.NormalizedEmail == email.ToUpperInvariant()));
}

[Fact]
public async Task InvalidPassword_KnownAndUnknownEmail_ReturnSameValidationErrors()
{
await using var factory = new FullWorthApiFactory();
using var client = factory.CreateHttpsClient();
var known = await CreateUserAsync(factory);
using var existing = await RegisterAsync(client, known.Email!, "short");
using var unknown = await RegisterAsync(client, $"unknown-{Guid.NewGuid():N}@fullworth.local", "short");
Assert.Equal(HttpStatusCode.BadRequest, existing.StatusCode);
Assert.Equal(existing.StatusCode, unknown.StatusCode);
Assert.Equal(await ReadErrorsAsync(existing), await ReadErrorsAsync(unknown));
}

[Theory]
[InlineData("!", false)]
[InlineData("bm90LWEtdmFsaWQtdG9rZW4", false)]
[InlineData("!", true)]
[InlineData("bm90LWEtdmFsaWQtdG9rZW4", true)]
public async Task InvalidConfirmationProof_KnownAndUnknownUser_ReturnSameResponse(
string code,
bool changeEmail)
{
await using var factory = new FullWorthApiFactory();
using var client = factory.CreateHttpsClient();
var user = await CreateUserAsync(factory);
var before = await ReadStateAsync(factory, user.Email!);
var suffix = changeEmail ? "&changedEmail=replacement%40fullworth.local" : string.Empty;

using var known = await client.GetAsync(
$"/api/auth/confirmEmail?userId={user.Id}&code={Uri.EscapeDataString(code)}{suffix}");
using var unknown = await client.GetAsync(
$"/api/auth/confirmEmail?userId={Guid.NewGuid()}&code={Uri.EscapeDataString(code)}{suffix}");

Assert.Equal(HttpStatusCode.Unauthorized, known.StatusCode);
await AssertSameResponseAsync(known, unknown);
Assert.Equal(before, await ReadStateAsync(factory, user.Email!));
}

[Fact]
public async Task ValidConfirmationProof_StillConfirmsEmail()
{
await using var factory = new FullWorthApiFactory();
using var client = factory.CreateHttpsClient();
var user = await CreateUserAsync(factory);
string code;
await using (var scope = factory.Services.CreateAsyncScope())
{
var manager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var stored = await manager.FindByIdAsync(user.Id.ToString());
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(
await manager.GenerateEmailConfirmationTokenAsync(stored!)));
}

using var response = await client.GetAsync($"/api/auth/confirmEmail?userId={user.Id}&code={code}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True((await ReadStateAsync(factory, user.Email!)).EmailConfirmed);
}

private static Task<HttpResponseMessage> RegisterAsync(HttpClient client, string email, string password) =>
client.PostAsJsonAsync("/api/auth/register", new
{
email,
password,
acceptedTermsAndPrivacy = true,
legalTermsVersion = FullWorthLegalDocuments.CurrentVersion
});

private static async Task<ApplicationUser> CreateUserAsync(FullWorthApiFactory factory)
{
await using var scope = factory.Services.CreateAsyncScope();
var manager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var email = $"identity-{Guid.NewGuid():N}@fullworth.local";
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = email, UserName = email };
Assert.True((await manager.CreateAsync(user, Password)).Succeeded);
return user;
}

private static async Task<AccountState> ReadStateAsync(FullWorthApiFactory factory, string email)
{
await using var scope = factory.Services.CreateAsyncScope();
var manager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = await manager.FindByEmailAsync(email);
Assert.NotNull(user);
return new AccountState(user!.Id, user.Email, user.UserName, user.EmailConfirmed, user.SecurityStamp);
}

private static async Task AssertSameResponseAsync(HttpResponseMessage known, HttpResponseMessage unknown)
{
Assert.Equal(known.StatusCode, unknown.StatusCode);
Assert.Equal(known.Content.Headers.ContentType?.ToString(), unknown.Content.Headers.ContentType?.ToString());
Assert.Equal(known.Headers.Location, unknown.Headers.Location);
Assert.False(known.Headers.Contains("Set-Cookie"));
Assert.False(unknown.Headers.Contains("Set-Cookie"));
Assert.Equal(await known.Content.ReadAsStringAsync(), await unknown.Content.ReadAsStringAsync());
}

private static async Task<string> ReadErrorsAsync(HttpResponseMessage response)
{
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return JsonSerializer.Serialize(document.RootElement.GetProperty("errors").EnumerateObject()
.OrderBy(property => property.Name, StringComparer.Ordinal)
.Select(property => new
{
property.Name,
Values = property.Value.EnumerateArray().Select(value => value.GetString()).ToArray()
}).ToArray());
}

private sealed record AccountState(Guid Id, string? Email, string? UserName, bool EmailConfirmed, string? SecurityStamp);
}
Loading
Loading