SAP BTP Security: How to realize client-credentials flow with IAS [3]: Across Instances

This blog post shows how to do client-credentials flow with IAS using 2 different instances of  “identity” service in SAP BTP.
We create a minimalistic sample app2app scenario where 2 application communicate with each other.
Today, both apps are bound to a different instance of identity service.
Used technologies:
SAP Business Technology Platform (SAP BTP), Cloud Foundry Environment,
SAP Cloud Identity Services – Identity Authentication (IAS),
Identity service,
Node.js.

Quicklinks:
Quick Guide
Sample Code

Content

0. Introduction
1. Preparation
2. Backend Application
3. Frontend Application
4. Configuration in IAS
5. Run
Appendix: Sample Code

0.1. Introduction

Please refer to previous blog post for introduction into our scenario. (Anyways, it is little dumb scenario, so you can skip it).
Up to now, we’ve been doing client-credentials request from app to app, while both apps were bound to the same instance of IAS.
This should be a normal use case for normal applications with normal separation of normal user interface and normal backend service app.

Now, today we want to try a slightly different use case:
We want to have 2 separate applications, each app is bound to its own instance of identity service.
This should be a use case for scenarios where an external service-app that offers common functionality should be called.
Quite common scenario as well, but less normal.

How protection is realized

We have to remember the OAuth 2.0 scenario:
The protected backend app is running on the Resource Server.
The Authorization Server takes care of authorizing a client that wants to consume the resource (backendapp endpoint).
The client app (OAuth client) has to register at Authorization server, then it gets an ID (clientid) and password (clientsecret).
Afterwards it can request an access token from Authorization Server.

This process is typically done “on behalf of a user”.
Means, typically, the Authorization Server would ask the user (via logon screen) to agree that the client app calls the resource.
In our scenario, there’s no user, so the client app will get the access token without user interaction.
That’s why it is called client-credentials grant type.

The client app uses the access token to get access to the desired resource (the backend app).
The token is a JWT token, which is just some piece of JSON formatted data, intended to be compact and fast.
This data consists of some properties, called claims.
You might find this blog useful for learning more about JWt and claims.
One of the claims for example contains the clientid of the client that requested this token.
This clientID is also added to the “aud” claim which contains the audience of the token.
The receiving resource, the backend app, will check if the audience contains allowed values.
This check could be phrased like this: “I’m the backend-resource, so my backend-clientid must be contained in the aud claim. Otherwise that token is not intended for me, hence invalid”

In our scenario in the SAP BTP, we use client libraries that perform all necessary checks, and use the help of the Authorization Server.
As the backend app is bound to an instance of the security service (today it is the identity service, but earlier we used XSUAA), the clientid of this binding is used for verification.
As such, the request is rejected, if the calling client app uses a different service instance than the protected resource.

In brief:
A caller with different instance is not allowed.

However:
There’s a solution to overcome this hurdle.

Solution

Let’s look back:
XSUAA
In the past, with XSUAA, such scenario was realized by “granting” scopes.
I described it in detail in this blog post.
The providing app (the backend, or service app) had to define a “grant” statement in the xs-security.json file:
“grant-as-authority-to-apps” : [ “$XSAPPNAME(application, frontendapp)”]
In addition, the consuming app (the frontend, or client app) had to define an “accept” statement, in order to accept the granted scope:
“authorities”:[“$XSAPPNAME(application,xsappforproviderapp).backendscope”]

The consequence:
The frontendapp calls its own xsuaa instance to fetch a token.
The granted scope gets added to the “scope” claim of this JWT token.
Furthermore, the backend ClientID gets added to the “aud” claim of this token.
This means that the token is intended not only for the frontendapp, but also for the backend app.
The backend app validates the incoming token, finds its own clientid in the “aud”, and accepts the token and returns the desired data to the calling frontendapp.
Granting a scope was a prerequisite for getting the clientid added to the aud.
Above description is valid for scenarios with XSUAA.

