Dynamic User Routing with Cloudflare Gateway
On this page
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 GitHub Repository link.
Prerequisites
In order to complete this tutorial you'll need:
-
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
. -
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
- accountId:
-
The
wrangler
tool to deploy the worker's code. Have a look at the documentation if you need to install the tool. -
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 thewrangler.toml
file and put the following contents inside it:
name = "dynamic-user-routing"type = "webpack"account_id = "894..a6c"workers_dev = falseroute = "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 tofalse
, 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 = targetHostnamereturn 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 cookielet zone = getZoneFromCookie(request)// Otherwise, for POST messages look for a JWT in the form bodyif (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 cookiereturn;}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 tokenreturn 'code'} else if (args['grant_type'] === 'refresh_token') {// The refresh token field is a wrapped tokenreturn 'refresh_token'} else if (args['token'] && !args['state']) {// This includes user info requests and excludes authorize requestsreturn '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.
Join our Newsletter
Get the latest on identity management, API Security and authentication straight to your inbox.
Start Free Trial
Try the Curity Identity Server for Free. Get up and running in 10 minutes.
Start Free Trial