Compare commits

...

2 Commits

Author SHA1 Message Date
spouliot 92f71f62d0 Fix iOS passkey enrollment sheet appearing on password form submit
Switch passkeySupported() from isConditionalMediationAvailable() to
isUserVerifyingPlatformAuthenticatorAvailable(). The conditional API
signals to iOS 17/18 that the page wants autofill passkey interception,
causing Safari to show its own native enrollment bottom sheet when the
password Sign In button is clicked. The platform authenticator check
simply asks if the device has biometrics, with no UI side-effects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:56:17 -04:00
spouliot c71332740e 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>
2026-04-25 15:49:45 -04:00
4 changed files with 47 additions and 25 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": {
+12 -2
View File
@@ -186,11 +186,21 @@ async function loginWithPasskey() {
// ─── Feature detection ──────────────────────────────────────────────────────── // ─── Feature detection ────────────────────────────────────────────────────────
/** True if this browser + platform support WebAuthn conditional UI (passkeys). */ /**
* True if the device has a user-verifying platform authenticator (Face ID,
* fingerprint, Windows Hello, etc.) that can handle our modal passkey flow.
*
* Deliberately uses isUserVerifyingPlatformAuthenticatorAvailable() rather than
* isConditionalMediationAvailable(). The conditional API signals to iOS Safari
* that the page wants autofill-style passkey interception, which causes iOS 17+
* to show its own native passkey enrollment sheet when the password form is
* submitted — not what we want. The platform authenticator check simply asks
* "can this device do biometrics?" with no side-effects.
*/
async function passkeySupported() { async function passkeySupported() {
if (!window.PublicKeyCredential) return false; if (!window.PublicKeyCredential) return false;
try { try {
return await PublicKeyCredential.isConditionalMediationAvailable?.() ?? false; return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch { } catch {
return false; return false;
} }