Fix passkey RPID mismatch across environments

Derive ServerDomain and Origin from the incoming HTTP request instead of
appsettings.json, so WebAuthn works on localhost, dev, and production
without any environment-specific configuration. Removed IFido2 from DI
and the Fido2 appsettings block — PasskeyController instantiates Fido2
per-request via BuildFido2().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 15:49:45 -04:00
parent edc599a1a2
commit c71332740e
3 changed files with 35 additions and 23 deletions
@@ -15,11 +15,14 @@ namespace PowderCoating.Web.Controllers;
/// Registration requires an authenticated session (user logs in once with password, /// Registration requires an authenticated session (user logs in once with password,
/// then enrolls a passkey for future logins). Authentication is anonymous — the /// then enrolls a passkey for future logins). Authentication is anonymous — the
/// browser sends the credential before any session exists. /// browser sends the credential before any session exists.
///
/// Fido2 is constructed per-request from the incoming Host header so the RPID
/// matches automatically on localhost, dev, staging, and production without any
/// environment-specific configuration.
/// </summary> /// </summary>
[Route("[controller]/[action]")] [Route("[controller]/[action]")]
public class PasskeyController : Controller public class PasskeyController : Controller
{ {
private readonly IFido2 _fido2;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager; private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ApplicationDbContext _db; private readonly ApplicationDbContext _db;
@@ -29,19 +32,41 @@ public class PasskeyController : Controller
private const string AuthChallengeKey = "passkey:auth:challenge"; private const string AuthChallengeKey = "passkey:auth:challenge";
public PasskeyController( public PasskeyController(
IFido2 fido2,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager, SignInManager<ApplicationUser> signInManager,
ApplicationDbContext db, ApplicationDbContext db,
ILogger<PasskeyController> logger) ILogger<PasskeyController> logger)
{ {
_fido2 = fido2;
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
_db = db; _db = db;
_logger = logger; _logger = logger;
} }
/// <summary>
/// Builds a Fido2 instance whose RPID and origin are derived from the current
/// request, so the same code works on localhost, dev, and production unchanged.
/// </summary>
private IFido2 BuildFido2()
{
var req = HttpContext.Request;
var host = req.Host.Host; // "localhost" or "myapp.azurewebsites.net"
var port = req.Host.Port;
var origin = port.HasValue
? $"{req.Scheme}://{host}:{port}"
: $"{req.Scheme}://{host}";
var config = new Fido2Configuration
{
ServerDomain = host,
ServerName = "Powder Coating Logix",
Origins = new HashSet<string> { origin },
TimestampDriftTolerance = 300
};
return new Fido2(config);
}
// ─── Registration ──────────────────────────────────────────────────────── // ─── Registration ────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -77,7 +102,7 @@ public class PasskeyController : Controller
UserVerification = UserVerificationRequirement.Required UserVerification = UserVerificationRequirement.Required
}; };
var options = _fido2.RequestNewCredential(new RequestNewCredentialParams var options = BuildFido2().RequestNewCredential(new RequestNewCredentialParams
{ {
User = fidoUser, User = fidoUser,
ExcludeCredentials = excludeCredentials, ExcludeCredentials = excludeCredentials,
@@ -112,7 +137,7 @@ public class PasskeyController : Controller
try try
{ {
var options = CredentialCreateOptions.FromJson(optionsJson); var options = CredentialCreateOptions.FromJson(optionsJson);
credential = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams credential = await BuildFido2().MakeNewCredentialAsync(new MakeNewCredentialParams
{ {
AttestationResponse = attestationResponse, AttestationResponse = attestationResponse,
OriginalOptions = options, OriginalOptions = options,
@@ -155,7 +180,7 @@ public class PasskeyController : Controller
[AllowAnonymous] [AllowAnonymous]
public IActionResult LoginOptions() public IActionResult LoginOptions()
{ {
var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams var options = BuildFido2().GetAssertionOptions(new GetAssertionOptionsParams
{ {
AllowedCredentials = [], AllowedCredentials = [],
UserVerification = UserVerificationRequirement.Required UserVerification = UserVerificationRequirement.Required
@@ -199,7 +224,7 @@ public class PasskeyController : Controller
try try
{ {
var options = AssertionOptions.FromJson(optionsJson); var options = AssertionOptions.FromJson(optionsJson);
verifyResult = await _fido2.MakeAssertionAsync(new MakeAssertionParams verifyResult = await BuildFido2().MakeAssertionAsync(new MakeAssertionParams
{ {
AssertionResponse = assertionResponse, AssertionResponse = assertionResponse,
OriginalOptions = options, OriginalOptions = options,
+3 -10
View File
@@ -290,16 +290,9 @@ builder.Services.AddSession(options =>
// Add memory cache // Add memory cache
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
// Register Fido2/WebAuthn for passkey (biometric) login // Fido2/WebAuthn: no DI registration needed — PasskeyController builds a
builder.Services.AddFido2(options => // per-request Fido2 instance from the incoming Host header so the RPID matches
{ // automatically on every environment without config changes.
options.ServerDomain = builder.Configuration["Fido2:ServerDomain"] ?? "localhost";
options.ServerName = builder.Configuration["Fido2:ServerName"] ?? "Powder Coating Logix";
var origins = builder.Configuration.GetSection("Fido2:Origins").Get<HashSet<string>>();
if (origins?.Count > 0) options.Origins = origins;
options.TimestampDriftTolerance = int.Parse(
builder.Configuration["Fido2:TimestampDriftTolerance"] ?? "300");
});
// Configure authorization policies for multi-tenancy // Configure authorization policies for multi-tenancy
builder.Services.AddAuthorization(options => builder.Services.AddAuthorization(options =>
-6
View File
@@ -68,12 +68,6 @@
"Enterprise": "price_enterprise_monthly_id_here" "Enterprise": "price_enterprise_monthly_id_here"
} }
}, },
"Fido2": {
"ServerDomain": "localhost",
"ServerName": "Powder Coating Logix",
"Origins": [ "https://localhost:58461", "http://localhost:58462" ],
"TimestampDriftTolerance": 300
},
"Storage": { "Storage": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=powdercoatingappdev;AccountKey=DN3eVfhytXb7aBC0md9h/6jE0Uzg6FJ+PK6MFc772qyqpf0kgTeXH0C2VCBBun9PiuItPd9CDKTP+ASthFCuCg==;EndpointSuffix=core.windows.net", "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=powdercoatingappdev;AccountKey=DN3eVfhytXb7aBC0md9h/6jE0Uzg6FJ+PK6MFc772qyqpf0kgTeXH0C2VCBBun9PiuItPd9CDKTP+ASthFCuCg==;EndpointSuffix=core.windows.net",
"Containers": { "Containers": {