Introduction

This is the 3rd part of the series related to calling Google Cloud APIs using signed JWTs. The first part explains the general method used and uses on-premise ABAP in S4/HANA. The second one moves the code to the cloud and the APIs are called from ABAP Environment in SAP BTP with the help of a Cloud Foundry app as a backend for signing functionality.

Here I continue using ABAP in the cloud, but this time instead of Cloud Foundry environment I wanted to try out the way with Kubernetes/Kyma Environment and put the signing functionality into a "regular" backend based on a k8s deployment/service).

GitHub repo with all samples: https://github.com/wozjac/samples-abap-cloud-google-api

Kyma

First step was adjusting the Node.js app I prepared in the previous post for Cloud Foundry. It was using Passport, xsenv and xssec modules but now they can be removed as they are CF specific. It does not mean that there will be no security applied - OAuth will be used using Kyma-specific resources.

Node.js signing API endpoint

const express = require("express");
const jwt = require("jsonwebtoken");

let PORT = process.env.PORT || 5000;

const app = express();
app.use(express.json());
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} ...`);
});

Docker image

To make it usable for Kubernetes, a Docker image is needed. I prepared a simple one:

FROM node:17.8.0-slim

ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV

ARG PORT=5000
ENV PORT=$PORT

RUN groupadd -g 10001 app && \
  useradd -u 10000 -g app app

COPY package.json package-lock.json ./
RUN npm ci
COPY . ./

USER 10000:10001

EXPOSE ${PORT}
CMD [ "npm", "start" ]

Next, prepared the image and pushed it to my account in Docker Hub:

docker build -t wozjac/jwt-backend-api:1.0.0 .
docker push wozjac/jwt-backend-api:1.0.0

Kubernetes service

All is ready to deploy the deployment/service (check the GitHub for the yaml files). I am using my Kyma cluster host c-531ecbb.kyma.ondemand.com in several places in the config. The namespace I use is jwt-backend-api. api-service.yaml contains k8s Service and Deployment with a ReplicaSet (see GitHub for the source):

kubectl apply -f namespace.yaml
kubectl apply -f api-service.yaml

Deployed resources are also also nicely visible in the Kyma dashboard:

API exposure with APIRule

The signing backend need to be exposed to the outside using API Rule - this is a Kyma-specific custom resource responsible for exposing services using Istio Gateway. Here just for testing OAuth is not added yet, the handler just allow all traffic.

apiVersion: gateway.kyma-project.io/v1beta1
kind: APIRule
metadata:
  name: jwt-backend-api-apirule
  namespace: jwt-backend-api
spec:
  gateway: kyma-system/kyma-gateway
  host: jwt-backend-api.c-531ecbb.kyma.ondemand.com
  rules:
    - accessStrategies:
        - config: {}
          handler: allow
      methods:
        - GET
      path: /.*
  service:
    name: jwt-backend-api-service
    port: 5000

After kubectl apply -f apirule.yaml the API rule was shown in the dashboard:

BTW in the Services page there is a nice visualization of the resources created:

Now let's test the service. My signing endpoints is on /sign path and in the API rule I used jwt-backend-api.c-531ecbb.kyma.ondemand.com host (jwt-backend-api + cluster domain).

The backend was responding, the error is because required environment variables are not yet set.

Kubernetes secret with Google Cloud service account details

As described in the first blog post, for the Google Cloud project we can download a JSON file with data required for signing JWTs. They should be passed as environment variables to the signing service with the use of Kubernetes secret. Hint: this is a simplified sample, there are more sophisticated ways of handling sensitive data.

My service account JSON file look like this:

I extracted the required data - private_key_id, private_key and client_email to separate files with values only inside; file names were respectively PRIVATE_KEY_ID, PRIVATE_KEY AND CLIENT_EMAIL.

Next I created the secret directly from them. Secrets store data as key=value pairs and here the keys will be the filenames:

kubectl create secret generic jwt-backend-api-secret --from-file=CLIENT_EMAIL --from-file=PRIVATE_KEY_ID
 --from-file=PRIVATE_KEY --namespace jwt-backend-api

Now for the deployment I added section for my environment variables in the file api-service.yaml

env:
  - name: JWT_BACKEND_KEY_ID
    valueFrom:
      secretKeyRef:
        name: jwt-backend-api-secret
        key: PRIVATE_KEY_ID
  - name: JWT_BACKEND_CLIENT_EMAIL
    valueFrom:
      secretKeyRef:
        name: jwt-backend-api-secret
        key: CLIENT_EMAIL
  - name: JWT_BACKEND_PRIVATE_KEY
    valueFrom:
      secretKeyRef:
        name: jwt-backend-api-secret
        key: PRIVATE_KEY

I recreated the service and now testing via Postman...

...and it responded with a signed JWT. The first part - a working service exposed via APIRule was done, but it was not secured.

Adding OAuth to the service

I wanted to protect the service using OAuth (client credentials flow). For this purpose again Kyma offers Custom Resources to use. First I needed to create an OAuth client:

apiVersion: hydra.ory.sh/v1alpha1
kind: OAuth2Client
metadata:
  name: jwt-backend-api-client
  namespace: jwt-backend-api
spec:
  grantTypes:
    - "client_credentials"
  scope: "sign"
  secretName: jwt-backend-api-client

Note: I had to provide a scope (a non-empty string is required), even though I am not using it later.

When this file is applied, it will create a secret with a name taken from secretName and this secret can be used to get OAuth client ID and client secret.

kubectl get secret -n jwt-backend-api jwt-backend-api-client -o jsonpath='{.data.client_id}' | base64 --decode
kubectl get secret -n jwt-backend-api jwt-backend-api-client -o jsonpath='{.data.client_secret}' | base64 --decode

Next I updated the API rule, section accessStrategies now uses oauth2_introspection:

host: jwt-backend-api.c-531ecbb.kyma.ondemand.com
rules:
  - accessStrategies:
      - config:
        handler: oauth2_introspection
    methods:
      - GET

kubectl apply -f apirule.yaml and first tested via POSTMAN. The service was no longer opened to the world:

but when we use the OAuth flow with client ID/secret extracted from the secret, everything worked as expected. The URL for the token: https://oauth2.c-531ecbb.kyma.ondemand.com/oauth2/token is composed from my cluster domain.

ABAP code

With this everything was ready to call it via ABAP. The code and steps are identical as in the the previous part, only different path and credentials are used. For testing based on a communication scenario/arrangement I created another communication system, pointing to my Kyma endpoint and with OAuth credentials from Kyma OAuth client. Then added a new communication arrangement. I used Cloud Resource Manager API to get the details of my Google Cloud project.

ABAP code for using it:

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

  PRIVATE SECTION.
    METHODS:
      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_via_kyma IMPLEMENTATION.

  METHOD if_oo_adt_classrun~main.

    TRY.
        " Call Google API using a communication arrangement
        DATA(signed_jwt) = get_signed_jwt_by_arrangement( ).
        DATA(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_signed_jwt_by_arrangement.

    " Call Google API using a communication arrangement

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

    DATA(comm_system_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_system_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.

And I get a response from Google Cloud API:

Serverless version

The last part of this series shows the Kyma way but this time with a serverless Function.