IAS
Now, when using IAS and identity service, there is no concept of “scopes” anymore, hence no “grant” anymore.
But still the validation of incoming JWT tokens is the same:
The JWT token must be intended for the backend app.
With other words: the receiving backend app must be in the “aud” claim.
We’re of course talking of the clientid which is bound to the backend app.
However, the calling clientapp (frontendapp) is bound to a different instance of identity service.
As such, the JWT token will contain the frontendapp-clientid in the aud, not the backendapp-clientid.

So the challenge to solve for IAS-scenario:
How to add a clientid to the “aud” claim.

<note: “content from here needs to be updated in 2023”>
The bad news:
Right now (Q4 2022), there’s no such feature available in IAS/identity-service.

The good news:
There’s a workaround…

This is not really good news, and the workaround is not nice, and I should not tell it in this blog.
That’s why I’m making the screenshots small…

The workaround-solution:
Manually add the clientid to an aud property in the IAS dashboard.

Below diagram gives an overview and it is small, because it contains the temporary workaround (Tipp: Click on the image to enlarge it.):

The (secret) steps:
Go to the IAS tenant of the subaccount.
Navigate to frontendapp application.
Create a “Default Attribute” called “aud” with cliendid of the backendapp as value.
That’s it.

Note:
In this scenario, both apps are deployed to the same subaccount, so the IAS tenant is the same for both apps

Note:
The clientid of the backend app can be found in the environment of the deployed app
with command: : cf env backendapp

Alternatively, it can be found in the IAS, when selecting the entry in the “applications” list, which represents the OAuth client of the identity service which is bound to the backend app.

<”note”: “end of temporary content”>

After this long and detailed intro, let’s now go through a hands-on tutorial, again long and detailed.

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

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.
Today we’re creating 2 separate applications, which is represented by creating 2 manifest files.

C:\iasclicre
backend
config-ias-backend.json
manifest-backend.yml
app
package.json
server.js
frontend
config-ias-frontend.json
manifest-frontend.yml
app
package.json
server.js

The file content can be copied from the appendix.

2. Backend Application

As mentioned, we’re creating 2 apps that are bound to different instance of identity service and deployed separately.

2.1. Create instance of Identity Service

The instance of identity service doesn’t require any specific setting:

{ "display-name": "backendias"
}

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

2.2. Create Application

The backend application offers one REST endpoint called /endpoint.
This endpoint is protected with OAuth.
To do so, it uses the passport middleware which is configured with the instance of identity service bound to the app.
So everything is just normal.
It doesn’t enforce any authorization (only today, so enjoy the reduced security complexity…)
The response of this endpoint just sends the received JWT token back to the caller.
The caller will have special  interest in the the “aud” claim.

const xsenv = require('@sap/xsenv')
const IAS_CREDENTIALS = xsenv.getServices({myIas: {label:'identity'}}).myIas
. . .
passport.use('JWT', new JWTStrategy(IAS_CREDENTIALS, "IAS"))
. . .
app.use(passport.authenticate('JWT', { session: false, failWithError: true }));
. . . app.get('/endpoint', async (req, res) => { const claims = _formatClaims(req.tokenInfo) res.send(`<h5>Content of received JWT:</h5>${claims}`)
})

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

2.3. Deploy

Today we don’t need to view the manifest.yml, we can directly go ahead and push the app from the backend folder:
cf push -f manifest-backend.yml

3. Frontend Application

Coming to the frontend app.

3.1. Create instance of Identity Service

Again, no special setting required:

{ "display-name": "frontendias"
}

The command:
cf cs identity application frontendIas -c config-ias-frontend.json

3.2. Create Application

The frontend application does nothing than call the backend endpoint.
The frontend app’s endpoint itself is not protected, to keep things as simple as possible.
Before calling the backend endpoint, it is necessary to fetch a JWT token.

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

Important to note:
The frontend app is bound to its own instance of identity service.
The JWT token is fetched with the credentials that are retrieved from the binding.
As such, this clientid is different from the clientid of the backendapp-binding.
And as we’ve learned: by default, a different clientid has to be rejected by the backendapp-validation.

