I am trying to write an automated E2E test for signing up with WebAuthn, using Cypress. I am trying to use Chrome DevTools Protocol as suggested here (example 1). I have also looked at the examples here (example 2), which are in Playwright but offer a guideline as to how the code should go.
Update: after some more digging, I realised that the problem may not be my code. For the front-end we use simplewebauthn/browser. Well it seems simplewebauthn's startRegistration method returns an error:
"NotAllowedError: The 'publickey-credentials-create' feature is not enabled in this document. Permissions Policy may be used to delegate Web Authentication capabilities to cross-origin child frames"
I have checked the developer's toolbar in Chrome (where we run the tests), in Application -> Frames there are 2 frames under "top": Your Spec: '/__cypress/iframes/cypress%2Fcucumber etc. and Your project: 'Test Project' (webauthn). The YourSpec frame has publickey-credentials-create, publickey-credentials-get as Allowed. The Your Project frame has them as disabled. Does anyone know how to change that? In our cypress.config.ts we already have
chromeWebSecurity: false,
experimentalModifyObstructiveThirdPartyCode: true,
so that's no the problem.
Original post follows:
I have written the following code:
function addVirtualAuthenticator() {
console.log("virtual auth")
return Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.enable",
params: {},
}).then(() => {
console.log("enabled")
return Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.addVirtualAuthenticator",
params: {
options:{
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: false,
}
},
}).then((result) => {
console.log(result)
//cy.wrap(result.authenticatorId).as("authenticatorId");
return result.authenticatorId
});
});
}
function removeVirtualAuthenticator(authenticatorId: string) {
Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.removeVirtualAuthenticator",
params: {
authenticatorId,
},
}).then((result) => {
console.log("WebAuthn.removeVirtualAuthenticator", result);
Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.disable",
params: {},
})
});
}
function simulateSuccessfulPasskeyInput(authenticatorId: string){//, operationTrigger: () => void) {
console.log("Simulating")
return Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.setUserVerified",
params: {
authenticatorId,
isUserVerified: true,
},
}).then( () => {
console.log("step 2 "+authenticatorId)
return Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.setAutomaticPresenceSimulation",
params: {
authenticatorId,
enabled: true,
},
});
}).then (() => {
return Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.setAutomaticPresenceSimulation",
params: {
authenticatorId,
enabled: false,
},
});
}).then (() => {
removeVirtualAuthenticator(authenticatorId)
})
}
and in the test step for authenticating I have
cy.wrap(
addVirtualAuthenticator()
.then((authenticatorId) => {
console.log("to simulation")
console.log(authenticatorId)
return simulateSuccessfulPasskeyInput(authenticatorId)
})
)
I have tried the above code both with and without cy.wrap, and I have tried setting automaticPresenceSimulation=true when creating the Virtual Authenticator and skipping the simulateSuccessfulPasskeyInput() step altogether. Nothing works. How it works when run manually is you get redirected to WebAuthn page, the fingerprint dialogue pops up, and once you successfully register your passkey you are redirect to the success page. When running the Cypress test I get redirected to WebAuthn page, the fingerprint dialogue does not pop up, I get a message "Biometrics registration failed", and I don't get redirected to success.
In example 2 there was an extra step where you wait for the operation that triggers WebAuthn to start (that would be the user clicking somewhere). But in my case there is no user click, when the page loads WebAuthn registration starts automatically. I don't know if adding this step would help.
If anyone has done this before and has any pointers I would be grateful for your help.
OK finally found the solution, and I am posting here for whoever has the same issue. Here are the steps needed for WebAuthn registration:
Step 1: Add Virtual Authenticator
function addVirtualAuthenticator() {
return Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.enable",
params: {},
}).then(() => {
return Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.addVirtualAuthenticator",
params: {
options:{
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,//set to true to automatically succeed
}
},
}).then((result) => {
return result.authenticatorId
});
});
}
cy.wrap(
addVirtualAuthenticator()
.then((authenticatorId) => {
return authenticatorId
})
).as("authenticatorId")
Step 2 - go to the WebAuthn registration page Wherever that is in your app, and trigger the action that initiates the registration (for us it was simply navigating to the page, registration started on page mounted).
Step 3 (OPTIONAL): Create Credential and submit it to the backend for verification
This is where the big problem was. Our Cypress test started running on http://domain1 and then redirected to http://domain2. That is why we got the "NotAllowedError" related to cross-origin frames. If you don't have a cross-origin problem, you can skip this step, and the virtual authenticator will create the credentials automatically.
In our case, we had to explicitly call navigator.credentials.create()
and submit the created credential to the backend for verification. We did it like this:
cy.window().then((win) => {
const url = new URL(win.location.href);
const webauthn_options = url.searchParams.get("webauthn_options")
const publicKey = createPublicKey(webauthn_options as string)
return navigator.credentials.create({ publicKey })
.then((credential) => {
cy.window().then(() => {
cy.request({
method: 'POST',
url: our_backend_url,
form: true, // indicates the body should be form urlencoded and sets Content-Type: application/x-www-form-urlencoded headers
body: {
credential: JSON.stringify(formatCredential(credential))
},
followRedirect: false,
}).then((response) => {
//our backend response redirects us to the success page, for you it may be different
expect(response.status).to.eq(302)
cy.visit(response.redirectedToUrl as unknown as string)
})
})
})
});
For the functions createPublicKey() and formatCredential() I copied code from @simplemwebauthn/browser (it is what we use in the frontend). See the startRegistration method. Because we had a cross-origin problem, at first I got an error from the backend that the credential's origin didn't match the expected one. So when formatting the credential, I had the code edit the origin:
const { id, rawId, response, type } = credential;
const clientDataStr = arrayBufferToStr(response.clientDataJSON);
const clientDataObj = JSON.parse(clientDataStr);
clientDataObj.origin = correct_expected_origin
const newClientDataJSON = strToArrayBuffer(JSON.stringify(clientDataObj))
and then submitted the newClientDataJSON
instead of the original one. Helper functions:
function arrayBufferToStr(buf: ArrayBuffer): string {
return String.fromCharCode.apply(null, new Uint8Array(buf));
}
function strToArrayBuffer(str: string): ArrayBuffer {
const buf = new ArrayBuffer(str.length * 2); // 2 bytes for each char
const bufView = new Uint16Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}
Step 4: remove Virtual Authenticator after the test is done
function removeVirtualAuthenticator(authenticatorId: string) {
Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.removeVirtualAuthenticator",
params: {
authenticatorId,
},
}).then(() => {
Cypress.automation("remote:debugger:protocol", {
command: "WebAuthn.disable",
params: {},
})
});
}
cy.get("@authenticatorId").then((authenticatorId) => {
removeVirtualAuthenticator(authenticatorId as unknown as string)
})
That's it. Good luck!