From fd3105e285adc1fda8ee86356d698413ce0f3b5b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 26 Jun 2025 11:01:11 -0400 Subject: [PATCH 1/6] Improved error experience --- .../Account/Shared/PasskeySubmit.razor.js | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js index f234215ef2d8..39febf481ed4 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js @@ -45,7 +45,7 @@ customElements.define('passkey-submit', class extends HTMLElement { this.internals.form.addEventListener('submit', (event) => { if (event.submitter?.name === '__passkeySubmit') { event.preventDefault(); - this.obtainCredentialAndSubmit(); + this.obtainAndSubmitCredential(); } }); @@ -56,31 +56,37 @@ customElements.define('passkey-submit', class extends HTMLElement { this.abortController?.abort(); } - async obtainCredentialAndSubmit(useConditionalMediation = false) { + async obtainCredential(useConditionalMediation, signal) { + if (this.attrs.operation === 'Create') { + return await createCredential(signal); + } else if (this.attrs.operation === 'Request') { + const email = new FormData(this.internals.form).get(this.attrs.emailName); + const mediation = useConditionalMediation ? 'conditional' : undefined; + return await requestCredential(email, mediation, signal); + } else { + throw new Error(`Unknown passkey operation '${this.attrs.operation}'.`); + } + } + + async obtainAndSubmitCredential(useConditionalMediation = false) { this.abortController?.abort(); this.abortController = new AbortController(); const signal = this.abortController.signal; const formData = new FormData(); try { - let credential; - if (this.attrs.operation === 'Create') { - credential = await createCredential(signal); - } else if (this.attrs.operation === 'Request') { - const email = new FormData(this.internals.form).get(this.attrs.emailName); - const mediation = useConditionalMediation ? 'conditional' : undefined; - credential = await requestCredential(email, mediation, signal); - } else { - throw new Error(`Unknown passkey operation '${operation}'.`); - } + const credential = await this.obtainCredential(useConditionalMediation, signal); const credentialJson = JSON.stringify(credential); formData.append(`${this.attrs.name}.CredentialJson`, credentialJson); } catch (error) { - if (error.name === 'AbortError') { - // Canceled by user action, do not submit the form + console.error(error); + if (useConditionalMediation || error.name === 'AbortError') { + // We do not relay the error to the user if: + // 1. We are attempting conditional mediation, meaning the user did not initiate the operation. + // 2. The user explicitly canceled the operation and aborting the operation is expected. return; } - formData.append(`${this.attrs.name}.Error`, error.message); - console.error(error); + const errorMessage = error.name === 'NotAllowedError' ? 'Unable to authenticate.' : error.message; + formData.append(`${this.attrs.name}.Error`, errorMessage); } this.internals.setFormValue(formData); this.internals.form.submit(); @@ -88,7 +94,7 @@ customElements.define('passkey-submit', class extends HTMLElement { async tryAutofillPasskey() { if (this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable()) { - await this.obtainCredentialAndSubmit(/* useConditionalMediation */ true); + await this.obtainAndSubmitCredential(/* useConditionalMediation */ true); } } }); From 183bb9481abf7467090797f3e1743136c50649bb Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 26 Jun 2025 11:02:13 -0400 Subject: [PATCH 2/6] Check for browser passkey support --- .../Account/Shared/PasskeySubmit.razor.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js index 39febf481ed4..95cd6f7cdd7c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js @@ -1,4 +1,10 @@ -async function fetchWithErrorHandling(url, options = {}) { +const browserSupportsPasskeys = + typeof navigator.credentials !== 'undefined' && + typeof window.PublicKeyCredential !== 'undefined' && + typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' && + typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === 'function'; + +async function fetchWithErrorHandling(url, options = {}) { const response = await fetch(url, { credentials: 'include', ...options @@ -57,6 +63,10 @@ customElements.define('passkey-submit', class extends HTMLElement { } async obtainCredential(useConditionalMediation, signal) { + if (!browserSupportsPasskeys) { + throw new Error('Some passkey features are missing. Please update your browser.'); + } + if (this.attrs.operation === 'Create') { return await createCredential(signal); } else if (this.attrs.operation === 'Request') { @@ -93,7 +103,7 @@ customElements.define('passkey-submit', class extends HTMLElement { } async tryAutofillPasskey() { - if (this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable()) { + if (browserSupportsPasskeys && this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable?.()) { await this.obtainAndSubmitCredential(/* useConditionalMediation */ true); } } From ae78ebb583807a35c9e20a270bda0ca420df716a Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 26 Jun 2025 11:03:15 -0400 Subject: [PATCH 3/6] Reduce error verbosity --- .../BlazorWeb-CSharp/Components/Account/Pages/Login.razor | 2 +- .../Components/Account/Pages/Manage/Passkeys.razor | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor index ce7cd0dc1d68..502bda03638c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor @@ -101,7 +101,7 @@ { if (!string.IsNullOrEmpty(Input.Passkey?.Error)) { - errorMessage = $"Error: Could not log in using the provided passkey: {Input.Passkey.Error}"; + errorMessage = $"Error: {Input.Passkey.Error}"; return; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor index 948711d5a201..78b0f7407cbb 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor @@ -90,7 +90,7 @@ else if (!string.IsNullOrEmpty(Input.Error)) { - RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add a passkey: {Input.Error}", HttpContext); + RedirectManager.RedirectToCurrentPageWithStatus($"Error: {Input.Error}", HttpContext); return; } From 6b5317bebe94537847b6088384495b77b4c0c4b3 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 26 Jun 2025 11:27:02 -0400 Subject: [PATCH 4/6] Improve comment --- .../Components/Account/Shared/PasskeySubmit.razor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js index 95cd6f7cdd7c..1f0b31525c23 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js @@ -92,7 +92,7 @@ customElements.define('passkey-submit', class extends HTMLElement { if (useConditionalMediation || error.name === 'AbortError') { // We do not relay the error to the user if: // 1. We are attempting conditional mediation, meaning the user did not initiate the operation. - // 2. The user explicitly canceled the operation and aborting the operation is expected. + // 2. The user explicitly canceled the operation. return; } const errorMessage = error.name === 'NotAllowedError' ? 'Unable to authenticate.' : error.message; From dda7d9a2fff3c6354e67a43695bbf1f4a7d917d2 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 26 Jun 2025 15:20:49 -0400 Subject: [PATCH 5/6] PR feedback --- .../Account/Pages/Manage/Passkeys.razor | 2 +- .../Account/Shared/PasskeySubmit.razor.js | 4 ++- .../BlazorTemplateTest.cs | 36 +++++++++++++++++-- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor index 78b0f7407cbb..ae3dcd9ab0d2 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor @@ -110,7 +110,7 @@ else var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson, options); if (!attestationResult.Succeeded) { - RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}.", HttpContext); + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}", HttpContext); return; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js index 1f0b31525c23..42d5d150aa70 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js @@ -95,7 +95,9 @@ customElements.define('passkey-submit', class extends HTMLElement { // 2. The user explicitly canceled the operation. return; } - const errorMessage = error.name === 'NotAllowedError' ? 'Unable to authenticate.' : error.message; + const errorMessage = error.name === 'NotAllowedError' + ? 'No passkey was provided by the authenticator.' + : error.message; formData.append(`${this.attrs.name}.Error`, errorMessage); } this.internals.setFormValue(formData); diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index 50a93deeebe9..258cd8f83aad 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -150,7 +150,7 @@ await Task.WhenAll( protocol = "ctap2", transport = "internal", hasResidentKey = false, - hasUserIdentification = true, + hasUserVerification = true, isUserVerified = true, automaticPresenceSimulation = true, } @@ -208,11 +208,26 @@ await Task.WhenAll( await page.WaitForSelectorAsync("text=Manage your account"); + // Check that an error is displayed if passkey creation fails await Task.WhenAll( page.WaitForURLAsync("**/Account/Manage/Passkeys**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("a[href=\"Account/Manage/Passkeys\"]")); - // Register a new passkey + await page.EvaluateAsync(""" + () => { + navigator.credentials.create = () => { + const error = new Error("Simulated passkey creation failure"); + error.name = "NotAllowedError"; + return Promise.reject(error); + }; + } + """); + + await page.ClickAsync("text=Add a new passkey"); + await page.WaitForSelectorAsync("text=Error: No passkey was provided by the authenticator."); + + // Now check that we can successfully register a passkey + await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle }); await page.ClickAsync("text=Add a new passkey"); await page.WaitForSelectorAsync("text=Enter a name for your passkey"); @@ -221,11 +236,26 @@ await Task.WhenAll( await page.WaitForSelectorAsync("text=Passkey updated successfully"); - // Login with the passkey + // Check that an error is displayed if passkey retrieval fails await Task.WhenAll( page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Logout")); + await page.EvaluateAsync(""" + () => { + navigator.credentials.get = () => { + const error = new Error("Simulated passkey retrieval failure"); + error.name = "NotAllowedError"; + return Promise.reject(error); + }; + } + """); + + await page.ClickAsync("text=Log in with a passkey"); + await page.WaitForSelectorAsync("text=Error: No passkey was provided by the authenticator."); + + // Now check that we can successfully login with the passkey + await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle }); await page.WaitForSelectorAsync("[name=\"Input.Email\"]"); await page.FillAsync("[name=\"Input.Email\"]", userName); From 4542f083d00ca2cfd04e0667f8c205dd5194b7a3 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 27 Jun 2025 10:41:59 -0400 Subject: [PATCH 6/6] Attempt to improve test reliability --- .../BlazorTemplateTest.cs | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index 258cd8f83aad..ddc5ee58c81c 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -186,17 +186,19 @@ await Task.WhenAll( page.WaitForURLAsync("**/Account/ConfirmEmail**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Click here to confirm your account")); + // Now we attempt to navigate to the "Auth Required" page, + // which should redirect us to the login page since we are not logged in + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Auth Required")); + // Now we can login - await page.ClickAsync("text=Login"); await page.WaitForSelectorAsync("[name=\"Input.Email\"]"); await page.FillAsync("[name=\"Input.Email\"]", userName); await page.FillAsync("[name=\"Input.Password\"]", password); await page.ClickAsync("button[type=\"submit\"]"); - // Verify that we can visit the "Auth Required" page - await Task.WhenAll( - page.WaitForURLAsync("**/auth", new() { WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("text=Auth Required")); + // Verify that we return to the "Auth Required" page await page.WaitForSelectorAsync("text=You are authenticated"); if (authenticationFeatures.HasFlag(AuthenticationFeatures.Passkeys)) @@ -236,11 +238,21 @@ await page.EvaluateAsync(""" await page.WaitForSelectorAsync("text=Passkey updated successfully"); - // Check that an error is displayed if passkey retrieval fails + // Logout so that we can test the passkey login flow await Task.WhenAll( page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Logout")); + // Navigate home to reset the return URL + await page.ClickAsync("text=Home"); + await page.WaitForSelectorAsync("text=Hello, world!"); + + // Now navigate to the login page + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Login")); + + // Check that an error is displayed if passkey retrieval fails await page.EvaluateAsync(""" () => { navigator.credentials.get = () => { @@ -258,13 +270,13 @@ await page.EvaluateAsync(""" await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle }); await page.WaitForSelectorAsync("[name=\"Input.Email\"]"); await page.FillAsync("[name=\"Input.Email\"]", userName); - await page.ClickAsync("text=Log in with a passkey"); - // Verify that we can visit the "Auth Required" page - await Task.WhenAll( - page.WaitForURLAsync("**/auth", new() { WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("text=Auth Required")); + // Verify that we return to the home page + await page.WaitForSelectorAsync("text=Hello, world!"); + + // Verify that we can visit the "Auth Required" page again + await page.ClickAsync("text=Auth Required"); await page.WaitForSelectorAsync("text=You are authenticated"); } }