The retrieved JWT token is used in the authorization header, when calling the backend:

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) return response.data }

Note:
You might need to adapt the hardcoded URL of the backend endpoint

The full file content can be found in appendix.

3.3. Deploy

The command for pushing the frontend app from the frontend folder:
cf push -f manifest-frontend.yml

4. Configuration in IAS

<”note”: “begin of temporary content”>

After deploy, we may call the frontend app, just to realized that it fails.
(And it fails with a good old crash, revealing the lack of any error handling…)

To fix the problem, we’re now going through the secret temporary workaround which has been described in the intro:
The frontend app fetches a JWT token which contains the own clientid in the “aud”.
Which is the frontend-clientid.
But: the backend-clientid must be contained in the aud claim as well.
So we’re now adding it manually.

We go to the configuration screen of the OAuth client of the frontend.
We configure the settings of the OpenID connect protocol, which itself uses the OAuth framework.
Here we can configure attributes that are required in the token and that can be mapped to user-attributes.
In our case, we define a special attribute name and assign a fixed value.
That’s why we choose the “Default Attributes” setting.

1. Find backend clientid
First we need to figure out the clientID of the OAuth client of the backend application:
IAS -> Applications & Resources-> Applications
Select “backendias”.
Then choose Client Authentication -> Client ID
Use copy-button to copy the client ID into clipboard:

Although we have the ID in the clipboard, let’s try to remember it:
It starts with 09c…

2. Create Default Attribute
Now we can create a default attribute:
Select “frontendias” in the list of applications.
Choose Default Attributes.
Press + Add
Attribute: Enter aud
Value: enter the copied clientid from clipboard
Press Save

As we can see, it starts with 09c…
That’s it.

<”note”: “end of temporary content”>

5. Run

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

As a result, we get a lot of silly characters and numbers:

However, we recognize the backend-clientid, which we remember:
It starts with 09c…

Note:
It is important to see that our default attribute has been added, it does not overwrite existing values.

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 backendIas
cf ds -f frontendIas

7. Optional: Multiple Targets

<”note”: “begin of temporary content”>

You may ask:
What to do if more than one backend has to be called?
How to add more than one clientid to the “aud”?
Answer:
Create more than one “aud” attributes.
Example:

<”note”: “end of temporary content”>

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.
Also, we’ve learned some basics about 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
how it is validated
The focus learning has been:
We’ve learned how to configure security settings in order to get a value into the aud claim

Quick Guide

How to do client credentials flow when there are 2 instances of identity service (same IAS tenant)

The feature of extending the aud claim with IAS / identity service is currently not available.
The workaround:
In IAS, configure the frontend OAuth client, the “application” entry responsible for fetching the JWT token.
In “Default Attributes” screen, create an attribute with name “aud” and enter the target clientid as value.

Blogs
First example for client-credentials with IAS.
Example for client-credentials with mTLS.

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.

backend

config-ias-backend.json

{ "display-name": "backendias"
}

manifest-backend.yml

---
applications: - name: backendapp path: app memory: 64M routes: - route: backendapp.cfapps.sap.hana.ondemand.com services: - name: backendIas

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}`)
}) function _formatClaims(tokenInfo){ const jwtEncoded = tokenInfo.getTokenValue() console.log(`===> The full JWT: ${jwtEncoded}`) 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

config-ias-frontend.json

{ "display-name": "frontendias"
}

manifest-frontend.yml

---
applications: - name: frontendapp path: app memory: 64M routes: - route: frontendapp.cfapps.sap.hana.ondemand.com services: - name: frontendIas

package.json

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

server.js

const xsenv = require('@sap/xsenv')
const IAS = 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>`)
}) async function _fetchJwtToken (){ const options = { method: 'POST', url: `${IAS.url}/oauth2/token?grant_type=client_credentials&client_id=${IAS.clientid}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': "Basic " + Buffer.from(IAS.clientid + ':' + IAS.clientsecret).toString("base64") } } const response = await axios(options); return response.data.access_token
} 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) return response.data }