Using Cerbos authorization with JSON Web Tokens

Published by Fernando Doglio on October 24, 2024
Using Cerbos authorization with JSON Web Tokens

JSON Web Tokens (or JWT) is a tried and true technique used to authenticate users & systems against stateless systems (mainly API).

But what happens when, on top of your authentication flow, you also want to include or integrate authorization?

That's right, we're now opening up that can of worms, and lucky for you, there is a straightforward answer: JWT + Cerbos.

Let's take a look at how we can do this without breaking a sweat.

What exactly is Cerbos?

If for some reason you're reading this and you don't know what the blip is Cerbos, I got your back: Cerbos is an open-source, policy-based authorization engine designed to make authorization simple, scalable, and maintainable.

That's a fancy way of saying that with Cerbos you can decouple your authorization logic from your application's code, allowing you to define, manage, and enforce complex access control policies easily.

Check out Cerbos' Github project if you'd like to know more about their OSS solutions (called PDP), or check out Cerbos Hub if you don't want to worry about anything.

What's the difference between authentication and authorization?

Just to make sure we're all on the same page, while JWT is great for authentication (A.K.A AuthN), it's not that great for authorization (A.K.A AuthZ). Which is where Cerbos comes in. Comment Suggest edit

However, what's the difference between AuthZ and AuthN?

  • Authentication is the process of verifying the identity of a user or system. In digital systems, authentication typically involves methods like passwords, biometric scans, multi-factor authentication, or, you guessed it, token exchange (as in JSON Web Tokens). The goal is to establish the authenticity of the user before granting them access to the system and in following requests performed by the user.

  • Authorization, on the other hand, comes into play after authentication. It’s the process of determining what an authenticated user is allowed to do once inside the system.

In short: With AuthN you get through the door. With AuthZ you know what you can interact with.

Implementing JWT in JavaScript with Cerbos

Granted, JWT can be implemented in any language, and given how popular the methodology is, no matter what programming language you're using, chances are there is a library/module for you to use.

However, since we're showing you a practical example, here's how you can implement the JWT + Cerbos combo in JavaScript (specifically using NextJS).

0. What are we implementing?

To give you a simple example of how to mix these 2 technologies, we're going to implement a simple set of API routes:

  1. A log-in route that will authenticate you and give you back a JWT token.
  2. A secure API route, which will request that you provide a valid token and once validated, it will also double-check if your specific user has access to this route.

The logic from step #2 will be mainly handled by Cerbos (that way, we don't have to worry about it).

1. Set up Next.js

First, let's set up a Next.js project for us to work on. You can easily do that, assuming of course you've already installed Node.js on your dev computer:

npx create-next-app jwt-next-cerbos
cd jwt-next-cerbos
npm install jsonwebtoken @cerbos/grpc

With those 3 lines, we've:

  1. Created the project with the boilerplate code.
  2. And we've installed the JWT library and the Cerbos' GRPC client.

Let's now set up the API route for logging into our simple service.

Create these two files: app/login/route.js and app/secured/route.js:

app/login/route.js

import jwt from 'jsonwebtoken';
import { NextResponse } from 'next/server';

const SECRET_KEY = 'your_secret_key';

export const POST = async (req) => {
  const { username, password } = await req.json();
  // Replace with your user validation logic
  if (username === 'admin' && password === 'password') {
    const token = jwt.sign({ username, role: 'admin' }, SECRET_KEY);
    return NextResponse.json({ token });
  } else if (username === 'user' && password === 'password') {
    const token = jwt.sign({ username, role: 'user' }, SECRET_KEY);
    return NextResponse.json({ token });
  } else {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 403 });
  }
};

This file takes the username and password received as part of the request, and checks if they're correct.

Here we would usually validate the data against a secured database, but considering the simplicity of this example, we're hardcoding everything!

If the username/password combo is correct, we're creating the token, and for that, we need to "sign" the token that will be generated using the data received in the first argument of the jwt.sign method.

Note that here we're passing a JSON object with the username and their role ('admin'). This information will be useful in a second, so remember this.

