Introduction

Presented below is a sample approach for calling Google Cloud APIs using signed JWTs. The method is described here with code for on-prem ABAP. Here I use ABAP environment in SAP BTP (aka Steampunk). To get the understanding how it works read the previous post and continue here.

So, what has changed for ABAP Steampunk - in the cloud there is no STRUST transaction to create a PSE and import the private key. I was also not able to find released objects for signing etc. I tried to check SAP Business API if there is an API available which might help, but no luck. My own API for signing using Node app with jsonwebtoken seemed to be a quite quick approach to check.

Cloud Foundry part - API endpoint for signing JWTs

I prepared a simple API using Express with the help of sap/xssec, sap/xsenv and passport modules to use OAuth/client credentials as a protection. My jwt-backend has one route - /sign and accepts a JSON with a Google API endpoint which I would like to call. For simplicity of PoC all key details - private key id, private key and client email - are taken from environment variables. For production use BTP "Credential Store" service might be used.

[I use SAP BTP Free Tier, Cloud Foundry CLI v8] The code presented here is also available in the GitHub repo.

const express = require("express");
const jwt = require("jsonwebtoken");
const passport = require("passport");
const { JWTStrategy } = require("@sap/xssec");
const xsenv = require("@sap/xsenv");

let PORT = process.env.PORT || 5000;
passport.use(new JWTStrategy(xsenv.getServices({ uaa: { tag: "xsuaa" } }).uaa));

const app = express();
app.use(express.json());
app.use(passport.initialize());
app.use(passport.authenticate("JWT", { session: false }));
app.get("/sign", sign);

function sign(req, res, next) {
  const privateKeyId = process.env.JWT_BACKEND_KEY_ID;
  const clientEmail = process.env.JWT_BACKEND_CLIENT_EMAIL;
  let privateKey = process.env.JWT_BACKEND_PRIVATE_KEY;

  if (!privateKey || !clientEmail || !privateKeyId) {
    res.status(500).send("Missing values from environment variables");
  } else {
    privateKey = privateKey.replace(/\\n/gm, "\n");

    const payload = {
      iss: clientEmail,
      sub: clientEmail,
      aud: req.body.endpoint,
    };

    const signed = jwt.sign(payload, privateKey, {
      algorithm: "RS256",
      expiresIn: 3600,
      keyid: privateKeyId,
    });

    res.status(200).send(signed);
  }
}

app.listen(PORT, () => {
  console.log(`Server is up and running on ${PORT} ...`);
});

xs-security.json for this app is simple:

{
  "xsappname": "jwt-backend",
  "tenant-mode": "dedicated"
}

Creating a XSUUA service instance and a service key for it:

cf create-service xsuaa application jwt-backend-xsuaa -c xs-security.json
cf create-service-key jwt-backend-xsuaa jwt-backend-key

manifest.yml for the app:

---
version: 1
applications:
  - name: jwt-backend
    memory: 128M
    path: jwt-backend
    routes:
      - route: jwt-backend.cfapps.us10.hana.ondemand.com
    instances: 1
    command: node index.js
    buildpacks:
      - nodejs_buildpack
    services:
      - jwt-backend-xsuaa

cf push the app and all pieces are in BTP:

The next step - setting the environment variables required by the code. The values are taken from the Google Cloud service account JSON file.

cf set-env jwt-backend JWT_BACKEND_KEY_ID "4df......................d3e"
cf set-env jwt-backend JWT_BACKEND_CLIENT_EMAIL "abap-backend@jwt-call-sample.iam.gserviceaccount.com"
cf set-env jwt-backend JWT_BACKEND_PRIVATE_KEY "-----BEGIN PRIVATE KEY-----\nMIIEvAI......."
cf restage jwt-backend

A quick test in browser - Unauthorized is the right response...

