Skip to content

Instantly share code, notes, and snippets.

@ptesny
Last active August 4, 2023 08:04
Show Gist options
  • Save ptesny/55d301039ae0fcb6bb5e684150bf4612 to your computer and use it in GitHub Desktop.
Save ptesny/55d301039ae0fcb6bb5e684150bf4612 to your computer and use it in GitHub Desktop.
SAP Configure,Price,Quote (CPQ) APIs with resilience with SAP BTP, Kyma runtime

Using the default BTP destination service trust

Certificate Information:
openssl x509 -in certificate.crt -text -noout

Common Name: cfapps.<region>.hana.ondemand.com/b3736226-***
Subject Alternative Names:
Organization: SAP
Organization Unit: CP Destination Configuration
Locality:
State:
Country:
Valid From: June 13, 202*
Valid To: June 13, 202*
Issuer: cfapps.<region>.hana.ondemand.com/b3736226-***, SAP
Key Size: 4096 bit
Serial Number: **********

The culprit here is that CN (Common Name) of this default trust cannot be used as the assertionIssuer value of the generated SAML Assertion.

However, the destination service has it all. In lieu, you need to create your own key pair, create a keystore and upload it the BTP subacount's destinations.

Then one needs to put the issuer's URL both as the value of the destination service assertionIssuer property and as the issuer URL in the CPQ'a OAuth2 client application definition.

That's all the magic.

SAML assertion ID attribute must not start with a number

Caution: This issue has been fixed with the BTP desination service. Thus you can skip reading this section.

https://learn.microsoft.com/en-us/azure/active-directory/develop/single-sign-on-saml-protocol

ID must not begin with a number, so a common strategy is to prepend a string like "ID" to the string representation of a GUID. For example, id6c1c178c166d486687be4aaf5e482730 is a valid ID.
However, with a dose of patience, after a few retries you will get the correct payload from the destination service.

the payload (when it works)

  "authTokens": [
    {
      "type": "bearer",
      "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dLWllcy1zYWxlcy1kZXYuY3BxLmNsb3VkLnNhcCJ9.ONlkKoIFzcKGoiATrSzuNnzoxQntCH5LuRWeLW-ay28",
      "http_header": {
        "key": "Authorization",
        "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzWllcy1zYWxlcy1kZXYuY3BxLmNsb3VkLnNhcCJ9.ONlkKoIFzcKGoiATrSzuNnzoxQntCH5LuRWeLW-ay28"
      },
      "expires_in": "299"
    }
  ]

and when it does not work:

  "authTokens": [
    {
      "type": "",
      "value": "",
      "error": "SAML assertion ID attribute must not start with number. Error Code: 600014.\nDetails:Assertion ID: 9fa7444e-3fdf-4b2d-955a-9588d8f3f210\r\n ReferenceId: 6a1e59b0-2101-466c-9f5a-7409de32ac78.",
      "expires_in": "0"
    }
  ]

SAP Cloud-SDK with built-in resilience middleware with Kyma runtime to the rescue

SAP Cloud SDK for JavaScript Logo

SAP Cloud-SDK with built-in resilience middleware with Kyma runtime

As aforementioned the destination service generated SAML Assertion will be rejected by SAP CPQ token issuance endpoint whenever the SAML Assertion ID starts with a number, as depicted below:
faas-srv:function /srv/harmony Failed to build headers. ErrorWithCause: Failed to build headers.
at buildHeaders (/usr/src/app/function/node_modules/@sap-cloud-sdk/http-client/dist/http-client.js:296:15)

Caused by: Original error messages: SAML assertion ID attribute must not start with number. Error Code: 600014.

And that is an excellent use case for the SAP Cloud-SDK retry function, as stated below:

The retry middleware should be used with caution, because it is often mitigating a problem that should be solved properly. Also, if something fails consistently, it does not help to press the same button multiple times. You should consider some rules for adding retries:
  • The error should be the exception, not the default.
  • The error should happen randomly so a second call has a high likelihood of returning something.
  • The source of the error is out of your domain to fix.
Thus, every time the executeHttpRequest method errors, it will be retried, for up to 3 times as documented here: https://sap.github.io/cloud-sdk/docs/js/features/connectivity/destinations#destination-fetch-options

If the retry of 3 were not enough you can always implement yourself. And, eventually, this is what I had done, as shown here:

const { retrieveJwt } = require('@sap-cloud-sdk/connectivity');
const retryme = require('async-retry');

const ResilienceOptions = {
  retry: 10,
  circuitBreaker: false,
  timeout: 300*1000 // 5 minutes in milliseconds
};          

