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:
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.
The auth server redirects the user to the consumer service with a code.
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.
The auth server gives the consumer the access token.
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:
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.