Showing posts with label auth provider. Show all posts
Showing posts with label auth provider. Show all posts

Tuesday, August 18, 2020

Custom Client Credential Grant Type Auth Provider in Salesforce for Named Credentials based Webservice Invocation

 If we have to create named credentials for a OAuth 2.0(client credentials grant type) based webservice endpoint in Salesforce we have to provide authentication provider. All of the standard auth provider type supported by salesforce are authorization code based grant types. In order to overcome this we will have to go for a custom auth provider where we can override some of the authorization flow behavior to get the bearer token and refresh token.

Lets start with the implementation in following steps

Step 1 - Create a custom auth provider

We can create a custom auth provider by extending standard salesforce class Auth.AuthProviderPluginClass 
Please refer the below code for reference

/**
 Developer : Tarique Habibullah
 forked from https://github.com/bobbywhitesfdc/ApigeeAuthProvider
**/
public class MyCustomAuth extends Auth.AuthProviderPluginClass{

    public static final String RESOURCE_CALLBACK = '/services/authcallback/';
    public static final String DEFAULT_TOKEN_TYPE = 'BearerToken';
    public static final String ENCODING_XML = 'application/x-www-form-urlencoded;charset=UTF-8';
    public static final String ENCODING_JSON = 'application/json';
    public static final String DUMMY_CODE = '999';
    public static final String DOUBLEQUOTE = '"';

    // This class is dependant on this Custom Metadata Type created to hold custom parameters
    public static final String CUSTOM_MDT_NAME = 'MyCustomMetadata__mdt'; 
    public static final String CMT_FIELD_CALLBACK_URL = 'Callback_URL__c';
    public static final String CMT_FIELD_PROVIDER_NAME = 'Auth_Provider_Name__c';
    public static final String CMT_FIELD_AUTHTOKEN_URL = 'Access_Token_URL__c';
    public static final String CMT_FIELD_CLIENT_ID = 'Client_Id__c';
    public static final String CMT_FIELD_CLIENT_SECRET = 'Client_Secret__c';
    public static final String CMT_FIELD_USE_JSON = 'Use_JSON_Encoding__c';
    public static final String CMT_FIELD_SCOPE = 'Scope__c';

    public static final String GRANT_TYPE_PARAM = 'grant_type';
    public static final String CLIENT_ID_PARAM = 'client_id';
    public static final String CLIENT_SECRET_PARAM = 'client_secret';
    public static final String SCOPE_PARAM = 'scope';
    public static final String GRANT_TYPE_CLIENT_CREDS = 'client_credentials';


    /**
     Added Constructor purely for debugging purposes to have visibility as to when the class
     is being instantiated.
    **/
    public MyCustomAuth() {
        super();
        System.debug('Constructor called');
    }
    
    
    /**
        Name of custom metadata type to store this auth provider configuration fields
        This method is required by its abstract parent class.

    **/
    public String getCustomMetadataType() {
        return CUSTOM_MDT_NAME;
    } 
    
    /**
    Initiate callback. No End User authorization required in this flow so skip straight to the Token request.
    The interface requires the callback url to be defined. 
    Eg: https://test.salesforce.com/services/authcallback/
    **/
    public PageReference initiate(Map config, String stateToPropagate) {
        System.debug('initiate');

        final PageReference pageRef = new PageReference(getCallbackUrl(config)); //NOSONAR
        pageRef.getParameters().put('state',stateToPropagate);
        pageRef.getParameters().put('code',DUMMY_CODE); // Empirically found this is required, but unused
        System.debug(pageRef.getUrl());
        return pageRef;
    } 

    /**
      This method composes the callback URL automatically UNLESS it has been overridden through Configuration.
      Normally one should not override the callback URL, but it's there in case the generated URL doesn't work.
    **/
    private String getCallbackUrl(Map config) {
        // https://{salesforce-hostname}/services/authcallback/{urlsuffix}
        final String overrideUrl = config.get(CMT_FIELD_CALLBACK_URL);
        final String generatedUrl = URL.getSalesforceBaseUrl().toExternalForm() + RESOURCE_CALLBACK + config.get('Auth_Provider_Name__c');
        System.debug('>>>'+(String.isEmpty(overrideUrl) ? generatedUrl : overrideUrl));
        return String.isEmpty(overrideUrl) ? generatedUrl : overrideUrl;
        
    }
    
