Implementing Google OAuth to use Google API in Cloudflare Workers


The original post can be found at Apiumhub :: Tech blog.

Recently I had the opportunity to build a small application that needed to authenticate and authorize a user using Google’s sign-in mechanism, and requests on their behalf data from a Google API.

I choose to implement this as a Cloudflare Worker as a serverless compute service leveraging Cloudflare key-value storage (KV) for session storage. The tooling from Cloudflare (wrangler) has evolved nicely since my first attempt at Cloudflare Workers, so I thought it was a high time that I gave it another try.

As any good software engineer would, I started searching for any repository I could use a template to wire up Google OAuth easily. But I failed to find anything that would play nicely with Cloudflare Web worker, or had some proper documentation/tests, or had a decent quality to it. So in this blog post (and in the companion GitHub repository jazcarate/cloudflare-worker-google-oauth), I want to document, explain and go over some interesting decisions so someone just like me would have this to springboard their development.

That being said, feel free to yoink any or all of the code from the repo.

Result

First of all, this is what we’ll develop: An app that can display and filter a user’s Drive files; and provide a link to them.

Result — Design is my passion

I choose Google’s Drive listing API as an excuse. Everything we’ll see from now on can be easily changed to use any of the myriad of Google APIs, as they all require roughly the same authentication and setup.

One picture summary

Sequence of requests

A more detail explanation of how Google Sign in should behave can be found in Google’s docs: Using OAuth 2.0 for Web Server Applications.

Structure and Systems

To decouple the logic from external sources *such as Google), the project is best understood as a slim entry point index.ts, the core business logic in the handling method (handler.ts), and every external dependency in the lib/ folder.

The main interface from handler.ts is a function that injects all the systems

export default function (
  kv: KVSystem,
  google: GoogleSystem,
  env: EnvSystem,
  crypto: CryptoSystem,
): (event: FetchEvent) => Promise<Response> {
  const { remove, get, save } = kv
  const { tokenExchange, removeToken, listDriveFiles } = google
  const { isLocal, now } = env
  const { generateAuth } = crypto
  //...

This not only helps separate concerns but allows us to mock external dependencies in the tests.

Initial request

Once we have all systems initialized, we do match if the request is an /auth callback. We’ll come back to this section later. If the request is not a callback, then I check if the user is authenticated. There are several ways in which a user might not be.

Like if they don’t present any cookies.

const cookies = request.headers.get('Cookie')
if (!cookies) return login(env, google, url)

Or if they have cookies, but not the one we care (auth)

const auth = findCookie(AUTH_COOKIE, cookies)
if (!auth) return login(env, google, url)

Or the KV does not have an entry for that auth cookie (either because it has expired, or that the client is malicious and trying to guess)

const token = await get(auth)
if (!token) return login(env, google, url)

Authenticated

After this point, I know that the user is authenticated. This means I have access to their token to query Google API.

So now I need to choose one of three paths:

  1. If it is to the root (/)
  2. If the request is to /logout
  3. or any other path.

Of note, this is just the pathname, it does not include search params like ?q=foo; so the URL /?q=foo .pathname is just '/'

switch (url.pathname) {
    case '/':       // ...
    case '/logout': // ...
    default:        // ...
}

List files

If the request is to the root, then I query Google API with the Authorization token like the API expects

async function listDriveFiles(
  accessToken: string,
  query: string | null,
): Promise<DriveFiles> {
  const url = new URL('https://www.googleapis.com/drive/v2/files')
  if (query) url.searchParams.append('q', `title contains '${query}'`)

  const response = await fetch(url.toString(), {
    headers: { Authorization: `Bearer ${accessToken}` },
  })
  /// ...
}

If the fetch returns something, I check the body for errors and panic if needed

const resp = await response.json()
if (resp.error) throw new Error(JSON.stringify(resp.error))

Once Google answers a list of files, then it is just a matter of rendering them. I decided to keep the rendering simple, and inline the whole HTML; but this can be easily adapted to return a JSON, or use a proper template engine. I’ll leave this as an exercise for the reader.

Logout

In the logout case, we revoke the token with google, we remove the auth from the KV and we reply to the client with a deleted cookie.

event.waitUntil(Promise.allSettled([removeToken(token), remove(auth)]))
        
return new Response('Loged out', {
    headers: setCookie('deleted', EXPIRED),
})

I leverage the waitUntil lifecycle to respond to the client immediately, and call google and remove the KV in the background. And I use allSettled as I don’t particularly care if the KV couldn’t be deleted, or if Google had trouble removing the token as there is not much more I would be able to do.

Not found

Otherwise, I simply return a status 404 Not Found.

return new Response('Not found', { status: 404 })

Callback

Going back to the /auth request; this needs no authentication (as we are in the process of creating it). So I just check for the contract that Google sign-in documents. The query params has no errors

const error = url.searchParams.get('error')
if (error !== null)
    return new Response(`Google OAuth error: [${error}]`, { status: 400 })

And it has a code

const code = url.searchParams.get('code')
if (code === null)
    return new Response(`Bad auth callback (no 'code')`, { status: 400 })

If so, we can exchange the code for a proper token via another Google API

const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
    },
    body,
})

