close icon
FGA

OpenFGA for an Express + Typescript Node.js API

How to add Fine-Grained Authorization (FGA) to an Express + Typescript API using the OpenFGA Javascript and Node.js SDK.

October 25, 2024

OpenFGA is an open-source Relationship-Based Access Control (ReBAC) system designed by Okta for developers, and adopted by the Cloud Native Computing Foundation (CNCF). It offers scalability and flexibility, and it also supports the implementation of RBAC and ABAC authorization models, moving authorization logic outside application code, making it simpler to evolve authorization policies as complexity grows. In this guide, you will learn how to secure a Node.js API with Auth0 and integrate Fine-Grained Authorization (FGA) into document operations with OpenFGA.

This tutorial was created with the following tools and services: - Node 20.10.0 - Auth0 account - Auth0 CLI 1.4.0 - Docker 24.0.7 - FGA CLI v0.2.7

Add API Authorization

Auth0 is an easy to implement, adaptable authentication and authorization platform, and you can implement authentication for any application in just minutes. With Auth0 CLI you can create access tokens and use them for identifying the user when making requests to the document API. Sign up at Auth0 and install the Auth0 CLI. Then in the command line run:

auth0 login

The command output will display a device confirmation code and open a browser session to activate the device.

You don't need to create a client application at Auth0 for your API if not using opaque tokens. But you must register the API within your tenant, you can do it using Auth0 CLI:

auth0 apis create \
  --name "Document API" \
  --identifier https://document-api.okta.com \
  --offline-access=false

Leave scopes empty and default values when prompted.

Checkout the document API repository, which already implements basic request handling:

git clone https://github.com/indiepopart/express-typescript-fga.git

The repository contains two project folders, start and final. The bare bones document API is a Node.js project in the start folder, open it with your favorite IDE. Add the express-oauth2-jwt-bearer dependency:

cd express-typescript-fga/start
npm install express-oauth2-jwt-bearer

Create the middleware for token validation in the file src/middleware/auth0.middleware.ts, with the following content:

// src/middleware/auth0.middleware.ts
import * as dotenv from "dotenv";
import { auth } from "express-oauth2-jwt-bearer";

dotenv.config();

export const validateAccessToken = auth({
  issuerBaseURL: `https://${process.env.AUTH0_DOMAIN}`,
  audience: process.env.AUTH0_AUDIENCE,
});

Call validateAccessToken from the router, for example:

// src/documents/document.router.ts
...
documentRouter.get("/", validateAccessToken, async (req, res, next) => {
  try {
    const documents = await getAllDocuments();
    res.status(200).json(documents);
  } catch (error) {
    next(error);
  }
});
...

Add error handling to error.middleware.ts, the final code should look like this:

import { Request, Response, NextFunction } from "express";
import {
  InvalidTokenError,
  UnauthorizedError,
} from "express-oauth2-jwt-bearer";

import mongoose from "mongoose";

export const errorHandler = (
  error: any,
  request: Request,
  response: Response,
  next: NextFunction
) => {
  console.log(error);

  if (error instanceof InvalidTokenError) {
    const message = "Bad credentials";

    response.status(error.status).json({ message });

    return;
  }

  if (error instanceof UnauthorizedError) {
    const message = "Requires authentication";

    response.status(error.status).json({ message });

    return;
  }

  if (error instanceof mongoose.Error.ValidationError) {
    const message = "Bad Request";

    response.status(400).json({ message });

    return;
  }

  if (error instanceof mongoose.Error.CastError) {
    console.log("handle ValidationError");
    const message = "Bad Request";

    response.status(400).json({ message });

    return;
  }

  const status = 500;
  const message = "Internal Server Error";

  response.status(status).json({ message });
};

Copy .env.example to .env and add the properties:

AUTH0_AUDIENCE=https://document-api.okta.com
AUTH0_DOMAIN=<your-auth0-domain>

Run the MongoDB database with:

docker compose up mongodb mongo-express

Run the API with:

npm install && npm run dev

Obtain a test access token with Auth0 CLI:

auth0 test token -a https://document-api.okta.com -s openid

Choose an available [Generic] client when prompted. Set the access token in an environment var:

ACCESS_TOKEN=<access-token>

Create a document with cURL:

curl -i -X POST \
  -H "Authorization:Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "planning.doc"}' \
  http://localhost:6060/api/documents

The output should look like:

{
  "name": "planning.doc",
  "_id": "66feb9c1f106b84c28644d3e",
  "__v": 0
}

Retrieve all documents:

curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:6060/api/documents

The output should look like:

[
  {
    "_id": "66feb9c1f106b84c28644d3e",
    "name": "planning.doc",
    "__v": 0
  }
]

Verify access is denied if the access token is not present:

curl -i localhost:6060/api/documents

The response code should be 401 Unauthorized.

Initialize an authorization model in OpenFGA

At a high level, an authorization model is defined by indicating user types, object types, and relationships between them. As we are not going to deep-dive on ReBAC in this guide, you can refer to OpenFGA documentation for learning about modeling concepts. Under the Advanced use-cases section in the doc, there is a simplified authorization model for a Google Drive application ready to test. Prepare the model for import to the OpenFGA service, creating the file auth-model.fga in the directory start/openfga:

model
  schema 1.1

type user

