Skip to Content
Technical Articles
Author's profile photo Carlos Roggan

Using Job Scheduler in SAP BTP [11]: Multitenancy (4): Secure Job Creation, Token Exchange

This blog post shows how to use token exchange to generate a job.

This blog post is part of a series that intends to show in an easy step-by-step way how to use the SAP Job Scheduling Service (Jobscheduler) running on SAP Business Technology Platform (aka SAP Cloud Platform).
In the current sub-series of the series, we’re trying to shed some light into the dark multi tenant space of multi confusancy.

Quicklinks:
Intro Blog
Sample Code

Content

0. Intro
1. Create MT application
2. Run the scenario
Appendix Sample code

0. Intro

Today’s tutorial is a follow-up of an earlier blog post and is completely based on it.
So here we’re only explaining the differences.

In the previous tutorial, we demonstrated how Jobscheduler calls a protected action endpoint in a multitenant application.
This means that Jobscheduler is able to send a tenant-specific JWT token.
To send a tenant-specific token, the corresponding job needs to be generated in a tenant-aware way.
We generated the job using the Jobscheduler-REST-API and to call that API, we fetched a JWT token using the Jobscheduler-credentials and the tenant-specific OAuth URL.
This method is called client-credentials (OAuth flow)

Today we want to learn how to use token exchange.
Why?
A professional application is typically secured, such that only valid users can access it.
After user-login, a JWT token is issued and this token carries information about the user.
However, this token is not valid for calling the REST API of Jobscheduler.
BUT: we can use this token to fetch a new valid token.
The resulting token will still carry the info about the user.
Which is an advantage over client-credentials flow.

Goal / Requirements

We want to create a MT application with user login and token exchange.
Then generate a job etc as usual.

Endpoints

/app
The homepage is unprotected, because we want to simplify the user-login. We’ll do hardcoded login.

/createJob
Here we assume that we’re in a professional app and that a user-token is available.
Therefore this endpoint is protected.

/action
To simplify the sample app, we leave this endpoint unprotected.
We learned here how to deal with protected action endpoint

Features

– Display user-information
– Protect the /createjob endpoint
– Do token exchange

Flow

Open homepage without login-screen.
Login user under the hood, use token info to display user-info on screen.
The homepage contains a link to create job.
When click, a REST request is executed to call the /createJob endpoint.
The JWT token which was fetched previously, is passed to the REST call.
The /createJob endpoint is protected and validates the incoming token.
The tenant information is extracted from token, in order to generate tenant-specific job.
Finally, the job runs and calls the /action endpoint.

Scenario

We described the scenario as use case 2 in the intro blog.
Below diagrams are borrowed from the intro blog, but modified to illustrate today’s application flow.

First diagram shows that we’re dealing with 2 different JWT tokens.
The end user opens the unprotected application-subscription, which internally fetches a JWT token with hardcoded user and password.
This token is sent to the protected createJob-endpoint.
In order to call the REST API, a new token is required. This token is fetched via token exchange.

The second diagram shows that both tokens are fetched from customer-specific XSUAA.
Why do we need another token, if it is anyways from same XSUAA?
The difference is the OAuth-client which is used when fetching the token.
That’s the reason why e.g. the user-token would be rejected by REST API.

1. Create Multitenant Application

Time to go into details of the sample application.
As mentioned, please refer to the earlier tutorial for project creation and setup.
The full code of today’s sample can be found in the Appendix section.
Again, today’s application is based on the previous tutorials, so we discuss the changes only.

Note:
Usually, we’re sticking to native libraries as much as possible, for known reasons.
But today we’re using helper libs, to reduce the amount of code.
As such, we need to adapt the package.json file, to include the node-fetch module which we use to fire requests:

{
    "dependencies": {
        "@sap/xssec": "^3.0.10",
        "passport": "^0.4.0",
        "express": "^4.16.2",
        "node-fetch": "2.6.2"
    }
}

Code Walkthrough

We’re going to walk through the code like the user walks through our app.
We’re ignoring the MT callbacks, though.

The /app endpoint