...because we need an OAuth token. Additionally, a JSON body with the Google API endpoint need to be provided. Before going to ABAP, a quick check via Postman with a token generated via client credentials flow. A client id/secret/token URL are taken from the service key generated for the jwt-backend-xsuaa service instance in the previous steps.

The body of the request:

{
  "endpoint": "https://cloudresourcemanager.googleapis.com/"
}

The response contains my signed token:

ABAP Cloud part

Now the ABAP in the cloud side. The first, simple approach for testing (method get_signend_jwt_by_url) uses just URLs for destinations and perform an OAuth client credentials flows explicitly. At the beginning the token endpoint is called (url taken from the service key of my jwt-backend-key for the created XSUAA service instance), next the token is attached as a bearer header and the final request to the Google API endpoint (Cloud Resource Manager in this case) is performed.

The second approach (method get_signed_jwt_by_arrangement) uses a communication arrangement. This method requires design and runtime artifacts. Firstly, in Eclipse I created my outbound service:

Next, a communication scenario which uses my service:

Now the runtime part. In ABAP cloud dashboards there is a group for communication management:

I first created a communication system pointing to my backend, JWT signing app:

Here I need to provide client id/client secret in the "Users for Outbound Communication" tab:

Next I created a communication arrangement, based on my design-time communication scenario:

All the puzzles together allows to use them in code to perform an OAuth client credentials flow - firstly the scenario is read, then the arrangement. No need to code OAuth calls.

The whole class with both methods:

CLASS zcl_google_api_jwt DEFINITION PUBLIC FINAL CREATE PUBLIC.
  PUBLIC SECTION.
    INTERFACES if_oo_adt_classrun.

  PRIVATE SECTION.
    METHODS:
      get_signend_jwt_by_url RETURNING VALUE(result) TYPE string
                             RAISING   cx_http_dest_provider_error
                                       cx_web_http_client_error,

      get_signed_jwt_by_arrangement RETURNING VALUE(result) TYPE string
                                    RAISING   cx_http_dest_provider_error
                                              cx_web_http_client_error,

      get_project_details_json IMPORTING signed_jwt    TYPE string
                               RETURNING VALUE(result) TYPE string
                               RAISING   cx_web_http_client_error
                                         cx_http_dest_provider_error.
ENDCLASS.

