3

Objective is to implement PKCE (proof key for code exchange ) verification as part of SSO with an external identity provider and Salesforce Experience Cloud

Tried so far:
Setup a custom Auth. Provider with a custom authentication provider class (which uses a custom metadata type as per doc here) and followed steps provided in the answer How to implement PKCE flow in Salesforce to generate the code_challenge from code_verifier.

Result
HTTP callout response gives error Invalid parameter: code_verifier in handlecallback method.

Debug logs shows that initiate and handleCallback method are executed in 2 separate transactions, which is the most probably the reason why code_verifier is never the same in handleCallback.

Tried these other options knowing that none of them is secure even if it worked :
1/ Added logic in initiate method to save the code_verifier in a platform session cache but the current user session id is always null, so data cannot be saved

2/ Saved the code_verifier value in a hierarchical custom settings but field came up as empty in handleCallback method

Question:
So how to perform PKCE verification in a custom authentication provider class ?

Here is the relevant code for custom authentication provider class :

global with sharing class CustomAuthProvider extends Auth.AuthProviderPluginClass {
    private String customMetadataTypeApiName = 'CustomAuth__mdt'; 
    public  String redirectUrl; 
    private String key;
    private String secret;
    private String authUrl;  
    private String accessTokenUrl; 
    private String userInfoUrl; 
    private String scope;
    String codeVerifier  = generateRandomString(34);
    Blob sha256          = Crypto.generateDigest('SHA-256', Blob.valueOf(codeVerifier));
    String codeChallenge = base64URLencode(sha256);
    
    global String getCustomMetadataType(){
        return customMetadataTypeApiName;
    }

    private static String base64URLencode(Blob input){
        String output = encodingUtil.base64Encode(input);
        output = output.replace('+', '-');
        output = output.replace('/', '_');
        while ( output.endsWith('=')){
            output = output.subString(0,output.length()-1);
        }
        return output;
    }
    
    private static String generateRandomString(Integer len) {
        final String chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz';
        String randStr = '';
        while (randStr.length() < len) {
            Integer idx = Math.mod(Math.abs(Crypto.getRandomInteger()), chars.length());
            randStr += chars.substring(idx, idx+1);
        }
        return randStr; 
    }

    global PageReference initiate(Map<string,string> authProviderConfiguration, String stateToPropagate) { 

        key         = authProviderConfiguration.get('ConsumerKey__c');
        authUrl     = authProviderConfiguration.get('AuthURL__c');
        scope       = authProviderConfiguration.get('Scope__c');
        redirectUrl = authProviderConfiguration.get('CallbackURL__c');

        String urlToRedirect = authUrl+'?client_id='+key+'&response_type=code&redirect_uri='+redirectUrl+'&state='+stateToPropagate
                                +'&code_challenge_method=S256&code_challenge='+codeChallenge;

        PageReference pageRef = new PageReference(urlToRedirect);
        return pageRef;
    } 
    
    global Auth.AuthProviderTokenResponse handleCallback(Map<string,string> authProviderConfiguration, Auth.AuthProviderCallbackState state ) { 
        key = authProviderConfiguration.get('ConsumerKey__c'); 
        secret = authProviderConfiguration.get('ConsumerSecret__c'); 
        accessTokenUrl = authProviderConfiguration.get('TokenURL__c'); 
        redirectUrl = authProviderConfiguration.get('CallbackURL__c');
        
        Map<String,String> queryParams = state.queryParameters; 
        String code = queryParams.get('code'); 
        String sfdcState = queryParams.get('state'); 

        HttpRequest req = new HttpRequest(); 
        String requestBody = 'grant_type=authorization_code&client_id='+key+'&client_secret='+secret+'&code='+code
                             +'&redirect_uri='+redirectUrl+'&state='+sfdcState +'&code_verifier='+codeVerifier;
        req.setEndpoint(accessTokenUrl); 
        req.setHeader('Accept','application/x-www-form-urlencoded'); 
        req.setMethod('POST'); 
        req.setBody(requestBody);
        Http http = new Http(); 
        HTTPResponse res = http.send(req); //Invalid parameter: code_verifier error here
        
        String responseBody = res.getBody(); 
        System.debug(SYSTEM.LoggingLevel.DEBUG, '\n **** responseBody '+responseBody); 
        
        return new Auth.AuthProviderTokenResponse('CustomAuthProvider', null, null, sfdcState); 
    } 
   
               
    global Auth.UserData getUserInfo(Map<string,string> authProviderConfiguration, Auth.AuthProviderTokenResponse response) { 
       ///getUserInfo
    }     

}
3
  • @identigral Do you have any insight on this topic ?
    – Shamina
    Mar 29, 2022 at 8:07
  • Try using org-wide cache with a key that uniquely identifies this request
    – identigral
    Apr 2, 2022 at 16:40
  • @identigral noted, will try this, thanks
    – Shamina
    Apr 6, 2022 at 12:37

1 Answer 1

1

For anyone finding this thread later and trying todo the same.

Me and a college implemented this for Experience Cloud just a few months ago. Initially we tried using a custom object to store and retrieve code_challenge, but we where unable to retrieve this records in the handleCallback method due to DML issues.

We then opted for saving it in the org-wide PlatformCache. It's worth mentioning that the key length is quite short. So you would need to find a way to get a unique key that is both in the initiated and in the handleCallback method. As you saw this are in to different transactions. We opted for using the state provided by salesforce, but shortened it down with the method below:

public string getSessionId(String state) {    
  return base64URLencode(
   Crypto.generateDigest('SHA1', Blob.valueOf(state))
  )      
  .remove('_')      
  .remove('-');  
}

Using the PlatformCache also had some issues, but got recently resolved here: https://issues.salesforce.com/issue/a028c00000smXe9/sso-failing-with-no_oauth_state-error-code-with-custom-auth-provider

Example Implementation

Implement a PlatformCache class that is responsible of storing and retriving from the org-partion. You can take inspiration from PlatformCacheRecipies class from Apex-Recipies repo.
Here you only would only need the methods storeValueInOrgCache and getValueFromOrgCache. Then setup your own platform cache partiton in setup. You might find this video helpful on the platform cache setup by Matt Gerry.

In your custom auth provider class that extends Auth.AuthProviderPluginClass you will need to create the code_challenge and code_verifier in the initiate method.

public PageReference initiate(
  Map<String, String> config,
  String stateToPropagate
) {
  String sessionId = this.getSessionId(stateToPropagate);
  String codeChallenge = this.getCodeChallenge(
    // CODE_VERIFIER_LENGTH can be set to eg. 64
    this.getCodeVerifier(CODE_VERIFIER_LENGTH, sessionId),
    sessionId
  );
  // Create your authorizationUrl
  return new PageReference(authorizationUrl);
}

The getCodeVerifier should return code_verifier or create, store and return code_verifier depending on if it is called from initiate method or handleCallback.

public String getCodeVerifier(Integer length, String sessionId) {
  String key = 'codeVerifier' + sessionId;
    // return codeVerifier from cache if found
    this.codeVerifier = PlatformCache.getValueFromOrgCache(key);
    if (String.isNotBlank(this.codeVerifier)) {
      return this.codeVerifier;
    }
    // generate new code verifier in the pattern you like. 
    // Here represented by randStr.
      this.codeVerifier = randStr;
      PlatformCache.storeValueInOrgCache(key, this.codeVerifier, TIME_TO_LIVE);
  return this.codeVerifier;
}

The getCodeChallenge is only needed in the initiate method so there should be no need to store this in the cache.

public String getCodeChallenge(String codeVerifier, String sessionId) {
  return this.base64URLencode(
        Crypto.generateDigest('SHA-256', Blob.valueOf(this.codeVerifier))
      );
}

The base64URLencode is used both in getSessionId and getCodeChallenge

private String base64URLencode(Blob oneWayHash) {
  String output = encodingUtil.base64Encode(oneWayHash);
  // Remove unsupported characters to be able to store in cache.
  output = output.replace('+', '-');
  output = output.replace('/', '_');
  while (output.endsWith('=')) {
    output = output.subString(0, output.length() - 1);
  }
  return output;
}

To cleanup our work with the platform cache i would recommend to remove any used code_verifiers or code_challenge at the end or the handleCallback method. It will also be removed automatically based on the seconds set in TIME_TO_LIVE.

Tip: We used Nebula logger with platform events to debug this as DML behaviour was limited in custom Auth.Provider https://github.com/jongpie/NebulaLogger

3
  • Thanks for your inputs. I will look into this later. could you also provide the minimum code snippet showing how you are saving the code_challenge in the platform cache
    – Shamina
    Jul 13, 2023 at 11:46
  • 1
    No problem @Shamina! Have updated my initial comment with example methods. Jul 20, 2023 at 14:34
  • Thanks a lot Erlend for taking the time to update the answer, much appreciated
    – Shamina
    Jul 20, 2023 at 15:20

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .