Dynamic User Routing with Cloudflare Gateway

Dynamic User Routing with Cloudflare Gateway

tutorials

This tutorial shows how to implement dynamic routing of OAuth requests using a Cloudflare Gateway. This routing capability enables companies to deploy the Curity Identity Server to multiple regions, while forwarding OAuth requests for users to their home region. The overall solution is described in the Implementing Dynamic User Routing walkthrough.

The code described here is available in the Dynamic User Routing GitHub Repository.

Prerequisites

In order to complete this tutorial you’ll need:

  1. A Cloudflare account with a website configured to use the Cloudflare CDN. This should be the domain under which your Curity Identity Server cluster is reachable. In this tutorial it is assumed that the Curity Identity Server cluster runs on https://idsvr.example.com.

  2. A Cloudflare accountId and site’s zoneId will be needed. You can check these values on the Overview page of the Cloudflare dashboard. In this tutorial, the following values will be used:

    • accountId: 894..a6c
    • zoneId: 13e...b2e
  3. The wrangler tool to deploy the worker’s code. Have a look at the documentation if you need to install the tool.

  4. The Curity Identity Server running in a multi-region cluster. This GitHub repository contains an example configuration together with Docker files which will enable you to run a cluster with two zones.

Setup Cloudflare

The dynamic routing has a bit more complicated logic than can be configured with the Cloudflare’s Rules, so a worker will be needed for this feature. A Cloudflare Worker is a Node.js script which runs whenever a requests comes to a route configured to use the given worker. Follow these steps to create the worker:

  • On your machine, generate a new worker, by running:
wrangler generate dynamic-user-routing

Note that this will create a dynamic-user-routing directory in the location from where the script is run.

  • In the dynamic-user-routing directory edit the wrangler.toml file and put the following contents inside it:
name = "dynamic-user-routing"
type = "webpack"
account_id = "894..a6c"
workers_dev = false
route = "idsvr.example.com/*"
zone_id = "13e...b2e"

A quick explanation of the settings:

  • type = "webpack" is needed so that external classes can be imported in the Javascript code.
  • accountId is the Cloudflare account ID.
  • workers_dev = false means that the worker will be attached to a route in a zone. When this is set to false, the next two settings are required.
  • zone_id identifies the Cloudflare site.
  • route provides Cloudflare with information on which route should the worker be run. The dynamic routing should be done on every route, hence the asterisk at the end.

The worker will inspect each incoming request in order to route it to the proper zone. To do this, the worker will first try to read a zone cookie to check if it can find a zone ID in it. If not, it will try to find a wrapper token in the request. A wrapper token can be used as an access token, a refresh token or as the code parameter. If such token is found, it is decoded and the claim zone is checked to find the proper zone ID. Once the zone is found, the request is routed to a proper domain, which corresponds to the zone ID (or a default domain).

  • Open the index.js file and replace the contents of the file with the following code:
import querystring from 'querystring'

addEventListener('fetch', event => {
    event.respondWith(handleRequest(event.request))
})

const zonesMap = {    default: 'idsvr-eu.example.com',    eu: 'idsvr-eu.example.com',    us: 'idsvr-us.example.com'}
const config = {
    cookieName: 'zone',
    claimName: 'zone'
}

/**
 * Route user to proper zone
 * @param {Request} request
 */
async function handleRequest(request) {
    const zone = await getZoneValue(request)
    const targetHostname = zonesMap[zone] || zonesMap["default"]
    const zonedTargetURL = new URL(request.url)
    zonedTargetURL.hostname = targetHostname

    return await fetch(zonedTargetURL.toString(), request)
}

/**
 * Try to read the zone value from an OAuth request
 */
async function getZoneValue(request) {
    const method = request.method.toLowerCase()

    if (method === 'options' || method === 'head') {
        return null
    }

    // First try to find a value in the zone cookie
    let zone = getZoneFromCookie(request)
    // Otherwise, for POST messages look for a JWT in the form body
    if (zone === null && method === 'post') {
        zone = await getZoneFromForm(request)
    }

    return zone
}

/**
 * Read the zone from a front channel cookie
 */
function getZoneFromCookie(request) {
    if (!request.headers) {
        return null
    }

    const cookies = parseCookieHeader(request.headers.get('Cookie'))
    const zone = cookies[config.cookieName]

    return zone || null
}

function parseCookieHeader(cookies) {
    const parsed = {}
    if (!cookies) {
        return parsed;
    }

    cookies.split(";").forEach(cookie => {
        const splitCookie = cookie.split("=");
        if (splitCookie.length !== 2) { // Skip things which do not look like a proper cookie
            return;
        }

        parsed[splitCookie[0].trim()] = splitCookie[1].trim();
    });

    return parsed;
}

/**
 * Read a token field from a form URL encoded request body
 */
async function getZoneFromForm(request) {
    const body = await request.clone().text()
    const args = querystring.parse(body)
    const wrappedTokenField = getWrappedTokenFieldName(args)

    if (wrappedTokenField !== null) {
        const jwt = args[wrappedTokenField]
        if (jwt) {
            return await getZoneClaimValue(jwt)
        }
    }

    return null
}

/**
 * Try to get the name of the field containing the wrapped token
 */
function getWrappedTokenFieldName(args) {
    if (args['grant_type'] === 'authorization_code') {
        // The authorization code field is a wrapped token
        return 'code'
    } else if (args['grant_type'] === 'refresh_token') {
        // The refresh token field is a wrapped token
        return 'refresh_token'
    } else if (args['token'] && !args['state']) {
        // This includes user info requests and excludes authorize requests
        return 'token'
    }

    return null
}

/**
 * Load the wrapped token / JWT and look for a claim in the payload
 */
async function getZoneClaimValue(wrappedToken) {
    try {
        const jwt = decodeJWT(wrappedToken)
        return jwt[config.claimName] || null
    } catch (err) {
        // Log error
    }

    return null
}

function decodeJWT(jwt) {
    // FIXME - token should verified, at least if it is a well-formed JWT.
    return JSON.parse(Buffer.from(jwt.split(".")[1], 'base64').toString('binary'))
}

Note lines 7-11. They contain a map of zone ids to URLs. These are the URLs under which your Curity Identity Server zones can be reached.

  • publish the worker to Cloudflare, by running the following command:
wrangler publish

Now the Gateway is ready to route users to proper zones.

Test the Flow

Everything is now in place to start testing the solution. If you’ve used the Curity Identity Server cluster from the GitHub repository, then you can log in using one of the existing accounts: testuser.eu or testuser.us and the password Password1. You can use OAuth.tools to easily start an authorization flow. You will see that you’re able to log in as any of those users even though their data resides only in one zone. Cloudflare and the Curity Identity Server take care of routing the user to the proper zone, so they can log in. You can also study the logs of each instance of the Curity Identity Server to see which zone is called.

Have a look also at the Implementing Dynamic User Routing tutorial to learn what configuration is needed in the Curity Identity Server for this feature.

Conclusion

It is a standard job to implement dynamic routing in API gateways, and Cloudflare Gateway Workers provide some extensibility features to help with this. When combined with the Curity Identity Server’s multi zone features, a company can deploy a global IAM system with user data separated by region, and with good reliability.

Keep up with our latest articles and how-tos RSS feeds.