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..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 @@ -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; } @@ -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 f234215ef2d8..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 @@ -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 @@ -45,7 +51,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,39 +62,51 @@ customElements.define('passkey-submit', class extends HTMLElement { this.abortController?.abort(); } - async obtainCredentialAndSubmit(useConditionalMediation = false) { + 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') { + 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. return; } - formData.append(`${this.attrs.name}.Error`, error.message); - console.error(error); + 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); this.internals.form.submit(); } async tryAutofillPasskey() { - if (this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable()) { - await this.obtainCredentialAndSubmit(/* useConditionalMediation */ true); + if (browserSupportsPasskeys && this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable?.()) { + await this.obtainAndSubmitCredential(/* useConditionalMediation */ true); } } }); diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index 50a93deeebe9..ddc5ee58c81c 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, } @@ -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)) @@ -208,11 +210,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,20 +238,45 @@ await Task.WhenAll( await page.WaitForSelectorAsync("text=Passkey updated successfully"); - // Login with the passkey + // 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 = () => { + 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); - 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"); } }