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

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 { withMiddleware } from '~/core/middleware/with-middleware';
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 withMiddleware(
  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 updates

Read more about
Tutorials

Cover Image for Caching a Next.js API with Redis

Caching a Next.js API with Redis

·5 min read

Find out how to cache a Next.js Serverless API with Redis

Cover Image for The complete guide to Stripe and Next.js

The complete guide to Stripe and Next.js

·20 min read

Learn everything you need to start collecting payments for your Next.js application with Stripe Checkout

Cover Image for Improve your Next.js website Core Web Vitals

Improve your Next.js website Core Web Vitals

·7 min read

In this post, we share how to optimize the performance of your Next.js website and improve your Core Web Vitals

Cover Image for How to call an API with Next.js

How to call an API with Next.js

·8 min read

Learn how to call API endpoints in your Next.js application

Cover Image for When to use SSR with Next.js

When to use SSR with Next.js

·4 min read

Learn when to use SSR or SSG with your Next.js application

Cover Image for Blocking authentication with Firebase Auth Functions

Blocking authentication with Firebase Auth Functions

·2 min read

Firebase has introduced functions that allow us to write server logic before or after authenticating. Let's see how to use them.