TutorialsGroup Security with Firestore Storage and Next.js

Learn how to write Firebase security rules for groups of users

·6 min read
Cover Image for Group Security with Firestore Storage and Next.js

As a result of the new changes in Firebase Storage allowing you to read Firestore data, we suggest to use the new method outlined in the following article: Using Firestore in Firebase Storage Rules


Firebase Storage's security rules are relatively simple to write when the entity to protect is individual: for example, making sure that the current user can perform certain actions, or that an individual organization can access certain assets in a bucket.

What's slightly trickier with Firebase Storage is writing rules that affect several users or groups. What makes it particularly hard is the lack of context we can use in the security rules: for example, we cannot read any data from Firestore, and are confined to the session's authentication data of the signed-in user.

Among the authentication data, we can access within the Firebase Storage security rules, we also find custom claims, which are some metadata that you can attach to each user. This is great, but the bad news is that the space is extremely limited, hence we cannot simply store all the groups an individual belongs to.

For example, if your user belongs to an organization, you may want the user to be able to read all the assets belonging to the organization. Unfortunately, this is simpler said than done.

While the Firebase documentation does indeed mention a solution involving custom tokens for this specific problem, but it does not go as far as showing a proper implementation.

Thankfully, we at Makerkit have thought of a simple and painless solution, which we will describe in this blog post.

Scenario: A shared folder within an Organization

The following is an extremely common scenario in any SaaS that supports users of an organization, project, or team: sharing a folder among team members of the same team (or whatever we want to call it).

Writing rules that can guard against nonauthorized access to a specific organization would be fairly simple, but how can we write the logic necessary to protect access to the users that do not belong to the team?

  1. One way would be to use Firebase Storage from the Firebase Admin on the server, and block access using server-side logic. Unfortunately, this is cumbersome and hard to maintain, which defeats the purpose of using Firebase Storage straight from the client. So, in most cases, this is not the way to go.

  2. Another way can be using Custom Claims, and storing the group information (such as an ID) in the user's session data. Because we can access these in the Firebase Storage security rules, we can write them such to block or grant access to the user.

Let's assume our Organization has the ID 123, and the current user belongs to such organization. Now, let's try to write a security rule that knowing the user's custom claims would be able to grant access to the user:

So, let's write a rule that, given an organization organizationId, can grant access to only the users who have a custom claim named organizationId that matches the path being written.

The path we want to write to is: /{organizationId}/uploads/{fileName=**}:

match /{organizationId}/uploads/{fileName=**} {
  allow create, read: if organizationId == request.auth.token.organizationId;
  allow delete, update: if resource.metadata.organizationId == request.auth.token.organizationId;
}

This rule would prevent users who do not have the correct custom claim organizationId to rear, write, update or delete any item within that path.

The question now is, how can we assign the organizationId custom claim to the user?

We can do this when:

  • the server renders a page
  • the user changes organization

Let's see how we do it in Makerkit!

Assigning Custom Claims

On any guarded page (eg. server-side protected) of the application, we have the possibility to assign the current organization ID (passed via session cookie) to the user's metadata.

The logic is simple:

  1. fetch custom claims
  2. get the current organization ID from the cookies
  3. if these are different, then update the custom claims with the current organization ID

Assigning custom claims at page load

Assigning custom claims is also reasonably simple using the Firebase Authentication SDK.

1) First, we define the function to update the custom claims:

function setOrganizationIdCustomClaims(
  userId: string,
  organizationId: string,
  existingClaims: UnknownObject
) {
  const auth = getAuth();

  return auth.setCustomUserClaims(userId, {
    ...existingClaims,
    organizationId,
  });
}

2) Here is a snippet of code with the logic above:

const userDidChangeOrganization =
  authOrganizationId !== currentOrganizationId;

const shouldUpdateTokenClaims =
    !authOrganizationId || userDidChangeOrganization;

if (shouldUpdateTokenClaims) {
  await setOrganizationIdCustomClaims(
    user.id,
    organization.id,
    customClaims
  );
}

Where to add this logic?

Remember: the snippet above should be called before rendering the page, ideally within the getServerSideProps function.

Assigning custom claims when the user switches organization

If your users can switch to another organization, we need to make sure to update the metadata with the current organization ID.

