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

Using Job Scheduler in SAP BTP [10]: Multitenancy (3): Securing tenant-specific endpoint

This blog post shows how a tenant-specific action endpoint is secured and how it is called by Jobscheduler.

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 Post
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 the previous blog post and is completely based on it.
So here we’re only explaining the differences.
In the previous tutorial, we created a multitenant application which was able to generate jobs, and delete them automatically.
Today, the only thing we’re adding is: protection of the /action endpoint.
Securing an action endpoint is not new to us, as we learned in this blog, how an action endpoint is protected with OAuth and how Jobscheduler is enabled to call it.
Below diagram illustrates how a normal singletenant application with protected endpoint is called by Jobscheduler:

However, there are some interesting details when it comes to multitenancy.
Below diagram shows what we’re going to talk about: Jobscheduler needs to send a JWT token when it executes the tenant-specific action endpoint:

1. Create Multitenant Application

As mentioned, please refer to the previous tutorial for project creation and setup.
The full code of today’s sample can be found in the Appendix section.
For today, there are no changes in config-saasreg.json and textmanifest.yml files.
So let’s have a look at changes in the other files:

xs-security.json
In the security descriptor of our MuTeTe app, we define a scope:

"scopes": [
   {
      "name": "$XSAPPNAME.actionscope",
      "description": "This scope is required for calling the action-endpoint of mutete app",
      "grant-as-authority-to-apps": ["$XSSERVICENAME(myJobschedulerInstance)"]

This is the scope that is used to protect our action endpoint.
With other words: whoever might want to call the action endpoint, needs to have this scope.
If not?
No mercy: 403.
See here for explanation.

package.json
For adding security features, we’re relying on the 2 usual libraries:

    "dependencies": {
        "@sap/xssec": "^3.0.10",
        "passport": "^0.4.0",

server.js
In the application code, we’re only adding a few lines to protect our action endpoint.
All the rest remains the same like in previous blog:
We have tenant-specific job creation endpoint.
It generates a job that is used to invoke an “/action” endpoint that is tenant-specific as well.
This is important to note:
We’re protecting a tenant-specific “/action” endpoint.
And this is what we’re going to discuss in detail.

Protecting a tenant-specific endpoint

To be honest, there’s nothing new in the code for protecting our endpoint.
It is just the standard code, similar like in tutorial 3:

const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES);
const UAA_CREDENTIALS = VCAP_SERVICES.xsuaa[0].credentials;

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.get('/action', passport.authenticate('JWT', {session: false}), (req, res) => {      
    const MY_SCOPE = UAA_CREDENTIALS.xsappname + '.actionscope'
    if(req.authInfo.checkScope(MY_SCOPE)){
       res.send('The endpoint was properly called, the required scope has been found in JWT token.');
    }else{
       return res.status(403).json({error: 'Unauthorized', message: `The caller doesn't have the required scope: ${MY_SCOPE}`});
    }
});

We use the instance of XSUAA which we bound to our app.
We use passport for protection and we configure passport with our XSUAA.
“With our XSUAA” means basically that any incoming JWT token that is not issued by our XSUAA will be rejected.
In addition, the code of the protected endpoint, checks if the scope is contained in the JWT token.
-> Nothing interesting here.

But how CAN that work?
Remember that we’re talking here about an endpoint that is specific to the subscriber.
We know that the subscriber has its own OAuth-server in its own subscriber-subaccount.
BUT, we’re protecting our endpoint with the OAuth-server of our provider-subaccount.

And how can Jobscheduler do it?
We know that Jobscheduler is enabled to send the scope, because we’ve specified the GRANT.
BUT: Jobscheduler is bound to our provider-app. What does that imply for the JWT token that Jobscheduler sends?
The endpoint is tenant-specific.
It is protected with provider-XSUAA.
Jobscheduler sends a JWT-token, is it specific or not???
Can this work or not???

To clarify these details, let’s write the content of the JWT token to the console.

function readJWT(req){
    const authHeader = req.headers.authorization
    const jwtToken = authHeader.substring(7)
    const jwtBase64Encoded = jwtToken.split('.')[1];
    const jwtDecodedAsString = Buffer.from(jwtBase64Encoded, 'base64').toString('ascii')
    
    console.log(`==> JWT token issued by: ${JSON.parse(jwtDecodedAsString).iss}`)       
    console.log(`==> JWT token issued from subdomain: ${JSON.parse(jwtDecodedAsString).ext_attr.zdn}`)   
    console.log(`==> JWT token issued from subaccount: ${JSON.parse(jwtDecodedAsString).ext_attr.subaccountid}`)   
    console.log(`==> JWT token requested by jobschduler instance: ${JSON.parse(jwtDecodedAsString).ext_attr.serviceinstanceid}`)       
    console.log(`==> JWT token issued for clientID: ${JSON.parse(jwtDecodedAsString).client_id}`)   
    console.log(`==> JWT token issued for audience: ${JSON.parse(jwtDecodedAsString).aud}`)   
    console.log(`==> JWT token contains scopes: ${JSON.parse(jwtDecodedAsString).scope}`)   
}

That’s it about the code.

2. Run the Scenario

After deploy, we go to our customer-subaccount (in my example it is called jobschedulertest) and subscribe to the app. Then open the app and click on the (tenant-specific) link to create a job.
Afterwards we switch back to the provider subaccount and check the Dashboard, to verify that the job was generated and executed successfully.

As of now we have proven that the scenario works – in spite of the doubts that we had.
Next we go to command line to open the log (cf logs mutete –recent).
It shows the relevant content of the JWT token which was sent by Jobscheduler:

What we want is to clarify our doubts with respect to the XSUAA, so we need to compare these values with our XSUAA.
“Our XSUAA”, this means the instance of XSUAA which we created in our provider account and which we have bound to our provider app.
To see the values, we run cf env mutete
Below screenshot shows the relevant values of my example:

Now we can compare the values.

Issuer
In my example the issuer has this URL
https://jobschedulertest.authentication.eu10.hana.ondemand.com/oauth/token
We can see that Jobscheduler sends a token that was issued by the OAuth-server of the customer-subaccount (customer-specific).
And if we compare to our binding: we can see that obviously the “url” property points to our provider subaccount.

Subdomain
We can see that the JWT token which is sent by Jobscheduler has the customer-specific subdomain

Subaccount
To compare the subaccount ID, we can go to the BTP cockpit and open the overview screen of our customer-subaccount.
There we can see, that it is the same like the one which is contained in the JWT token.
And we can see that it is different from the subaccountID of the binding of our provider app.

Jobscheduler instance ID
To verify this ID, we can have a look at the URL of the Jobscheduler Dashboard

ClientID
The clientid is the OAuth client which was registered by Jobscheduler.
This is expected, but to make it clear: it is not the clientid of the instance of XSUAA which we created for our app.
It is the clientid of the xsuaa-instance which is used by jobscheduler.
As such, it is the same as the id of the jobscheduler-binding

Audience
The aud claim of the JWT token contains the allowed receivers of the token.
Obviously, the jobscheduler-client is contained here.
Interesting for us is the xsappname: this is the name which we specified, when we created the instance of xsuaa. This instance of XSUAA is used to protect our endpoint.
As such, it is relevant, because it means that the protection-logic of our action-endpoint will accept this JWT token.
This mechanism, however, has nothing to do with tenants.

Scopes
The last info which we print is the scope claim of the JWT token.
We can see that Jobscheduler sends the scope which we defined in our xs-security.json file.
And we can see the full name, including the generated suffix of xsappname.

That’s all what we wanted to see.
We’ve proven that – in a multitenant setup – Jobscheduler sends a JWT token that not only is able to carry the required scope, moreover it is tenant-specific.
Good to know and nice feature.
Below diagram ilustrates what we’ve been talking about:

Summary

We can now understand better how the authorization of action endpoint for Jobscheduler works in case of multitenancy.
We’ve learned that Jobscheduler sends a JWT token with tenant-specific information.

Next Steps

Next tutorial will be focusing on creation of job. This has been discussed yet, however it is time to use token exchange instead of client credentials.

Links

Introduction to Job Scheduler in different multitenancy scenarios.
First multitenancy tutorial with very first steps including project setup and service creation.
Previous tutorial which introduced job generation with client credentials flow.
And also 0verview blog post for more links.

Appendix: 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",
    "scopes": [
        {
            "name": "$XSAPPNAME.actionscope",
            "description": "This scope is required for calling the action-endpoint of mutete app",
            "grant-as-authority-to-apps": ["$XSSERVICENAME(myJobschedulerInstance)"]
        }
    ]
}

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"
   }
}
  