CLASS zcl_google_api_jwt IMPLEMENTATION.

  METHOD if_oo_adt_classrun~main.

    DATA: signed_jwt      TYPE string,
          project_details TYPE string.

    TRY.
        " Approach no 1: Call Google API using direct URLs
        signed_jwt = get_signend_jwt_by_url( ).
        project_details = get_project_details_json( signed_jwt ).

        " Approach no 2: call Google API using a communication arrangement
        signed_jwt = get_signed_jwt_by_arrangement( ).
        project_details = get_project_details_json( signed_jwt ).

        out->write( project_details ).

      CATCH cx_root INTO DATA(exception).
        out->write( exception->get_text( ) ).
    ENDTRY.

  ENDMETHOD.



  METHOD get_signend_jwt_by_url.

    " Call Google API using direct URLs

    TYPES:
      BEGIN OF ty_token_json,
        access_token  TYPE string,
        token_type    TYPE string,
        id_token      TYPE string,
        refresh_token TYPE string,
        expires_in    TYPE i,
        scope         TYPE string,
        jti           TYPE string,
      END OF ty_token_json.

    " 1. Fetch token from XSUUA jwt-backend-xsuaa, details are in the service key
    DATA(destination) = cl_http_destination_provider=>create_by_url(
      'https://main-2f80y5al.authentication.us10.hana.ondemand.com/oauth/token' ).

    DATA(http_client) = cl_web_http_client_manager=>create_by_http_destination( destination ).
    DATA(request) = http_client->get_http_request( ).
    DATA(body) = |grant_type=client_credentials|.

    request->set_header_fields( VALUE #(
      ( name = 'Content-Type'
        value = 'application/x-www-form-urlencoded; charset=UTF-8' )
      ( name = 'Accept'
        value = 'application/json' )
      ( name = 'Content-Length'
        value = strlen( body ) ) ) ).

    request->set_authorization_basic(
      i_username = 'sb-jwt-backend...'
      i_password = 's......' ).

    request->append_text( body ).
    DATA(token_response) = http_client->execute( if_web_http_client=>post ).
    DATA(token) = VALUE ty_token_json( ).

    /ui2/cl_json=>deserialize(
      EXPORTING
        json = token_response->get_text( )
      CHANGING
        data = token ).

    " 2. Get signed JWT using deployed API
    destination = cl_http_destination_provider=>create_by_url(
      'https://jwt-backend.cfapps.us10.hana.ondemand.com/sign' ).

    http_client = cl_web_http_client_manager=>create_by_http_destination( destination ).
    request = http_client->get_http_request( ).
    DATA(bearer) = |Bearer { token-access_token }|.

    request->set_header_field(
      i_name  = 'Authorization'
      i_value = bearer ).

    DATA(json_body) = '{ "endpoint": "https://cloudresourcemanager.googleapis.com/" }'.

    request->set_header_fields( VALUE #(
      ( name = 'Content-Type'
        value = 'application/json' )
      ( name = 'Content-Length'
        value = strlen( json_body ) ) ) ).

    request->set_text( i_text = CONV #( json_body )
                       i_length = strlen( json_body ) ).

    DATA(response) = http_client->execute( if_web_http_client=>get ).
    result = response->get_text( ).

  ENDMETHOD.


  METHOD get_signed_jwt_by_arrangement.

    DATA(communication_system) = 'JWT_SIGNER'.
    DATA(arrangement_factory) = cl_com_arrangement_factory=>create_instance( ).

    DATA(comm_arrangement_range) = VALUE if_com_arrangement_factory=>ty_query-cs_id_range(
      ( sign = 'I' option = 'EQ' low = communication_system ) ).

    arrangement_factory->query_ca(
      EXPORTING
        is_query = VALUE #( cs_id_range = comm_arrangement_range )
      IMPORTING
        et_com_arrangement = DATA(arrangements) ).

    DATA(arrangement) = arrangements[ 1 ].

    DATA(destination) = cl_http_destination_provider=>create_by_comm_arrangement(
      comm_scenario  = 'ZGOOGLE_API'
      service_id     = 'ZGOOGLE_API_REST'
      comm_system_id = arrangement->get_comm_system_id( ) ).

    DATA(http_client) = cl_web_http_client_manager=>create_by_http_destination( destination ).
    DATA(request) = http_client->get_http_request( ).
    DATA(json_body) = '{ "endpoint": "https://cloudresourcemanager.googleapis.com/" }'.

    request->set_header_fields( VALUE #(
      ( name = 'Content-Type'
        value = 'application/json' )
      ( name = 'Content-Length'
        value = strlen( json_body ) ) ) ).

    request->set_text( i_text = CONV #( json_body )
                       i_length = strlen( json_body ) ).


    DATA(response) = http_client->execute( if_web_http_client=>get ).
    result = response->get_text( ).
  ENDMETHOD.


  METHOD get_project_details_json.

    DATA(destination) = cl_http_destination_provider=>create_by_url(
      'https://cloudresourcemanager.googleapis.com/v1/projects/jwt-call-sample' ).

    DATA(http_client) = cl_web_http_client_manager=>create_by_http_destination( destination ).
    DATA(request) = http_client->get_http_request( ).

    request->set_header_field(
      i_name = 'Authorization'
      i_value = |Bearer { signed_jwt }| ).

    DATA(response) = http_client->execute( if_web_http_client=>get ).
    result = response->get_text( ).

  ENDMETHOD.

ENDCLASS.

Both methods produces a JSON with my Google Cloud project details:

Next steps

The next part of this series moves this signing app to Kubernetes using SAP BTP Kyma. The last part takes the Kyma approach but with the use of a serverless Function.