Technology Blogs by SAP
Learn how to extend and personalize SAP applications. Follow the SAP technology blog for insights into SAP BTP, ABAP, SAP Analytics Cloud, SAP HANA, and more.
cancel
Showing results for 
Search instead for 
Did you mean: 
zhongjie_fang
Explorer
This blog post is to present a solution how external systems use OAuth2 password grant to call applications deployed in Cloud Foundry environment.

The screenshots in this blog post are from real development systems.

Background


The standard architecture of publishing SaaS applications in Cloud Foundry environment is like below.




  1. In Cloud Foundry environment, usually we use one subaccount for deploying business apps, and customers create their own subaccounts for subscription purpose. In this blog post, the former subaccount is named as provider subaccount; the latter is named as consumer subaccount.

  2. The business apps are published by SaaS Manager from provider subaccount, and customers can subscribe the SaaS apps from consumer subaccount. In the meanwhile, the authorization roles in XSUAA instance are propagated to consumer subaccount, so customers can create role collections and assign roles to app users.

  3. In business apps, we need to implement the SaaS interface (/callback/v1.0/tenants/...) to specify subscription URL. We usually use App router URL as subscription URL, thereby App router can work as the single point of entry for our apps. In runtime, App router navigates HTTP requests to specific apps.


Above architecture is designed for SaaS subscription UI flow. Based on this deployment, customers can subscribe SaaS apps, and logon with their own custom identity service (IdP). But what if customer would like to call SaaS apps from program? Above architecture works for UI flow, but doesn't work for machine to machine communication. This blog post tries to give an answer to the requirement that some customers would like to call our SaaS apps from their own program deployed in whatever external systems.

Solution Architecture


In order to call the apps deployed in provider subaccount, we need a mechanism to get client credentials for consumer subaccount. Service Broker API is designed for this purpose. Service Broker API can help us create a clone of XSUAA instance to generate tenant specific credentials, so consumer subaccount can use its own client credentials for authentication.

SAP delivered the Service Broker Framework (SBF) as a default implementation of Service Broker. This blog post makes use of SBF to build up password grant flow. The overall architecture is as below.




  1. Here service broker app is for creating a clone of XSUAA instance to provide client credentials for consumer subaccount. In the meanwhile, service broker also propagate authorization roles (defined in XSUAA), which can be used for roles assignment in consumer subaccount.

  2. In consumer subaccount, customer can register the Service Broker API for business app in marketplace, then service instance and service instance key can be created. The service instance key includes information of client credentials (client ID, client secret, and authentication URL) as well as business app URL. Customers can use these information in OAuth2 password grant flow for calling business apps (deployed in provider subaccount).


Demonstration


Below sections show steps to build up password grant flow. All key source code and configuration scripts are listed in appendix.

1 Create an XSUAA instance with broker plan in provider subaccount


Logon a provider subaccount in Cloud Foundry environment, and create an XSUAA instance ‘callapiwithsrvbroker-uaa’ based on below configuration (or see xs-security.json in appendix). This configuration defines only one scope 'GetConsumerToken'.
{
"xsappname": "callapiwithsrvbroker-uaa",
"description": "Roles for callapiwithsrvbroker",
"tenant-mode": "shared",
"scopes": [
{
"name": "$XSAPPNAME.GetConsumerToken",
"description": "GetConsumerToken scope"
}
],
"role-templates": [
{
"name": "GetConsumerToken",
"description": "Role for GetConsumerToken",
"scope-references": [
"$XSAPPNAME.GetConsumerToken"
]
}
]
}

When creating the XSUAA instance, please make sure to choose 'broker' as the service plan, which is required by Service Broker API.

2 Create an Audit Log Service instance in provider subaccount


Create an Audit Log Service instance with name 'callapiwithsrvbroker-auditlog'. Please note audit logging is mandatory for deploying service broker.

3 Deploy a business app/service in provider subaccount


