Skip to content

fix(identity): correct password-reset email link (trailing slash, tenant, URL-encoding)#1302

Merged
iammukeshm merged 1 commit into
fullstackhero:mainfrom
marcelo-maciel:fix/identity-reset-link
Jun 19, 2026
Merged

fix(identity): correct password-reset email link (trailing slash, tenant, URL-encoding)#1302
iammukeshm merged 1 commit into
fullstackhero:mainfrom
marcelo-maciel:fix/identity-reset-link

Conversation

@marcelo-maciel

Copy link
Copy Markdown
Contributor

Summary

The password-reset email builds a link the dashboard SPA cannot open. With OriginOptions.OriginUrl
set to a host-only URL, the emitted link is malformed in three independent ways, so the reset page
rejects it as a broken link. This fixes the link builder in UserPasswordService.ForgotPasswordAsync.

Environment

  • .NET SDK: 10.0.301
  • DB provider: PostgreSQL (Npgsql)
  • Branch: main @ 8c216ad

Reproduction

  1. Default appsettings.Production.json (OriginOptions.OriginUrl empty):
    POST /api/v1/identity/forgot-password returns 500 ("Origin URL is not configured.").
  2. Set OriginOptions__OriginUrl=https://app.example.com and request a reset for a real user.
  3. Emitted link: https://app.example.com//reset-password?token=...&email=user+x@example.com
    (double slash, no tenant, unencoded email) — the reset page shows "this link is incomplete".

Defects fixed

  1. Double slash. OriginOptions.OriginUrl is a Uri; ForgotPasswordCommandHandler.cs:24 uses
    OriginUrl.ToString(), which appends a trailing slash for a host-only URL. Combined with
    UserPasswordService.cs:42 ($"{origin}/reset-password...") this yields //reset-password. The SPA
    route is /reset-password (clients/dashboard/src/routes.tsx), so it 404s in the client-side router.
  2. Missing tenant. The reset page requires token + email + tenant and shows a "malformed link"
    state when any is absent (clients/dashboard/src/pages/auth/reset-password.tsx). The backend built
    only ?token=...&email=....
  3. Email not URL-encoded. The email went raw into the query string, so addresses with +
    (sub-addressing) or other reserved characters were corrupted.

Change

Build the link with QueryHelpers.AddQueryString, matching the sibling email-link builder
UserRegistrationService.GetEmailVerificationUriAsync. This trims the trailing slash, URL-encodes each
value, and adds the tenant the reset page requires.

- var resetPasswordUri = $"{origin}/reset-password?token={token}&email={email}";
+ var resetPasswordUri = QueryHelpers.AddQueryString(
+     $"{origin.TrimEnd('/')}/reset-password",
+     new Dictionary<string, string?>
+     {
+         ["token"] = token,
+         ["email"] = email,
+         ["tenant"] = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id,
+     });

tenantId is already guaranteed non-empty by EnsureValidTenant() earlier in the method; token is
already Base64Url-encoded. No public signature changes. No src/BuildingBlocks/ changes.

Tests

Adds UserPasswordServiceTests:

  • reset link built with a trailing-slash origin and an email containing +/@, asserting single slash,
    tenant present and the + encoded (an unencoded + would decode to a space);
  • anti-enumeration: an unknown user enqueues no mail.

dotnet test src/Tests/Identity.Tests → 312 passing. Build clean under TreatWarningsAsErrors.

Out of scope

There is a broader structural issue (single global origin; register/confirm derive the origin from
the API host), which affects any deployment with a front-end separate from the API. That part is
non-trivial, so per CONTRIBUTING I will raise it as a Discussion rather than fold it into this fix.

Build the link with QueryHelpers: trim the trailing slash from the
configured origin, URL-encode each value, and include the tenant the
reset page requires. A host-only OriginUrl produced "//reset-password"
(404 in the SPA router), the link lacked the tenant query param
(treated as malformed), and the email was not encoded. Adds
UserPasswordServiceTests.
@iammukeshm iammukeshm merged commit 62b0c51 into fullstackhero:main Jun 19, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants