Skip to Content
Technical Articles

Writing Function-as-a-Service [13]: Secure scenario with scope and consumer

This blog is part of a series of tutorials explaining how to write serverless functions using the Functions-as-a-Service offering in SAP Cloud Platform Serverless Runtime

Quicklinks:
Quick Guide
Sample Code

Introduction

In the previous blog we’ve learned the basics about protecting a function with OAuth
What we didn’t learn: our function didn’t require a scope
In this blog, let’s learn how to enforce a scope in the JWT token and
– assign the scope to our user and call the function (REST client)
– call the function from a client application (node.js)

Overview

Small recap:
In the previous tutorial, we’ve created a very silly small function and we’ve used framework functionality to protect it with small configuration snippet.
Furthermore, we created a very basic instance of XSUAA and connect the function to it
That was enough to protect our function with OAuth 2.0
The FaaS Runtime took care of rejecting HTTP requests which didn’t send a valid JWT token
The FaaS Runtime used the connected XSUAA instance for validation
That was OK.
Fair enough for the beginning
Now, in today’s tutorial we’re going to
– add a scope
– and in the function we check if the scope exists in the JWT

These are the steps we’re going to cover:

  1. Create XSUAA
  2. Create Function
    Implement scope check
  3. Call Function in user centric scenario
  4. Call Function with client app

Prerequisites

1. Create Instance of XSUAA

We need an instance of XSUAA which is configured with a scope
We can update the existing one, or create a new one
In my example, I create a new instance with the following security configuration in a file called xs-security_faas_scope.json

{
  "xsappname" : "xsappforfaaswithscope",
  "tenant-mode" : "dedicated",
  "scopes": [
    {
      "name": "$XSAPPNAME.scopeforfunction",
      "description": "Scope required for accessing function"
    }
  ],
  "role-templates": [ { 
    "name"                : "FunctionRoleTemplate", 
    "description"         : "Role for serverless function", 
    "default-role-name"   : "RoleForFunction",
    "scope-references"    : ["$XSAPPNAME.scopeforfunction"]
  }],
  "authorities":["$XSAPPNAME.scopeforfunction"]
}

Explanation:

scopes
We define a scope.
For us, thie means: when calling the function, a JWT token is not enough. The caller must have the scope. If yes, the scope is contained in the token
However, in the security configuration we only define the scope, such that XSUAA knows about it
If things go well (see below) XSUAA will issue a JWT token that contains the scope
However, XSUAA doesn’t enforce the scope.

role-templates
A scope is nothing that can be assigned to a human user. For that, we need to define a “role”, along with “role-template”. That can be found in the Cloud Cockpit and an admin can assign the role to users

authorities
This attribute is meant for non-human users, for client apps, in a client-credentials scenario
With this statement, we accept that scope. A client application bound to XSUAA will get the scope in the JWT token

Create XSUAA

We create an instance of XSUAA and we use above JSON parameters
Can be done in the Cloud Cockpit, or on command line:

cf cs xsuaa application xsuaa_faas_scope -c xs-security_faas_scope.json

Create Service Key

As usual, we need a service key in order to reference it from FaaS and also from REST client (see below)

cf csk xsuaa_faas_scope xsuaa_faasscope_servicekey

See Appendix for all commands

2. Create Function

After creating an instance of XSUAA service, we need to register it in FaaS, and use it in the function definition.
We’ve learned that in a previous tutorial

Register the service in faas

For your convenience, find here the necessary commands

Always need to login first to Cloud Foundry and/or FaaS client

xfsrt-cli login

The convenient command to register a service interactively (the command line client will propose existing services, so we can choose. Precondition: only service instances with service key are proposed)

xfsrt-cli faas service register

After registration, in the console, we get the info which we need to specify in faas.json:
the service key and the GUID of the service instance

Use the service in FaaS project

faas.json

    "services": {
        "xsuaa-with-scope": {
            "type": "xsuaa",
            "instance": "<your GUID of service instance>",
            "key": "xsuaa_faasscope_servicekey"         
        }
    }

See Appendix for full faas.json

Configure Security

As learned in previous blog post, with below setting, we tell the FaaS runtime that we want them to enforce a valid JWT token

