SAP BTP Security: How to realize client-credentials flow with IAS

This blog post shows how to do client-credentials flow with IAS using “identity” service in SAP BTP.
We create a minimalistic sample app2app scenario where 2 application communicate with each other while authentication is done with the OAuth flow called “client-credentials”.
Used technologies:
SAP Business Technology Platform (SAP BTP), Cloud Foundry Environment,
SAP Cloud Identity Services – Identity Authentication (IAS),
Node.js.

Quicklinks:
Quick Guide
Sample Code

Content

0. Introduction
1. Preparation
2. Sample Scenario
2.1. Identity Service
2.2. Backend Application
2.3. Frontend Application
2.4. Deploy
3. Run
Appendix: Sample Code

0.1. Introduction

We want to create 2 applications in SAP BTP, backendapp and frontendapp.
The frontendapp needs to call the backendapp in order to fetch some data or information.
This call should be done without any user interaction, no user-login.
Such app-to-app scenario uses the client-credentials flow, as specified by OAuth 2.0.
This flow is supported by IAS and can be configured in the dashboard.
In our case, we’re using IAS from within the BTP, so it is configured automatically, during instance creation.
In the present tutorial, we’re going through the steps required to get a simple scenario running.

Note:
For those who are already familiar with client-credentials flow: there’s no surprise when it comes to using it with IAS.
The only thing you need to know is the token-url, so you can directly head to the quick guide.

Scenario:
The backend application is protected with OAuth via IAS and requires a valid access token (JWT).
It offers a REST endpoint that is called by the frontend app.
This endpoint validates the incoming token.
Instead of responding with dummy data, this endpoint decodes the incoming JWT token and returns some of this decoded (but anyways useless) info.

The frontend application calls the protected backend endpoint.
To do so, it first needs to fetch a JWT token, via client-credentials flow.
The useless response is printed to the browser.

Architecture:
Both applications are simple server apps written in Node.js based on express.
Both apps are bound to the same instance of Identity service.
Both apps are deployed to the same subaccount in SAP BTP. Cloud foundry environment.
The backend app uses IAS to protect its endpoint.
The frontend app is not protected and uses Identity service to fetch a valid JWT token.
The Identity service communicates with the tenant of IAS.

Out of scope:
Authorization handling. The backend application doesn’t require any scope/role for accessing the endpoint.

Technical Info:

The required information which we need for the given scenario can be found in the config overview page or our IAS tenant.
The URL is as follows:
https://<tenant>.accounts400.ondemand.com/.well-known/openid-configuration

The screenshot marks the info relevant for us:

First of all, we need to know if the grant type client-credentials is supported at all.
Secondly, we need to know how to authenticate when fetching a token. In our tutorial, we’re going to use basic authentication with client secret.
Finally, we need to know the path to the token endpoint, used to request the token.

Disclaimer:
This is not an official reference application, I’m just sharing my personal experiences.

0.2. Prerequisites

To follow this tutorial, we need

  • Access to an IAS tenant with admin permission.
  • Access to SAP Business Technology Platform (SAP BTP) and permission to create instances and to deploy applications.
  • Basic Node.js skills.
  • Some basic understanding of OAuth and JWT token.
  • The tutorial is using the Cloud Foundry command line client, but all tasks can be done in the cockpit as well

1. Preparation

Before we start with the sample application, we need 2 preparation tasks:
Establish trust between IAS and BTP
Create project

1.1. Configure Trust

If not already done, you need to connect your subaccount to the IAS tenant.
This is done by just pressing a button.
Go to your subaccount -> Security -> Trust Configuration
Press “Establish Trust”
Afterwards, we can check the IAS tenant and view the newly created Application entry at
https://<tenant>.accounts400.ondemand.com/admin/#/applications

1.2. Create Project Structure

To follow this tutorial, we create a project iasclicre which contains our scenario with 2 apps:

C:\iasclicre
backend
package.json
server.js
frontend
package.json
server.js
config_ias.json
manifest.yml

Or see this screenshot:

