Call Google Cloud APIs from SAP BTP ABAP Environment using a signed JWT (+ Kubernetes/Kyma)
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.