server.js

const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES);
//credentials required to call the REST API of jobscheduler
const JOBSCH_CREDENTIALS = VCAP_SERVICES.jobscheduler[0].credentials;
const JOBSCH_CLIENTID = JOBSCH_CREDENTIALS.uaa.clientid  
const JOBSCH_SECRET = JOBSCH_CREDENTIALS.uaa.clientsecret 
const JOBSCH_URL = JOBSCH_CREDENTIALS.url //"https://jobscheduler-rest.cfapps.eu10.hana.ondemand.com" 
// credentials used to protect our endpoint 
const UAA_CREDENTIALS = VCAP_SERVICES.xsuaa[0].credentials;

const https = require('https');
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 server */
app.listen(process.env.PORT, () => {});

/* App endpoints */

app.get('/app', function(req, res){      
    const url = `https://${req.hostname}/createjob`   
    res.send(`<h1>Homepage</h1><h4>Products List</h4><p>...</p>Click <a href="${url}">here</a> to update your status.`);
});

app.get('/createjob', async function(req, res){    
    const hostname = req.hostname 
    const subdomain = hostname.substring(0,hostname.lastIndexOf('-')) 
    const jwtToken = await fetchJwtToken(subdomain)
    const result = await createJob(jwtToken, subdomain)

    res.send(`Job created for customer ${subdomain}. Check dashboard/CF logs. Result of job creation: ${JSON.stringify(result)}`)
});

