javascriptfirebasegoogle-cloud-functionsfirebase-toolsgoogle-iam

How to initialize v2 Firebase Cloud Functions to authenticate as Firebase Admin SDK service account?


I really love Google products but sometimes the documentation can be painfully terse or fragmented. I read this incredibly eloquent answer by someone who was as clueless as me who felt compelled to write a step-by-step children's guide. Unfortunately, his answer was too specific to his project and not mine.

I have a number of cloud functions from an existing Firebase project (deployed prior to v2 but have since been refactored for v2) that I've cloned into a new project and I'm running into obstacles that I've never encountered before. This is because in the past, everything was funneled through Firebase, and now, it's all done in Google Cloud. And since v2, GC has been significantly buffed with security protocols and permission controls and the documentation hasn't kept up, unfortunately.

The following function throws an insufficient-permission error:

const {initializeApp} = require("firebase-admin/app");
const {getAuth} = require("firebase-admin/auth");
const {onDocumentCreated} = require("firebase-functions/v2/firestore");

initializeApp();

exports.assignAdminPrivileges = onDocumentCreated("admins/{docId}", async (event) => {
    try {
        const doc = event.data.data();
        const emailAddress = doc.private.emailAddress;
        const userRecord = await getAuth().getUserByEmail(emailAddress);

        return getAuth().setCustomUserClaims(userRecord.uid, {"admin": true});
    } catch (error) {
        throw new Error(error);
    }
});

As far as I understand, this function, by default, uses the "default compute service account" in Google Cloud for its authentication, which has insufficient permissions to execute this task. And, I believe, that this function should be using the "Firebase Admin SDK service account" instead, a service account that came with this Google Cloud account. To be honest, I'm not even sure this will work but it makes the most sense to me. Now, there are a number of ways to accomplish this and I believe that the best approach for my use case is to download the key of the Firebase Admin SDK service account and use it somehow when I deploy this function, despite all of the warnings by Google urging me not to download this key. And I must use this key (packaged as a JSON file) in concert with a custom initialization of the Admin SDK in the source code.

const {initializeApp} = require("firebase-admin/app");

initializeApp({
    credential: // I think this is what I need, but what goes here?,
});

// and what do I do with the JSON file?
  1. How do I initialize the Admin SDK properly in the source code with Credential so that the function uses the Firebase Admin SDK service account for authentication?
  2. What do I do with the JSON file that I've successfully obtained from the GC console that contains the Admin SDK service account's credentials (including its private key)?

Please be as specific as you can, I would greatly appreciate it.


Solution

  • Working with Firebase in concert with Google Cloud (GC) is not what it used to be. Unlike before, GC now comes locked down with permission controls out of the box. The purpose is to compel projects only to grant the specific access when and where it's needed, across the board. If you want to grant access to something, you will often have to first grant access to yourself to be able to grant access to something else.

    When you deploy Firebase Cloud Functions using the Firebase CLI, the account that is logged into the CLI is the account that will need permissions to perform the deployment. This is most likely your Firebase user account. In GC, navigate to IAM and grant this user account the necessary roles. We're never told what roles are needed because what Google wants us to do is to hit errors on purpose so that we can decipher from the error messages precisely what permissions are needed. So do that here.

    After the logged-in Firebase account has the necessary roles, you must initialize the app in your source code with another set of credentials. Here, you will likely want to initialize the app with a service account. Service accounts are accounts like any other, that can take on roles, except that they don't have owners, they have borrowers. They're there to be borrowed by whoever or whatever has permission to borrow it. And the ideal service account here will likely be the Firebase Admin SDK service account, which comes with every Firebase project by default, readymade with a wide scope of roles relevant for Firebase work.

    The official documentation is a bit confusing here because it will recommend both (a) passing nothing to the initializeApp() method in the source code (to make use of the default service account) and (b) passing credentials to the initializeApp() method, ideally using a JSON file. The problem is that using JSON credentials poses a security risk, which Google warns you about when you attempt to download the highly-sensitive keys. But if you don't use them, the default service account won't do because it's not the Firebase Admin SDK service account, it's the default Compute Engine service account.

    There is a third option, and that is to change the default service account for the function itself, which can be done either in source code or in GC. To do it in GC, the function must first be deployed, and then navigate to Cloud Run, open the function, and edit and deploy a new revision, where you can change the default service account in the security tab.

    To do it in source code, use the GlobalOptions object:

    setGlobalOptions({
        serviceAccount: "firebase-adminsdk-xyz@my-project-xyz789.iam.gserviceaccount.com",
    });
    

    If you prefer using keys instead, then navigate to IAM, then navigate to service accounts, then select the Firebase Admin SDK service account, then navigate to keys, and add a key (opt for JSON). This will allow you to download a JSON file onto your computer. You can put that file anywhere on your computer but for this example I will put it in the "functions" folder where the "index.js" file resides and name it "service-account-credentials.json" for this example. Be warned that pushing this file to publicly-accessible remote repos could get that key revoked by GC. You may not want to check this file into source control at all depending on your situation. Regardless, use this file to initialize the app instance itself in the function's source code:

    const {initializeApp, cert} = require("firebase-admin/app");
    
    initializeApp({
        credential: cert("./service-account-credentials.json"),
    });
    

    Note that for v1 functions, you will need to use the JSON file. Setting the service account in the global options is in the v2 API. Therefore, if you have a mix of v1 and v2 functions, employ both—set it globally and supply a JSON certificate.

    Now you should be able to deploy functions from the Firebase CLI and the functions should execute when called... depending on what they do.

    You can pretty much forget the Firebase console for managing your cloud functions because everything is now performed in GC. You can even deploy your functions using the gcloud CLI, which is more robust than the Firebase CLI, but that isn't necessary here. Just understand that your cloud functions are deployed to GC, not Firebase. Firebase is an abstraction layer for Cloud Functions mostly useful for seeing what exists in GC.

    By default, functions deployed to GC are guarded with permission controls, as you would now expect--specifically, they all require authentication to be invoked. Therefore, if you deployed a function that can be called by "random" clients, like users of an app, such as a function that creates a Firebase user account for a user that doesn't already have one, that call will fail with a permission error. And that is because the function was invoked by an unauthenticated entity. The app instance that served the function was properly initialized by a trusted service account (the Firebase Admin SDK service account) but the entity that invoked the function was an unauthenticated user. What you must do, in a case like this, is to navigate to Cloud Run (in GC), check the box next to this function, navigate to permissions, add a principal, type "allUsers", and select the Cloud Run/Cloud Run Invoker role. If your function is still factored for v1 then this is done in the Cloud Functions tab and the role is Cloud Functions/Cloud Functions Invoker. Now this function can be invoked by unauthenticated users.

    It's worth noting that to determine which roles to add when you encounter an error, open GC and navigate to Logging and look for errors in the log. Expand the errors to read their messages and they will sometimes tell you exactly which permissions are missing and other times it will require some detective work.

    You will also undoubtedly run into constraint issues when trying to loosen permissions. Constraints are permission controls imposed on organizations. When you are met with a constraint error, such as a domain restricted sharing constraint error, which you will likely encounter at some point, navigate to IAM, then navigate to organization policies. Here, you can search for the constraint that is preventing you from doing what it is you're trying to do. To disable this constraint, however, you may need to grant the account you're logged in with a role that gives it the authority to edit organization policies.