Skip to Content
Technical Articles

Using Job Scheduler in SAP Cloud Platform [3]: enable OAuth Authorization

This tutorial is part of a little series about SAP Cloud Platform Job Scheduler

Quicklinks:
Intro Blog
Tutorial 1
Tutorial 2
Project Files

In the previous tutorial, we created a Node.js application which was protected with OAuth 2.0,
and we used JobScheduler to call our app
That app required authentication, but not authorization

In this tutorial we‘re going to add authorization requirement to the app
It is again a simple tutorial, there are only a few steps which require special attention

Note that it requires productive account, it cannot be executed in Trial account

Overview

** Create instances of JobScheduler and xsuaa (security settings: xs-security) **
** Create Node.js application **
** Optional: Test with human user **
** Configure JobScheduler **
** Appendix 1: assign role to user **
** Appendix 2: list of useful CLI commands**
** Appendix 3: Logging the JWT content**
** Appendix 4: more apps with more scopes **
** Appendix 5: all project files **

Prerequisites

  • Access to productive account of SAP Cloud Platform
  • The previous blog is not a prerequisite, as we’re going to re-create the app from scratch
    However, we’re not going to re-explain the same stuff
  • So, let’s make it a prerequisite: the previous blog

Create instance of Job Scheduler service

The instance of Job Scheduler service needs to be created first (before xsuaa) and with below given parameter.
Reason:
The Job Scheduler instance must exist before proceeding with next step.
Reason:
The instance name will be referenced by xsuaa security descriptor

Below parameter is required, because we’re going to configure Job Scheduler to call an OAuth-protected REST endpoint of our app.
This parameter is not required, when JobScheduler is used to call Cloud Foundry Tasks

See here to create the service instance using the cockpit

Details:

Service Plan: Standard
Parameter:

{
   "enable-xsuaa-support": true 
}

Instance Name: jobschedulerinstance

Command for creation on command line:

cf create-service jobscheduler standard jobschedulerinstance -c "{\"enable-xsuaa-support\":true}"

Note:
Windows users need to escape the quotation marks as shown above

Takeaway: Create JOBs first

Create instance of XSUAA service

In this tutorial, we want to add authorization requirement to our application
This means, that users who want to call our REST endpoint, need to be authenticated AND must have a special role
This role is defined by us, the app developers. We require it
Why?
Because our endpoint might be sensitive, so we want to ensure that e.g. only administrators can invoke it

How to define a role?
When creating an instance of XSUAA, we pass JSON parameters which contain the scope (role) that we require
We can specify any arbitrary name of our choice
Let’s give it a stupid name: scopeformyapp
To make that name a bit more unique, we concatenate our chosen name to the property xsappname  which has to be unique anyways.
This is a best practice and we can use a variable to avoid typos:

