From a36c59de77a6b17d00d9ae9e9ba64731efc00770 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Wed, 14 Oct 2026 12:30:40 -0300
Subject: [PATCH 1/3] build(deps): move the .NET 10 packages to 10.0.13 for the
October advisories
Advisories published on 2026-10-13 cover every 10.0.x up to 10.0.12 of
System.Security.Cryptography.Xml, System.Security.Cryptography.Pkcs and
Microsoft.AspNetCore.SignalR.Common, and NuGetAudit under
TreatWarningsAsErrors fails restore for every project, main included.
SignalR.Common 10.0.13 requires Microsoft.Extensions.* 10.0.13, so a
transitive pin alone does not resolve; the whole 10.0.12 line moves to
10.0.13 and Xml/Pkcs stay pinned.
---
src/Directory.Packages.props | 68 +++++++++++++++++++-----------------
1 file changed, 35 insertions(+), 33 deletions(-)
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 39ac673040..0c7bae922c 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -57,40 +57,40 @@
-
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -104,24 +104,26 @@
-
+
-
-
+
+
-
+
From e946a689b30774a1a84c21a5a3221242c52e98c8 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Wed, 14 Oct 2026 18:27:01 -0300
Subject: [PATCH 2/3] fix(identity): make the two-factor issuer name
configurable
EnrollTwoFactorCommandHandler hardcoded "FullStackHero" as the issuer of
the otpauth URI, so every product built on the kit showed that name in
the user's authenticator app. The issuer now comes from
TwoFactorOptions:Issuer, defaulting to "FullStackHero" and validated on
start so a blank value fails fast. It stays percent-encoded in both the
label and the issuer parameter.
Only new enrollments pick up a changed issuer; existing enrollments keep
working because the shared secret does not change.
Fixes #1440
---
src/Host/FSH.Starter.Api/appsettings.json | 3 +
.../Enroll/EnrollTwoFactorCommandHandler.cs | 13 ++-
.../Modules.Identity/IdentityModule.cs | 5 +
.../Modules.Identity/TwoFactorOptions.cs | 14 +++
.../EnrollTwoFactorCommandHandlerTests.cs | 102 ++++++++++++++++++
.../Authentication/TwoFactorAuthTests.cs | 3 +-
6 files changed, 135 insertions(+), 5 deletions(-)
create mode 100644 src/Modules/Identity/Modules.Identity/TwoFactorOptions.cs
create mode 100644 src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs
diff --git a/src/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json
index 9484241d44..3029e4de77 100644
--- a/src/Host/FSH.Starter.Api/appsettings.json
+++ b/src/Host/FSH.Starter.Api/appsettings.json
@@ -78,6 +78,9 @@
"PasswordExpiryWarningDays": 14,
"EnforcePasswordExpiry": true
},
+ "TwoFactorOptions": {
+ "Issuer": "FullStackHero"
+ },
"AllowedHosts": "*",
"OpenApiOptions": {
"Enabled": true,
diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs
index 2a95e8678c..f762288140 100644
--- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs
+++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs
@@ -6,21 +6,26 @@
using FSH.Modules.Identity.Domain;
using Mediator;
using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Options;
namespace FSH.Modules.Identity.Features.v1.TwoFactor.Enroll;
public sealed class EnrollTwoFactorCommandHandler
: ICommandHandler
{
- private const string IssuerName = "FullStackHero";
-
private readonly UserManager _userManager;
private readonly ICurrentUser _currentUser;
+ private readonly TwoFactorOptions _options;
- public EnrollTwoFactorCommandHandler(UserManager userManager, ICurrentUser currentUser)
+ public EnrollTwoFactorCommandHandler(
+ UserManager userManager,
+ ICurrentUser currentUser,
+ IOptions options)
{
+ ArgumentNullException.ThrowIfNull(options);
_userManager = userManager;
_currentUser = currentUser;
+ _options = options.Value;
}
public async ValueTask Handle(
@@ -47,7 +52,7 @@ public async ValueTask Handle(
var authenticatorUri = string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6",
- UrlEncoder.Default.Encode(IssuerName),
+ UrlEncoder.Default.Encode(_options.Issuer),
UrlEncoder.Default.Encode(email),
sharedKey);
diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs
index 3c64f20eaf..0ec72e9db7 100644
--- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs
+++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs
@@ -128,6 +128,11 @@ public void ConfigureServices(IHostApplicationBuilder builder)
// Tenant subscription grace period (shared "Billing" section) — used by the login expiry check.
services.Configure(builder.Configuration.GetSection(TenantGraceOptions.SectionName));
+ services.AddOptions()
+ .BindConfiguration(nameof(TwoFactorOptions))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
// Register password history service
services.AddScoped();
diff --git a/src/Modules/Identity/Modules.Identity/TwoFactorOptions.cs b/src/Modules/Identity/Modules.Identity/TwoFactorOptions.cs
new file mode 100644
index 0000000000..2a484c4675
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity/TwoFactorOptions.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace FSH.Modules.Identity;
+
+///
+/// TOTP settings (config section "TwoFactorOptions"). is the name an
+/// authenticator app shows next to the account; changing it only affects new enrollments, existing
+/// secrets keep working.
+///
+public sealed class TwoFactorOptions
+{
+ [Required]
+ public string Issuer { get; set; } = "FullStackHero";
+}
diff --git a/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs
new file mode 100644
index 0000000000..a8db8e0b84
--- /dev/null
+++ b/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs
@@ -0,0 +1,102 @@
+using System.ComponentModel.DataAnnotations;
+using FSH.Framework.Core.Context;
+using FSH.Framework.Core.Exceptions;
+using FSH.Modules.Identity;
+using FSH.Modules.Identity.Contracts.v1.TwoFactor;
+using FSH.Modules.Identity.Domain;
+using FSH.Modules.Identity.Features.v1.TwoFactor.Enroll;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+
+namespace Identity.Tests.Handlers;
+
+///
+/// Tests for EnrollTwoFactorCommandHandler - the otpauth URI an authenticator app scans.
+///
+public sealed class EnrollTwoFactorCommandHandlerTests
+{
+ private const string SharedKey = "JBSWY3DPEHPK3PXP";
+ private static readonly Guid UserId = Guid.Parse("7f1c2a4e-3b5d-4c6e-8f9a-0b1c2d3e4f50");
+
+ private readonly UserManager _userManager;
+ private readonly ICurrentUser _currentUser;
+
+ public EnrollTwoFactorCommandHandlerTests()
+ {
+ _userManager = Substitute.For>(
+ Substitute.For>(), null, null, null, null, null, null, null, null);
+ _currentUser = Substitute.For();
+
+ var user = new FshUser { Id = UserId.ToString(), Email = "jane@example.com" };
+ _currentUser.IsAuthenticated().Returns(true);
+ _currentUser.GetUserId().Returns(UserId);
+ _userManager.FindByIdAsync(UserId.ToString()).Returns(user);
+ _userManager.ResetAuthenticatorKeyAsync(user).Returns(IdentityResult.Success);
+ _userManager.GetAuthenticatorKeyAsync(user).Returns(SharedKey);
+ }
+
+ private EnrollTwoFactorCommandHandler CreateHandler(TwoFactorOptions options) =>
+ new(_userManager, _currentUser, Options.Create(options));
+
+ [Fact]
+ public async Task Handle_Should_UseFullStackHeroIssuer_When_IssuerIsNotConfigured()
+ {
+ var sut = CreateHandler(new TwoFactorOptions());
+
+ var result = await sut.Handle(new EnrollTwoFactorCommand(), CancellationToken.None);
+
+ result.AuthenticatorUri.ShouldBe(
+ $"otpauth://totp/FullStackHero:jane@example.com?secret={SharedKey}&issuer=FullStackHero&digits=6");
+ }
+
+ [Fact]
+ public async Task Handle_Should_UseConfiguredIssuer_InLabelAndIssuerParameter()
+ {
+ var sut = CreateHandler(new TwoFactorOptions { Issuer = "Acme" });
+
+ var result = await sut.Handle(new EnrollTwoFactorCommand(), CancellationToken.None);
+
+ result.AuthenticatorUri.ShouldBe(
+ $"otpauth://totp/Acme:jane@example.com?secret={SharedKey}&issuer=Acme&digits=6");
+ }
+
+ [Theory]
+ [InlineData("Canal Correto", "Canal%20Correto")]
+ [InlineData("Ação", "A%C3%A7%C3%A3o")]
+ [InlineData("A:B & C", "A%3AB%20%26%20C")]
+ public async Task Handle_Should_PercentEncodeIssuer_When_ItHasSpacesAccentsOrReservedCharacters(
+ string issuer, string encoded)
+ {
+ var sut = CreateHandler(new TwoFactorOptions { Issuer = issuer });
+
+ var result = await sut.Handle(new EnrollTwoFactorCommand(), CancellationToken.None);
+
+ result.AuthenticatorUri.ShouldBe(
+ $"otpauth://totp/{encoded}:jane@example.com?secret={SharedKey}&issuer={encoded}&digits=6");
+ Uri.TryCreate(result.AuthenticatorUri, UriKind.Absolute, out _).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Handle_Should_ThrowUnauthorized_When_UserIsNotAuthenticated()
+ {
+ _currentUser.IsAuthenticated().Returns(false);
+ var sut = CreateHandler(new TwoFactorOptions());
+
+ await Should.ThrowAsync(
+ async () => await sut.Handle(new EnrollTwoFactorCommand(), CancellationToken.None));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void TwoFactorOptions_Should_RejectBlankIssuer(string issuer)
+ {
+ var options = new TwoFactorOptions { Issuer = issuer };
+
+ var isValid = Validator.TryValidateObject(
+ options, new ValidationContext(options), new List(), validateAllProperties: true);
+
+ isValid.ShouldBeFalse();
+ }
+}
diff --git a/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs b/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs
index 5ece6d65ba..af1ec89a77 100644
--- a/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs
@@ -59,7 +59,8 @@ public async Task Enroll_Should_ReturnSharedKey_AndAuthenticatorUri()
var enrollment = await DeserializeAsync(response);
enrollment.ShouldNotBeNull();
enrollment.SharedKey.ShouldNotBeNullOrWhiteSpace();
- enrollment.AuthenticatorUri.ShouldStartWith("otpauth://totp/");
+ enrollment.AuthenticatorUri.ShouldStartWith("otpauth://totp/FullStackHero:");
+ enrollment.AuthenticatorUri.ShouldContain("&issuer=FullStackHero&");
enrollment.AuthenticatorUri.ShouldContain("secret=");
}
From 967704e58a458d92e2d9a028c5764b73154e50be Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Wed, 14 Oct 2026 18:42:06 -0300
Subject: [PATCH 3/3] test(identity): keep the two-factor tests valid in
scaffolded projects
The template replaces "FullStackHero" with the project's display name,
which can contain spaces, so a literal expectation breaks the scaffolded
build. The default-issuer test now derives the expected label from
TwoFactorOptions, and the integration assertion goes back to the prefix.
---
.../Handlers/EnrollTwoFactorCommandHandlerTests.cs | 13 +++++++++++--
.../Tests/Authentication/TwoFactorAuthTests.cs | 3 +--
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs
index a8db8e0b84..78a5d3188d 100644
--- a/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs
+++ b/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs
@@ -40,14 +40,23 @@ private EnrollTwoFactorCommandHandler CreateHandler(TwoFactorOptions options) =>
new(_userManager, _currentUser, Options.Create(options));
[Fact]
- public async Task Handle_Should_UseFullStackHeroIssuer_When_IssuerIsNotConfigured()
+ public async Task Handle_Should_UseDefaultIssuer_When_IssuerIsNotConfigured()
{
+ // The template rewrites the brand in scaffolded projects (possibly to a name with spaces),
+ // so the expectation is derived from the default instead of spelling it out.
+ var issuer = Uri.EscapeDataString(new TwoFactorOptions().Issuer);
var sut = CreateHandler(new TwoFactorOptions());
var result = await sut.Handle(new EnrollTwoFactorCommand(), CancellationToken.None);
result.AuthenticatorUri.ShouldBe(
- $"otpauth://totp/FullStackHero:jane@example.com?secret={SharedKey}&issuer=FullStackHero&digits=6");
+ $"otpauth://totp/{issuer}:jane@example.com?secret={SharedKey}&issuer={issuer}&digits=6");
+ }
+
+ [Fact]
+ public void TwoFactorOptions_Should_DefaultIssuerToTheBrand()
+ {
+ new TwoFactorOptions().Issuer.ShouldBe("FullStackHero");
}
[Fact]
diff --git a/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs b/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs
index af1ec89a77..5ece6d65ba 100644
--- a/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs
@@ -59,8 +59,7 @@ public async Task Enroll_Should_ReturnSharedKey_AndAuthenticatorUri()
var enrollment = await DeserializeAsync(response);
enrollment.ShouldNotBeNull();
enrollment.SharedKey.ShouldNotBeNullOrWhiteSpace();
- enrollment.AuthenticatorUri.ShouldStartWith("otpauth://totp/FullStackHero:");
- enrollment.AuthenticatorUri.ShouldContain("&issuer=FullStackHero&");
+ enrollment.AuthenticatorUri.ShouldStartWith("otpauth://totp/");
enrollment.AuthenticatorUri.ShouldContain("secret=");
}