EDIT (January 2023)
Since writing this blog post in March 2022 I was made aware of an even simpler way of securely calling an API using an API key than the way I initially described in this blog post (see
Gregor Wolf's comment). This is it:
When defining a destination via the SAP BTP Cockpit or a configuration file, these additional properties can be used to pass the API key:
URL.headers.HEADER_KEY
URL.headers.QUERY_KEY
This is described in the
SAP Cloud SDK documentation.
I will keep the old blog post below for reference.
In this blog post I will describe how you can call an API that requires an API key from a UI5 app - without exposing the key to the client, which would be a severe security flaw.
I recently came across an API I wanted to consume that required an API key as a query parameter attached to the URL of the call. This is what the API expects:
https://www.domain.com/api?key=my-actual-api-key
The architecture of the app
In this scenario, the UI5 app is bound to an instance of the destination service in Cloud Foundry and runs with a standalone node.js based approuter (learn more about standalone and managed approuters
here). To avoid any CORS issues, the app doesn't call the actual domain of the API, but calls a route (
/myDestination) that is defined in the xs-app.json of the approuter. The approuter proxies all request that hit that route to the destination which is defined in the Cloud Foundry Environment (including the actual domain of the API).
What we should not do
Let's start with how we shouldn't include the API key in our app. The easiest thing to do would be to simply attach it to the
uri of the
dataSource in the
manifest.json application descriptor:
"sap.app": {
"dataSources": {
"myAPI": {
"uri": "/myDestination/api?key=my-actual-api-key",
"type": "JSON"
},
...
}
},
...
If we did that the key would be visible to the client (anyone opening the app in their browser) through the
Sources and
Network tab of the Developer Tools. This would be a severe security flaw, as anyone could copy the key and do all kinds of things with it. We definitely want to avoid that. Another issue with this technique is that it is very likely we accidentally push this key to GitHub. The
manifest.json is a very important file for UI5 projects and we cannot simply put it in the
.gitignore file.
So what other options do we have? Unlike OAuth or other authentication methods the destination service in Cloud Foundry unfortunately doesn't support API keys or query parameters attached to the URL. This means we have to implement something custom, and it has to be server side, so it is not visible to the client.
What we will do
We will extend the approuter with a custom middleware (see the
official documentation for more info), which will handle the API call on its own, instead of using the destination service. By writing a custom middleware we have full control over what the approuter does once our front end app hits a specific route. The approuter runs server side and it's code is not visible to the client, which is why it's a safe place to attach the API key to the request URL.
In our approuter folder, we create a new
middleware.js file that imports the approuter package and starts the approuter manually:
const approuter = require('@sap/approuter');
const ar = approuter();
ar.start();
We modify the start script of the approuter in its
package.json file so that it points to our new
middleware.js file JavaScript file:
{
"name": "approuter",
"dependencies": {
"@sap/approuter": "^10"
},
"scripts": {
"start": "node middleware.js"
}
}
At this point, not a lot has changed. In fact, the behaviour of the approuter has not changed at all. It is just instantiated and started from a different place. But we can use the
middleware.js file to define a
beforeRequestHandler that will execute code just after a route is hit and before the request is proxied. We will then execute a new call using
node-fetch, so let's install this package. Make sure to install version 2, as version 3 is not compatible with CommonJS modules (which we are using in this example):
npm install node-fetch@2
Next, we can create a new file called
default-env.json (if it doesn't exist yet) and store our API key in there. This way it will be accessible to the
middleware.js file as a node.js environment variable. The benefit of this is that we can later put the
default-env.json file in our
.gitignore file to avoid releasing the key to GitHub:
{
"MY_API_KEY": "my-actual-api-key"
}
Let's put all the pieces together. In our
middleware.js we define the
beforeRequestHandler, which replaces the
/myDestination route with the actual domain and the keyword
MY_API_KEY in the URL with actual API key (stored as node.js environment variable). It then fetches the data from the API, and sends the json response back to the client. The call is ended without proxying the request to a destination.
ar.beforeRequestHandler.use("/myDestination", async function myMiddleware(req, res, next) {
let newUrl =
req.url.replace("/myDestination", "https://domain.com")
.replace("MY_API_KEY", process.env.MY_API_KEY)
const response = await fetch(newUrl);
const data = await response.json();
res.end(JSON.stringify(data))
});
To trigger this handler and make it work properly we have to define the following
uri as the
dataSource in our
manifest.json of our UI5 app:
"sap.app": {
"dataSources": {
"myAPI": {
"uri": "/myDestination/api?key=MY_API_KEY",
"type": "JSON"
},
...
}
},
...
And there we go, our UI5 app can now call the API (well, it calls the approuter, which then calls the API on our behalf) without even knowing the API key. And if the app doesn't even know the key, there is also no way for a hacker to find it
😉
Limitations
This simple solution comes with a few limitations. For now it only works for GET requests, as we haven't put much thought into supporting other request methods in our
beforeRequestHandler function. This is definitely doable though.
Also, one of the downsides of this custom solution is that it is... well, custom. This means you as the developer of the app are responsible for the code and any updates/ changes that might be necessary in the future. This is especially true for anything related to security. It's usually easier (more secure) to rely on SAP's built in solutions such as the destination service. On a related note, I currently don't see any way of implementing this solution or something similar with a managed approuter.
Please share your thoughts and feedback.