Integrating with Kong Open Source

Integrating with Kong Open Source

On this page

Overview

This article will show how to to implement the Phantom Token Pattern using the Kong Open Source API Gateway. This will result in an end to end solution with the following behavior:

  • An OAuth Client will get an opaque access token
  • The opaque access token will be sent to an API via Kong
  • The opaque token will be introspected to get a JWT, which will be forwarded to the API
  • The API will successfully validate the JWT and return some data

Run the Default Kong Docker Image

First ensure that Docker Desktop is installed locally as a prerequisite. Next create a local folder called kong and add a minimal docker-compose.yml file:

version: '3.8'
services:
  my_kong:
    image: kong:3.0.0-alpine
    ports:
    - 8080:8080
    - 8081:8081
    volumes:
    - ./kong.yml:/usr/local/kong/declarative/kong.yml
    environment:
      KONG_DATABASE: 'off'
      KONG_DECLARATIVE_CONFIG: '/usr/local/kong/declarative/kong.yml'
      KONG_PROXY_LISTEN: '0.0.0.0:8080'
      KONG_ADMIN_LISTEN: '0.0.0.0:8081'

This tutorial uses a DB-less and Declarative Configuration which provides the simplest local deployment options. A kong.yml file is needed, which will initially contain only some default content:

_format_version: '2.1'
_transform: true

Next run Kong with the following command, which will download the docker image:

docker-compose up --force-recreate

The example setup runs Kong on port 8080 and there is also an admin endpoint that can be browsed to at http://localhost:8081, to visualize the system settings:

Kong Admin

Deploy the Phantom Token Plugin

Next extend the deployment to include the phantom token plugin, which is done by creating a custom Dockerfile. The luarocks command uses git to download LUA files, and it may be necessary to run git config commands first, depending on your environment.

FROM kong:3.0.0-alpine
USER root
RUN git config --global url."https://".insteadOf git:// && \
    git config --global advice.detachedHead false && \
    luarocks install kong-phantom-token 2.0.0
USER kong

This deploys three files called handler.lua, schema.lua and access.lua to the docker image, inside a phantom-token folder within the Kong plugins location, which is a path such as /usr/local/share/lua/5.1/kong/plugins. The Docker Compose file then needs to reference the additional plugin in the KONG_PLUGINS environment variable.

In addition, the standard Kong image should not be used and instead the custom image built from the Dockerfile that installs the phantom token plugin should be used. Replace image: kong:3.0.0-alpine with build: ./ in the Docker Compose file.

version: '3.8'
services:
  my_kong:
    build: ./    ports:
    - 8080:8080
    - 8081:8081
    volumes:
    - ./kong.yml:/usr/local/kong/declarative/kong.yml
    environment:
      KONG_DATABASE: 'off'
      KONG_DECLARATIVE_CONFIG: '/usr/local/kong/declarative/kong.yml'
      KONG_PROXY_LISTEN: '0.0.0.0:8080'
      KONG_ADMIN_LISTEN: '0.0.0.0:8081'
      KONG_PLUGINS: 'bundled,phantom-token'      KONG_NGINX_HTTP_LUA_SHARED_DICT: 'phantom-token 10m'

Also edit the kong.yml file, to add an API route that points to an API that will run at port 3000 on the host. The plugin is configured to point to the Curity Identity Server's introspection endpoint on the host, and this configuration assumes that the default ports are being used. In order for the Kong docker container to be able to route requests to the local computer, a host name of host.docker.internal is used:

_format_version: '2.1'
_transform: true

services:
- name: demo-api
  url: http://host.docker.internal:3000/api
  routes:
  - name: demo-api-route
    paths:
    - /api
  plugins:
  - name: phantom-token
    config:
      introspection_endpoint: http://host.docker.internal:8443/oauth/v2/oauth-introspect
      client_id: api-gateway-client
      client_secret: Password1
      token_cache_seconds: 900
      scope: read

A this point you can re-run the docker-compose command to start Kong with the custom plugin activated. The following commands can be used from another terminal to confirm that the plugin and configuration have been correctly deployed within the running container:

CONTAINER_ID=$(docker container ls | grep my_kong | awk '{print $1}')
docker exec -it $CONTAINER_ID sh -c "ls /usr/local/share/lua/5.1/kong/plugins/phantom-token"
docker exec -it $CONTAINER_ID sh -c "cat /usr/local/kong/declarative/kong.yml"

Run an API on the Host

Next an API will be run on the host, and called via port 8080, so that the request is routed via Kong. The phantom token plugin will introspect incoming access tokens to get a JWT, then forward them to the real API endpoint.

If you prefer, you can choose a different technology from the API Code Examples, or plug in your own API. This tutorial will use the Kotlin Example API, which you can download via this command:

git clone https://github.com/curityio/kotlin-api-jwt-validation

Next edit the API's api.properties file to ensure that it points to your instance of the Curity Identity Server:

port=3000
jwks_endpoint=http://localhost:8443/oauth/v2/oauth-anonymous/jwks
issuer_claim=http://localhost:8443/oauth/v2/oauth-anonymous
audience_claim=api.example.com

Then run the API, and this particular sample requires Maven and Java 8+ to run:

cd kotlin-api-jwt-validation
mvn package
java -jar target/secureapi-1.0-SNAPSHOT.jar

The Kong API Gateway will route requests to these URLs on the host computer:

URLDescription
http://localhost:8443/oauth/v2/oauth-introspectThe introspection endpoint of the Curity Identity Server
http://localhost:3000/api/dataThe sample API endpoint which will receive a digitally signed JWT

Next call the sample API with the following command, where a real token is not being supplied to the phantom token plugin, so the request will fail validation:

curl -i -H "Authorization: Bearer abc123" http://localhost:8080/api/data

As expected, this results in an introspection failure, and you can understand the cause from the phantom token plugin's log output, which is routed to stderr:

Introspect 401

Plugin Implementation Details

There are plenty of online tutorials on Writing LUA plugin for Kong, and only the following simple logic is needed to implement the phantom token pattern:

  • Read the opaque access token from the incoming request's HTTP Authorization Header
  • Call the introspection endpoint, sending an accept: application/jwt header
  • Update the API request's HTTP Authorization Header with the JWT before routing it
  • Cache the JWT for future requests with the same access token, using Kong's built in cache

With LUA and Kong this is quite simple to code, and the access.lua file contains the details of the OAuth processing code:

function _M.run(config)

    if ngx.req.get_method() == "OPTIONS" then
        return
    end

    local access_token = ngx.req.get_headers()["Authorization"]
    if access_token then
        access_token = pl_stringx.replace(access_token, "Bearer ", "", 1)
    end

    if not access_token then
        ngx.log(ngx.WARN, "No access token was found in the Authorization bearer header")
        invalid_token_error_response()
    end

    local res = verify_access_token(access_token, config)
    local jwt = res.body

    if not verify_scope(jwt, config.scope) then
        error_response(ngx.HTTP_FORBIDDEN, "forbidden", "The token does not contain the required scope: " .. config.scope)
    end

    ngx.log(ngx.INFO, "The request was successfully authorized by the gateway")
    ngx.req.set_header("Authorization", "Bearer " .. jwt)
end

Test the End to End Flow

Next configure a simple OAuth client in order to get an opaque access token. This tutorial uses a simple Client Credentials client that uses a read scope. An additional introspection client is also required, and is used by the phantom token plugin in the Kong docker container. The combined configuration XML for both clients is shown below and can be imported:

<config xmlns="http://tail-f.com/ns/config/1.0">
  <profiles xmlns="https://curity.se/ns/conf/base">
    <profile>
      <id>token-service</id>
      <type xmlns:as="https://curity.se/ns/conf/profile/oauth">as:oauth-service</type>
      <settings>
        <authorization-server xmlns="https://curity.se/ns/conf/profile/oauth">
          <scopes>
            <scope>
              <id>read</id>
            </scope>
          </scopes>
          <client-store>
            <config-backed>
              <client>
                <id>api-client</id>
                <client-name>api-client</client-name>
                <secret>Password1</secret>
                <audience>api.example.com</audience>
                <scope>read</scope>
                <capabilities>
                    <client-credentials/>
                </capabilities>
                <use-pairwise-subject-identifiers>
                    <sector-identifier>api-client</sector-identifier>
                </use-pairwise-subject-identifiers>
              </client>
              <client>
                  <id>api-gateway-client</id>
                  <client-name>api-gateway-client</client-name>
                  <secret>Password1</secret>
                  <capabilities>
                      <introspection/>
                  </capabilities>
                  <use-pairwise-subject-identifiers>
                      <sector-identifier>api-gateway-client</sector-identifier>
                  </use-pairwise-subject-identifiers>
              </client>
            </config-backed>
          </client-store>
        </authorization-server>
      </settings>
    </profile>
  </profiles>
</config>

Next get an opaque access token via the following simple curl request:

curl -u 'api-client:Password1' -X POST http://localhost:8443/oauth/v2/oauth-token \
-d grant_type=client_credentials \
-d scope=read

Take the access token from the response and then run a second curl request to send a valid token through Kong to our API:

curl -H "Authorization: Bearer e4d51903-0f9d-41e5-b50e-b7fe7d630612" http://localhost:8080/api/data

You should now see a successful end to end flow, where the Kotlin API validates the JWT and echoes back some scopes and claims contained in it:

Introspection Success

For debugging purposes it can also be useful to test the introspection manually from the host, with the following command. Notice that a special form of OAuth introspection is used, with accept=application/jwt, so that the response is a digitally signed JWT and not a JSON object:

curl -u 'api-gateway-client:Password1' \
-H "accept: application/jwt" \
-X POST http://localhost:8443/oauth/v2/oauth-introspect \
-d token=e4d51903-0f9d-41e5-b50e-b7fe7d630612

Handle CORS Requests

If you are making cross domain API requests from an OAuth secured Single Page Application (SPA), you will also need to ensure that any error responses from the plugin are readable by the SPA. This can be done by configuring trusted web origins as follows:

_format_version: '2.1'
_transform: true

services:
- name: demo-api
  url: http://host.docker.internal:3000/api
  routes:
  - name: demo-api-route
    paths:
    - /api
  plugins:
  - name: phantom-token
    config:
      introspection_endpoint: http://host.docker.internal:8443/oauth/v2/oauth-introspect
      client_id: api-gateway-client
      client_secret: Password1
      token_cache_seconds: 900
      scope: read
      trusted_web_origins:      - http://www.example.com

Conclusion

The phantom token pattern can be quickly implemented in an API Gateway, since it typically only requires a small plugin. Once this is working on a development computer it is then easy to use Docker in the same way to publish to deployed environments. Using the phantom token approach results in a secure solution, where no sensitive token details are exposed to internet clients.