type document
  relations
    define owner: [user, domain#member] or owner from parent
    define writer: [user, domain#member] or owner or writer from parent
    define commenter: [user, domain#member] or writer or commenter from parent
    define viewer: [user, user:*, domain#member] or commenter or viewer from parent
    define parent: [document]

type domain
  relations
    define member: [user]

For saving the authorization model in an OpenFGA store, it must be transformed to JSON format with FGA CLI:

fga model transform --file=auth-model.fga > auth-model.json

Run the OpenFGA service with:

docker compose up openfga

Create a store:

 export FGA_API_URL=http://localhost:8090
 fga store create --name "documents-fga"

Set the store id in the output as an env var, and write the model:

 export FGA_STORE_ID=<store-id>
 fga model write --store-id=${FGA_STORE_ID} --file auth-model.json

Set the model ID in the output as an env var:

 export FGA_MODEL_ID=<model-id>

Add Fine-Grained Authorization (FGA) with OpenFGA

First, add the following vars to the .env file:

FGA_API_URL=http://localhost:8090
FGA_STORE_ID=<store-id>
FGA_MODEL_ID=<model-id>

Create a middleware for permission checks using OpenFGA Node.js SDK. Install the dependency:

npm install @openfga/sdk

Add the file src/middleware/openfga.middleware.ts:

// src/middleware/openfga.middleware.ts
import * as dotenv from "dotenv";
import { NextFunction, Request, Response } from "express";
import { ClientCheckRequest, OpenFgaClient } from "@openfga/sdk";

dotenv.config();

export class PermissionDenied extends Error {
  constructor(message: string) {
    super(message);
  }
}

const fgaClient = new OpenFgaClient({
  apiUrl: process.env.FGA_API_URL, // required
  storeId: process.env.FGA_STORE_ID, // not needed when calling `CreateStore` or `ListStores`
  authorizationModelId: process.env.FGA_MODEL_ID, // Optional, can be overridden per request
});

export const forView = (req: Request): ClientCheckRequest => {
  const userId = req.auth?.payload.sub;
  const tuple = {
    user: `user:${userId}`,
    object: `document:${req.params.id}`,
    relation: "viewer",
  };
  return tuple;
};

export const forUpdate = (req: Request): ClientCheckRequest => {
  const userId = req.auth?.payload.sub;
  const tuple = {
    user: `user:${userId}`,
    object: `document:${req.params.id}`,
    relation: "writer",
  };
  return tuple;
};

export const forDelete = (req: Request): ClientCheckRequest => {
  const userId = req.auth?.payload.sub;
  const tuple = {
    user: `user:${userId}`,
    object: `document:${req.params.id}`,
    relation: "owner",
  };
  return tuple;
};

export const forCreate = (req: Request): ClientCheckRequest | null => {
  const userId = req.auth?.payload.sub;
  const parentId = req.body.parentId;
  const tuple = parentId
    ? {
        user: `user:${userId}`,
        object: `document:${parentId}`,
        relation: "writer",
      }
    : null;
  return tuple;
};

export const checkPermissions = (
  createTuple: (req: Request) => ClientCheckRequest | null
) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      const tuple = createTuple(req);

      console.log("tuple", tuple);

      if (!tuple) {
        next();
        return;
      }
      const result = await fgaClient.check(tuple);

      if (!result.allowed) {
        next(new PermissionDenied("Permission denied"));
        return;
      }

      next();
    } catch (error) {
      next(error);
    }
  };
};

Call the middleware from the document router:

// src/documents/document.router.ts

documentRouter.post(
  "/",
  validateAccessToken,
  checkPermissions(forCreate),
  async (req, res, next) => {
    try {
      const document = await saveDocument(req.body);
      res.status(200).json(document);
    } catch (error) {
      next(error);
    }
  }
);

documentRouter.put(
  "/:id",
  validateAccessToken,
  checkPermissions(forUpdate),
  async (req, res, next) => {
    try {
      const document = await updateDocument(req.params.id, req.body);
      if (!document) {
        res.status(404).json({ message: "Document not found" });
        return;
      }
      res.status(200).json(document);
    } catch (error) {
      next(error);
    }
  }
);

documentRouter.delete(
  "/:id",
  validateAccessToken,
  checkPermissions(forDelete),
  async (req, res, next) => {
    try {
      const document = await deleteDocumentById(req.params.id);
      if (!document) {
        res.status(404).json({ message: "Document not found" });
        return;
      }
      res.status(200).send();
    } catch (error) {
      next(error);
    }
  }
);

documentRouter.get(
  "/:id",
  validateAccessToken,
  checkPermissions(forView),
  async (req, res, next) => {
    try {
      const document = await findDocumentById(req.params.id);
      if (!document) {
        res.status(404).json({ message: "Document not found" });
        return;
      }
      res.status(200).json(document);
    } catch (error) {
      next(error);
    }
  }

Update the error handling middleware:

// src/middleware/error.middleware.ts
if (error instanceof PermissionDenied) {
  const message = "Permission denied";

  response.status(403).json({ message });

  return;
}

Send requests to the Express API

Run the API and try a read operation:

curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:6060/api/documents
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:6060/api/documents/<document-id>

It should fail with the following 403 Forbidden response:

{
  "message": "Permission denied"
}

Go to https://jwt.io/ and decode the Auth0 access token, and copy the sub claim value to use it as UserIDº.

Then grant read access to the document with FGA CLI:

fga tuple write --store-id=${FGA_STORE_ID} --model-id=$FGA_MODEL_ID 'user:<sub-claim>' viewer document:<document-id>

You can add other relationships for the user and document like owner, writer. Retry the read operation and it should succeed with 200 OK.

Learn more about Node.js and Fine-Grained Authorization

In this post, you learned about OpenFGA integration to a Node.js API using the OpenFGA Javascript and Node.js SDK. I hope you enjoyed this quick tutorial, and if you'd rather skip the step-by-step and prefer running a sample application, follow the README instructions in the same repository.

Also, if you liked this post, you might enjoy these related posts:

Check out the Javascript resources in our Developer Center:

Please follow us on Twitter @oktadev and subscribe to our YouTube channel for more Node.js and microservices knowledge.

You can also sign up for our developer newsletter to stay updated on everything Identity and Security.

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon