Disclaimer: I am not a security expert. This is an implementation based on my learnings and for my particular use-case. If you use it, you do so at your own risk.
🔬 Why?
After trying several services like aws cognito, netlify and a combination of spa hosting + serverless backends I found a lot of them lacking in functionality, price and dev UX. After more trial and error, I found cloudflare pages paired with its functions capabilities to be able to handle anything I throw at it including websockets, storage and basic http compatibility. In addition Cloudflare pages have a decent free teir and make static application hosting a breeze.
The one thing I was missing for my latest experiment was a reliable way to authenticate users and allow them access to their profile. There are other services that can be easily integrated but they can get quite expensive. Firebase on the other hand has a good free quota; perfect for this use case so I started by installing the Firebase Admin SDK. But alas the admin sdk has some dependencies on nodejs which are not included in the workers/functions runtime. 🤦♂️
Luckily after another rummage in the firebase docs, it turns out its possible to manually verify the User's JWT token so we're in business 😊 Some hacking required.
⚗️ Setup
For this example we'll need a cloudflare pages project and function to go with it. Below we'll create a skeleton page-function that verifies that the User is legit.
After setting up a Cloudflare Page Project, we can create a backend function, simply by creating a folder 'functions/api'.
Let's call the function list-files; therefore in the functions/api folder we create list-files.ts
// functions/api/list-files.ts
export async function onRequest(context) {
const {
request, // same as existing Worker API
env, // same as existing Worker API
params, // if filename includes [id] or [[path]]
waitUntil, // same as ctx.waitUntil in existing Worker API
next, // used for middleware or to fetch assets
data, // arbitrary space for passing data between middlewares
} = context;
let result = {};
return new Response(result, {
headers: {
'content-type': 'application/json;charset=UTF-8',
},
});
}
⚙️ CF Functions Middleware
If your backend only has one endpoint you could add JWT verification code straight into the onRequest method. For more complex setups, middleware functions offer a more elegant pattern. Middleware code can be run before and/or after any request in your functions folder. The scope of the the middleware is defined by its location in the hierarchy.
So let's add _middleware.ts in functions/api. All Requests in that folder will now run the middleware, so if you intend to have a mix of auth and public methods you may want to create a couple more sub folders.
// functions/api/_middleware.ts
const checkIfAuthenticated = async ({ request, next, data }) => {
return await next();
};
export const onRequest = [checkIfAuthenticated];
A middleware file can have multiple functions, as long as they are an exported array. When a middleware function is called it has access to the request object; information can be passed between middleware's and onRequest functions using the data variable; And the next hof allows control on when the next layer is called before and or after the logic in this method.
👮♀️ Validating JWT Tokens
To authenticate a user we'll need
- The correct public certificate
- A function to get the user JWT
- Some logic to decode and verify values
Retrieving the public certificate
- First let's install jose. It's a nifty 0 dependency library with functions to hash, encode and decode all sorts of data.
- In the function below, we fetch the certificates (the response has two). This function is called with every request and fetching every time will slow things down, thus it makes sense to cache.
- The documentation states that we should check the cache expiry value in the header to determine when to refresh. We'll do that later
- Finally we use jose and import the cert using RS256 and save it to memory.
import * as jose from 'jose'
let rsaPublicKey = {};
const algorithm = 'RS256';
async function getPublicCert() {
if (Object.keys(rsaPublicKey).length == 0) {
const response = await fetch('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com');
const publicKeys = await response.json();
//TODO Add cache expiry
console.log('Cert expires on', response.headers);
for (const [key, value] of Object.entries(publicKeys)) {
rsaPublicKey[key] = await jose.importX509(publicKeys[key], algorithm);
}
}
}
Get the JWT Token
Next a helper function to retrieve the user token. The token is passed via the header 'authentication' key.
The value should be Bearer so the code below extracts that and returns the token.
const parseToken = (req) => {
// Authorization: "Bearer 123asdasdasd"
if (req.headers.get('authorization') && req.headers.get('authorization').split(' ')[0] === 'Bearer') {
return req.headers.get('authorization').split(" ")[1];
} else {
return null;
}
}
With the cert and the token in hand we can finally validate the user info
- First we get the cert and user token
- Next we get that the correct certificate. Usually there are two, so we need kid in the token to get the correct one.
- jose.jwtverify verifies the token has been issued by google. If this fails we stop here and return an authorised response.
- If successful we can access the payload. Firebase docs then states we should do a number of checks as shown in the if condition
- You'll want to update with your own.
- Next we add the payload to data.user so its available to the rest of the pipeline
- And Finally we call next() to process the next part of the pipeline
const checkIfAuthenticated = async ({ request, next, data }) => {
await getPublicCert();
const authToken = parseToken(request);
try {
const ph = JSON.parse(Buffer.from(authToken.split('.')[0], 'base64').toString());
const { payload, protectedHeader } = await jose.jwtVerify(authToken, rsaPublicKey[ph.kid]);
//Verify
const now = new Date().getTime() / 1000;
if (payload.exp < now
|| payload.auth_time > now
|| payload.iat > now
|| payload.aud !== `<fire-base-project-id>`
|| payload.iss !== 'https://securetoken.google.com/<firebase-project-id>'
|| (!payload.sub || payload.sub != payload.user_id)) {
return new Response(`Failed Token Validation`, { status: 401 });
}
data.user = payload;
}
catch (e) {
console.log(e);
return new Response(`${e.message}`, { status: 401 });
}
const res = await next();
return res;
};
Use the User object.
Accessing the user info is straight forward enough. Since im using type script I created a couple of Types to make life easier
type Env = {
STORE: R2Bucket
}
type MiddleData = {
user?: {
aud: string,
auth_time: number,
user_id: string,
sub: string,
iat: number,
exp: number,
email: string,
email_verified: boolean,
firebase: any
}
}
Now when we need the user's info we simply access context.data.user 💥
export async function onRequestPost(context: EventContext<Env, null, MiddleData>) {
//Contents of context object
const {
request, // same as existing Worker API
env, // same as existing Worker API
data, // arbitrary space for passing data between middlewares
} = context;
const user = data?.user;
if (!user) {
return new Response('Not logged in', { status: 403 });
}
// As need get user's email or id
console.log('Processing', user.email)
//or
console.log('User Id', user.user_id)
}
🍪 Client side
Last piece of the puzzle is making sure the Token is added to server requests.
I'm using axios so it looks something like
import { getAuth } from "firebase/auth";
export async function getToken() {
const auth = getAuth();
const user = auth.currentUser;
if (user == null) {
throw "Can't sync you are not logged in";
}
const token = await user.getIdToken();
return token;
}
async function DoAuthenticatedServerCall() {
const token = await getToken();
const config = token
? { headers: { Authorization: `Bearer ${token}` } }
: {};
const response = await axios.post(url, request, config);
}
Full code example here
and that's it! Hope you found this useful. G'day!!
Top comments (0)