This tutorial is part of a
little series about
SAP 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
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
Security Glossary.
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
path: app
memory: 128M
buildpacks:
- nodejs_buildpack
services:
- xsuaawithscopeandgrant
- jobschedulerinstance
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(){})