Skip to content

Commit 9e69f19

Browse files
Fix passkey login error experience (#62480)
1 parent 4d23e51 commit 9e69f19

File tree

4 files changed

+95
-35
lines changed

4 files changed

+95
-35
lines changed

src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
{
102102
if (!string.IsNullOrEmpty(Input.Passkey?.Error))
103103
{
104-
errorMessage = $"Error: Could not log in using the provided passkey: {Input.Passkey.Error}";
104+
errorMessage = $"Error: {Input.Passkey.Error}";
105105
return;
106106
}
107107

src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ else
9090

9191
if (!string.IsNullOrEmpty(Input.Error))
9292
{
93-
RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add a passkey: {Input.Error}", HttpContext);
93+
RedirectManager.RedirectToCurrentPageWithStatus($"Error: {Input.Error}", HttpContext);
9494
return;
9595
}
9696

@@ -110,7 +110,7 @@ else
110110
var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson, options);
111111
if (!attestationResult.Succeeded)
112112
{
113-
RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}.", HttpContext);
113+
RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}", HttpContext);
114114
return;
115115
}
116116

src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
async function fetchWithErrorHandling(url, options = {}) {
1+
const browserSupportsPasskeys =
2+
typeof navigator.credentials !== 'undefined' &&
3+
typeof window.PublicKeyCredential !== 'undefined' &&
4+
typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' &&
5+
typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === 'function';
6+
7+
async function fetchWithErrorHandling(url, options = {}) {
28
const response = await fetch(url, {
39
credentials: 'include',
410
...options
@@ -45,7 +51,7 @@ customElements.define('passkey-submit', class extends HTMLElement {
4551
this.internals.form.addEventListener('submit', (event) => {
4652
if (event.submitter?.name === '__passkeySubmit') {
4753
event.preventDefault();
48-
this.obtainCredentialAndSubmit();
54+
this.obtainAndSubmitCredential();
4955
}
5056
});
5157

@@ -56,39 +62,51 @@ customElements.define('passkey-submit', class extends HTMLElement {
5662
this.abortController?.abort();
5763
}
5864

59-
async obtainCredentialAndSubmit(useConditionalMediation = false) {
65+
async obtainCredential(useConditionalMediation, signal) {
66+
if (!browserSupportsPasskeys) {
67+
throw new Error('Some passkey features are missing. Please update your browser.');
68+
}
69+
70+
if (this.attrs.operation === 'Create') {
71+
return await createCredential(signal);
72+
} else if (this.attrs.operation === 'Request') {
73+
const email = new FormData(this.internals.form).get(this.attrs.emailName);
74+
const mediation = useConditionalMediation ? 'conditional' : undefined;
75+
return await requestCredential(email, mediation, signal);
76+
} else {
77+
throw new Error(`Unknown passkey operation '${this.attrs.operation}'.`);
78+
}
79+
}
80+
81+
async obtainAndSubmitCredential(useConditionalMediation = false) {
6082
this.abortController?.abort();
6183
this.abortController = new AbortController();
6284
const signal = this.abortController.signal;
6385
const formData = new FormData();
6486
try {
65-
let credential;
66-
if (this.attrs.operation === 'Create') {
67-
credential = await createCredential(signal);
68-
} else if (this.attrs.operation === 'Request') {
69-
const email = new FormData(this.internals.form).get(this.attrs.emailName);
70-
const mediation = useConditionalMediation ? 'conditional' : undefined;
71-
credential = await requestCredential(email, mediation, signal);
72-
} else {
73-
throw new Error(`Unknown passkey operation '${operation}'.`);
74-
}
87+
const credential = await this.obtainCredential(useConditionalMediation, signal);
7588
const credentialJson = JSON.stringify(credential);
7689
formData.append(`${this.attrs.name}.CredentialJson`, credentialJson);
7790
} catch (error) {
78-
if (error.name === 'AbortError') {
79-
// Canceled by user action, do not submit the form
91+
console.error(error);
92+
if (useConditionalMediation || error.name === 'AbortError') {
93+
// We do not relay the error to the user if:
94+
// 1. We are attempting conditional mediation, meaning the user did not initiate the operation.
95+
// 2. The user explicitly canceled the operation.
8096
return;
8197
}
82-
formData.append(`${this.attrs.name}.Error`, error.message);
83-
console.error(error);
98+
const errorMessage = error.name === 'NotAllowedError'
99+
? 'No passkey was provided by the authenticator.'
100+
: error.message;
101+
formData.append(`${this.attrs.name}.Error`, errorMessage);
84102
}
85103
this.internals.setFormValue(formData);
86104
this.internals.form.submit();
87105
}
88106

89107
async tryAutofillPasskey() {
90-
if (this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable()) {
91-
await this.obtainCredentialAndSubmit(/* useConditionalMediation */ true);
108+
if (browserSupportsPasskeys && this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable?.()) {
109+
await this.obtainAndSubmitCredential(/* useConditionalMediation */ true);
92110
}
93111
}
94112
});

src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ await Task.WhenAll(
150150
protocol = "ctap2",
151151
transport = "internal",
152152
hasResidentKey = false,
153-
hasUserIdentification = true,
153+
hasUserVerification = true,
154154
isUserVerified = true,
155155
automaticPresenceSimulation = true,
156156
}
@@ -186,17 +186,19 @@ await Task.WhenAll(
186186
page.WaitForURLAsync("**/Account/ConfirmEmail**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
187187
page.ClickAsync("text=Click here to confirm your account"));
188188

189+
// Now we attempt to navigate to the "Auth Required" page,
190+
// which should redirect us to the login page since we are not logged in
191+
await Task.WhenAll(
192+
page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
193+
page.ClickAsync("text=Auth Required"));
194+
189195
// Now we can login
190-
await page.ClickAsync("text=Login");
191196
await page.WaitForSelectorAsync("[name=\"Input.Email\"]");
192197
await page.FillAsync("[name=\"Input.Email\"]", userName);
193198
await page.FillAsync("[name=\"Input.Password\"]", password);
194199
await page.ClickAsync("button[type=\"submit\"]");
195200

196-
// Verify that we can visit the "Auth Required" page
197-
await Task.WhenAll(
198-
page.WaitForURLAsync("**/auth", new() { WaitUntil = WaitUntilState.NetworkIdle }),
199-
page.ClickAsync("text=Auth Required"));
201+
// Verify that we return to the "Auth Required" page
200202
await page.WaitForSelectorAsync("text=You are authenticated");
201203

202204
if (authenticationFeatures.HasFlag(AuthenticationFeatures.Passkeys))
@@ -208,11 +210,26 @@ await Task.WhenAll(
208210

209211
await page.WaitForSelectorAsync("text=Manage your account");
210212

213+
// Check that an error is displayed if passkey creation fails
211214
await Task.WhenAll(
212215
page.WaitForURLAsync("**/Account/Manage/Passkeys**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
213216
page.ClickAsync("a[href=\"Account/Manage/Passkeys\"]"));
214217

215-
// Register a new passkey
218+
await page.EvaluateAsync("""
219+
() => {
220+
navigator.credentials.create = () => {
221+
const error = new Error("Simulated passkey creation failure");
222+
error.name = "NotAllowedError";
223+
return Promise.reject(error);
224+
};
225+
}
226+
""");
227+
228+
await page.ClickAsync("text=Add a new passkey");
229+
await page.WaitForSelectorAsync("text=Error: No passkey was provided by the authenticator.");
230+
231+
// Now check that we can successfully register a passkey
232+
await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle });
216233
await page.ClickAsync("text=Add a new passkey");
217234

218235
await page.WaitForSelectorAsync("text=Enter a name for your passkey");
@@ -221,20 +238,45 @@ await Task.WhenAll(
221238

222239
await page.WaitForSelectorAsync("text=Passkey updated successfully");
223240

224-
// Login with the passkey
241+
// Logout so that we can test the passkey login flow
225242
await Task.WhenAll(
226243
page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
227244
page.ClickAsync("text=Logout"));
228245

246+
// Navigate home to reset the return URL
247+
await page.ClickAsync("text=Home");
248+
await page.WaitForSelectorAsync("text=Hello, world!");
249+
250+
// Now navigate to the login page
251+
await Task.WhenAll(
252+
page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
253+
page.ClickAsync("text=Login"));
254+
255+
// Check that an error is displayed if passkey retrieval fails
256+
await page.EvaluateAsync("""
257+
() => {
258+
navigator.credentials.get = () => {
259+
const error = new Error("Simulated passkey retrieval failure");
260+
error.name = "NotAllowedError";
261+
return Promise.reject(error);
262+
};
263+
}
264+
""");
265+
266+
await page.ClickAsync("text=Log in with a passkey");
267+
await page.WaitForSelectorAsync("text=Error: No passkey was provided by the authenticator.");
268+
269+
// Now check that we can successfully login with the passkey
270+
await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle });
229271
await page.WaitForSelectorAsync("[name=\"Input.Email\"]");
230272
await page.FillAsync("[name=\"Input.Email\"]", userName);
231-
232273
await page.ClickAsync("text=Log in with a passkey");
233274

234-
// Verify that we can visit the "Auth Required" page
235-
await Task.WhenAll(
236-
page.WaitForURLAsync("**/auth", new() { WaitUntil = WaitUntilState.NetworkIdle }),
237-
page.ClickAsync("text=Auth Required"));
275+
// Verify that we return to the home page
276+
await page.WaitForSelectorAsync("text=Hello, world!");
277+
278+
// Verify that we can visit the "Auth Required" page again
279+
await page.ClickAsync("text=Auth Required");
238280
await page.WaitForSelectorAsync("text=You are authenticated");
239281
}
240282
}

0 commit comments

Comments
 (0)