{
  "xsappname" : "xsappwithscopeandgrant",
  "scopes": [{
      "name": "$XSAPPNAME.scopeformyapp",

This means that at runtime, our role name will be resolved to something like
xsappwithscopeandgrant!t22273.scopeformyapp

The value of property xsappname can be viewed:
-> in the env of your app,
-> VCAP for xsuaa,
-> credentials section

Note:
It makes sense to create an endpoint in a productive application which is only used by JobScheduler for recurring tasks, like clean-up database.
So it would make sense to name the scope accordingly.
Furthermore, if the scope is not referenced by a role-template, then it cannot be assigned to a human user, then it cannot be found in the list of available roles (see appendix)

OK, we’ve learned to define roles when creating an instance of xsuaa.
That role must be assigned to any user who wants to call an application, which is bound to the xsuaa instance which knows about that role

Problem:
JobScheduler is not a user, so we cannot assign a role to it

Solution:
The SAP Cloud Platform provides a mechanism to assign a role to an application:
grant-as-authority-to-apps

We only have to add the following line to our scope-definition:

"grant-as-authority-to-apps": ["$XSSERVICENAME(jobschedulerinstance)"]

Note:
Make sure to adapt to the name of your instance name of JobScheduler service created above

Note:
Now you can see the reason why JobScheduler instance has to be created first
(remember: create JOBs first)

Finally, the JSON parameters which are passed to the instance of XSUAA look as follows:

{
  "xsappname" : "xsappwithscopeandgrant",
  "tenant-mode" : "dedicated",
  "scopes": [{
      "name": "$XSAPPNAME.scopeformyapp",
      "description": "Users of my great app need this special role",
      "grant-as-authority-to-apps": ["$XSSERVICENAME(jobschedulerinstance)"]
  }]
}

The above snippet can be copy&pasted during instance creation in the cockpit
However, it makes sense to store these parameters in a file.
Such file is usually called xs-security.json, but the name can be any arbitrary name
Also, that file is not required at runtime, so it doesn’t need to be deployed along with the app

Details for xsuaa instance creation:
Service Plan: application or broker
Parameters: as given above
Instance Name: xsuaawithscopeandgrant

Command line users can use the following command /assuming that the above parameters are stored in a file with name xs-security.json in the same directory)

cf create-service xsuaa broker xsuaawithscopeandgrant -c xs-security.json

Note for user who already have an app and xsuaa

In our tutorial, we’re going to create a new application
However, if you already have an app which is bound to xsuaa:
You can just update the existing xsuaa instance to add the above JSON parameters (e.g. the grant for JobScheduler)
Command for update looks as follows:

cf update-service xsuaawithscopeandgrant -c xs-security.json

However, after update-service, make sure to re-bind the service to your app
But always:

FIRST bind XSUAA to app
THEN bind Jobscheduler to your app

Background:
When JobScheduler is bound to our app, the JobScheduler reads the grant-as-authority-to-apps
Thus: our app MUST be bound to xsuaa (with grant) BEFORE binding to JobScheduler

Otherwise the invocation of our endpoint would fail, because JobScheduler wouldn’t have the required scope

Note:
You can always unbind and bind JobScheduler instance after having updated the xsuaa service (after bind: restage or restart the app)

These commands are useful:

cf unbind-service <yourappname> jobschedulerinstance
cf bind-service <yourappname> jobschedulerinstance
cf restage <yourappname>

Takeaway: Create JOBs first
Takeaway: Bind xsUSA first again

Create Application

We’re going to reuse the application of previous blog
Here, we’re only explaining the differences

1. manifest.yml

The first step for enhancing our app with authorization was done in previous step, when creating the instance of xsuaa
In manifest.yml file, we just bind our app to the service instances and define the ACL

applications:
- name: appauthandscope
  . . .
  services:
    - xsuaawithscopeandgrant
    - jobschedulerinstance
  env:
    SAP_JWT_TRUST_ACL: >
      [
        {"clientid":"*", "identityzone":"*"}
      ]

Note:
The services-section of above snippet shows that we bind the app to xsuaa first.
This order of binding will be kept during deployment. At least in most cases. However, there’s no guarantee
So in case of trouble, remember:
Bind xsUSA first again
And: after binding xsuaa, don’t forget to unbind and bind JobScheduler and restage

2. package.json

<No changes>

Don’t forget to run npm install

3. Code

In the previous app we’ve enforced the authentication with passport and the JWT strategy of xssec library
Now we only need to enforce the authorization
We’re going to do it manually

We have to understand:
Whenever a user authenticates against XSUAA, he receives a JWT token
(Remember: xsuaa acts as “Authorization Server” in the OAuth flow)
That token contains info about clientid etc and also info about the scopes (roles) which the user has

So our task seems to be clear:
When our endpoint is invoked,
-> we have to extract the scopes out of the token
-> and check if our required scope is available.

And we have to react accordingly:
If the scope $XSAPPNAME.scopeformyapp is not contained in the JWT token, we have to fail with corresponding HTTP status code

For the application developer, it doesn’t make a difference if the user is a human, or if it is the JobScheduler:
If the instance creation and binding was done properly, the JobScheduler will send the required scope in the JWT token

And one more good news:
There’s a helper method available which does the validation of the token
We don’t really need to manually decode the token to get the list of available scopes to check if our required scope can be found
We only need to use the helper method and pass our required scope name to it

But first we have to figure out, which is the correct name of the scope (role) which a user needs to have to call our endpoint successfully (remember that the full xsappname is generated during deployment)
In our xs-security.json we defined it as follows

xsuaaCredentials.xsappname
  "scopes": [{
      "name": "$XSAPPNAME.scopeformyapp",

We’ve learned that during deployment it will be generated and look somehow like this:
xsappwithscopeandgrant!t22273.scopeformyapp

So we cannot hard-code the required name.
We have to obtain the $XSAPPNAME from our app environment
It is easy
We already have parsed the environment in the previous tutorial:

const xsuaaService = xsenv.getServices({ myXsuaa: { tag: 'xsuaa' }});
const xsuaaCredentials = xsuaaService.myXsuaa; 

Now we can access the credentials to get the value of the property “xsappname”

xsuaaCredentials.xsappname

We could store it in a constant.
But no, in this simple tutorial we make it the simple way…
See here how the implementation of our endpoint looks like:

app.get('/doSomething', function(req, res){      
   const MY_SCOPE = xsuaaCredentials.xsappname + '.scopeformyapp'
   if(req.authInfo.checkScope(MY_SCOPE)){
      res.send('The endpoint was properly called, the required scope has been found in JWT token. Finished doing something successfully');
   }else{
      return res.status(403).json({
         error: 'Unauthorized',
         message: 'The endpoint was called by user who does not have the required scope: <scopeformyapp> ',
     });
   }
});

The promised helper is this one:

req.authInfo.checkScope('theScope'))

Actually, there are 2 helpers.

First helper:

req.authInfo

This is an object that is filled by the framework, it is a convenience object
We don’t need to know how to access the “Authorization” header
And best: it contains info extracted from parsed JWT token

BTW, see Appendix for a simple example for manually extracting info from JWT token

Second helper:

authInfo.checkScope('theScope')

The authInfo object provides not only properties, but also a helper method
It doesn’t do sophisticated stuff, but is handy
It checks if the JWT token contains the scope
If the check fails, then our app responds with a proper status code
As of specification, the code 403 is the right one for our error (required scope not available)

OK, that’s all

As I promised, also in this tutorial, there’s not much work to do and the work is simple:

1. Compose the scope name which our app requires
2. Validate the scope of incoming call

Deploy

<empty chapter>

Optional: Test the endpoint of our app

In the previous tutorial, I gave brief description about how to use REST client for OAuth flow
You can repeat the steps for current scenario
However: -> it will fail

Why?
As a human user, I proceed as follows:
– Open the cockpit, go to the app details page, open the Environment Variables and view the VCAP_SERVICES variable for the xsuaa-binding of my app
– With the help of postman, ask the authentication endpoint of XSUAA (the Authorization server in terms of OAuth) to provide a JWT token for my user
– The identity provider is contacted to verify my user.
– And here comes the weak point:
my user has roles assigned. But obviously not the silly role $XSAPPNAME.scopeformyapp required by the simple app

How to fix it?
-> See Appendix

Create Job

Create Job with endpoint URL like this

https://appauthandscope.cfapps.eu10.hana.ondemand.com/doSomething

And result like this:

Summary

Here comes a summary of 2 blogs:

We’ve learned how to write a node.js app which is protected by OAuth
We’ve learned a bit about the protection mechanism:
Using passport and xssec JWT Strategy
We’ve learned how to define a scope
We’ve learned how to enforce a scope

I almost forgot that this tutorial series is about Jobscheduler:
We almost don’t need to do anything to make JobScheduler call our protected app
Add jobscheduler to ACL
Assign (grant) the role to JobScheduler

Remember:
The order of creation and order of binding is important
Never forget the mnemonics for correct Order :

Takeaway: Create JOBs first
Takeaway: Bind xsUSA first again

Maybe a diagram helps to remember?

Links

SAP Help Portal: Security in Cloud Foundry
SAP Help Portal: xs-security.json docu
SAP Help Portal: xs-security.json reference
SAP Help Portal: little docu about grant

HTTP status codes: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html

More links: see previous blog

Series:
0 Intro
1 Simple
2 Authentication
3 This blog
4 App Router

Appendix 1: assign role to user

My apologies, I didn’t expect that you would be interested in calling that endpoint with REST client
That’s why above I described a rather simple security descriptor.
It contained only the parameters needed by JobScheduler
Now, you want to call your silly app endpoint with your (smart) human user
OK.
I can describe that
sigh

We have to enhance the security descriptor.

Copy the following content into xs-security.json file or create a second file with some silly name of your choice, e.g. xs-humanSecurity.json

This time, the parameters contain an additional role-template:

{
  "xsappname" : "xsappwithscopeandgrant",
  "tenant-mode" : "dedicated",
  "scopes": [{
      "name": "$XSAPPNAME.scopeformyapp",
      "description": "Users of my great app need this special role",
      "grant-as-authority-to-apps": ["$XSSERVICENAME(jobschedulerinstance)"]
  }],
  "role-templates": [ { 
      "name"                : "RoleTemplateForMyGreatApp", 
      "description"         : "Users of my great app need this special role", 
      "default-role-name"   : "My App My Role",
      "scope-references"    : ["$XSAPPNAME.scopeformyapp"]
  }]
}

See SAP Help Portal for more information about params

Now update the xsuaa service instance with following command

cf update-service xsuaawithscopeandgrant -c xs-humanSecurity.json

Now you have to ask the admin (probably yourself) to assign that silly role to your user
2 steps are required:

1. Create Role Collection containing our Role

In cockpit,
-> go to subaccount
-> expand Security Menu on the left pane
-> click Role Collections

Create Role Collection with any name of your choice, e.g. rc_app_with_scope
Click hyperlink of new Role Collection
Click button “Add Role”, choose our xsappname (xsappwithscopeandgrant) and see the role template and default role as defined in the security descriptor above

Press Save

2. Assign Role Collection to user

In cockpit
-> subaccount
-> Security menu
-> click Trust Configuration

Click on current identity provider (e.g. SAP ID Service)
In “Trust Configuration” screen, enter your E-Mail Address
Then press “Show Assignments” to view the Role Collections assigned to your user
Obviously, the new Role Collection is not listed
Press button “Assign Role Collection” and select the new Role Collection

Press Assign
Now your user can be proud to be a bearer of the great new role
And now you can proceed with chapter Optional: Test the endpoint of our app
And I promise: it will work fine now!

Appendix 2: useful commands

For your convenience, I’m listing all commands for Cloud Foundry Command Line Client, ready to copy&paste for this tutorial

Creating the services:

cf create-service jobscheduler standard jobschedulerinstance -c "{\"enable-xsuaa-support\":true}"
cf create-service xsuaa broker xsuaawithscopeandgrant -c xs-security.json

Update:

cf update-service xsuaawithscopeandgrant -c xs-security.json

Re-binding xsuaa:

cf unbind-service appauthandscope xsuaawithscopeandgrant
cf bind-service appauthandscope xsuaawithscopeandgrant
cf restage appauthandscope

Re-binding jobscheduler

cf unbind-service appauthandscope jobschedulerinstance
cf bind-service appauthandscope jobschedulerinstance
cf restage appauthandscope

Appendix 3: app with JWT logger

If you’re interested in adding logs to your app, to view the JWT token which is sent, and to verify the scopes with your eyes, use the following code snippet in your node app

One hint: the property user_name will be filled only when you call the endpoint with your user and REST client. JobScheduler doesn’t have a user_name

const express = require('express');
const passport = require('passport');
const xsenv = require('@sap/xsenv');
const JWTStrategy = require('@sap/xssec').JWTStrategy;

//configure passport
const xsuaaService = xsenv.getServices({ myXsuaa: { tag: 'xsuaa' }});
const xsuaaCredentials = xsuaaService.myXsuaa; 
const jwtStrategy = new JWTStrategy(xsuaaCredentials)
passport.use(jwtStrategy);

// scope name copied from xs-security.json
const SCOPE_NAME = 'scopeformyapp'
const MY_SCOPE = xsuaaCredentials.xsappname + '.' + SCOPE_NAME;

// configure express server with authentication middleware
const app = express();

// Middleware to read JWT sent by JobScheduler
function jwtLogger(req, res, next) {
   console.log('===> [MIDDLEWARE]  decoding auth header' )
   let authHeader = req.headers.authorization;
   if (authHeader){
      var theJwtToken = authHeader.substring(7);
      if(theJwtToken){
         console.log('===> [MIDDLEWARE] the received JWT token: ' + theJwtToken )
         let jwtBase64Encoded = theJwtToken.split('.')[1];
         if(jwtBase64Encoded){
            let jwtDecoded = Buffer.from(jwtBase64Encoded, 'base64').toString('ascii');
            let jwtDecodedJson = JSON.parse(jwtDecoded);
            //console.log('User: ' + jwtDecodedJson.user_name);
            console.log('===> [MIDDLEWARE]: JWT: scopes: ' + jwtDecodedJson.scope);
            console.log('===> [MIDDLEWARE]: JWT: client_id: ' + jwtDecodedJson.client_id);
            console.log('===> [MIDDLEWARE]: JWT: user: ' + jwtDecodedJson.user_name);
         }
      }
   }
   next()
}
app.use(jwtLogger)

app.use(passport.initialize());
app.use(passport.authenticate('JWT', { session: false }));

// app endpoint with authorization check
app.get('/doSomething', function(req, res){      
   if(req.authInfo.checkScope(MY_SCOPE)){
      res.send('The endpoint was properly called, the required scope has been found in JWT token. Finished doing something successfully');
   }else{
      return res.status(403).json({
         error: 'Unauthorized',
         message: 'The endpoint was called by user who does not have the required scope: <scopeformyapp> ',
     });
   }
});

const port = process.env.PORT || 3000;
app.listen(port, function(){})

Note:
Better not write scopes to logs when running in productive mode

Appendix 4: more apps with more scopes

Curious about how JobScheduler behaves when there are many apps bound to it?
ONE instance of JobScheduler and MANY apps bound to it.
As a consequence, MANY Jobs created for many apps

You can try, little experiment:

Create a second xsuaa, add multiple scopes in json params
Then create a second (silly) app, bind it to this xsuaa and to Jobscheduler.
Deploy it
Then run job of first app again and check the log output (previous Appendix) for the scopes.

You’ll see: the JWT token which is sent by JobScheduler to first app, contains all scopes: the one of first app and those of second app

You can use this xs-security.json:

{
  "xsappname" : "xsappwithscopeandgrant2",
  "tenant-mode" : "dedicated",
  "scopes": [
    {
      "name": "$XSAPPNAME.scopeformyapp2",
      "description": "scope2",
      "grant-as-authority-to-apps": ["$XSSERVICENAME(jobschedulerinstance)"]
    },
    {
      "name": "$XSAPPNAME.scopeformyapp3",
      "description": "scope3",
      "grant-as-authority-to-apps": ["$XSSERVICENAME(jobschedulerinstance)"]
    }
  ]
}

Appendix 5: All Project Files

manifest.yml

---
applications:
- name: appauthandscope
  host: appauthandscope
  path: myapp
  memory: 128M
  buildpacks:
    - nodejs_buildpack
  services:
    - xsuaawithscopeandgrant
    - jobschedulerinstance
  env:
    SAP_JWT_TRUST_ACL: >
      [
        {"clientid":"*", "identityzone":"*"}
      ]

xs-security.json

{
  "xsappname" : "xsappwithscopeandgrant",
  "tenant-mode" : "dedicated",
  "scopes": [{
      "name": "$XSAPPNAME.scopeformyapp",
      "description": "Users of my great app need this special role",
      "grant-as-authority-to-apps": ["$XSSERVICENAME(jobschedulerinstance)"]
  }]
}

package.json

{
  "main": "server.js",
  "dependencies": {
    "@sap/xsenv": "latest",
    "@sap/xssec": "latest",
    "express": "^4.16.3",
    "passport": "^0.4.1"
  }
}

server.js

const express = require('express');
const passport = require('passport');
const xsenv = require('@sap/xsenv');
const JWTStrategy = require('@sap/xssec').JWTStrategy;

//configure passport
const xsuaaService = xsenv.getServices({ myXsuaa: { tag: 'xsuaa' }});
const xsuaaCredentials = xsuaaService.myXsuaa; 
const jwtStrategy = new JWTStrategy(xsuaaCredentials)
passport.use(jwtStrategy);

// configure express server with authentication middleware
const app = express();

// Middleware to read JWT sent by JobScheduler
function jwtLogger(req, res, next) {
   console.log('===> [MIDDLEWARE]  decoding auth header' )
   let authHeader = req.headers.authorization;
   if (authHeader){
      var theJwtToken = authHeader.substring(7);
      if(theJwtToken){
         console.log('===> [MIDDLEWARE] the received JWT token: ' + theJwtToken )
         let jwtBase64Encoded = theJwtToken.split('.')[1];
         if(jwtBase64Encoded){
            let jwtDecoded = Buffer.from(jwtBase64Encoded, 'base64').toString('ascii');
            let jwtDecodedJson = JSON.parse(jwtDecoded);
            console.log('===> [MIDDLEWARE]: JWT: scopes: ' + jwtDecodedJson.scope);
            console.log('===> [MIDDLEWARE]: JWT: client_id: ' + jwtDecodedJson.client_id);
            console.log('===> [MIDDLEWARE]: JWT: user: ' + jwtDecodedJson.user_name);
         }
      }
   }
   next()
}
app.use(jwtLogger)

app.use(passport.initialize());
app.use(passport.authenticate('JWT', { session: false }));

// app endpoint with authorization check
app.get('/doSomething', function(req, res){      
   const MY_SCOPE = xsuaaCredentials.xsappname + '.scopeformyapp'
   if(req.authInfo.checkScope(MY_SCOPE)){
      res.send('The endpoint was properly called, the required scope has been found in JWT token. Finished doing something successfully');
   }else{
      return res.status(403).json({
         error: 'Unauthorized',
         message: 'The endpoint was called by user who does not have the required scope: <scopeformyapp> ',
     });
   }
});

const port = process.env.PORT || 3000;
app.listen(port, function(){})

 

7 Comments
You must be Logged on to comment or reply to a post.