async function harmony(req) {
  try {

    const data = await retryme(async () => {
    const url = req.query.path;
    const jwt = retrieveJwt(req);

    var params = new URLSearchParams();
    params.append('Param', '{"OpportunityIds" : ["3********"],"MainOnly" : false}');
    
    const options = {
          method: 'post',
          url: url || '',
          timeout: ResilienceOptions.timeout,
          headers: {'Content-Type': 'application/x-www-form-urlencoded' },
          data : params
        }
    console.log(options);
    try {
      const response = await httpClient.executeHttpRequest(
        {
          destinationName: req.query.destination
          ,
          jwt: jwt
          ,
          useCache: true
        },
        options
      );
      
      const results = JSON.stringify(response.data,0,2); 
      console.log('executeHttpRequest:', results);

      return results;
    } catch (err) {
      console.log('Retrying.....')
      throw err;
    }
    }
    );  

    return data;

    } catch (err) {
      console.log('harmony: ', err.message);
      return err.message;
    }
}

Good to know. How to use the resilience middleware with executeHttpRequest ?

 

DIY: SAML Assertion and the bearer access token

That solution consists of bypassing the destination service, at least for now, and using kyma runtime to host a nodejs function to replace it.

https://idp.ssocircle.com/sso/toolbox/samlDecode.jsp

 

Generate SAML Bearer Grant Type. Kyma runtime to the rescue.

The above issue is intermittent and that makes it unreliable.

Thus how to address it? The answer is to DIY-it. How ? The SAP CPQ documentation described all the steps here: Generate SAML Bearer Grant Type | SAP Help

  1. Generate the assertion request in an external system as explained in the documentationInformation published on non-SAP site.

    You will need to sign the request with a certificate, specifically with the certificate private key. You will use the public key to confirm and validate the private key signature.

{ 
  "name": "test-cpq",
  "version": "1.0.0",
   "dependencies": {
    "axios": "latest",
    "btoa": "latest",         
    "saml": "latest"
  },
  "devDependencies": {
  }  
}

const axios = require('axios')
var btoa = require('btoa');
var saml = require('saml').Saml20;