The file content can be copied from the appendix.

2. Create Sample Scenario

As mentioned, we’re creating 2 apps that are bound to the same instance of identity service.
Both apps will be deployed together.

2.1. Create instance of Identity Service

We have an IAS tenant which lives outside of our SAP BTP account but is assigned to our subaccount.
By establishing Trust, we’ve connected the subaccount to the IAS-tenant.
Inside of our account, there is a service offering which takes the role of communicating between IAS-tenant and cloud-apps.
Now we create an instance of this service offering called “Identity” service.
The config params for our example are very simple, we only specify a name which helps to find the entry in the IAS:

config_ias.json

{ "display-name": "clicreias"
}

Note:
We don’t need oauth configuration, as we don’t have a user centric scenario, hence no Approuter is required.

To create the instance, we jump with command prompt into the project root folder and run the following command:
cf cs identity application clicreIas -c config_ias.json

After service instance creation, we can open our IAS tenant and find the newly created entry in the list of “Applications”.

2.2. Create Backend Application

The backend application represents a kind of reuse app that is meant to provide information or data to other applications.
It offers a REST endpoint that is protected with OAuth.
The protection is handled by the passport middleware which is configured with a “Strategy” provided by the client library @sap/xssec.
This “Strategy” is configured with the credentials of the Identity service, provided to our app in the binding:

const IAS_CREDENTIALS = xsenv.getServices({myIas: {label:'identity'}}).myIas
const JWTStrategy = xssec.JWTStrategy
passport.use('JWT', new JWTStrategy(IAS_CREDENTIALS, "IAS"))

Finally, the middleware is used to configure express and is applied in general to all endpoints:

const app = express();
app.use(passport.initialize())
app.use(passport.authenticate('JWT', { session: false, failWithError: true }));

For our simple example, we need the REST endpoint only in order to verify if the client-credentials access is successful.
The validation of the incoming bearer token (JWT token) is done by passport together with the xssec lib and IAS.
We could respond with a simple text, but for the sake of our learning and interest, we return the JWT token that was sent to our endpoint.
Before returning it, we decode the payload such that we can view the content of the token.
The full content is printed to the console, a few of the interesting claims are returned by our endpoint:

const jwtDecoded = tokenInfo.getPayload() console.log(`===> The full JWT decoded: ${JSON.stringify(jwtDecoded)}`) const claims = new Array()
claims.push(`subject: ${tokenInfo.getSubject()}`)
claims.push(`<br>zone_uuid: ${tokenInfo.getZoneId()}</br>`)
claims.push(`issuer: ${tokenInfo.getIssuer()}`)
claims.push(`<br>aud: ${jwtDecoded.aud}</br>`)

That’s it for the simple backend app.
The full code can be found in the appendix.

2.3. Create Frontend Application

The frontend app has nothing more to do than calling the backend app.
However, as the backend endpoint is protected, we need to first fetch an access token.
To do so, we need the credentials which we obtain from the instance of “Identity” service which is bound to our app.

const CREDENTIALS = xsenv.getServices({myIas: {label:'identity'}}).myIas

The HTTP request which we execute in order to obtain a JWT token is the “normal” client-credentials request.
The only thing which is important to know:
The token endpoint segments.
The authorization base URL is provided in the binding, but we need to append the token endpoint path.
We found the info about the path in the introduction, it is the following:

/oauth2/token

Note:
Don’t confuse it with /oauth/token
Don’t forget the 2

So the snippet for fetching the JWT token via client credentials looks as follows:

async function _fetchJwtToken (){ const clientid = CREDENTIALS.clientid const auth = "Basic " + Buffer.from(clientid + ':' + CREDENTIALS.clientsecret).toString("base64"); const options = { method: 'POST', url: `${CREDENTIALS.url}/oauth2/token?grant_type=client_credentials&client_id=${clientid}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': auth } } const response = await axios(options); return response.data.access_token
} 

Once we have the token, we can use it to call the backend endpoint:

async function _callBackend(token){ const options = { method: 'GET', url: 'https://backendapp.cfapps.sap.hana.ondemand.com/endpoint', headers: { 'Accept': 'application/json', 'Authorization': 'bearer ' + token } } const response = await axios(options)

Note:
The backend endpoint will accept the token because both apps are bound to the same instance of identity service.
With other words: the same OAuth client is used.
With more words:
The frontend app is bound to the instance of Identity service, which passes its credentials in the binding.
The frontend app uses the clientID that is contained in the credentials to fetch a token.
Thus, the token is issued for this client.
This is expressed by adding the clientID to the “aud” claim of the token.
The receiver of the token, the protected backend endpoint will check if the JWT token is valid.
Amongst other checks, the “aud” field will be checked.
The backend app uses the bound instance of Identity service for protection.
As such, it checks if the bound clientID is contained in the JWT token.
This check can be phrased like “is this JWT token issued for me?? If yes, then my clientid must be in the aud”.
Finally, we know that this is the case, because both apps are bound to the same instance, hence clientID is the same.
Below diagram tries to illustrate the explanation:

In order to trigger the flow, the frontendapp offers an endpoint which is not protected:

app.get('/homepage', async (req, res) => { const jwtToken = await _fetchJwtToken() const result = await _callBackend(jwtToken) res.send(`<h3>Response from backend:</h3><p>${result}</p>`)
})

The response of the backendapp is printed to the browser window and we can see e.g. the content of the aud claim.

That’s it for the simple frontend app.
The full file content can be found in appendix.

2.4. Deploy

Before we deploy both apps, just one word about the deployment descriptor.
The manifest.yml file does nothing interesting, it just declares the 2 apps and the binding:

applications: - name: backendapp routes: - route: backendapp.cfapps.eu10.hana.ondemand.com services: - name: clicreIas - name: frontendapp routes: - route: frontendapp.cfapps.eu10.hana.ondemand.com services: - name: clicreIas

Just wanted to mention:
If we declare the binding to the Identity service without params, then the default credentials type will be used:
We will get a client secret in the binding, which we use to authenticate against IAS when requesting a token. This is the default.
The explicit setting would be:

 services: - name: clicreIas parameters: credential-type: "SECRET" 

However, the better option would be to specify a client certificate, but that’s a different topic.
Anyways, in either case, the identity service takes care of configuring the IAS accordingly.
So we don’t need to go to the IAS dashboard and generate a secret.

To deploy, we jump into the root folder and execute:
cf push

Alternatively, if not in the root folder, specify the location of the manifest

cf push -f ./mypath/manifest.yml

Alternatively, to deploy only one of the apps:
cf push frontendapp

3. Run

After deploy, we open our frontendapp at
https://frontendapp.cfapps.eu10.hana.ondemand.com/homepage

As a result, we can see that the client-credentials flow has been successful and we can see some info from the JWT token.

To verify the “aud” content, we can view the clientID in IAS (Applications) or in the env of the apps.
The content of the “aud” should be equal to the clientID.

6. Cleanup

For your convenience, here are the commands to remove the artifacts created within this tutorial:

cf d -r -f backendapp
cf d -r -f frontendapp
cf ds -f clicreIas

Deleting the instance of Identity service will also trigger removal of the OAuth client, i.e. the entry in the “Application” list of IAS.

Summary

In this blog post we’ve learned how to fetch a JWT token via the client-credentials grant type as specified by OAuth 2.0.
To get some more insights with a hands-on tutorial, we’ve created 2 apps, to show
how to fetch the token
how to use it
how it looks like

The authentication for fetching the token has been based on basic auth, means we’ve used clientid and clientsecret.
This can be improved by using mTLS, which I hope to cover in next blog post.

Quick Guide

How to call IAS for client credentials flow:

– Create instance of Identity Service (no special params required)
– Get credentials (via binding or service key)
– Use clientid, clientsecret and url
– Append the tokenendpoint path to the url: /oauth2/token

– See snippet:

const auth = "Basic " + Buffer.from(clientid + ':' + clientsecret).toString("base64");
const options = { method: 'POST', url: `${CREDENTIALS.url}/oauth2/token?grant_type=client_credentials&client_id=${clientid}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': auth

Links

IAS in SAP Help Portal
IAS main entry: https://help.sap.com/docs/IDENTITY_AUTHENTICATION
IAS main docu page.
OpenID Connect with client credentials flow
Configure Secrets in IAS

Identity Service
Main docu entry page
Reference for Params

Client libs
Passport homepage and download.
Node.js package xssec.

OAuth 2.0
Main: OAuth 2.0
Grant Type Client Credentials
The spec
Some more info
Understanding of OAuth for dummies like me.

Appendix: Sample Code

Note:
You might need to adapt the app names in manifest and the domain of the routes.
Also, the hardcoded URL pointing to backendapp would have to be adapted accordingly.

config_ias.json

{ "display-name": "clicreias"
}

manifest.yml

---
applications: - name: backendapp path: backend memory: 64M routes: - route: backendapp.cfapps.eu10.hana.ondemand.com services: - name: clicreIas - name: frontendapp path: frontend memory: 64M routes: - route: frontendapp.cfapps.eu10.hana.ondemand.com services: - name: clicreIas

backend

package.json

{ "dependencies": { "@sap/xsenv": "latest", "@sap/xssec": "^3.2.13", "express": "^4.17.1", "passport": "^0.4.0" }
}

server.js

const xsenv = require('@sap/xsenv')
const IAS_CREDENTIALS = xsenv.getServices({myIas: {label:'identity'}}).myIas const xssec = require('@sap/xssec')
const passport = require('passport')
const JWTStrategy = xssec.JWTStrategy
passport.use('JWT', new JWTStrategy(IAS_CREDENTIALS, "IAS"))
const express = require('express')
const app = express();
app.use(passport.initialize())
app.use(passport.authenticate('JWT', { session: false, failWithError: true })); app.listen(process.env.PORT) app.get('/endpoint', async (req, res) => { const claims = _formatClaims(req.tokenInfo) res.send(`<h5>Content of received JWT:</h5>${claims}`)
}) /* HELPER */
function _formatClaims(tokenInfo){ const jwtDecoded = tokenInfo.getPayload() console.log(`===> The full JWT decoded: ${JSON.stringify(jwtDecoded)}`) const claims = new Array() claims.push(`subject: ${tokenInfo.getSubject()}`) claims.push(`<br>zone_uuid: ${tokenInfo.getZoneId()}</br>`) claims.push(`issuer: ${tokenInfo.getIssuer()}`) claims.push(`<br>aud: ${jwtDecoded.aud}</br>`) return claims.join('')
}

frontend

package.json

{ "dependencies": { "@sap/xsenv": "latest", "express": "^4.17.1", "axios": "^1.1.2" }
}

server.js

const xsenv = require('@sap/xsenv')
const CREDENTIALS = xsenv.getServices({myIas: {label:'identity'}}).myIas const axios = require('axios')
const express = require('express')
const app = express(); app.listen(process.env.PORT) app.get('/homepage', async (req, res) => { const jwtToken = await _fetchJwtToken() const result = await _callBackend(jwtToken) res.send(`<h3>Response from backend:</h3><p>${result}</p>`)
}) /* HELPER */ async function _fetchJwtToken (){ const clientid = CREDENTIALS.clientid const auth = "Basic " + Buffer.from(clientid + ':' + CREDENTIALS.clientsecret).toString("base64"); const options = { method: 'POST', url: `${CREDENTIALS.url}/oauth2/token?grant_type=client_credentials&client_id=${clientid}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': auth } } const response = await axios(options); return response.data.access_token
} async function _callBackend(token){ const options = { method: 'GET', url: 'https://backendapp.cfapps.eu10.hana.ondemand.com/endpoint', headers: { 'Accept': 'application/json', 'Authorization': 'bearer ' + token } } const response = await axios(options) return response.data }