Open Closed

Passkey registration fails with 1Password/.NET 10: registerPasskey payload missing clientExtensionResults #10643


User avatar
0

Environment

  • ABP Framework version: 10.2.0
  • UI type: MVC / Razor Pages
  • AuthServer separated: Yes
  • Database system: EF Core / SQL Server
  • .NET version: .NET 10
  • Browser / authenticator: Chrome and Edge with the 1Password passkey flow

Exception message and stack trace

The client-side request fails with:

POST https://next-identity.cab.md/api/account/registerPasskey 403 (Forbidden)

Response body:

{
  "message": "Passkey attestation failed.",
  "details": null,
  "validationErrors": null
}

The server log shows the more specific cause:

Could not add the passkey: The attestation credential JSON had an invalid format:
JSON deserialization for type
'Microsoft.AspNetCore.Identity.PublicKeyCredential`1[Microsoft.AspNetCore.Identity.AuthenticatorAttestationResponse]'
was missing required properties including: 'clientExtensionResults'.

This is followed by:

Volo.Abp.UserFriendlyException: Passkey attestation failed.
   at Volo.Abp.Account.Public.Web.Areas.Account.Controllers.AccountController.RegisterPasskey(String credential)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.TaskResultExecutor.Execute(...)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeActionMethodAsync>g__Logged|12_1(...)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeNextActionFilterAsync>g__Awaited|10_0(...)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextExceptionFilterAsync>g__Awaited|26_0(...)

Steps to reproduce

  1. Enable passkeys in the ABP Account module.

  2. Sign in to the MVC/AuthServer application.

  3. Navigate to:

    /Account/Manage
    
  4. Open the Passkeys profile management tab.

  5. Click Add Passkey.

  6. Complete the browser passkey prompt using 1Password.

  7. Observe that the browser posts to:

    /api/account/registerPasskey
    
  8. The server responds with:

    403 Forbidden
    
  9. The UI displays:

    Passkey attestation failed.
    

Expected behavior

Passkey registration should succeed.

Alternatively, ABP should normalize the WebAuthn credential payload before passing it to ASP.NET Core Identity’s passkey APIs.


Actual behavior

Passkey registration fails because the posted credential JSON does not include the required top-level clientExtensionResults property.

ASP.NET Core Identity’s .NET 10 passkey deserialization rejects the payload before attestation can complete.


Suspected cause

The WebAuthn credential payload posted by the ABP Account module appears to omit clientExtensionResults.

In .NET 10, Microsoft.AspNetCore.Identity.PublicKeyCredential<TResponse> appears to require this property during JSON deserialization. When it is missing, deserialization fails with:

was missing required properties including: 'clientExtensionResults'

As a result, the ABP Account controller returns the generic error:

Passkey attestation failed.

Possible workaround

Adding clientExtensionResults: {} to the serialized credential payload before posting to:

/api/account/registerPasskey

appears to avoid the deserialization failure.

The client-side payload should include:

clientExtensionResults: credential.getClientExtensionResults?.() ?? {}

or, at minimum:

clientExtensionResults: {}

when the property is missing.

Markdown supported.
Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)