app.get('/action', passport.authenticate('JWT', {session: false}), (req, res) => {      
    readJWT(req)

    const MY_SCOPE = UAA_CREDENTIALS.xsappname + '.actionscope'
    if(req.authInfo.checkScope(MY_SCOPE)){
       res.send('The endpoint was properly called, the required scope has been found in JWT token.');
    }else{
       return res.status(403).json({error: 'Unauthorized', message: `The caller doesn't have the required scope: ${MY_SCOPE}`});
    }
});

function readJWT(req){
    const authHeader = req.headers.authorization
    const jwtToken = authHeader.substring(7)
    const jwtBase64Encoded = jwtToken.split('.')[1];
    const jwtDecodedAsString = Buffer.from(jwtBase64Encoded, 'base64').toString('ascii')   
    console.log(`==> JWT token issued by: ${JSON.parse(jwtDecodedAsString).iss}`)       
    console.log(`==> JWT token issued from subdomain: ${JSON.parse(jwtDecodedAsString).ext_attr.zdn}`)   
    console.log(`==> JWT token issued from subaccount: ${JSON.parse(jwtDecodedAsString).ext_attr.subaccountid}`)   
    console.log(`==> JWT token requested by jobschduler instance: ${JSON.parse(jwtDecodedAsString).ext_attr.serviceinstanceid}`)       
    console.log(`==> JWT token issued for clientID: ${JSON.parse(jwtDecodedAsString).client_id}`)   
    console.log(`==> JWT token issued for audience: ${JSON.parse(jwtDecodedAsString).aud}`)   
    console.log(`==> JWT token contains scopes: ${JSON.parse(jwtDecodedAsString).scope}`)   
}

/* 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) => {
    console.log(`==> onSubscription: the TenantID of subscriber: ${req.body.subscribedTenantId}`) 
    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')
});


/* HELPER */

async function  fetchJwtToken(subdomain) {
    return new Promise ((resolve, reject) => {
        const uaadomain = JOBSCH_CREDENTIALS.uaa.uaadomain
        const oauthEndpoint = `${subdomain}.${uaadomain}` 
        const options = {
            host: oauthEndpoint, 
            path: '/oauth/token?grant_type=client_credentials&response_type=token',
            headers: {
                Authorization: "Basic " + Buffer.from(JOBSCH_CLIENTID + ':' + JOBSCH_SECRET).toString("base64")
            }
        }
 
        https.get(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) {}
            })
        })
    })   
}
 
async function createJob(jwtToken, subdomain){
    return new Promise ((resolve, reject) => {
       const options = {
            host:  JOBSCH_URL.replace('https://', ''),  
            path:  `/scheduler/jobs`,
            method: 'POST',
            headers: {
                Authorization: 'Bearer ' + jwtToken,
                'Content-type': 'application/json'
            }
       }         
 
       const req = https.request(options, (res) => {
            resolve({status: `Job result: ${res.statusCode} - ${res.statusMessage}`})
       });

       const data = JSON.stringify({
         name: `MuteteJob_${new Date().getMilliseconds()}`,
         action: `https://${subdomain}-mutete.cfapps.eu10.hana.ondemand.com/action`, 
         active: true,
         httpMethod: 'GET',
         schedules: [{
            time: 'now',
            active: 'true'
         }]
       })
       req.write(data)
       req.end()   
    })   
 }

Assigned tags

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