    /**
    Handle callback (from initial loop back "code" step in the flow).
    In the Client Credentials flow, this method retrieves the access token directly.

    Required by parent class.

    Error handling here is a bit painful as the UI never displays the exception or error message 
    supplied here.  The exception is thrown for Logging/Debugging purposes only. 
    **/
    public Auth.AuthProviderTokenResponse handleCallback(Map config, Auth.AuthProviderCallbackState state ) {
        System.debug('handleCallback');
        final TokenResponse response = retrieveToken(config);

        if (response.isError()) {
            throw new TokenException(response.getErrorMessage());
        }
        return new Auth.AuthProviderTokenResponse(config.get(CMT_FIELD_PROVIDER_NAME)
                                                  , response.access_token
                                                  , config.get(CMT_FIELD_CLIENT_SECRET) // No Refresh Token
                                                  , state.queryParameters.get('state'));
    } 
    
    /**
        Refresh is required by the parent class and it's used if the original Access Token has expired.
        In the Client Credentials flow, there is no Refresh token, so its implementation is exactly the
        same as the Initiate() step.
    **/
    public override Auth.OAuthRefreshResult refresh(Map config, String refreshToken) {
        System.debug('refresh');
        final TokenResponse response = retrieveToken(config);
        return new Auth.OAuthRefreshResult(response.access_token, response.token_type);
    }

       
    /**
        getUserInfo is required by the Parent class, but not fully supported by this provider.
        Effectively the Client Credentials flow is only useful for Server-to-Server API integrations
        and cannot be used for other contexts such as a Registration Handler for Communities.
     **/
    public Auth.UserData getUserInfo(Map config, Auth.AuthProviderTokenResponse response) {
        System.debug('getUserInfo-was-called');
        final TokenResponse token = retrieveToken(config);

        final Auth.UserData userData = new Auth.UserData(
              token.application_name // identifier
            , null // firstName
            , null // lastName
            , null // fullName
            , token.developer_email // email
            , null // link
            , token.developer_email // userName
            , null  //locale
            , config.get(CMT_FIELD_PROVIDER_NAME) //provider
            , null // siteLoginUrl
            , new Map());


        return userData;
    }
    
    
    /**
       Private method that gets the Auth Token using the Client Credentials Flow.
    **/
     private TokenResponse retrieveToken(Map config) {
         
        System.debug('retrieveToken');

        final Boolean useJSONEncoding = Boolean.valueOf(config.get(CMT_FIELD_USE_JSON));
        
        final HttpRequest req = new HttpRequest();

        final PageReference endpoint = new PageReference(config.get(CMT_FIELD_AUTHTOKEN_URL)); //NOSONAR -- Protected by RemoteSite Setting
        if (!useJSONEncoding) { // Including the Query String breaks JSON encoded OAuth
            endpoint.getParameters().put('grant_type',GRANT_TYPE_CLIENT_CREDS);            
        }

        // Determine whether or not to use JSON encoding
        final String encoding = useJSONEncoding ? ENCODING_JSON : ENCODING_XML;
        final String encodedParams = encodeParameters(config,encoding);

        System.debug('Endpoint: ' + endpoint.getUrl());
        System.debug('Content-Type:' + encoding);
        //System.debug('Body:' + encodedParams);

        req.setEndpoint(endpoint.getUrl()); 
        req.setHeader('Content-Type',encoding); 
        req.setMethod('POST'); 
        req.setBody(encodedParams);

        final HTTPResponse res = new Http().send(req); 

        System.debug('Token Response Status: ' + res.getStatus() + ' ' + res.getStatusCode());
        final Integer statusCode = res.getStatusCode();

        if ( statusCode == 200) {
            TokenResponse token =  deserializeToken(res.getBody());
            // Ensure values for key fields
            token.token_type = (token.token_type == null) ? DEFAULT_TOKEN_TYPE : token.token_type;
            return token;

        } else  {
            return deserializeToken(res.getBody());
        }

    }
    
    //deserialise response and return token
    @testVisible
    private TokenResponse deserializeToken(String responseBody) {
        
        System.debug('token response:' +responseBody);
        
        // use default parsing for everything we can.
        TokenResponse parsedResponse = (TokenResponse) System.JSON.deserialize(responseBody, TokenResponse.class);
        // explicitly parse out the developer.email property because it's an illegal identifier
        Map props = (Map) System.JSON.deserializeUntyped(responseBody);
        parsedResponse.developer_email = (String) props.get('developer.email');
       
        return parsedResponse;
    }