and again check for errors

const resp = await response.json()
if (resp.error) throw new Error(resp.error)

With a valid Google access_token in hand, we generate a random string for the application’s authentication

const newAuth = generateAuth()

And store in the KV the way to translate from newAuth to access_token

await save(
    newAuth,
    tokenResponse.access_token,
    //...
)

Finally, we send the client back to wherever they came from, with their new authentication cookie. We stored the original URL in the state param that Google OAuth allows us to send.

return redirect(
    '/' + decodeURIComponent(url.searchParams.get('state') || ''),
    setCookie(newAuth, new Date(expiration)),
)

Other systems

The systems in the lib/ folder are quite straightforward, and can be divided into two subgroups conceptually:

Cloudflare enhanced dependencies

As this application is running in Cloudflare Workers (both the real environment in the cloud and the dev environment generated by npm run dev), some variables are injected into the global scope. These variables are typed in the bindings.d.ts file; and are generated by steps 2 and 3 from README.md#Setup wrangler.

  • KV module uses the global authTokens variable that Cloudflare Worker injects into the worker. More information about how KV work can be found here.
  • Env module keeps the environment injectable. Even though we could use the global variables (CLIENT_ID and CLIENT_SECRET) injected to the web worker, this approach allows me to test the handler without having to re-wire global variables; that is a pain.

Utils

And some other systems that are not Cloudflare Worker dependant, but more like utility functions, grouped by their specific domain.

  • http module has some utils to parse the Cookies header format and build a 302 Redirect response.
  • Google module has a types API using just fetch.
  • Crypto module is a small utility to use the crypto API to generate a secure-random string.
Melian

Random Posts

Deckchecks, Heuristics, and Decision Trees

One of the tools judges employ to thwart attempts to cheat is to require players to write down what cards they will be playing within a particular tournament, and routinely check them. Judges must perform these checks the fastest way possible to not delay the tournament. As such, they developed a myriad of techniques. I feel we under-use the availability of the decklist, so having a clear plan ahead of actually getting the deck in our hands can prove highly effective. In this post, I would like to explore some techniques' inner workings. Propose a hypothesis on why they might work in some situations but not others. Consider unexplored alternatives. And build a heuristic on what method is better for each circumstance. Join me! #mtg

carbn

We are constantly trying to write code that easier for a human to comprehend. These high-level languages gave us the power of expression. And we like this!

We want more expression, and safety in the code we write; but we also want code that humans can understand and reason about. So what if we re-think our current software stack? And have humans evaluate other human’s code.

carbn is a Humans-as-a-Service, cutting edge technology that makes your code run in the distributed brains of our agents.

#raving

On costs, graphs and integrals

Yet another tool to pick a technology. How does the cost of the choice evolve over the complexity? Is there a one-solution-fits-all? In this blog post, we’ll delve into some visualization to understand the difference between choosing Java, Haskell, Hibernate, Ruby on Rails, or drop programming altogether for a no-code approach. Let’s dive into the world of optimizations, integrals, and many many graphs! #rambling #coding

Haskell para mentes imperativas

En este post quiero explorar algunas cosas que creo que me hubiesen servido para aprender Haskell. Teniendo una base en algún lenguaje imperativo, usar esta para programar en Haskell. #xpost