Throughout this article, we will guide you on how to implement Passkey Authentication for Salesforce Digital Experience (Community Cloud). This blog article is divided into 2 parts. In previous section we’ve covered Passkey registration flow and now we have user credentials saved in our Salesforce, let’s go through how we can authenticate using them.
To trigger authentication we’ll need to call navigator.credentials.get(options) method with “publicKey” parameter and following options:
Challenge generation during registration was easy, because we had a user’s session and could utilize Platform Cache, which doesn’t work in the context of Guest user. How can we securely generate it? We could generate a random string and then store it in a custom table, but then we’ll have to make sure that we invalidate tokens when used and delete unused ones once after a certain time… There should be a better way…
NOTE:
As a cryptographic protocol, Web Authentication is dependent upon randomized challenges to avoid replay attacks. Therefore, the values of both PublicKeyCredentialCreationOptions.challenge and PublicKeyCredentialRequestOptions.challenge MUST be randomly generated by Relying Parties in an environment they trust (e.g., on the server-side), and the returned challenge value in the client’s response MUST match what was generated. This SHOULD be done in a fashion that does not rely upon a client’s behavior, e.g., the Relying Party SHOULD store the challenge temporarily until the operation is complete. Tolerating a mismatch will compromise the security of the protocol.
Each reCAPTCHA user response token is valid for two minutes, and can only be verified once to prevent replay attacks. If you need a new token, you can re-run the reCAPTCHA verification. If you’re protecting an action with reCAPTCHA, make sure to call execute when the user takes the action rather than on page load.
To add reCAPTCHA to our community we’ll need to add following code to our Head Markup:
And then subscribe to an grecaptchaVerified event in our LWC component:
addRecaptchaVerificationHandler() {
let that = this;
document.addEventListener("grecaptchaVerified", function(e) {
if (e.detail.action !== 'passkeyLogin') {
return;
}
let challenge = e.detail.response;
var options = {
// The challenge is produced by Google reCAPTCHA
challenge: string2ArrayBuffer(challenge),
timeout: 300000, // 5 minutes
allowCredentials: [],
userVerification: "required",
rpId: that.experienceInfo.host
};
navigator.credentials.get({ "publicKey": options })
.then(function (assertion) {
let authInfo = {
userId: arrayBuffer2String(assertion.response.userHandle),
credentialId: assertion.id,
signature: arrayBuffer2Base64(assertion.response.signature),
authenticatorData: arrayBuffer2Base64(assertion.response.authenticatorData),
clientDataJSON: new TextDecoder().decode(assertion.response.clientDataJSON),
startUrl: that.currentPageReference.state['startURL']
};
loginWithPasskey({ authInfo: JSON.stringify(authInfo) }).then(result => {
window.open(result, "_self");
}).catch(err => {
console.log(err)
});
}).catch(function (err) {
console.log(err);
// No acceptable credential or user refused consent. Handle appropriately.
});
});
}
On successful execution of navigator.credentials.get() method we are getting a set of authentication parameters:
Having all this we can securely verify our customer!
clientDataJSON.challenge verification with Google will eliminate possibility of replay attacks
and userHandle alongside id will help us retrieve User_Credential__c
record with related Public Key and Algorithm for signature verification.
NOTE:
Since authentication is happening under Guest user and we don’t want to give it any access to User_Credential__c object our Apex will run with “without sharing” keyword and SOQL with “WITH SYSTEM_MODE”. This will ensure that it still has access to necessary information without exposing it externally.
Our Apex Controller method to authenticate user would look like this:
public static String loginWithPasskey(AuthenticationInfo authInfo) {
try {
ClientData cData = (ClientData) JSON.deserialize(authInfo.clientDataJSON, ClientData.class);
if(cData.type != 'webauthn.get') {
throw new PasskeyVerificationException('Unexpected verification response type: ' + cData.type);
}
//Validating challenge
if(cData.challenge != null && Recaptcha.verify(cData.challenge)) {
throw new PasskeyVerificationException('Invalid challenge');
}
User_Credential__c credential = [SELECT User__r.Username, Public_Key__c, Public_Key_Algorithm__c
FROM User_Credential__c
WHERE User__c = :authInfo.userId AND Credential_Id__c = :authInfo.credentialId
WITH SYSTEM_MODE
LIMIT 1];
//signature structure: https://w3c.github.io/webauthn/#fig-signature
String hexAuthenticatorData = EncodingUtil.convertToHex(EncodingUtil.base64Decode(authInfo.authenticatorData));
String hexClientData = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', Blob.valueOf(authInfo.clientDataJSON)));
Blob data = EncodingUtil.convertFromHex(hexAuthenticatorData + hexClientData);
Boolean verified = Crypto.verify(credential.Public_Key_Algorithm__c,
data,
EncodingUtil.base64Decode(authInfo.signature),
EncodingUtil.base64Decode(credential.Public_Key__c));
if(verified) {
String accessToken = getUserAccessToken(credential.User__r.Username);
String startUrl = String.isBlank(authInfo.startUrl) ? '/' : authInfo.startUrl;
//Pass access token to frontdoor to create a web session
return Utils.COMMUNITY_VF_URL + '/secur/frontdoor.jsp?sid=' + accessToken + '&retURL=' + startUrl;
} else {
throw new PasskeyVerificationException('Invalid signature / credential details');
}
} catch (Exception ex) {
System.debug(ex.getMessage() + '\n' + ex.getStackTraceString());
throw new AuraHandledException('Failed to authenticate with Passkey');
}
}
Looking through the code you probably noticed that after we verify signature we generate access token and return url pointing to frontdoor.jsp, this process is covered in detail in our “How to generate Magic Links for Community Users” blog post, in short it creates a valid web session for a given user, so that user can proceed into community.
Right, we are all set now! Our users can add Passkey to their accounts and easily use them to authenticate with our Digital Experience site!You can play with this implementation in a scratch or sandbox by grabbing a code from this repository: https://github.com/Nubessom/sfec-passkey
It will return us a Promise that will resolve into True or False depending on the feature availability. It is best to trigger this function within connectedCallback() of our component, so that we could hide or show the Registration button.
Salesforce Consultant & Architect, Business Applications, Business Processes and Technology Consulting, Entrepreneurship. More than 15 years working on the CRM and Salesforce Ecosystems.
© 2024 Nubessom Consulting All Rights Reserved