Created
December 7, 2025 00:51
-
-
Save rbrayb/193c1b43d0ce3846bd6c51ca0f084983 to your computer and use it in GitHub Desktop.
An issue with the .NET 10 passkey implementation and 1Password
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const browserSupportsPasskeys = | |
| typeof navigator.credentials !== 'undefined' && | |
| typeof window.PublicKeyCredential !== 'undefined' && | |
| typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' && | |
| typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === 'function'; | |
| // Passkey fix: Helper function to convert ArrayBuffer to base64url string | |
| // Base64url encoding is required by WebAuthn spec - it's base64 but URL-safe | |
| // (replaces + with -, / with _, and removes padding =) | |
| function arrayBufferToBase64Url(buffer) { | |
| if (!buffer) return undefined; | |
| const bytes = new Uint8Array(buffer); | |
| let binary = ''; | |
| for (let i = 0; i < bytes.byteLength; i++) { | |
| binary += String.fromCharCode(bytes[i]); | |
| } | |
| const base64 = btoa(binary); | |
| // Convert to base64url: replace + with -, / with _, and remove padding = | |
| return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); | |
| } | |
| 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); | |
| // Passkey fix: Manually convert ArrayBuffer fields to base64url strings | |
| // credential.toJSON() is not available in all browsers/authenticators (like 1Password) | |
| // This ensures proper base64url encoding as required by the WebAuthn spec | |
| let credentialData; | |
| if (typeof credential.toJSON === 'function') { | |
| // Use toJSON if available | |
| credentialData = credential.toJSON(); | |
| } else { | |
| // Manually convert ArrayBuffer fields to base64url | |
| credentialData = { | |
| id: credential.id, | |
| rawId: arrayBufferToBase64Url(credential.rawId), | |
| type: credential.type, | |
| response: {}, | |
| // Passkey fix: clientExtensionResults is required by server-side deserialization | |
| // Always include it, even if empty, to avoid JSON deserialization errors | |
| clientExtensionResults: credential.clientExtensionResults || {} | |
| }; | |
| if (credential.response.attestationObject) { | |
| // For credential creation (registration) | |
| credentialData.response = { | |
| attestationObject: arrayBufferToBase64Url(credential.response.attestationObject), | |
| clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON) | |
| }; | |
| if (credential.response.transports) { | |
| credentialData.response.transports = credential.response.transports; | |
| } | |
| if (credential.response.publicKey) { | |
| credentialData.response.publicKey = arrayBufferToBase64Url(credential.response.publicKey); | |
| } | |
| if (credential.response.publicKeyAlgorithm !== undefined) { | |
| credentialData.response.publicKeyAlgorithm = credential.response.publicKeyAlgorithm; | |
| } | |
| if (credential.response.authenticatorData) { | |
| credentialData.response.authenticatorData = arrayBufferToBase64Url(credential.response.authenticatorData); | |
| } | |
| } else if (credential.response.authenticatorData) { | |
| // For credential assertion (login) | |
| credentialData.response = { | |
| authenticatorData: arrayBufferToBase64Url(credential.response.authenticatorData), | |
| clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON), | |
| signature: arrayBufferToBase64Url(credential.response.signature) | |
| }; | |
| if (credential.response.userHandle) { | |
| credentialData.response.userHandle = arrayBufferToBase64Url(credential.response.userHandle); | |
| } | |
| } | |
| if (credential.authenticatorAttachment) { | |
| credentialData.authenticatorAttachment = credential.authenticatorAttachment; | |
| } | |
| } | |
| const credentialJson = JSON.stringify(credentialData); | |
| 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); | |
| } | |
| } | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment