Subscribe to receive notifications of new posts:

OAuth Auth Server through Workers

2018-12-11

3 min read

Let’s pretend I own a service and I want to grant other services access to my service on behalf of my users. The familiar OAuth 2.0 is the industry standard used by the likes of Google sign in, Facebook, etc. to communicate safely without inconveniencing users.

Implementing an OAuth Authentication server is conceptually simple but a pain in practice. We can leverage the power of Cloudflare Workers to simplify the implementation, reduce latency, and segregate our service logic from the authentication layer.

For those unfamiliar with OAuth, I highly recommend reading a more in depth article.

The steps of the OAuth 2.0 workflow are as follows:

  1. The consumer service redirects the user to a callback URL that was setup by the auth server. At this callback URL, the auth server asks the user to sign in and accept the consumer permissions requests.

  2. The auth server redirects the user to the consumer service with a code.

  3. The consumer service asks to exchange this code for an access token. The consumer service validates their identity by including their client secret in the callback URL.

  4. The auth server gives the consumer the access token.

  5. The consumer service can now use the access token to get resources on behalf of the user.

In the rest of this post, I will be walking through my implementation of an OAuth Authentication server using a Worker. For simplicity, I will make the assumption the user has already logged in and obtained a session token in the form of a JWT that I will refer to as “token” herein. My full implementation has a more thorough flow that includes initial user login and registration.

Setup

We must be able to reference valid user sessions, codes and login information. Because Workers do not maintain state between executions, we will store this information using Cloudflare Storage. We setup up three namespaces called: USERS, CODES, and TOKENS .

On your OAuth server domain, create two empty worker scripts called auth and token. Bind the three namespaces to the two workers scripts. Then configure the namespaces to the scripts so that your resources end up looking like:

Screen-Shot-2018-12-11-at-3.46.15-PM

To put and get items from storage using KV Storage syntax:

// @ts-ignore 
CODES.get(“[email protected]”) 

We include // @ts-ignore preceding all KV storage commands. We do not have type definitions for these variables locally, so Typescript would throw an error at compile time otherwise.

To set up a project using Typescript and the Cloudflare Previewer, follow this blog post. Webpack will allow us to import which we will need to use the JWT library jsonwebtoken.

import * as jwt from "jsonwebtoken";

Remember to run:

npm install jsonwebtoken && npm install @types/jsonwebtoken

Optionally, we can set up a file to specify endpoints and credentials.

import { hosts } from "./private";

export const credentials = {/* for demo purposes, ideally use KV to store secrets */
  clients: [{
    id: "victoriasclient",
    secret: "victoriassecret"
  }],
  storage: {
    secret: "somesecrettodecryptfromtheKV"
  }

};

export const paths = {
  auth: {
    authorize: hosts.auth + "/authorize",
    login: hosts.auth + "/login",
    code: hosts.auth + "/code",
  },
  token: {
    resource: hosts.token + "/resource",
    token: hosts.token + "/authorize",
  }
}

1. Accept page after callback

The consumer service generates some callback URL that redirects the user to our authentication server. The authentication server then presents the user with a login or accept page to generate a code. The authentication server thus must listen on the authorize url endpoint and return giveLoginPageResponse.

addEventListener("fetch", (event: FetchEvent) => {
 const url = new URL(event.request.url);
 if (url.pathname.includes("/authorize"))
  return event.respondWith(giveLoginPageResponse(event.request));
}

export async function giveLoginPageResponse(request: Request) {
 ...checks for cases where I am not necessarily logged in... 
 let token = getTokenFromRequest(request)
 if (token) { //user already signed in
  return new Response(giveAcceptPage(request)
 }

Since the user already has a stored session, we can use a method giveAcceptPage. To display the accept page and return a redirect to generate the code.

export function giveAcceptPage(request: Request) {
  let req_url = new URL(request.url);
  let params = req_url.search
  let fetchCodeURL = paths.auth.code + params
  return `<!DOCTYPE html>
  <html>
    <body>
      <a href="${fetchCodeURL}"> Accept</button>
    </body>
  </html>
    `;
}

2. Redirect back to consumer

At the endpoint for fetchCodeURL, the authentication server will redirect the user’s browser to the consumer’s page as specified by redirect_uri in the original URL params of the callback with the code.

addEventListener("fetch", (event: FetchEvent) => {
 ...
 if (url.pathname.includes("/code"))
  return event.respondWith(redirectCodeToConsumer(event.request));
}
export async function redirectCodeToConsumer(request: Request) {
 let session = await verifyUser(request)

 if (session.msg == "403") return new Response(give403Page(), { status: 403 })
 if (session.msg == "dne") return registerNewUser(session.email, session.pwd)
 let code = Math.random().toString(36).substring(2, 12)
 try {
  let req_url = new URL(request.url)
  let redirect_uri = new URL(encodeURI(req_url.searchParams.get("redirect_uri")))
  let client_id = new URL(encodeURI(req_url.searchParams.get("client_id")))
  // @ts-ignore
  await CODES.put(client_id + email, code)
  redirect_uri.searchParams.set("code", code);
  redirect_uri.searchParams.set("response_type", "code");
  return Response.redirect(redirect_uri.href, 302);
 } catch (e) {
  // @ts-ignore
  await CODES.delete(email, code)
  return new Response(
   JSON.stringify(factoryIError({ message: "error with the URL passed in" + e})),
  { status: 500 });
 }
}

3. Code to Token Exchange

Now the consumer has the code. They can use this code to request a token. On our token worker, configure the endpoint to exchange the code for the consumer service to grant a token.

addEventListener("fetch", (event: FetchEvent) => {
...
 if (url.pathname.includes("/token"))
  return event.respondWith(giveToken(event.request));

Grab the code from the request and validate this code matches the code that is stored for this client. Once the code is verified, deliver the token by grabbing the existing token from the KV storage or by signing the user information to generate a new token.

export async function giveToken(request: Request) {
 let req_url = new URL(request.url);
 let code = req_url.searchParams.get("code");
 let email = req_url.searchParams.get("email");
 if (code){
   if(!validClientSecret(request) return errorResponse()
  // @ts-ignore
  let storedCode = await CODES.get(email)
  if(code != storedCode) return new Response(give403Page(), { status:403})

  let tokenJWT = jwt.sign(email, credentials.client.secret);
  ... return token

4. Give the token to the consumer

Continuing where step 3 left off from the giveToken method, respond to the consumer with this valid token.

  ...
  headers.append("set-cookie", "token=Bearer " + tokenJWT);
  // @ts-ignore
  await TOKENS.put(email, tokenJWT)
  var respBody = factoryTokenResponse({
   "access_token": tokenJWT,
   "token_type": "bearer",
   "expires_in": 2592000,
   "refresh_token": token,
   "token": token
  })
 } else {
  respBody.errors.push(factoryIError({ message: "there was no code sent to the authorize token url" }))
 }
 return new Response(JSON.stringify(respBody), { headers });
}

5. Accepting the token

At this point voila, your duty as an OAuth 2.0 Authentication server is complete! The consumer service that wishes to use your service has the token that you have not so magically generated.

The consumer server would send a request including the token:

GET /resource/some-goods
Authorization: Bearer eyJhbGci..bGAqA

Authentication server would validate the token and give the goods:

export async function giveResource(request: Request) {
 var respBody: HookResponse = factoryHookResponse({})
 let token = ""
 let decodedJWT = factoryJWTPayload()
 try { //validate request is who they claim
  token = getCookie(request.headers.get("cookie"), "token")
  if (!token) token = request.headers.get("Authorization").substring(7)
  decodedJWT = jwt.verify(token, credentials.storage.secret)
  // @ts-ignore
  let storedToken = await TOKENS.get(decodedJWT.sub)
  if (isExpired(storedToken)) throw new Error("token is expired") /* TODO instead of throwing error send to refresh */
  if (storedToken != token) throw new Error("token does not match what is stored")
 }
 catch (e) {
  respBody.errors.push(factoryIError({ message: e.message, type: "oauth" }))
  return new Response(JSON.stringify(respBody), init)
 }
 respBody.body = getUsersPersonalBody(decodedJWT.sub)
 return new Response(JSON.stringify(respBody), init)
}

The boundaries of serverless are pushed everyday, though if your app just needs to authorize users, you may be better off using Cloudflare Access. We've demonstrated that a full blown OAuth 2.0 authentication server implementation can be achieved with Cloudflare Workers and Storage.

Stay tuned for a follow-up blog post on an OAuth consumer implementation.

Cloudflare's connectivity cloud protects entire corporate networks, helps customers build Internet-scale applications efficiently, accelerates any website or Internet application, wards off DDoS attacks, keeps hackers at bay, and can help you on your journey to Zero Trust.

Visit 1.1.1.1 from any device to get started with our free app that makes your Internet faster and safer.

To learn more about our mission to help build a better Internet, start here. If you're looking for a new career direction, check out our open positions.
Cloudflare AppsDevelopersCloudflare WorkersServerlessDeveloper Platform

Follow on X

Cloudflare|@cloudflare

Related posts

October 31, 2024 1:00 PM

Moving Baselime from AWS to Cloudflare: simpler architecture, improved performance, over 80% lower cloud costs

Post-acquisition, we migrated Baselime from AWS to the Cloudflare Developer Platform and in the process, we improved query times, simplified data ingestion, and now handle far more events, all while cutting costs. Here’s how we built a modern, high-performing observability platform on Cloudflare’s network. ...