Deploy the Spring Boot application 'serviceprovider'. The main files (pom.xml, ServiceProviderController.java, SecurityConfig.java, and manifest.yml) can be found in appendix.

  1. This app exposes only one endpoint: when receiving HTTP request targeted at '/getconsumertoken', this app returns access token it receives (see ServiceProviderController.java).

  2. This app makes use of Spring Framework Security to check if the caller is authorized to execute the endpoint; only caller with authorization scope 'GetConsumerToken' is eligible to get results (see SecurityConfig.java).

  3. In root path of the project, 'manifest.yml' specifies this app is bound to the XSUAA instance 'callapiwithsrvbroker-uaa' created previously.


Under the root path of the project, run below commands to build and deploy the app into Cloud Foundry environment.
mvn clean install
cf push

4 Deploy the service broker in provider subaccount


Deploy the Service Broker 'servicebroker'. Main files (catalog.json, package.json, and manifest.yml) can be found in appendix.

  1. Refer to @sap/sbf official document to understand technical details and how to deploy a service broker.

  2. In directory '/servicebroker/', catalog.json defines service identity and service plans. In this example, only one service plan 'default' is defined. The services.id and services.plans[].id  should be globally unique within the platform marketplace, and these UUIDs can be generated via command 'npx gen-catalog-ids' (see https://preview.npmjs.com/package/@sap/sbf#create-the-service-catalog)

  3. manifest.yml specifies deployment details. Environment variable SBF_BROKER_CREDENTIALS defines the user and password. For simplicity, this example uses plain text password. For security consideration, we can also use hashed password (see https://preview.npmjs.com/package/@sap/sbf#generate-secure-broker-password). SBF_SERVICE_CONFIG.per_plan.default.url refers to the business app which the service broker works for, and this URL matches the business app URL defined by 'route' in /serviceprovider/manifest.yml.

  4. manifest.yml also specifies this service broker is bound to services callapiwithsrvbroker-uaa and callapiwithsrvbroker-auditlog created previously.

  5. Run cf push to deploy the service broker in Cloud Foundry environment.


5 Register service broker in consumer subaccount


Up to now all deployment in provider subaccount is done. Business app and service broker are ready, and next we can use the service broker in consumer subaccount.

Logon consumer subaccount, and run below command.
cf create-service-broker callapiwithsrvbroker-srvbroker-fzjconsumer broker_user broker_password [service broker URL] --space-scoped

This command registers the service broker with an unique name 'callapiwithsrvbroker-srvbroker-fzjconsumer' in marketplace. Here the 'service broker URL' should match the service broker route defined in provider subaccount.

After the service broker is registered, we can run command 'cf marketplace' to see the available services in marketplace (refer to below screenshot). This command shows service name (callapiwithsrvbroker-srvprovider-fzj), service plan (default), and service broker name (callapiwithsrvbroker-srvbroker-fzjconsumer).



6 Create service instance and service instance key in consumer subaccount


Still in the consumer subaccount, run below commands to create service instance and check if the service instance is created successfully.
cf create-service callapiwithsrvbroker-srvprovider-fzj default callapiwithsrvbroker-srvprovider-instance
cf services

Then run below command to create service instance key.
cf create-service-key callapiwithsrvbroker-srvprovider-instance callapiwithsrvbroker-srvprovider-instancekey

Finally run below command (or use SAP Cloud Platform Cockpit) to see instance key details.
cf service-key callapiwithsrvbroker-srvprovider-instance callapiwithsrvbroker-srvprovider-instancekey

The information of service instance key includes client ID (uaa.clientid as in below screenshot), client secret (uaa.clientsecret), authentication URL (uaa.url), and business app URL (url). Customers can make use of these information in password grant flow.


With service instance key, we can already apply client credentials grant - use client ID and client secret to exchange access token and use the access token to call our business app. Because we haven't specify any authorities in service broker deployment (refer to https://preview.npmjs.com/package/@sap/sbf#additional-service-configuration for 'authorities' configuration), the access token won't include any authorization roles or scopes. So calling the business app with this access token won't return any results (the business app requires authorization scope 'GetConsumerToken').

7 Check the authorization roles in consumer subaccount


When we logon consumer subaccount, we can find service broker already propagates the authorization role 'GetConsumerToken' here (as in below screenshot).


In development phase, we might adapt the XSUAA by adding or changing authorization roles/scopes, and service broker will help us propagate authorization changes to consumer subaccount in real time.

8 Setup IdP with OpenID Connect in consumer subaccount


In this section, we will establish an IdP in consumer subaccount, and leverage this IdP in password grant flow.

In SAP Cloud Foundry enviroment, IdP can be setup with SAML2 protocol or OIDC (OpenID Connect) protocol. As explained by the XSUAA development team, SAML2 protocol is mainly designed for UI flow, and it's not suitable for password grant, so it's mandatory to adopt OIDC to establish IdP.

In SAP Cloud Platform Cockpit, we can enter the consumer subaccount, choose the sidebar menu Security/Trust Configuration, and click button 'Establish Trust' to select an available IdP (as in below screenshot), then the selected IdP will be created via OIDC protocol.


After OIDC enabled IdP is setup, we can create a user in IdP, and assign the authorization role 'GetConsumerToken' to the user, then this user is authorized to call our business app.

9 Test OAuth2 password grant in postman


In OAuth2 password grant flow, we make use of user & password + client ID & client secret to exchange an access token. This access token includes tenant information (consumer tenant ID and subaccount ID) as well as user authorities (roles and scopes).

Please refer to postman scripts (see GetAPIAccessToken.postman_collection.json and GetAPIAccessTokenEnv.postman_environment.json in appendix) to test password grant. In HTTP request body, we should use {"origin":"sap.custom"} for 'login_hint' (see below screenshot), which represents we are using the custom IdP established previously.



10 Test OAuth2 password grant in program


The sample app 'serviceconsumer' uses password grant flow to get an access token, and use the access token to call our business app. The main logic of password grant is like below (see serviceconsumercontroller.java in appendix).
private String getToken() throws Exception {
HttpResponse<JsonNode> jsonResponse = Unirest.post("<authentication URL in service instance key>/oauth/token")
.header("accept", "application/json")
.field("grant_type", "password")
.field("username", "<user in IdP>")
.field("password", "<password in IdP>")
.field("client_id", "<client ID in service instance key>")
.field("client_secret", "<client secret in service instance key>")
.field("login_hint", "{\"origin\":\"sap.custom\"}")
.asJson();
if (jsonResponse.getStatus() != HttpStatus.SC_OK) {
throw new Exception("Invalid response from UAA. Status code: " + String.valueOf(jsonResponse.getStatus()));
}
JSONObject response = jsonResponse.getBody().getObject();
Object accessToken = response.get("access_token");
if (accessToken == null) {
throw new Exception("No access token found. Response from UAA: " + response.toString());
}
return accessToken.toString();
}

Below source code is about calling the business app using the access token.
private HttpResponse<String> requestService(String token) throws UnirestException, MalformedURLException {
String productServiceRootUrl = "<business app URL in service instance key>";
String productServiceUrl = new URL(new URL(productServiceRootUrl), "/getconsumertoken").toString();
return Unirest.get(productServiceUrl).header("Authorization", "Bearer " + token).asString();
}

The app 'serviceconsumer' can be run in local machine. When the app receives the HTTP request 'localhost:8080/getconsumertoken', it will try to call the remote business app deployed in Cloud Foundry environment and print the returns in browser.

Summary


This blog post demonstrates how external systems can use OAuth2 password grant to call apps deployed in Cloud Foundry environment.

In the demo, a business app is deployed in provider subaccount in Cloud Foundry environment, and service broker is used to generate client credentials for consumer subaccount. Finally we can use client credentials in password grant flow for call the business app.

Appendix


1 XSUAA configuration


xs-security.json
{
"xsappname": "callapiwithsrvbroker-uaa",
"description": "Roles for callapiwithsrvbroker",
"tenant-mode": "shared",
"scopes": [
{
"name": "$XSAPPNAME.GetConsumerToken",
"description": "GetConsumerToken scope"
}
],
"role-templates": [
{
"name": "GetConsumerToken",
"description": "Role for GetConsumerToken",
"scope-references": [
"$XSAPPNAME.GetConsumerToken"
]
}
]
}

2 Main files of app 'serviceprovider'


pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.4.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.callapiwithsrvbroker</groupId>
<artifactId>serviceprovider</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>serviceprovider</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<!-- https://mvnrepository.com/artifact/com.sap.cloud.security.xsuaa/xsuaa-spring-boot-starter -->
<dependency>
<groupId>com.sap.cloud.security.xsuaa</groupId>
<artifactId>xsuaa-spring-boot-starter</artifactId>
<version>2.6.1</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

package com.callapiwithsrvbroker.serviceprovider.controller;

import com.sap.cloud.security.xsuaa.token.SpringSecurityContext;
import com.sap.cloud.security.xsuaa.token.Token;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ServiceProviderController {
@GetMapping("getconsumertoken")
String getConsumerToken(){
Token token = SpringSecurityContext.getToken();
return token.getAppToken();
}

}

package com.callapiwithsrvbroker.serviceprovider.config;

import com.sap.cloud.security.xsuaa.XsuaaServiceConfiguration;
import com.sap.cloud.security.xsuaa.token.TokenAuthenticationConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.Jwt;

import static org.springframework.http.HttpMethod.*;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

private final XsuaaServiceConfiguration xsuaaServiceConfiguration;

@Autowired
public SecurityConfig(XsuaaServiceConfiguration xsuaaServiceConfiguration) {
this.xsuaaServiceConfiguration = xsuaaServiceConfiguration;
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(GET, "/getconsumertoken/**").hasAuthority("GetConsumerToken") // checks scope $XSAPPNAME.GetConsumerToken
.anyRequest().denyAll() // denies anything not configured above
.and()
.oauth2ResourceServer()
.jwt()
.jwtAuthenticationConverter(getJwtAuthoritiesConverter());
}

Converter<Jwt, AbstractAuthenticationToken> getJwtAuthoritiesConverter() {
TokenAuthenticationConverter converter = new TokenAuthenticationConverter(xsuaaServiceConfiguration);
converter.setLocalScopeAsAuthorities(true);
return converter;
}

}

manifest.yml
applications:
- name: callapiwithsrvbroker-srvprovider
routes:
- route: <provider subaccount ID>-callapiwithsrvbroker-srvprovider.<domain>
path: target/serviceprovider-0.0.1-SNAPSHOT.jar
buildpack: java_buildpack
memory: 1024M
services:
- callapiwithsrvbroker-uaa

3 Main files of Service Broker 'servicebroker'


catalog.json
{
"services": [
{
"name": "callapiwithsrvbroker-srvprovider",
"description": "callapiwithsrvbroker-srvprovider",
"bindable": true,
"plans": [
{
"name": "default",
"description": "callapiwithsrvbroker-srvprovider plan",
"id": "240c1326-d380-4185-b401-cca5dc02f6a7"
}
],
"id": "10e75929-cef8-4ab7-b999-556877696aeb"
}
]
}

package.json
{
"name": "callapiwithsrvbroker-srvbroker",
"version": "1.0.0",
"description": "callapiwithsrvbroker-srvbroker",
"main": "server.js",
"scripts": {
"start": "start-broker",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"@sap/sbf": "^6.2.0"
},
"engines": {
"node": "^12.0.0"
}
}

manifest.yml
---
applications:
- name: callapiwithsrvbroker-srvbroker
host: callapiwithsrvbroker-srvbroker
memory: 128M
path: /
services:
- callapiwithsrvbroker-uaa
- callapiwithsrvbroker-auditlog
health-check-type: http
health-check-http-endpoint: /health
env:
SBF_CATALOG_SUFFIX: fzj
SBF_BROKER_CREDENTIALS: >
{
"broker_user": "broker_password"
}
SBF_SERVICE_CONFIG: >
{
"callapiwithsrvbroker-srvprovider": {
"extend_credentials": {
"shared": {
"vendor": "SAP"
},
"per_plan": {
"default": {
"url": "<Route defined in manifest.yml of serviceprovider>"
}
}
}
}
}

4 Postman scripts


GetAPIAccessToken.postman_collection.json
{
"info": {
"_postman_id": "955391ec-2c71-4b9e-a907-db99c94db696",
"name": "GetAPIAccessToken",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "GetToken",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "grant_type",
"value": "password",
"type": "text"
},
{
"key": "client_id",
"value": "{{client_id}}",
"type": "text"
},
{
"key": "client_secret",
"value": "{{client_secret}}",
"type": "text"
},
{
"key": "username",
"value": "{{username}}",
"type": "text"
},
{
"key": "password",
"value": "{{password}}",
"type": "text"
},
{
"key": "login_hint",
"value": "{\"origin\":\"sap.custom\"}",
"type": "text"
}
]
},
"url": {
"raw": "{{url}}/oauth/token?grant_type=password",
"host": [
"{{url}}"
],
"path": [
"oauth",
"token"
],
"query": [
{
"key": "grant_type",
"value": "password"
}
]
}
},
"response": []
}
],
"protocolProfileBehavior": {}
}

GetAPIAccessTokenEnv.postman_environment.json.
{
"id": "cfe8a93a-dc39-45d0-94d4-03a083471aac",
"name": "GetAPIAccessTokenEnv",
"values": [
{
"key": "url",
"value": "uaa.url in service key",
"enabled": true
},
{
"key": "client_id",
"value": "clien_id in service key",
"enabled": true
},
{
"key": "client_secret",
"value": "3aSwBuzLPkikcI3Nl8CXGw1MkW0=",
"enabled": true
},
{
"key": "username",
"value": "username in IDP",
"enabled": true
},
{
"key": "password",
"value": "passowrd",
"enabled": true
}
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2020-09-29T02:21:16.870Z",
"_postman_exported_using": "Postman/7.33.0"
}

5 Main files of Spring Boot app 'serviceconsumer'


pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.4.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.callapiwithsrvbroker</groupId>
<artifactId>serviceconsumer</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>serviceconsumer</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>com.mashape.unirest</groupId>
<artifactId>unirest-java</artifactId>
<version>1.4.9</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

package com.callapiwithsrvbroker.serviceconsumer.controller;

import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.JsonNode;
import com.mashape.unirest.http.Unirest;
import com.mashape.unirest.http.exceptions.UnirestException;
import org.apache.http.HttpStatus;
import org.json.JSONObject;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.net.MalformedURLException;
import java.net.URL;

@RestController
public class serviceconsumercontroller {
@GetMapping("getconsumertoken")
String getConsumerToken(){
HttpResponse<String> serviceResponse;
try {
String token = getToken();
serviceResponse = requestService(token);
} catch (Exception e) {
return "Error";
}
return serviceResponse.getBody().toString();
}


private String getToken() throws Exception {
HttpResponse<JsonNode> jsonResponse = Unirest.post("<authentication URL in service instance key>/oauth/token")
.header("accept", "application/json")
.field("grant_type", "password")
.field("username", "<user in IdP>")
.field("password", "<password in IdP>")
.field("client_id", "<client ID in service instance key>")
.field("client_secret", "<client secret in service instance key>")
.field("login_hint", "{\"origin\":\"sap.custom\"}")
.asJson();
if (jsonResponse.getStatus() != HttpStatus.SC_OK) {
throw new Exception("Invalid response from UAA. Status code: " + String.valueOf(jsonResponse.getStatus()));
}
JSONObject response = jsonResponse.getBody().getObject();
Object accessToken = response.get("access_token");
if (accessToken == null) {
throw new Exception("No access token found. Response from UAA: " + response.toString());
}
return accessToken.toString();
}

private HttpResponse<String> requestService(String token) throws UnirestException, MalformedURLException {
String productServiceRootUrl = "<business app URL in service instance key>";
String productServiceUrl = new URL(new URL(productServiceRootUrl), "/getconsumertoken").toString();
return Unirest.get(productServiceUrl).header("Authorization", "Bearer " + token).asString();
}
}

 
3 Comments