Notice: instead of adding a new organization, we simply update it due to the limited amount of space we can store within the custom claims.

You can achieve this in two different ways:

  1. Simply refresh the page when the user changes organization (which sometimes can be good for avoiding state management issues)
  2. A more seamless way that involves sending an HTTP request to update the current user's custom claims

In Makerkit, we opted for the second option, which is a bit more complex but offers a better UX.

An API endpoint to update the user's custom claims

  1. First of all, we need an API endpoint capable of updating the user's custom claims using the newly selected organization:
import { NextApiRequest, NextApiResponse } from 'next';
import { getAuth } from 'firebase-admin/auth';

import { withAuthedUser } from '~/core/middleware/with-authed-user';
import { withPipe } from '~/core/middleware/with-pipe';
import { withMethodsGuard } from '~/core/middleware/with-methods-guard';
import { getCurrentOrganization } from '~/lib/server/organizations/get-current-organization';
import { forbiddenException, notFoundException } from '~/core/http-exceptions';

export async function organizationTokenHandler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const userId = req.firebaseUser.uid;
  const organizationId = req.cookies.organizationId;

  if (!userId || !organizationId) {
    return forbiddenException(res);
  }

  const organization = await getCurrentOrganization(userId);

  if (!organization) {
    return forbiddenException(res);
  }

  const auth = getAuth();
  const user = await auth.getUser(userId);

  if (!user) {
    return notFoundException(res);
  }

  await auth.setCustomUserClaims(userId, {
    ...(user.customClaims ?? {}),
    organizationId,
  });

  return res.send({ success: true });
}

export default withPipe(
  withMethodsGuard(['POST']),
  withAuthedUser,
  organizationTokenHandler
);
  1. Secondly, we write a hook that takes care of calling this endpoint using our hook useApiRequest:
import { useApiRequest } from '~/core/hooks/use-api';

export function useUpdateOrganizationIdToken() {
  const path = `/api/organizations/token`;

  return useApiRequest(path);
}
  1. Finally, we call the hook when the user changes the organization. This involves two steps:
  1. Executing the HTTP request written above
  2. Hard refreshing the user's custom claims using the Firebase SDK's API auth.currentUser?.getIdTokenResult(true)
import { useUpdateOrganizationIdToken } from '~/lib/organizations/hooks/use-update-organization-id-token';

// within the Component

const [updateOrganizationIdToken] = useUpdateOrganizationIdToken();

// this is called by your component when the organization ID changes
const onChange = useCallback(async () => {
  await updateOrganizationIdToken();
  await auth.currentUser?.getIdTokenResult(true);
}, [auth.currentUser, updateOrganizationIdToken]);

return <OrganizationsListBox onChange={onChange} />;

Conclusion

And that's all!

With the code above, we can easily add Group Security to our organizations, teams, projects, and any entity where multiple users are a part of in Firebase.

I hope you enjoyed this post. Please consider subscribing to our newsletter or purchasing a copy of Makerkit, where we took care of all this code for you already.


Stay informed with our latest resources for building a SaaS

Subscribe to our newsletter to receive updatesor

Read more about

Cover Image for How to sell code with Gumroad and Github

How to sell code with Gumroad and Github

·7 min read
Sell and monetize your code by giving private access to your Github repositories using Gumroad
Cover Image for Migrating to Next.js Server Components Layouts

Migrating to Next.js Server Components Layouts

·6 min read
A simple guide to migrating your _app.tsx component to the new Server Components released with Next.js 13
Cover Image for Getting Started with Next.js Server Components

Getting Started with Next.js Server Components

·8 min read
A simple introduction to using Server Components and the new Layouts Folder Structure with Next.js 13
Cover Image for Counting a collection's documents with Firebase Firestore

Counting a collection's documents with Firebase Firestore

·2 min read
In this article, we learn how to count the number of documents in a Firestore collection using a custom React.js hook.
Cover Image for Pagination with React.js and Firebase Firestore

Pagination with React.js and Firebase Firestore

·6 min read
In this article, we learn how to paginate data fetched from Firebase Firestore with React.js
Cover Image for Building Multi-Step forms with React.js

Building Multi-Step forms with React.js

·12 min read
In this article, we explain how to build Multi-Step forms with Next.js and the library react-hook-form