Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
}
});

Expand All @@ -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);
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ await Task.WhenAll(
protocol = "ctap2",
transport = "internal",
hasResidentKey = false,
hasUserIdentification = true,
hasUserVerification = true,
isUserVerified = true,
automaticPresenceSimulation = true,
}
Expand Down Expand Up @@ -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))
Expand All @@ -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");
Expand All @@ -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");
}
}
Expand Down
Loading