Mutual TLS Secured API

Mutual TLS Secured API

Code Examples / api-integration

Overview

This tutorial shows how to combine OAuth API security with mutual TLS using X509 client certificates. The GitHub repository enables an end-to-end API focused solution to be quickly run on a development computer.

B2B Security

Mutual TLS is a standard security solution for API links between companies, and this can be combined with Certificate Bound Access Tokens to improve upon the strength of normal bearer tokens, to ensure that if an access token is somehow stolen it cannot be replayed by a malicious party.

In some industry sectors mutual TLS is a regulatory requirement, where high worth assets must be protected according to specifications such as the Open Banking Security Profile and the Financial-grade API (FAPI) Security Profile.

Components

The code example will demonstrate the following flow, where two requests are made from a client, and HTTPS traffic is routed via a reverse proxy to both the Identity Server and the API:

Components

The flow consists of the following main steps, which ensure that only the party that owns the client certificate and key can access API resources:

StepDescription
Client AuthenticationThe client authenticates over a mutual TLS channel by sending proof of a client certificate and key
Sender Constrained Access Tokens IssuedThe Authorization Server issues a client certificate claim and returns an opaque access token to the client
Client Calls APIThe client sends the access token to the API in the standard way, but again sends proof of the client certificate and key
Client TLS VerificationThe API verifies mutual TLS to ensure that only calls from trusted clients are accepted
Client Token VerificationThe API verifies that the client is supplying the same client certificate that was used at the time of authentication

Running the Example

The example first requires the following prerequisites to be installed:

PrerequisiteDescription
OpenSSLThis will be used to create some certificates for development purposes
Docker DesktopThe system will be deployed as a simple Docker compose network
NodeJSA simple API is provided, with some extra mutual TLS related code

Get the Code

Clone the GitHub repository and inspect the files, which consist primarily of a very simple API and some deployment resources:

![Components](/images/resources/tutorials/writing-apis/mutualtls/components.png)

Create Development certificates

The example uses certificates that are created by running the following script. In a real world setup these would be issued by a trusted third party authority. The What is Open Banking? article describes how eIDAS certicates are required in European Open Banking.

./1-create-certs.sh

To keep the infrastructure fairly simple, only three certificates and keys are created:

CertificateFile NameDescription
Root Authorityroot.pemA single self signed certificate authority is created and used to issue both client and server certificates
Server Certificateexample.tls.p12A single wildcard TLS certificate and key is used by all back end hosts in the Docker network
Client Certificateexample.client.p12A client certificate and key for an example business partner is created

Configure URLs

Next add the following entries to your hosts file, to represent public URLs used by the business partner client:

127.0.0.1      localhost api.example.com login.example.com
:1             localhost

The following URLs are used, so that there is only a single external port of 443, which is usually the case in production API setups:

Base URLDescription
https://login.example.comThe external URL used by the client to access the Curity Identity Server
https://api.example.comThe external URL used by the client to access the API
https://login-internal.example.com:8443The internal URL of the Curity Identity Server within the Docker compose network
https://api-internal.example.com:3000The internal URL of the API within the Docker compose network

Build Code

Next build the code and Docker containers via the following script:

./2-build.sh

Deploy the System

Before deploying the system, ensure that you have a valid license file for the Curity Identity Server, and copy it into the docker/idsvr folder, then run the deployment script:

./3-deploy.sh

Run the Partner Client

The example client is a very simple bash script that runs curl commands, to authenticate, then to call the API, with all requests using mutual TLS:

./4-run-test-client.sh

The test client simply makes secure connections and outputs the following text, though there is some complexity in the infrastructure setup, which is described in the following sections:

Client is authenticating with the Identity Server via mutual TLS ...
Client successfully authenticated and received an opaque access token
Client is creating a transaction at the API using mutual TLS and the opaque access token ...
Client successfully created the API transaction using mutual TLS

Identity Server Configuration

Browse to https://localhost:6749/admin to access the Admin UI of the Curity Identity Server and sign in with admin / Password1. Then navigate to Profiles / Token Service / Clients and note that there are two OAuth clients configured:

Clients