The second attribute is the secret key used, this is, obviously, a secret value that only you should know. This value will also be used as part of the validation logic to make sure the token received on the secured endpoint is valid (it won't be valid if it was signed using a different secret value).

Let's now set up the secured endpoint.

This is essentially a regular endpoint, with the addition of using Cerbos to check if we're actually allowed to access the resource.

app/secured/route.js

import jwt from 'jsonwebtoken';
import { GRPC } from '@cerbos/grpc';
import { NextResponse } from 'next/server';

const SECRET_KEY = 'your_secret_key';
const cerbos = new GRPC('localhost:3593', {tls: false});

export const GET = async (req) => {
  const token = req.headers.get('authorization');
  if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  try {
    const decoded = jwt.verify(token, SECRET_KEY);
    const { role } = decoded;

    const resource = {
      resourceKind: 'document',
      resourceId: '123',
      policyVersion: 'default',
      actions: ['read'],
    };

    const decision = await cerbos.check(role, resource);
    if (decision.isAllowed('read')) {
      return NextResponse.json({ message: 'This is a protected route and you have access to it!', user: decoded });
    } else {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }
  } catch (err) {
    return NextResponse.json({ error: 'Invalid token' }, { status: 403 });
  }
};

This endpoint is doing the following:

  1. Getting the JWT token from the 'authorization' header.
  2. Verifying the token, which uses the secret value. This process returns the original object we used to sign the token. This means we're getting the username and the role of the user (and all of that without having to query our database!).
  3. We use the user data as part of the payload for the cerbos.check method.

If we are, indeed, allowed to read the endpoint, we'll get a successful response. Otherwise, we'll get a 403 error (read more on the 403 and 401 error codes here).

2. Integrate Cerbos

By now you have your endpoints defined, however, we've not executed Cerbos anywhere, nor have we properly defined the access rules. So let's do just that.

Create a simple policies file inside the /policies folder. Keep in mind this is going to be a YAML file.

Let's create the policies/document.yaml file that looks like this:

policies/document.yaml

# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: default
  resource: document
  rules:
    - actions:
        - create
        - read
        - update
        - delete
      effect: EFFECT_ALLOW
      roles:
        - admin

    - actions:
        - read
      effect: EFFECT_ALLOW
      roles:
        - user

This policy file specifies that for all resources of type "document", admins can do everything and the rest (users or less) have only read access.

We now have to execute Cerbos somehow. There are several options, but the easiest one is to use a Docker image and run it using the following line:

docker run --rm --name cerbos -t \
  -v $(pwd)/policies:/policies \
  -p 3592:3592 \
  ghcr.io/cerbos/cerbos:latest server

3. Run the server

The final step is to run the server and test our logic.

To start your server, use the simple command:

npm run dev

1. Obtain JWT token by logging in

To test the login route and obtain a JWT token, run the following curl command:

curl -X POST http://localhost:3000/app/login -H "Content-Type: application/json" -d '{"username": "admin", "password": "password"}'

If the credentials are valid, you should receive a response containing a JWT token, like:

{
  "token": "your_jwt_token_here"
}

2. Access the secured route

After obtaining the token, you can use it to access the /secured route:

curl -X GET http://localhost:3000/app/secured -H "Authorization: your_jwt_token_here"

Replace your_jwt_token_here with the token you received in the previous step.

If the authorization is successful, you should receive a response similar to:

{
  "message": "This is a protected route",
  "user": {
    "username": "admin",
    "role": "admin"
  }
}

Final thoughts

In the end, using JWT for authentication is quite straightforward, as long as you remember to keep your secret key… secret, of course.

And in terms of authorization, it would be quite the enterprise to build it, however, thanks to Cerbos, we're able to have everything up & running in minutes, and all the code we need to add is just an IF statement.

Are you using JWT already on your system? Check out Cerbos' open-source version - Cerbos PDP, give it a try, and then realize you've been wasting a lot of your valuable time implementing something that's already been solved.

Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team