124 lines
4.8 KiB
JavaScript
124 lines
4.8 KiB
JavaScript
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
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
console.error(text);
|
|
throw new Error(`The server responded with status ${response.status}.`);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function createCredential(headers, signal) {
|
|
const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', {
|
|
method: 'POST',
|
|
headers,
|
|
signal,
|
|
});
|
|
const optionsJson = await optionsResponse.json();
|
|
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
|
|
return await navigator.credentials.create({ publicKey: options, signal });
|
|
}
|
|
|
|
async function requestCredential(email, mediation, headers, signal) {
|
|
const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, {
|
|
method: 'POST',
|
|
headers,
|
|
signal,
|
|
});
|
|
const optionsJson = await optionsResponse.json();
|
|
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
|
|
return await navigator.credentials.get({ publicKey: options, mediation, signal });
|
|
}
|
|
|
|
customElements.define('passkey-submit', class extends HTMLElement {
|
|
static formAssociated = true;
|
|
|
|
connectedCallback() {
|
|
this.internals = this.attachInternals();
|
|
this.attrs = {
|
|
operation: this.getAttribute('operation'),
|
|
name: this.getAttribute('name'),
|
|
emailName: this.getAttribute('email-name'),
|
|
requestTokenName: this.getAttribute('request-token-name'),
|
|
requestTokenValue: this.getAttribute('request-token-value'),
|
|
};
|
|
|
|
this.internals.form.addEventListener('submit', (event) => {
|
|
if (event.submitter?.name === '__passkeySubmit') {
|
|
event.preventDefault();
|
|
this.obtainAndSubmitCredential();
|
|
}
|
|
});
|
|
|
|
this.tryAutofillPasskey();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.abortController?.abort();
|
|
}
|
|
|
|
async obtainCredential(useConditionalMediation, signal) {
|
|
if (!browserSupportsPasskeys) {
|
|
throw new Error('Some passkey features are missing. Please update your browser.');
|
|
}
|
|
|
|
const headers = {
|
|
[this.attrs.requestTokenName]: this.attrs.requestTokenValue,
|
|
};
|
|
|
|
if (this.attrs.operation === 'Create') {
|
|
return await createCredential(headers, 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, headers, 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 {
|
|
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') {
|
|
// The user explicitly canceled the operation - return without error.
|
|
return;
|
|
}
|
|
console.error(error);
|
|
if (useConditionalMediation) {
|
|
// An error occurred during conditional mediation, which is not user-initiated.
|
|
// We log the error in the console but do not relay it to the user.
|
|
return;
|
|
}
|
|
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 (browserSupportsPasskeys && this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable?.()) {
|
|
await this.obtainAndSubmitCredential(/* useConditionalMediation */ true);
|
|
}
|
|
}
|
|
});
|