The first of these is an introspection client used by the reverse proxy to implement the Phantom Token Pattern, to swap opaque access tokens from the client for JWT access tokens to forward to the API.

The second client is more interesting, since it uses the Client Credentials Flow with a client certificate as the client secret, meaning that the sender needs to provide proof of the private key in order to authenticate:

Mutual TLS Client

The Curity Identity Server will only accept client certificates from the trusted issuer and will also verify that the client certificate received has the distinguished name configured.

In order to authenticate and get an access token, the partner client will call a token endpoint where mutual TLS is used, and in a curl request this contains the following parameters:

curl -s -X POST "https://login.example.com/oauth-token-mutual-tls" \
--cert ./certs/example.client.pem \
--key ./certs/example.client.key \
--cacert ./certs/root.pem \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=partner-client" \
-d "grant_type=client_credentials" \
-d "scope=transactions"

Companies will want to ensure that mutual TLS clients do not cause any problems for web or mobile clients that need to interact with a token endpoint. The example setup manages this by configuring a second token endpoint with a different path:

Mutual TLS Endpoint

The Curity Identity Server supports multiple ways to configure mutual TLS and validate client certificates. These include terminating TLS at the reverse proxy or using a dedicated port for mutual TLS. See the Token Service Admin Guide for full details on supported configurations.

Reverse Proxy Mutual TLS Verification

The code example uses OpenResty as an open source NGINX based reverse proxy, with good scripting capabilities. The code example uses the NGINX features to demonstrate a setup where mutual TLS is terminated in two different ways:

Request TypeReverse Proxy Behavior
AuthenticationThe reverse proxy does not decrypt the request and passes it straight through to the Identity Server, which verifies the client certificate
APIThe reverse proxy verifies the client certificate, performs token introspection, then verifies the certificate in the token, then forwards a JWT to the API

For authentication requests, NGINX is configured to receive requests at the transport layer, using the NGINX Stream Module. This means requests are not decrypted and that TLS is terminated at the Curity Identity Server, which will verify the client certificate.

stream {
    server {
        listen 3000;
        resolver 127.0.0.11;
        proxy_pass login-internal.example.com:8443;
        ssl_preread on;
    }
}

For API requests, the reverse proxy manages SSL termination and verifying the client certificate, which is done using the NGINX HTTP SSL Module via the ssl_verify_client and ssl_trusted_certificate directives.