    /**
        Conditionally encode parameters as URL-style or JSON
    **/
    @testVisible
    private String encodeParameters(Map config,String encoding) {

        // Pull out the subset of configured parameters that will be sent
        Map params = new Map();
        params.put(GRANT_TYPE_PARAM,GRANT_TYPE_CLIENT_CREDS);
        params.put(CLIENT_ID_PARAM, config.get(CMT_FIELD_CLIENT_ID));
        params.put(CLIENT_SECRET_PARAM, config.get(CMT_FIELD_CLIENT_SECRET));
        final String scope = config.get(CMT_FIELD_SCOPE);
        if (!String.isEmpty(scope)) {
            params.put(SCOPE_PARAM,scope);
        }

        return encoding == ENCODING_JSON ? encodeAsJSON(params) : encodeAsURL(params);
    }

    private String encodeAsJSON(Map params) {
        String output = '{';
        for (String key : params.keySet()) {
            output += (output == '{' ? '' : ', ');
            output += DOUBLEQUOTE + key + DOUBLEQUOTE + ':';
            output += DOUBLEQUOTE + params.get(key) + DOUBLEQUOTE;
        }
        output += '}';
        return output;
    }

    private String encodeAsURL(Map params) {
        String output = '';
        for (String key : params.keySet()) {
            output += (String.isEmpty(output) ? '' : '&');
            output += key + '=' + params.get(key);
        }
        return output;
    }

    /**

    OAuth Response is a JSON body like this on a Successful call

    {
      "refresh_token_expires_in" : "0",
      "api_product_list" : "[helloworld, HelloWorld_OAuth2-Product]",
      "api_product_list_json" : [ "helloworld", "HelloWorld_OAuth2-Product" ],
      "organization_name" : "bobbywhite-eval",
      "developer.email" : "developer@example.com",
      "token_type" : "BearerToken",
      "issued_at" : "1520478821362",
      "client_id" : "someKey",
      "access_token" : "kRxqmPr2b223uzTUGnndQhXWv8F4",
      "application_name" : "47bc6c8d-34f3-4141-b9e6-f1679a8240e7",
      "scope" : "",
      "expires_in" : "3599",
      "refresh_count" : "0",
      "status" : "approved"
    }

    On failure, the following structure from Apigee Edge (cloud hosted Gateway)

    { 
      "ErrorCode" : "invalid_client"
    , "Error" : "Client credentials are invalid"
    }

    The following response class is the Union of all responses
    **/

    public class TokenResponse {
        public String refresh_token_expires_in {get;set;}
        public String api_product_list {get;set;}
        public List api_product_list_json {get;set;}
        public String organization_name {get;set;}
        public String developer_email {get;set;}
        public String token_type {get;set;}
        public String issued_at {get;set;}
        public String client_id {get;set;}
        public String access_token {get;set;}
        public String application_name {get;set;}
        public String scope {get;set;}
        public String expires_in {get;set;}
        public String refresh_count {get;set;}
        public String status {get;set;}

        // Apigee Edge -- hosted version uses these fields for error handling
        public String ErrorCode {get; set;}
        public String Error {get; set;}

        // Apigee on premise version uses this Field for error handling
        public Fault fault {get; set;}

        public Boolean isError() {
            return Error != null || fault != null;
        }

        public String getErrorMessage() {
            if (Error != null) {
                return ErrorCode;
            }

            if (fault != null) {
                // Substitute the error code to compose
                return fault.faultString.replace('{0}',fault.detail.errorcode);
            }
            return null;
        }
    }

    public class Fault {
        public String faultstring {get;set;}
        public Detail detail {get;set;}
    }

    public class Detail {
        public String errorcode {get;set;}
    }

    /**
        Custom exception type so we can wrap and rethrow
    **/
    public class TokenException extends Exception {

    }
}


Step 2 - Create custom metadata which will be used by auth provider class in step 1



Step 3 - Create new auth provider by selecting the custom auth provider 


Step 4 - define named credentials to use the new auth provider.


Step 5 - execute the following code to invoke the named credentials

/**
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:My_Named_Credential/some_path');
req.setMethod('GET');
Http http = new Http();
HTTPResponse res = http.send(req);
System.debug(res.getBody());
 
 


Author: Tarique Habibullah