The /app endpoint of our sample applications represents the user interface.
To simulate a UI, we write few simple HTML tags.
We want to leave this endpoint unprotected.
Why?
Because when protecting, we would need to either add an approuter, to handle the user login
– But it would add complexity, so no-go.
Alternatively, we would need to call the endpoint with REST client like postman…
– another no-go
OK, no protection.
BUT, on the other side, we definitely need a user-token, because it is required for the token exchange.
The workaround which I’m proposing:
When the /app endpoint is invoked, we fetch a user-token under the hood.
To do this, we make use of the OAuth flow “Resource Owner Password Credentials”, which requires hardcoded username and password.
With other words, the hidden token-fetch simulates a user-login.
If everybody accepts my proposal, we can go ahead.
Ehm…but…
OK, accepted.

app.get('/app', async function(req, res){

The user login

Below snippet shows that we automatically fetch a tenant-specific user token when the endpoint is invoked, i.e. when the homepage is accessed:

app.get('/app', async function(req, res){
    const hostname = req.hostname 
    const subdomain = hostname.substring(0,hostname.lastIndexOf('-')) 
    const userJwtToken = await fetchUserTokenWithUserPassword(subdomain)

BTW, above code-snippet shows that the homepage is accessed in tenant-specific way.
We extract the subdomain in order to fetch a tenant-specific token.
How to fetch a token with password.credentials flow?
See here:

async function  fetchUserTokenWithUserPassword(subdomain) {
    const oauthEndpoint = `https://${subdomain}.${UAA_CREDENTIALS.uaadomain}/oauth/token?grant_type=password&username=${USER}&password=${PWD}`
    const result = await fetch(oauthEndpoint, {
        headers: {
            Authorization: "Basic " + Buffer.from(UAA_CREDENTIALS.clientid + ':' + UAA_CREDENTIALS.clientsecret).toString("base64")

The difference is the grant_type and username/password.
Note that you need to enter your credentials in the code.

Once we’re done with the simulation of user login, we can decode the JWT and use the user (first) name to present it on our UI:

const user = decode(userJwtToken).given_name

And we can display our user interface, which we call “website” although it is represented by just a few HTML tags and short text.

The website

Apart from displaying some text, our “website” requires some additional code:

function composeWebsite(userJwtToken, user, hostname){
    const src = `<script src="${path.basename(__filename)}"></script>`
    const ajx = `<script src = https://code.jquery.com/jquery-3.6.0.min.js></script>` 
    const href = `javascript:onClick('${hostname}', '${userJwtToken}')` 

    return `${src}${ajx}<h1>Homepage</h1><p>Hello ${user}, <a href="${href}">click</a> to create job.</p>`
}

We want to execute a function when the hyperlink is clicked.

const href = `javascript:onClick('${hostname}', '${userJwtToken}')` 

The onClick function is located in our server.js file. In order to be found at runtime, we need to load it into the web context.
To do so, we retrieve it dynamically with the native path module:

const src = `<script src="${path.basename(__filename)}"></script>`

However, a prerequisite is to use the express module for loading static files:

app.use(express.static(__dirname))

One more requirement: when clicking the hyperlink, we want to do a REST call.
For that we need jQuery in our website

const ajx = `<script src = https://code.jquery.com/jquery-3.6.0.min.js></script>` 

Finally, our “website” can be created, load the scripts and the link and display the user name

return `${src}${ajx}<h1>Homepage</h1><p>Hello ${user}, <a href="${href}">click</a> to create job.</p>`

When the hyperlink is clicked, our website calls our own /createjob endpoint.
We’re still in the browser context, so we need to execute an ajax request.
This is necessary for the following reason:
We want our /createjob endpoint to be protected.
As such, the click on the hyperlink cannot just take us to the endpoint.
We need a REST call, to pass the authorization.
Yes, we have the JWT token available (from user-login), so we can use it to send it to the protected endpoint, as bearer token in the authorization header.
So we fire the request and after we receive a successful response from our own endpoint, we write the response to the browser page:

async function onClick(hostname, jwtToken){
    $.ajax({
        url: `https://${hostname}/createjob`,
        headers: { "Authorization": "bearer " + jwtToken },
        success: function(data){
            const p = document.createElement("P")                        
            p.appendChild(document.createTextNode("-> Received response: " + data))                                           
            document.body.appendChild(p)

Note:
We can see that we pass the hostname to the function. The hostname is tenant-specific, like our homepage.
To be honest, today our /createjob endpoint doesn’t necessarily need to be tenant-specific.
Why?
Because now we have the JWT-token.
Ah
Yes, it is tenant-specific and it carries the information about the subdomain where it was issued.
So we don’t access the URL anymore, to extract the subdomain.
However, we stick to use case 2, the scenario, where the customer is aware of the job-handling.
To make that visible, our endpoints are tenant-specific.
See intro.

The job generation

In our code walkthrough, we’re following the app flow:
Open website
-> hidden login
-> click to  create job

So now we’re back on server-side code, discussing how to generate a job.

app.get('/createjob', passport.authenticate('JWT', {session: false}), async function(req, res) {
    const userJwtToken = req.headers.authorization.substring(7) 
    const exchangedToken = await doTokenExchange(userJwtToken)     
    const result = await createJob(exchangedToken)  

    res.send(`Result of job creation: ${JSON.stringify(result)}`)
});

The /createjob endpoint is called from the UI, with tenant-specific URL, but we don’t need to extract the subdomain from the URL, because the information about the subscribed customer is contained in the JWT token.
The endpoint is OAuth protected and the token is extracted from the Authorization header.
We can assume that our endpoint is invoked with a valid JWT token.
Why?
Because it is us who invoke it.

BTW, we fetched the user token from the XSUAA instance which is bound to our MT app.
Our endpoint is protected with passport which was instantiated with the same XSUAA instance (means, same OAuth client):

const UAA_CREDENTIALS = VCAP_SERVICES.xsuaa[0].credentials
passport.use('JWT', new JWTStrategy(UAA_CREDENTIALS))

And this JWTStrategy is used to protect the endpoint:

passport.authenticate('JWT'

Now we understand why the token which we’re sending is accepted.
I don’t get it…

And now, what do we want to do now?
In order to create a job, we call the REST API offered by Jobscheduler.
This REST API is OAuth-protected and the credentials are given in the binding.

const JOBSCH_CREDENTIALS = VCAP_SERVICES.jobscheduler[0].credentials
const JOB_UAA = JOBSCH_CREDENTIALS.uaa

We have a token.
Our /createjob endpoint is called with a valid JWT token, but as we’ve learned, it was fetched using the XSUAA credentials from the XSUAA-binding.
These are not the credentials given by Jobscheduler.
As such, this JWT token is not accepted by Jobscheduler.
Therefore, we need a new token.
To get a new token, in this tutorial, we’re doing a token exchange.
Why?
Because we already have a token. So we use it to exchange it.
How?

We receive the incoming token in the Authorization header, like this:

Bearer ey12abcd1234blablabla…

So we need separate the actual token from the “Bearer ”:

const userJwtToken = req.headers.authorization.substring(7)

Then we can call our helper method which does the following:

async function doTokenExchange (bearerToken){
    const jwtDecoded = decode(bearerToken)  
    const consumerSubdomain = jwtDecoded.ext_attr.zdn  
    return new Promise ((resolve, reject) => {
       xssec.requests.requestUserToken(bearerToken, JOB_UAA, null, null, consumerSubdomain, null, (error, token)=>{
          resolve(token)

In the helper method, we decode the token, in order to extract the subdomain (it can be found in the “external attributes” claim)

    const jwtDecoded = decode(bearerToken)  
    const consumerSubdomain = jwtDecoded.ext_attr.zdn  

Remember:
We want to fetch a tenant-specific JWT token for calling the REST API.
That’s why we need the tenant-specific subdomain.
Then we can execute the request to do the token exchange:

       xssec.requests.requestUserToken(bearerToken, JOB_UAA, null, null, consumerSubdomain, null, (error, token)=>{
          resolve(token)

Yes, again we’re using a helper lib, instead of using native https module for request.
See appendix for the native way of doing token exchange.
This time it makes sense to use the @sap/xssec client lib, because anyways we need to import it for the protection.
It is very convenient and hides all details of token exchange from us.
Above snippet shows that we need to pass the existing user token (bearerToken).
Furthermore, the credentials of Jobscheduler binding (JOB_UAA) are required (xssec will extract the clientid/secret and url from there).
And the tenant-specific subdomain is passed (consumerSubdomain).
As usual, we’re ignoring all error handling, to make the code shorter.
In real scenario, we would evaluate the “error” param.
Why we didn’t use xssec for the user token?
Because xssec doesn’t support this OAuth flow.

OK, xssec hides the details, but how would the details have looked like?
OK, let’s have a short look at the native code.
Everything looks like a normal token-fetch, including clientid/secret of Jobscheduler.
The difference is the grant_type and the assertion attributes:

grantType = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
body = `grant_type=${grantType}&response_type=token&assertion=${bearerToken}`      

Documentation in Cloud Foundry: UAA reference

Can we continue with job generation?
After we receive the exchanged token in the response, we can use it to finally generate the job.
There’s nothing new about it.
Only that this time we’re using the fetch-module, to make the code shorter:

async function  createJob(jwtToken) {
    const jwtDecoded = decode(jwtToken)  
    const body = {
        name: `MuteteJob_${new Date().getMilliseconds()}`,
        action: `https://${jwtDecoded.ext_attr.zdn}-mutete.cfapps.eu10.hana.ondemand.com/action`, 
        active: true,
        httpMethod: 'GET',
        schedules: [{
            time: 'now',
            active: 'true'
        }]
    }

    const response = await fetch(`${JOBSCH_CREDENTIALS.url}/scheduler/jobs`, {
        method: 'POST',
        body: JSON.stringify(body),
        headers: { 
            Authorization: 'Bearer ' + jwtToken,
            'Content-Type': 'application/json' 
        },
    }) 
    
    const responseJson = await response.json();
    return {jobName: responseJson.name, 
            jobID: responseJson._id,
            createdBy: jwtDecoded.user_name,
    }
}

Note:
When copying the sample, don’t forget to adapt the hardcoded URL of action endpoint.

Note:
Again, we’re using a tenant-specific action endpoint. In the next tutorial we’re going to change that.

In the return structure, we’re picking few properties from the response, such that we can directly print them in the browser.

Action endpoint

The last step of the flow is the action endpoint which is invoked by our generated job.
Nothing new here for today.

2. Run the Scenario

After deploy and subscribe, we “go to” the hompage and see that our user has been logged in:

After clicking the link, we get a success response:

We remember the job name and open the dashboard for verification:

And finally, we should as well check that the job has correctly invoked the action endpoint and that a successful response has been received.

Summary

Today we’ve learned how to do token exchange.
We’ve fetched a token for a real user and called our protected endpoint with it.
Then we’ve performed the token exchange in order to generate a job.

Next Steps

Next tutorial will be focusing on use case 3.
Customer doesn’t see that jobs are used under the thin hood of the silly user interface.
Finally, we’re getting rid of tenant-specific URLs and we’re going to use the approuter.

Links

Introduction to Job Scheduler in different multitenancy scenarios.
First multitenancy tutorial with very first steps including project setup and service creation.
Earlier tutorial (2) which introduced job generation with client credentials flow.
And the action endpoint was protected in the previous tutorial (3).

See also Overview blog post for more links.

Documentation in Cloud Foundry about token exchange: UAA reference
Github for node-fetch module to execute HTTP requests.
npm site for xssec library.
Note:
The xssec docu contains broken link.
To view the “token flow” content, currently the following workaround is required:
Downlog the module (npm install), navigate to node-modules folder and open the file manually:
C:\mutete\node_modules\@sap\xssec\doc\TokenFlows.md

Appendix 1: Classic Token Exchange

async function  fetchTokenForJobWithExchangeClassic(bearerToken) {
    const consumerSubdomain = decodeConsumerSubdomain(bearerToken) 
    const uaadomain = JOB_UAA.uaadomain
    const oauthEndpoint = `${consumerSubdomain}.${uaadomain}`
    return new Promise ((resolve, reject) => {     
       const options = {
          host: oauthEndpoint, 
          path: '/oauth/token',
          method: 'POST',
          headers: {
             Authorization: "Basic " + Buffer.from(JOB_UAA.clientid + ':' + JOB_UAA.clientsecret).toString("base64"),
             Accept: 'application/json',
             'Content-Type': 'application/x-www-form-urlencoded'
          }
       }
 
       const granttype = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
       // const data = `client_id=${JOB_CREDENTIALS.clientid}&client_secret=${JOB_CREDENTIALS.clientsecret}&grant_type=${granttype}&response_type=token&assertion=${bearerToken}`      
       // const data = `client_id=${JOB_CREDENTIALS.clientid}&grant_type=${granttype}&token_format=jwt&response_type=token&assertion=${bearerToken}`      
       // const data = `client_id=${JOB_CREDENTIALS.clientid}&grant_type=${granttype}&token_format=opaque&response_type=token&assertion=${bearerToken}`      
       // const data = `client_id=${JOB_CREDENTIALS.clientid}&grant_type=${granttype}&response_type=token&assertion=${bearerToken}`            
       // const data = `grant_type=${granttype}&response_type=token+id_token&assertion=${bearerToken}`      
       const data = `grant_type=${granttype}&response_type=token&assertion=${bearerToken}`      
       const req = https.request(options, (res) => {
          res.setEncoding('utf8')
          let response = ''
          res.on('data', chunk => {
            response += chunk
          })
          res.on('end', () => {
             try {
                const responseAsJson = JSON.parse(response)
                resolve(responseAsJson.access_token)
             } catch (error) {
                return reject(new Error('Error while fetching JWT token'))               
             }
          })
       })
       req.on('error', (error) => {
          return reject({error: error})
       });
       req.write(data)
       req.end() 
    })   
}

Appendix 2: All Sample Project Files

Note: everything can be copy&pasted, only the property appId and app URLs need to be adapted

xs-security.json

{
    "xsappname": "mutetexsappname",
    "tenant-mode": "shared"
}

config-saasreg.json

{
    "appId": "mutetexsappname!t17916",
    "appName": "muteteAppNameForSaasReg",
    "appUrls": {
        "getDependencies" : "https://mutete.cfapps.eu10.hana.ondemand.com/handleDependencies",
        "onSubscription" : "https://mutete.cfapps.eu10.hana.ondemand.com/handleSubscription/{tenantId}"
      },
    "displayName": "MuTeTe with Jobscheduler"
}
  

manifest.yml

---
applications:
  - name: mutete
    routes:
    - route: mutete.cfapps.eu10.hana.ondemand.com
    - route: jobschedulertest-mutete.cfapps.eu10.hana.ondemand.com
    memory: 128M
    services:
      - muteteSaasreg
      - muteteXsuaa
      - myJobschedulerInstance

package.json

{
    "dependencies": {
        "@sap/xssec": "^3.0.10",
        "passport": "^0.4.0",
        "express": "^4.16.2",
        "node-fetch": "2.6.2"
    }
}

server.js

const USER = 'my.user@mymail.com'
const PWD = 'mypwd'

const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES)
const UAA_CREDENTIALS = VCAP_SERVICES.xsuaa[0].credentials
const JOBSCH_CREDENTIALS = VCAP_SERVICES.jobscheduler[0].credentials
const JOB_UAA = JOBSCH_CREDENTIALS.uaa

const path = require('path') // required for dynamically retrieving current js file
const fetch = require('node-fetch')
const express = require('express')
const app = express()
app.use(express.json())
const passport = require('passport')
const xssec = require('@sap/xssec')
const JWTStrategy = xssec.JWTStrategy
passport.use('JWT', new JWTStrategy(UAA_CREDENTIALS))
app.use(passport.initialize())
app.use(express.static(__dirname)) // Express Middleware for serving static files, required for our ui-simulation (load our js file into webserver)


/* App server */

app.listen(process.env.PORT, () => {});

app.get('/app', async function(req, res){
    const hostname = req.hostname 
    const subdomain = hostname.substring(0,hostname.lastIndexOf('-')) 

    //simulation of user-login screen (tenant-specific)
    const userJwtToken = await fetchUserTokenWithUserPassword(subdomain)

    const user = decode(userJwtToken).given_name
    const website = composeWebsite(userJwtToken, user, hostname)

    res.send(website)
});
 
app.get('/createjob', passport.authenticate('JWT', {session: false}), async function(req, res) {
    const userJwtToken = req.headers.authorization.substring(7) //remove the "bearer_"

    // token exchange: send user token and get Jobscheduler token (for calling REST API)
    const exchangedToken = await doTokenExchange(userJwtToken)     
    const result = await createJob(exchangedToken)  

    res.send(`Result of job creation: ${JSON.stringify(result)}`)
});

app.get('/action', function(req, res){    
    res.send(`"/action" endpoint invoked by jobscheduler. Customer status updated for ${req.hostname}.`) 
});


/* Multi Tenancy callbacks */

app.get('/handleDependencies', (req, res) => {
    const dependencies = [{'xsappname': JOBSCH_CREDENTIALS.uaa.xsappname }]    
    res.status(200).json(dependencies);
});

app.put('/handleSubscription/:myConsumer', (req, res) => {
    const appHost = req.hostname  
    const subDomain = req.body.subscribedSubdomain
    res.status(200).send(`https://${subDomain}-${appHost}/app`)
});

app.delete('/handleSubscription/:myConsumer', (req, res) => {
    res.status(200).end('unsubscribed')
});


/* THE WEBSITE */

function composeWebsite(userJwtToken, user, hostname){
    const src = `<script src="${path.basename(__filename)}"></script>`
    const ajx = `<script src = "https://code.jquery.com/jquery-3.6.0.min.js"></script>`//"https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"
    const href = `javascript:onClick('${hostname}', '${userJwtToken}')` 

    return `${src}${ajx}<h1>Homepage</h1><p>Hello ${user}, <a href="${href}">click</a> to create job.</p>`
}

// when click on hyperlink we manually fire REST request to call our createJob endpoint  
async function onClick(hostname, jwtToken){
    $.ajax({
        url: `https://${hostname}/createjob`,
        headers: { "Authorization": "bearer " + jwtToken },
        success: function(data){
            const p = document.createElement("P")                        
            p.appendChild(document.createTextNode("-> Received response: " + data))                                           
            document.body.appendChild(p)
        }
    })
}


/* HELPER */

//simulating a user-login-screen. instead of login-form, fetch token with hard-coded credentials
async function  fetchUserTokenWithUserPassword(subdomain) {
    const oauthEndpoint = `https://${subdomain}.${UAA_CREDENTIALS.uaadomain}/oauth/token?grant_type=password&username=${USER}&password=${PWD}`
    const result = await fetch(oauthEndpoint, {
        headers: {
            Authorization: "Basic " + Buffer.from(UAA_CREDENTIALS.clientid + ':' + UAA_CREDENTIALS.clientsecret).toString("base64")
        }
    });
    const responseAsJson = await result.json();
    return responseAsJson.access_token;
}
 
async function doTokenExchange (bearerToken){
    const jwtDecoded = decode(bearerToken)  
    const consumerSubdomain = jwtDecoded.ext_attr.zdn  
    return new Promise ((resolve, reject) => {
       xssec.requests.requestUserToken(bearerToken, JOB_UAA, null, null, consumerSubdomain, null, (error, token)=>{
          resolve(token)
       })  
    })  
}

async function  createJob(jwtToken) {
    const jwtDecoded = decode(jwtToken)  
    const body = {
        name: `MuteteJob_${new Date().getMilliseconds()}`,
        action: `https://${jwtDecoded.ext_attr.zdn}-mutete.cfapps.eu10.hana.ondemand.com/action`, 
        active: true,
        httpMethod: 'GET',
        schedules: [{
            time: 'now',
            active: 'true'
        }]
    }

    const response = await fetch(`${JOBSCH_CREDENTIALS.url}/scheduler/jobs`, {
        method: 'POST',
        body: JSON.stringify(body),
        headers: { 
            Authorization: 'Bearer ' + jwtToken,
            'Content-Type': 'application/json' 
        },
    }) 
    
    const responseJson = await response.json();
    return {jobName: responseJson.name, 
            jobID: responseJson._id,
            createdBy: jwtDecoded.user_name,
    }
}

function decode(jwtToken){
    const jwtBase64Encoded = jwtToken.split('.')[1];
    const jwtDecodedAsString = Buffer.from(jwtBase64Encoded, 'base64').toString('ascii');
    return JSON.parse(jwtDecodedAsString);            
}

Assigned tags

      Be the first to leave a comment
      You must be Logged on to comment or reply to a post.