http {

    server {

        server_name api.example.com;
        listen 3001 ssl;

        ssl_certificate                 /usr/local/openresty/certs/example.tls.pem;
        ssl_certificate_key             /usr/local/openresty/certs/example.tls.key;
        ssl_trusted_certificate         /usr/local/openresty/certs/root.pem;

        ssl_client_certificate          /usr/local/openresty/certs/root.pem;
        lua_ssl_trusted_certificate     /usr/local/openresty/certs/root.pem;
        ssl_verify_client on;

        location ~ ^/ {
            set $internal_api_hostname 'api-internal.example.com:3000';
            proxy_pass https://$internal_api_hostname$uri$is_args$args;
        }

Reverse Proxy Token Verification

After the client has authenticated it will call the API, providing both an opaque access token and proof of ownership of the client certificate and key:

curl -s -X POST "https://api.example.com/api/transactions" \
--cert ./certs/example.client.pem \
--key ./certs/example.client.key \
--cacert ./certs/root.pem \
-H "Authorization: Bearer 42fb44ec-4d96-4f5a-ac48-abbd7e004a2a" \
-H "Content-Type: application/json"

The reverse proxy is also configured to use two small LUA plugins that address cross cutting concerns:

PluginRole
Phantom Token PluginA LUA implementation of the phantom token pattern, which performs introspection and result caching
Sender Constrained Token PluginA LUA plugin to compare the client certificate thumbprint in the JWT with the client certificate of the API request

These are configured as follows and run in this sequence, so that the JWT is produced by the first plugin and then used by the second plugin:

rewrite_by_lua_block {

    local phantomTokenConfig = {
        introspection_endpoint = 'https://login-internal.example.com:8443/oauth/v2/oauth-introspect',
        client_id = 'introspect-client',
        client_secret = 'Password1'
    }
    local phantomTokenPlugin = require 'phantom-token-plugin'
    phantomTokenPlugin.execute(phantomTokenConfig)

    local tokenConfig = {
        type = 'certificate-bound'
    }
    local senderConstrainedTokenPlugin = require 'sender-constrained-token-plugin'
    senderConstrainedTokenPlugin.execute(tokenConfig)
}

The JWT returned after introspection is a certificate bound access token due to its cnf/x5t#S256 claim, which contains the thumbprint of the client certificate used at the time of authentication. The mutual TLS secured API also uses JWTs signed with a PS256 algorithm, since this is a recommended algorithm from FAPI Specifications:

{
  "kid": "431674655",
  "alg": "PS256"
}
{
  "jti": "9837c9ba-8ddc-40b7-9c5c-f1ccb5bfcd9d",
  "delegationId": "17d3f7ed-1c40-4b1f-99f7-861afec31d45",
  "exp": 1632241215,
  "nbf": 1632240915,
  "scope": "transactions",
  "iss": "https://login.example.com/oauth/v2/oauth-anonymous",
  "sub": "c48945efa68b9fc8a448cbd61ddb2dcfe3669b805e3faf7f625db49d27704e2f",
  "aud": "api.example.com",
  "iat": 1632240915,
  "purpose": "access_token",
  "cnf": {
    "x5t#S256": "ls2Lhy1SwH4o1F_sU40C66HWfjQ-49Iz8w7_-gii6wU"
  }
}

The sender constrained token plugin then calculates the thumbprint of the client certificate in the current API request and compares it to the JWT value. If these do not match a 401 unauthorized response is returned:

local jwtThumbprint = read_token_thumbprint(access_token)
if jwtThumbprint == nil then
    ngx.log(ngx.WARN, 'Unable to parse the x5t#S256 from the received JWT access token')
    unauthorized_error_response()
end

local certThumbprint = get_sha256_thumbprint(ngx.var.ssl_client_raw_cert)
if certThumbprint ~= jwtThumbprint then
    ngx.log(ngx.WARN, 'The client certificate details of the request and the JWT do not match')
    unauthorized_error_response()
end

API Security Handling

Due to the work of the reverse proxy, the security code in the example API is very simple and just involves standard verification of the JWT. The code example uses the JOSE library, so that only a few lines of code are needed, after which the claims can be trusted for authorization:

const accessToken = this.readAccessToken(request);
if (!accessToken) {
    throw new Error('No access token was received in the incoming request')
}

const remoteKeySet = createRemoteJWKSet(new URL(this.configuration.jwksUrl))

const options = {
    algorithms: [this.configuration.algorithm],
    issuer: this.configuration.issuer,
    audience: this.configuration.audience,
};

const result = await jwtVerify(accessToken, remoteKeySet, options);
response.locals.claims = result.payload;

In some setups you may prefer to verify the token’s certificate details in the API rather than in the reverse proxy. To enable this for our example setup the nginx.conf file would be updated to forward the client certificate to the API via an HTTP header, using NGINX’s ssl_client_escaped_cert variable:

proxy_set_header x-example-client-public-key $ssl_client_escaped_cert;
set $internal_api_hostname 'api-internal.example.com:3000';
proxy_pass https://$internal_api_hostname$uri$is_args$args;

The API itself would then need to use crypto libraries to calculate the SHA256 thumbprint field of the certificate according to RFC8705. The code example uses Node.js so it could achieve this via libraries such as Forge and base64url:

import base64url from 'base64url';
import {asn1, md, pki} from 'node-forge';

private publicKeyCertToThumbprint() {

    const header = request.header('x-example-client-public-key');
    if (header) {
        const publicKey = decodeURIComponent(header);
        const cert = pki.certificateFromPem(publicKey);
        const derBytes = asn1.toDer(pki.certificateToAsn1(cert)).getBytes();
        const hashBytes = md.sha256.create().update(derBytes).digest().getBytes();
        const thumbprint = base64url.encode(hashBytes);
    }
}

Conclusion

The code example provides a fast working setup to enable higher security APIs, using mutual TLS and sender constrained tokens. The following related resources provide further information, and include links to mutual TLS tutorials for client applications: