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
Enable passkeys in the ABP Account module.
Sign in to the MVC/AuthServer application.
Navigate to:
/Account/ManageOpen the Passkeys profile management tab.
Click Add Passkey.
Complete the browser passkey prompt using 1Password.
Observe that the browser posts to:
/api/account/registerPasskeyThe server responds with:
403 ForbiddenThe 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.
2 Answer(s)
-
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) -
0
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 passkeyDefault.js) follow the same plainJSON.stringify(credential)pattern as the official Blazor passkey template. That works for nativePublicKeyCredentialinstances but breaks when 1Password (or other password managers) interceptnavigator.credentials.create()and return an object whosetoJSON()doesn't emitclientExtensionResults- which isrequiredonMicrosoft.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.toJSONthemselves, 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 (includingclientExtensionResultsfromcredential.getClientExtensionResults()), plus aconvertToBase64helper 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)