"triggers": {
   "prot": {
      "type": "HTTP",
         "function": "prot-func-with-scope",
            "auth": {
               "type": "xsuaa",
               "service": "xsuaa-with-scope"

Enforce scope

As mentioned earlier: the above setting will have the following consequence:

Whenever our function is called with HTTP trigger, the request must contain a valid JWT token
Otherwise the call is rejected by the FaaS runtime (with proper status code and error message) and our function code is not even invoked.

As such, the FaaS runtime enforces the authentication.
But it cannot take care of authorization in a generic way

For example, a function might have the following logic:
If EDIT scope is available, the function may accept POST requests, otherwise only GET
Such logic has to be implemented manually by us

We have to look into the JWT token, read the available scopes and check if the one which we require, is there.
The framework offers a convenience method which decodes the JWT token (if available)
We can then access the JWT payload as JSON object

const jwtToken = event.decodeJsonWebToken()
const jwtScopes = jwtToken.payload.scope

In order to check the scope, we need to know the exact scope name.
Background:

We defined the scope name as
$XSAPPNAME.scopeforfunction
The value of the variable $XSAPPNAME is generated on the cloud platform, as such we have to ask at runtime for the exact value.
The exact value is contained in the service key
And the service key is registered in FaaS
As such, we can ask FaaS for the xsuaa service
Then access the xsappname property

const xsuaaCredentials = await context.getServiceCredentialsJSON('xsuaa-with-scope')
const requiredScope = xsuaaCredentials.xsappname + '.scopeforfunction'

Based on above 2 code snippets we have the needed information:
Which scope do we expect (for whatever business case)
Which scope is contained in the JWT token

In our example, we require a scope for calling the function.
To check if the scope is sent, we just look into the array of available scopes
If not available, we reject the request.
In our case, the correct status code is 403, because use is authenticated, but lacking authorization
In order to set the status, we need to use the HTTP API (see previous blog post)

Below snippet puts the snippets together:

const xsuaaCredentials = await context.getServiceCredentialsJSON('xsuaa-with-scope')
const requiredScope = xsuaaCredentials.xsappname + '.scopeforfunction'

// read the JWT token and check required scope
const jwtToken = event.decodeJsonWebToken()
const jwtScopes = jwtToken.payload.scope
if(! jwtScopes.includes(requiredScope)){
   // fail with 403       
   const response = event.http.response // must be enabled in faas.json
   response.writeHead(403, {
   ...

See Appendix for full sample code

Note:
Before using the HTTP API, it must be enabled, which is done in the faas.json

Note:
SAP provides security libraries and there’s a little helper function, recommended to use for the scope check
The function I’m talking about:
@sap/xssec: checkScope(scope)
Please refer to Links section
In my examples, I’d like to keep the code free from dependencies (as there might be changes, etc), so I’m not using it. Please forgive
But for your convenience, I’ve created little sample code based on the library. See Appendix

Deploy function

To deploy the function, you might find this command useful (run from project directory)
xfsrt-cli faas project deploy

3. Call function in user centric scenario

After deploy, we want to test the security of our function

  • If we call the function in browser –> error
    Reason: no JWT token at all
  • If we call the function in REST client with OAuth flow –> error
    Reason: JWT token available, but doesn’t contain the required scope

Solution: To call the function we need to assign the required role to our user

The Role

In Cloud Foundry, authorization is controlled by means of Role Based Access Control (RBAC)
In the first step, we created an instance of XSUAA, based on a security configuration which defined a scope and a role-template.
After creating our instance of XSUAA, our role-definition has been added to the list of roles in the cockpit.
We can view it there

But viewing is not enough, we have to add the role to our user.

Assign Role to User

First, we create a new Role Collection, dedicated for your function
In the Cockpit, go to your subaccount->Security-> Role Collections

Second, we have to add the desired role to the new Role Collection
So we “edit” the new role collection and add our new role
Then press “save”

Third, we need to add our Role Collection to the Identity Provider (IDP)
Go to menu entry Trust Configuration
Click on default IDP
Enter the E-Mail Address of your user
Click “show Assignments”
Then “Assign Role Collection”

Test the function with human user

Now that our user has the required role, we can call the function
We don’t have a user interface in our scenario, so we use a REST client
See here for a detailed description about how to call an OAuth protected endpoint with REST client

Short description:
1. fetch a JWT token
2. Use token when calling our function

In my example, I’m using Postman which helps to do both steps in one request
To configure postman request, we need to view the service key of our xsuaa instance which we created above
To view the service key, we can use the following command (alternatively, use cockpit)

cf service-key xsuaa_faas_scope xsuaa_faasscope_servicekey

Back in Postman, we have to configure the request as follows

Method: GET
URL: The endpoint of our function, we get the info during deploy, or with xfsrt-cli faas project get
e.g. https://…-faas…..functions.xfs.cloud.sap/prot/
Don’t forget the slash at the end

Authorization: Oauth 2.0
Access Token:
press “Get new Access Token”
“Get Access Token” Dialog:
Grant Type: Password Credentials
Access Token URL: we copy the “url” property from the service key and append   /oauth/token
Example: https://<subaccount>.authentication…..hana.ondemand.com/oauth/token
Username: <your cloud user>
Password: <your cloud user password>
Client ID: copy the value of property clientid from service key
Client Secret: copy the value of property clientsecret from service key
Client Authentication: choose “Send as Basic Auth header”

Then press “Request Token” on the dialog
After getting the token response, press “Use Token”
Then, back in the main Postman window, press “Send” to send the request to the Function endpoint

As a result, we get our success message, which proves that the scope was included in the JWT token

Now we want to do the negative test, to check our function implementation works correctly and if the correct status code is sent
To do so, we have to remove the role from our user.
We can simply unassign the role collection in the “Trust Configuration”
Afterwards, we need to fetch a new JWT token via “Get New Access Token”
Then send the request again to call the function without scope

The result is 403, as coded by us, and the response message shows that the scope is not contained in the JWT token

Note:
In this scenario, we’re using only one instance of XSUAA
The function uses an instance of XSUAA to protect its endpoint
The user who calls the function, uses the same ClientID to call the function
This is OK, because we as developers of the function trust our user
In case that 2 instances of XSUAA are required, please refer to this blog for more information

 

4. Call function in client credentials scenario

Now we want to call the protected function from an application
Again, we’re using only one instance of XSUAA.
We bind our application to the same instance which is used to protect the function
(Sure, in a future blog, we’ll describe a scenario with different XSUAA instances)

In client credentials scenario, we may wonder: how to assign the required scope to the calling app?
We cannot assign it in the cockpit like we did for our (human) user.
To answer this question, somebody wrote this useful blog post

Our example is a bit different from above blog post, because both the function and the client app are “bound” to the same instance of XSUAA
The solution in our example:
When creating an instance of XSUAA, we defined a parameter called authorities
This is interesting and might be a new learning for us:
The function is attached to the XSUAA instance and the client app is bound to the same instance
As such, we would expect that the xsuaa instance (== OAuth client) trusts itself, because it is the same instance
In fact, trust is there. But the scope is not automatically there
In a client-credentials scenario, the scope must be explicitly granted, just like we assign a role to a user
To assign the scope to the client itself, no “grant” statement is required, but the authorities statement is necessary
This statement declares: our application wants to take the scope

"authorities":["$XSAPPNAME.scopeforfunction"]

 Create Client app

The only intention of our app is to call the function – and to send a JWT token which contains the required scope
Well… we’ve just learned how to get the required scope into the JWT token
Nothing special needs to be done in the app code
Just fetch a JWT token and then call the function
So we can skip explanations.
We can go ahead and copy the code from the Appendix

Note:
Don’t forget to replace the FUNC_HOST URL with the URL of your function

Note:
You might need to change the app name in the manifest.yml

Then deploy the clientapp with cf push
Finally, invoke the endpoint of our function caller app and hope to get a success message
http://functioncallerapp.cfapps.sap.hana.ondemand.com/call

To test the negative scenario, we have to remove the authorities statement from our xs-security_faas_scope.json file and then execute an update-service command in Cloud Foundry

cf update-service xsuaa_faas_scope -c xs-security_faas_scope.json

Note:
Instead of deleting the “authorities” statement, we can invalidate it by changing the name of the scope to anything non-existing
e.g.
“authorities”:[“$XSAPPNAME.scopeforfunctionXX”]

In fact, after running update-service, we can invoke our endpoint again and we get the expected error message:
It says that the function responded with 403, which was expected

Summary

We’ve learned that the FaaS runtime uses XSUAA for token validation, there’s no automatic check of available scopes
To manually check the available scopes, we can access the decoded JW T token
We need to access the scope prefix from xsuaa service key
In client-credentials scenario, we need to use the authorities statement to assign the scope to ourselves

Quick Guide

Few Code snippets

// access registered xsuaa service in order to get the value of variable $XSAPPNAME
const xsuaaCredentials = await context.getServiceCredentialsJSON('xsuaa-with-scope')
const requiredScope = xsuaaCredentials.xsappname + '.scopeforfunction'

// the decoded JWT token
const jwtTokenDecoded = event.decodeJsonWebToken()

// the raw JWT token
const jwtTokenRaw = event.auth.credentials

Links

Appendix 1: Console Commands

  • cf cs xsuaa application xsuaa_faas_scope -c xs-security_faas_scope.json
  • cf csk xsuaa_faas_scope xsuaa_faasscope_servicekey
    cf service-key xsuaa_client xsuaa_client_servicekey 
  • cf update-service xsuaa_faas_scope -c xs-security_faas_scope.json
  • xfsrt-cli login
  • xfsrt-cli faas service register
  • xfsrt-cli faas project deploy
  • xfsrt-cli faas project get
  • xfsrt-cli faas project logs

Appendix 2: All sample project files

For your convenience, I’m pasting the structure of my example project.
Folder names can be changed

Protected Function

These are the files required for the function
They are located in the function project folder, with “lib” subfolder

xs-security_faas_scope.json

{
  "xsappname" : "xsappforfaaswithscope",
  "tenant-mode" : "dedicated",
  "scopes": [
    {
      "name": "$XSAPPNAME.scopeforfunction",
      "description": "Scope required for accessing function"
    }
  ],
  "role-templates": [ { 
    "name"                : "FunctionRoleTemplate", 
    "description"         : "Role for serverless function", 
    "default-role-name"   : "RoleForFunction",
    "scope-references"    : ["$XSAPPNAME.scopeforfunction"]
  }],
  "authorities":["$XSAPPNAME.scopeforfunction"]
}

faas.json

{
	"project": "protectedwithscope",
	"version": "0.0.1",
	"runtime": "nodejs10",
	"library": "./lib",
    "functions": {
        "prot-func-with-scope": {
			"module": "functionImpl.js",
			"httpApi": true,
			"services": ["xsuaa-with-scope"]
        }
    },
    "triggers": {
        "prot": {
            "type": "HTTP",
            "function": "prot-func-with-scope",
			"auth": {
				"type": "xsuaa",
				"service": "xsuaa-with-scope"
			}			
		}
	},
	"services": {
	    "xsuaa-with-scope": {
			"type": "xsuaa",
			"instance": "d8819eda-41e6-40b0-9286-0afa1a59d12c",
			"key": "xsuaa_faasscope_servicekey"			
		}
	}
}          

package.json

{}

functionImpl.js

module.exports = async function (event, context) {  	
	// access registered xsuaa service in order to get the value of variable $XSAPPNAME
	const xsuaaCredentials = await context.getServiceCredentialsJSON('xsuaa-with-scope')
	const requiredScope = xsuaaCredentials.xsappname + '.scopeforfunction'

	// read the JWT token and check required scope
	const jwtToken = event.decodeJsonWebToken()
	const jwtScopes = jwtToken.payload.scope
	if(! jwtScopes.includes(requiredScope)){
		// HTTP API required for configuring response
		const response = event.http.response // must be enabled in faas.json
		response.writeHead(403, {
			'Content-Type': 'text/plain'
		});
		response.write(`Unauthorized: required scope '${requiredScope}' not found in JWT. Availbale scopes: '${jwtScopes}' ;-(`);
		response.end();
	} else{
		return `Reached protected function. Scope check successful: required scope '${requiredScope}' found in JWT. Availbale scopes: '${jwtScopes}'`
	}
}	

 

Function Caller Client App

These are the files required for a little node.js app which calls the protected function
Make sure to adapt the URL of the function in the application code

manifest.yml

The application is bound to the same instance of XSUAA which we registered in FaaS

---
applications:
- name: functioncallerapp
  memory: 128M
  buildpacks:
    - nodejs_buildpack
  services:
    - xsuaa_faas_scope

server.js

const express = require('express')
const app = express()
const https = require('https');

const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES)
const CREDENTIALS = VCAP_SERVICES.xsuaa[0].credentials
//oauth
const CLIENTID = CREDENTIALS.clientid; 
const SECRET = CREDENTIALS.clientsecret;
const OAUTH_HOST = CREDENTIALS.url;

const FUNC_HOST = 'https://abcd1234-...-...-faas-...-functions.xfs.cloud.sap' // adapt URL
const FUNC_TRIGGER = 'prot'


app.get('/call', function(req, res){       
   // call function endpoint 
   doCallEndpoint(FUNC_HOST, FUNC_TRIGGER, OAUTH_HOST, CLIENTID, SECRET)
   .then((response)=>{
      res.status(202).send('Successfully called remote endpoint. Function response: ' + response);
   }).catch((error)=>{
      res.status(500).send(`Error while calling remote endpoint: ${error} `);
   })
});


// helper method to call the endpoint
const doCallEndpoint = function(host, endpoint, token_uri, client_id, client_secret){
   return new Promise((resolve, reject) => {
      return fetchJwtToken(token_uri, client_id, client_secret)
         .then((jwtToken) => {
            const options = {
               host: host.replace('https://', ''),
              path:  `/${endpoint}/`,
               method: 'GET',
               headers: {
                  Authorization: 'Bearer ' + jwtToken
               }
            }
            
            const req = https.request(options, (res) => {
               res.setEncoding('utf8')
               const status = res.statusCode 
               const statusMessage = res.statusMessage               
               let response = ''
               res.on('data', chunk => {
                  response += chunk
                })
                
               res.on('end', () => {
                  if (status !== 200 && status !== 201) {
                     return reject(new Error(`Failed to call function. Message: ${status} - ${statusMessage} - ${response}`))
                  }
                  resolve(response)
               })
            
            });
            
            req.on('error', (error) => {
               return reject({error: error})
            });
         
            req.write('done')
            req.end()   
      })
      .catch((error) => {
         reject(error)
      })
   })
}

// jwt token required for calling REST api
const fetchJwtToken = function(token_uri, client_id, client_secret) {
   return new Promise ((resolve, reject) => {
      const options = {
         host:  token_uri.replace('https://', ''),
         path: '/oauth/token?grant_type=client_credentials&response_type=token',
         // path: '?grant_type=client_credentials&response_type=token',
         headers: {
            Authorization: "Basic " + Buffer.from(client_id + ':' + client_secret).toString("base64")
         }
      }

      https.get(options, res => {
         res.setEncoding('utf8')
         let response = ''
         res.on('data', chunk => {
           response += chunk
         })

         res.on('end', () => {
            try {
               const jwtToken = JSON.parse(response).access_token                
               resolve(jwtToken)
            } catch (error) {
               return reject(new Error('Error while fetching JWT token'))               
            }
         })
      })
      .on("error", (error) => {
         return reject({error: error})
      });
   })   
}

// Start server
app.listen(process.env.PORT || 8080, ()=>{})

package.json

{
  "dependencies": {
    "express": "^4.16.3"
  }
}

 

Appendix 3: Sample code using @sap/xssec

package.json

{
    "dependencies": {
        "@sap/xssec": "latest"
    }
}

functionImpl.js

const xssec = require('@sap/xssec')
const util = require('util');
const createSecurityContext = util.promisify(xssec.createSecurityContext);

module.exports = async function (event, context) {  	
	// access registered xsuaa service in order to get the value of variable $XSAPPNAME
	const xsuaaCredentials = await context.getServiceCredentialsJSON('xsuaa-with-scope')
	const requiredScope = xsuaaCredentials.xsappname + '.scopeforfunction'

	const jwtTokenRaw = event.auth.credentials
	const securityContext = await createSecurityContext(jwtTokenRaw, xsuaaCredentials)
	const jwtScopes = securityContext.getTokenInfo().getPayload().scope

	if(! securityContext.checkScope(requiredScope)){
		// HTTP API required for configuring response
		const response = event.http.response // must be enabled in faas.json
		response.writeHead(403, {
			'Content-Type': 'text/plain'
		});
		response.write(`Unauthorized: required scope '${requiredScope}' not found in JWT. Availbale scopes: '${jwtScopes}'`);
		response.end();
	} else{
		return `Reached protected function. Scope check successful: required scope found in JWT. All availbale scopes: '${jwtScopes}'`
	}
}

 

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