2 Answer(s)
  • User Avatar
    0

    This seems to work, as a patch, for the moment (fixes registration and login):

    (function ($) {
        if (!$ || window.__abpPasskeyCompatibilityPatch) {
            return;
        }
    
        const registerPasskeyPath = "/api/account/registerPasskey";
        const originalAjax = $.ajax;
        const originalFormDataAppend = window.FormData && window.FormData.prototype.append;
        const originalFormDataSet = window.FormData && window.FormData.prototype.set;
        const originalFormSubmit = window.HTMLFormElement && window.HTMLFormElement.prototype.submit;
        const originalFormRequestSubmit = window.HTMLFormElement && window.HTMLFormElement.prototype.requestSubmit;
        const originalXhrOpen = window.XMLHttpRequest && window.XMLHttpRequest.prototype.open;
        const originalXhrSend = window.XMLHttpRequest && window.XMLHttpRequest.prototype.send;
    
        /**
         * Patches the credential JSON to ensure it contains the clientExtensionResults property, which is expected by ABP's passkey registration endpoint.
         * @param {any} credentialJson - The credential JSON string to patch.
         * @returns {any} - The patched credential JSON string, or the original value if it was not a string or could not be parsed.
         */
        function patchCredentialJson(credentialJson) {
            if (!credentialJson || typeof credentialJson !== "string") {
                return credentialJson;
            }
    
            const credential = JSON.parse(credentialJson);
            if (!Object.prototype.hasOwnProperty.call(credential, "clientExtensionResults")) {
                credential.clientExtensionResults = {};
            }
    
            return JSON.stringify(credential);
        }
    
        /**
         * Patches the value of a form field if it is the "credential" field, ensuring that the credential JSON contains the clientExtensionResults property.
         * @param {any} name - The name of the form field.
         * @param {any} value - The value of the form field.
         * @returns {any} - The patched value if the field is "credential" and the value is a string, or the original value otherwise.
         */
        function patchCredentialFormValue(name, value) {
            if (name === "credential" && typeof value === "string") {
                try {
                    return patchCredentialJson(value);
                } catch {
                    return value;
                }
            }
    
            return value;
        }
    
        /**
         * Patches a FormData object by ensuring that if it contains a "credential" field, the value of that field is patched to include the clientExtensionResults property in the credential JSON.
         * @param {any} formData - The FormData object to patch.
         * @returns {any} - The patched FormData object, or the original value if it was not a FormData object or did not contain a "credential" field with a string value.
         */
        function patchFormData(formData) {
            if (!formData || typeof formData.get !== "function" || typeof formData.set !== "function") {
                return formData;
            }
    
            const credential = formData.get("credential");
            if (typeof credential === "string") {
                formData.set("credential", patchCredentialJson(credential));
            }
    
            return formData;
        }
    
        /**
         * Patches all credential input fields within the specified root element, ensuring that the credential JSON contains the clientExtensionResults property.
         * @param {any} root - The root element to search for credential input fields. If not provided, the entire document is used.
         */
        function patchCredentialInputs(root) {
            const scope = root && typeof root.querySelectorAll === "function"
                ? root
                : document;
    
            const inputs = scope.querySelectorAll(
                "input[name='LoginInput.CredentialJson'], input#credentialJson"
            );
    
            inputs.forEach(function (input) {
                if (typeof input.value !== "string" || !input.value) {
                    return;
                }
    
                try {
                    input.value = patchCredentialJson(input.value);
                } catch {
                    // Let the existing passkey error handling display the failure.
                }
            });
        }
    
        /**
         * Patches the request data to ensure that if it contains a "credential" field, the value of that field is patched to include the clientExtensionResults property in the credential JSON.
         * @param {any} data - The request data to patch.
         * @returns {any} - The patched request data, or the original value if it was not a recognized type or did not contain a "credential" field with a string value.
         */
        function patchRequestData(data) {
            if (!data) {
                return data;
            }
    
            if (window.FormData && data instanceof window.FormData) {
                return patchFormData(data);
            }
    
            if (typeof data === "string") {
                const parameters = new URLSearchParams(data);
                const credential = parameters.get("credential");
    
                if (!credential) {
                    return data;
                }
    
                parameters.set("credential", patchCredentialJson(credential));
                return parameters.toString();
            }
    
            if (typeof data === "object" && typeof data.credential === "string") {
                data.credential = patchCredentialJson(data.credential);
            }
    
            return data;
        }
    
        document.addEventListener("submit", function (event) {
            patchCredentialInputs(event.target);
        }, true);
    
        if (originalFormSubmit) {
            window.HTMLFormElement.prototype.submit = function () {
                patchCredentialInputs(this);
                return originalFormSubmit.apply(this, arguments);
            };
        }
    
        if (originalFormRequestSubmit) {
            window.HTMLFormElement.prototype.requestSubmit = function () {
                patchCredentialInputs(this);
                return originalFormRequestSubmit.apply(this, arguments);
            };
        }
    
        if (originalFormDataAppend) {
            window.FormData.prototype.append = function (name, value, fileName) {
                const patchedValue = patchCredentialFormValue(name, value);
    
                return arguments.length > 2
                    ? originalFormDataAppend.call(this, name, patchedValue, fileName)
                    : originalFormDataAppend.call(this, name, patchedValue);
            };
        }
    
        if (originalFormDataSet) {
            window.FormData.prototype.set = function (name, value, fileName) {
                const patchedValue = patchCredentialFormValue(name, value);
    
                return arguments.length > 2
                    ? originalFormDataSet.call(this, name, patchedValue, fileName)
                    : originalFormDataSet.call(this, name, patchedValue);
            };
        }
    
        if (originalXhrOpen && originalXhrSend) {
            window.XMLHttpRequest.prototype.open = function (method, url) {
                this.__abpPasskeyCompatibilityPatchUrl = url;
                return originalXhrOpen.apply(this, arguments);
            };
    
            window.XMLHttpRequest.prototype.send = function (body) {
                if (
                    this.__abpPasskeyCompatibilityPatchUrl &&
                    this.__abpPasskeyCompatibilityPatchUrl.indexOf(registerPasskeyPath) !== -1 &&
                    window.FormData &&
                    body instanceof window.FormData
                ) {
                    try {
                        patchFormData(body);
                    } catch {
                        // Let the existing passkey error handling display the failure.
                    }
                }
    
                return originalXhrSend.call(this, body);
            };
        }
    
        /**
         * Patches the AJAX request data to ensure that if it contains a "credential" field, the value of that field is patched to include the clientExtensionResults property in the credential JSON.
         * @param {any} url - The URL of the AJAX request.
         * @param {any} options - The options for the AJAX request.
         * @returns {any} - The patched AJAX request options, or the original value if it was not a recognized type or did not contain a "credential" field with a string value.
         */
        $.ajax = function (url, options) {
            const ajaxOptions = typeof url === "object"
                ? url
                : options || {};
    
            const requestUrl = typeof url === "object"
                ? ajaxOptions.url
                : url;
    
            if (requestUrl && requestUrl.indexOf(registerPasskeyPath) !== -1) {
                try {
                    ajaxOptions.data = patchRequestData(ajaxOptions.data);
                } catch {
                    // Let the existing passkey error handling display the failure.
                }
            }
    
            return typeof url === "object"
                ? originalAjax.call(this, ajaxOptions)
                : originalAjax.call(this, url, ajaxOptions);
        };
    
        window.__abpPasskeyCompatibilityPatch = true;
    })(window.jQuery);
    
    
    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    Your analysis is correct, and this is a known issue upstream in ASP.NET Core - not something specific to ABP. ABP's passkey scripts (addPasskey.js, Login.js, and the profile passkey Default.js) follow the same plain JSON.stringify(credential) pattern as the official Blazor passkey template. That works for native PublicKeyCredential instances but breaks when 1Password (or other password managers) intercept navigator.credentials.create() and return an object whose toJSON() doesn't emit clientExtensionResults - which is required on Microsoft.AspNetCore.Identity.PublicKeyCredential<TResponse> in .NET 10:

    https://github.com/dotnet/aspnetcore/blob/release/10.0/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs#L33

    The ASP.NET Core team is tracking it here:

    • https://github.com/dotnet/aspnetcore/issues/62916 - "Passkeys - Illegal invocation with 1Password"
    • https://github.com/dotnet/aspnetcore/pull/62917 - community fallback PR (closed, not merged into the template)
    • https://github.com/dotnet/aspnetcore/issues/63078 - tracked as a docs-only fix

    Their position is that password managers should fix PublicKeyCredential.prototype.toJSON themselves, so they declined to add the workaround to the Blazor template and documented it instead:

    https://learn.microsoft.com/aspnet/core/security/authentication/passkeys#mitigate-publickeycredentialtojson-error-typeerror-illegal-invocation

    The official guidance is to replace JSON.stringify(credential) with a manual object that explicitly populates each field (including clientExtensionResults from credential.getClientExtensionResults()), plus a convertToBase64 helper that base64url-encodes the binary fields.

    Your jQuery patch is a fine workaround for now - it covers form submit, FormData, XHR, and $.ajax, so both registration and login work.

    We'll apply Microsoft's documented fallback to the three ABP scripts in the next release so 1Password works out of the box. You can drop your patch once you upgrade.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on September 28, 2026, 11:44
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.