async function generateSAMLBearerAssertion_CPQ(tokenUrl, audienceUrl, clientId, userName, use_email, issuer) {

  const ateam_isveng_cert = '-----BEGIN CERTIFICATE-----\nMIIFGzCCAwMCBGBb1dwwDQYJKoZIhvcNAQELBQAwUjELMAkGA1UEBhMCVVMxDDAK\nBgNVBAoMA1NBUDEVMBMGA1UECwwMYURdaHVX6B270SUDiP6TDPApn9E+IaISzPRpk\nXT6c0QNVYg37DBU/qhSN\n-----END CERTIFICATE-----\n'; 
  const ateam_isveng_key = '-----BEGIN PRIVATE KEY-----\nMIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDFz/eQv30tj5oC\nLjT1Im7OtVAVo6mB/wQbEpbOh3LSILSxBTGdiZznqgLKnImxU1WDSA2xlKJy7J\nAwx8lLYgANSJ7qkKPgPR/t5ZHrx/plY=\n-----END PRIVATE KEY-----\n';

  let cert = ateam_isveng_cert, key = ateam_isveng_key;
  var options = {
    cert: Buffer.from(cert, 'utf-8'),
    key: Buffer.from(key, 'utf-8'),

    issuer: issuer,
    lifetimeInSeconds: 300,
    attributes: {
      'client_id': clientId,
    },
    includeAttributeNameFormat: true,
    sessionIndex: '_faed468a-15a0-4668-aed6-3d9c478cc8fa',
    authnContextClassRef:  'urn:oasis:names:tc:SAML:2.0:ac:classes:x509', 
    nameIdentifierFormat: use_email === true ? 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' : 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified', 
    nameIdentifier: userName,
    recipient: tokenUrl,
    audiences: audienceUrl,
    signatureNamespacePrefix: 'ds',
  };

  let  signedAssertion = saml.create(options);
  console.log('********* signedAssertion: ', signedAssertion);

  try {
    signedAssertion = btoa(signedAssertion);
    console.log('btoa-ed signedAssertion: ', signedAssertion);
    signedAssertion = encodeURIComponent(signedAssertion);

    console.log('signedAssertion: ', signedAssertion);
    return signedAssertion;    
  } 
  catch (err) {
    return err.message;
  }
}
 const tokenUrl = 'https://&lt;CPQ host&gt;.cpq.cloud.sap/api/token';
 const audienceUrl = 'https://&lt;CPQ host&gt;.cpq.cloud.sap/';
 const clientid = '&lt;clientid&gt;';
 const clientsecret = '&lt;clientsecret&gt;';
 const userName = '&lt;userName&gt;';
 const use_email = false;
 const issuer = 'https://auth.pingone.eu/5f3341ef-3cf9-4c5b-b27b-*********';

 const saml_bearer_assertion = await generateSAMLBearerAssertion_CPQ(tokenUrl, audienceUrl, clientid, userName, use_email, issuer);
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="_aA5Pn756kYCRMG0Y57ztsw3dyR5OzFYK" IssueInstant="2023-06-15T21:49:29.986Z">
<script/>
<saml:Issuer>https://auth.pingone.eu/5f3341ef-3cf9-4c5b-b27b-8f81241838ed</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="#_aA5Pn756kYCRMG0Y57ztsw3dyR5OzFYK">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>WnMDUwWeF36QHw1IazCdClhibUETednU7jET7uKrz+Q=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>rySPK3ZOT8Thlna/0W0CeV4tGyLVg8n/dQwnOHxWa8gi/IU2VGGcB/0SBLyxnxHJeCogmTOu8xasQeDdYJKaBquCkTYU0Qmc4kt90=</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>MIIFGzCCAwMCBGBb1dwwDQYJKoZIhvcNAQELBQAwUjELMAkGA1UEBhMCVVMxDDAKBgNVBAoMA1NBUpkXT6c0QNVYg37DBU/qhSN</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"><CPQ userId></saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="2023-06-15T22:49:29.986Z" Recipient="https://<CPQ tenant>.cpq.cloud.sap/api/token"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2023-06-15T21:49:29.986Z" NotOnOrAfter="2023-06-15T22:49:29.986Z">
<saml:AudienceRestriction>
<saml:Audience>https://<CPQ tenant>.cpq.cloud.sap/</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2023-06-15T21:49:29.986Z" SessionIndex="_faed468a-15a0-4668-aed6-3d9c478cc8fa">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:x509</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<saml:Attribute Name="client_id" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
<saml:AttributeValue xsi:type="xs:string"><client_id></saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
  • Create a trusted application in SAP CPQ.
  • Select a value in User Identifier Attribute Source:
    • NameId - contained in the subject of the assertion request. This should either be the username of the SAP CPQ user or its federation ID (both are available in user administration).

    • AdditionalAttributes - in the User Identifier Attribute Name field, enter the exact value of one Attribute Name under AttributeStatement in the generated assertion request.

  • Choose one of the supported algorithms in Certificate Hash Algorithm.
  • Copy the public key from the assertion request.
 
  • Encode the requst in the Base64 format using an online converter.

  • Open a platform used for building and testing APIs (for example, Postman) and populate the following:

    1. Choose POST as the method.

    2. Add /api/token to the request URL.

    3. As authorization, choose basic authorization.

    4. Copy the client ID and enter it in Postman as the username.

    5. Copy the client secret and enter it in Postman as the password.

    6. In the Body, choose the content type x-www-form-urlencoded.

    7. Set the following keys:

      • grant_type - urn:ietf:params:oauth:grant-type:saml2-bearer

      • assertion - assertion request encoded in the Base64 format

  • Send the request.

    In the response body you should get the access token.

async function getcpqaccesstoken() {

 const tokenUrl = 'https://&lt;CPQ host&gt;.cpq.cloud.sap/api/token';
 const audienceUrl = 'https://&lt;CPQ host&gt;.cpq.cloud.sap/';
 const clientid = '&lt;clientid&gt;';
 const clientsecret = '&lt;clientsecret&gt;';
 const userName = '&lt;userName&gt;';
 const use_email = false;
 const issuer = 'https://auth.pingone.eu/5f3341ef-3cf9-4c5b-b27b-*********';

 const saml_bearer_assertion = await generateSAMLBearerAssertion_CPQ(tokenUrl, audienceUrl, clientid, userName, use_email, issuer);

  const options = {
    auth: {
    username: clientid,
    password: clientsecret
    },
  headers: { 
      'Accept': 'application/json',
    }
  };

  var params = new URLSearchParams();
  params.append('client_id', clientid);
  params.append('grant_type',  "urn:ietf:params:oauth:grant-type:saml2-bearer");
  params.append("assertion", decodeURIComponent(saml_bearer_assertion));

  try {

    const response = await axios.post(tokenUrl, params, options);
    const documents = JSON.stringify(response.data, null, 2);
    const access_token = response.data.access_token;
    console.log('access_token: ', access_token);
    console.log(response.status);

    return documents;
  }
  catch(error) {
      console.log(error.message);
      const documents = JSON.stringify(error, null, 2);

      return documents;
  };
}
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dLWllcy1zYWxlcy1kZXYuY3BxLmNsb3VkLnNhcCJ9.Cf_JdD_FBZulNs1KDpWoN_gSw0AlMYK-tlTcWvKSZ8g",
  "token_type": "bearer",
  "expires_in": 299
}

Let's test it using curl as depicted below:

curl -X POST https://<CPQ host>.cpq.cloud.sap/customapi/executescript?scriptname=GetOpportunityQuotes -d 'Param={"OpportunityIds" : ["3*********"],"MainOnly" : false}' -H "Content-Type: application/x-www-form-urlencoded"  -H 'Authorization: Bearer '

sub-account level destination definition

{
  "Name": "cpq-anywhere",
  "Type": "HTTP",
  "URL": "https://<host>.cpq.cloud.sap",
  "Authentication": "OAuth2SAMLBearerAssertion",
  "ProxyType": "Internet",
  "KeyStorePassword": "<KeyStorePassword>",
  "tokenServiceURLType": "Dedicated",
  "audience": "https://sap-ies-sales-dev.cpq.cloud.sap/",
  "Description": "Callidus Cloud",
  "authnContextClassRef": "urn:oasis:names:tc:SAML:2.0:ac:classes:PreviousSession",
  "assertionIssuer": "https://auth.pingone.eu/5f3341ef-3cf9-4c5b-b27b-*********",
  "tokenServiceUser": "<tokenServiceUser>",
  "tokenServiceURL": "https://<host>.cpq.cloud.sap/api/token",
  "tokenServicePassword": "<tokenServicePassword>",
  "HTML5.DynamicDestination": "true",
  "clientKey": "<tokenServiceUser>",
  "KeyStoreLocation": "<KeyStoreLocation>",
  "nameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
}

instance level destination definition

cpq-anywhere.json

{
    "init_data": {
        "instance": {
            "destinations": [
                {
                  "Name": "cpq-anywhere",
                  "Type": "HTTP",
                  "URL": "https://<host>.cpq.cloud.sap",
                  "Authentication": "OAuth2SAMLBearerAssertion",
                  "ProxyType": "Internet",
                  "KeyStorePassword": "<KeyStorePassword>",
                  "tokenServiceURLType": "Dedicated",
                  "audience": "https://<host>.cpq.cloud.sap/",
                  "Description": "CPQ Callidus Harmony",
                  "authnContextClassRef": "urn:oasis:names:tc:SAML:2.0:ac:classes:PreviousSession",
                  "assertionIssuer": "https://auth.pingone.eu/5f3341ef-3cf9-4c5b-b27b-*********",
                  "tokenServiceUser": "<tokenServiceUser>",
                  "tokenServiceURL": "https://<host>.cpq.cloud.sap/api/token",
                  "tokenServicePassword": "<tokenServicePassword>",
                  "HTML5.DynamicDestination": "true",
                  "clientKey": "<tokenServiceUser>",
                  "KeyStoreLocation": "quovadis_ateam-isveng.p12",
                  "nameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
                },
                {
                  "Name": "cpq-anywhere-saml",
                  "Type": "HTTP",
                  "URL": "https://<host>.cpq.cloud.sap",
                  "Authentication": "SAMLAssertion",
                  "ProxyType": "Internet",
                  "KeyStorePassword": "<KeyStorePassword>",
                  "tokenServiceURLType": "Dedicated",
                  "audience": "https://<host>.cpq.cloud.sap/",
                  "Description": "CPQ Callidus Harmony",
                  "authnContextClassRef": "urn:oasis:names:tc:SAML:2.0:ac:classes:PreviousSession",
                  "assertionIssuer": "https://<host>.cpq.cloud.sap/api/token",
                  "tokenServiceUser": "<tokenServiceUser>",
                  "tokenServiceURL": "https://<host>.cpq.cloud.sap/api/token",
                  "tokenServicePassword": "<tokenServicePassword>",
                  "HTML5.DynamicDestination": "true",
                  "clientKey": "<tokenServiceUser>",
                  "KeyStoreLocation": "quovadis_ateam-isveng.p12",
                  "nameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
                }
            ],
           "certificates": [
              {
                "Name": "quovadis_ateam-isveng.p12",
                "Content": "MIIQeAIBAzCCEDIGCSqGSIb3DQEHAaCCECMEghAfMIIQGzCCCggGCSqGSIb3DQEHAaCCCfkEggn1MIIJ8TCCCe0GCyqGSIb3PG/HcO5xW0ai3ZkwPTAhMAkGBSsOAwIaBQAEFMyS26dKft/dWRSIVg/SQvDnQmaSBBQTST14Lse+rKA3igKg4Q7gzIB0mAICBAA=",
                "Type": "CERTIFICATE"
              }         
           ],
     
            "existing_certificates_policy": "update",
            "existing_destinations_policy": "update"
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment