5

I am trying to use the integration between Google Secrets Manager API and Firebase Functions to load environment variables into my Firebase functions, however they are all coming up as undefined. I was previously using .env. files to load these variables, which worked fine before I tried this, but now also isn't working! I'm using Node.js.

To set secrets on the Secrets API, I ran:

firebase functions:secrets:set MY_SECRET

I verified the secrets had been set successfully by running the following on each one:

firebase functions:secrets:access MY_SECRET

I'm defining my functions in index.ts as follows:

import * as functions from 'firebase-functions'
import apiApp from "./api/api"

const REGION = "my region as a string"
const secrets = ["SERVICE_ACCOUNT"]

export const api = functions
  .region(REGION)
  .runWith({ secrets })
  .https.onRequest(apiApp)

And in code, I'm accessing them with process.env.MY_SECRET. However, when I run firebase serve (to run in the Firebase emulator) or firebase deploy, I always get this error followed by a stack trace resulting from the env variable being undefined:

Error: Error occurred while parsing your function triggers.

InvalidCharacterError
    at /.../functions/node_modules/base-64/base64.js:23:36
    at Object.<anonymous> (/.../functions/node_modules/base-64/base64.js:164:2)
    at Module._compile (node:internal/modules/cjs/loader:1097:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1151:10)
    at Module.load (node:internal/modules/cjs/loader:975:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Module.require (node:internal/modules/cjs/loader:999:19)
    at require (node:internal/modules/cjs/helpers:102:18)
    at Object.<anonymous> (/.../functions/lib/admin.js:5:16)
    at Module._compile (node:internal/modules/cjs/loader:1097:14)

admin.ts:

import * as admin from 'firebase-admin'
import * as base64 from 'base-64'

const serviceAccount = JSON.parse(base64.decode(process.env.SERVICE_ACCOUNT))
const credential = admin.credential.cert(serviceAccount)

admin.initializeApp({ credential })

...

(I'm base64 decoding one of the secrets and get an error because it's undefined)

package.json:

{
  "name": "functions",
  "description": "Cloud Functions for Firebase",
  "scripts": {
    "build": "tsc",
    "serve": "npm run build && firebase emulators:start --only functions",
    "shell": "npm run build && firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log",
    "postbuild": "copyfiles -u 1 src/**/*.handlebars src/**/*.json lib/"
  },
  "engines": {
    "node": "16"
  },
  "main": "lib/index.js",
  "dependencies": {
    ...
    "base-64": "^1.0.0",
    "firebase-admin": "^10.0.2",
    "firebase-functions": "^3.18.0",
    ...
  },
  "devDependencies": {
    "@babel/runtime": "^7.17.2",
    "@types/base-64": "^1.0.0",
    ...
  },
  "private": true
}

I've tried modifying the code so I don't run into errors right away, but this just means my endpoints error later on because the env variable is undefined.

What is going wrong?

benomatis
  • 5,536
  • 7
  • 36
  • 59
mef27
  • 379
  • 4
  • 15
  • Hi @mef27, I've tried to reproduce your encountered error but I've had no success. I managed to serve or deploy using your code above. Could you please include the complete error stack-trace, package.json, and if possible, include the steps on how to reproduce your error. – Marc Anthony B Mar 15 '22 at 06:55
  • Just to add, does the `REGION` in `.region(REGION)` an env variable? if yes, try to change it to a string of your desired region. for e.g.: `.region('us-central1')`. You can't pass an env variable into firebase function that would cause an `undefined`. – Marc Anthony B Mar 15 '22 at 07:05
  • Hi Marc, thanks for answering. REGION is a hardcoded string. I'll update my question with these details – mef27 Mar 15 '22 at 11:44

3 Answers3

3

What you're doing will result in undefined due to incorrect ways of accessing secrets. In this code snippet:

import * as functions from 'firebase-functions'
import apiApp from "./api/api"

const REGION = "my region as a string"
const secrets = ["SERVICE_ACCOUNT"]

export const api = functions
  .region(REGION)
  .runWith({ secrets })
  .https.onRequest(apiApp)

You're adding the secret to the env variables which then can only be used on the .https.onRequest(apiApp). E.g.

app.get('/', (req, res) => {
    console.log(process.env.SERVICE_ACCOUNT);
    return res.send(`Done!`);
  });

const secrets = ["SERVICE_ACCOUNT"];

export const api = functions
.region('us-central1')
.runWith({ secrets })
.https.onRequest(app);

The above code will log the SERVICE_ACCOUNT secret on the function which you passed to. It's also stated in this documentation:

Only functions that specifically include a secret in their runWith parameter will have access to that secret as an environment variable. This helps you make sure that secret values are only available where they're needed, reducing the risk of accidentally leaking a secret.


For you to be able to access your secret without using the .runWith parameter of the https functions, you must first install the @google-cloud/secret-manager:

npm i @google-cloud/secret-manager

then initiate it:

import {SecretManagerServiceClient} from '@google-cloud/secret-manager';
const client = new SecretManagerServiceClient();

Accessing your secret versions:

/**
 * TODO(developer): Uncomment these variables before running the sample.
 */
// const name = 'projects/my-project/secrets/my-secret/versions/5';
// const name = 'projects/my-project/secrets/my-secret/versions/latest';

async function accessSecretVersion() {
  const [version] = await client.accessSecretVersion({
    name: name,
  });

  // Extract the payload as a string.
  const payload = version.payload.data.toString();

  // WARNING: Do not print the secret in a production environment - this
  // snippet is showing how to access the secret material.
  console.info(`Payload: ${payload}`);
}

accessSecretVersion();

For reference, here's the compiled code based on your admin.ts:

import * as admin from 'firebase-admin';
import {SecretManagerServiceClient} from '@google-cloud/secret-manager';
import * as base64 from 'base-64';

const client = new SecretManagerServiceClient();

// Must follow expected format: projects/*/secrets/*/versions/*
// You can always use `latest` if you want to use the latest uploaded version.
const name = 'projects/<PROJECT-ID>/secrets/SERVICE_ACCOUNT/versions/latest'
  
 let credentials: admin.app.App;

 export const db = async (): Promise<admin.app.App> => {
  if (credentials) {
      return credentials;
  } else {
      const [version] = await client.accessSecretVersion({
          name: name
      });
      const result: any = JSON.parse(version?.payload?.data?.toString());
      const params = {
          type: result.type,
          projectId: result.project_id,
          privateKeyId: result.private_key_id,
          privateKey: result.private_key,
          clientEmail: result.client_email,
          clientId: result.client_id,
          authUri: result.auth_uri,
          tokenUri: result.token_uri,
          authProviderX509CertUrl: result.auth_provider_x509_cert_url,
          clientC509CertUrl: result.client_x509_cert_url,
      };
      credentials = admin.initializeApp({
          credential: admin.credential.cert(params),
          storageBucket: `gs://${result.project_id}.appspot.com`,
      });
      return credentials;
  }
};

You can then import admin.ts and call db with these method.

For more information, check out these documentations:

You may also want to checkout Secret Manager Best Practices.

Marc Anthony B
  • 3,635
  • 2
  • 4
  • 19
  • Is the issue that my imports in index.ts eventually require that admin.ts is called? – mef27 Mar 15 '22 at 15:30
  • Also, the admin.ts file runs at the top level and exports db = admin.firestore(), how can I still do that if admin depends on an async process? – mef27 Mar 15 '22 at 16:28
  • Hi @mef27, I've updated my answer. You can integrate secret manager on `exports db` itself, import it and call it anywhere if needed to. – Marc Anthony B Mar 16 '22 at 02:14
  • 1
    Hi, I ended up getting it to work without using the secret-manager package. My problem was that I was mistakenly initialising admin as soon as index.ts was run, due to misconfigured imports. I will update with the code example – mef27 Mar 17 '22 at 11:05
2

I ran into this issue because my imports were leading to admin.initialiseApp being called in index.ts.

index.ts:

import apiApp from "./api/api"

...

api.ts was importing admin.ts through a number of other files, and admin.ts required process.env.SERVICE_ACCOUNT to be populated. As Marc Anthony B said, SERVICE_ACCOUNT wouldn't yet be populated if calling from index.ts, hence the error.

I solved by refactoring admin.ts to the following:

import * as admin from "firebase-admin";
import * as base64 from "base-64";

let dbInstance: admin.firestore.Firestore | null = null;
let authInstance: admin.auth.Auth | null = null;

function getAdmin() {
  const serviceAccount = JSON.parse(base64.decode(process.env.SERVICE_ACCOUNT));
  const credential = admin.credential.cert(serviceAccount);

  admin.initializeApp({ credential });

  dbInstance = admin.firestore();
  authInstance = admin.auth();

  return { db: dbInstance, auth: authInstance };
}

export const db = () => dbInstance || getAdmin().db;
export const auth = () => authInstance || getAdmin().auth;

So all my exports were functions, instead of instances of db and auth.

mef27
  • 379
  • 4
  • 15
0

I had a similar issue where the environment variables where undefined when trying to use initializeApp()

The solution of @mef27 also worked for me. But i'm using a newer version of the Firebase Admin sdk.

So for anyone reading this in 2023, this worked for me:

import { getAuth, Auth } from "firebase-admin/auth";
import { getStorage, Storage } from "firebase-admin/storage";
import { getApp, getApps, initializeApp, cert } from "firebase-admin/app";

let authInstance: Auth | null = null;
let storageInstance: ReturnType<Storage["bucket"]> | null = null;

function getAdmin() {
  const app = !getApps().length
    ? initializeApp({
        credential: cert({
          projectId: process.env.FB_PROJECT_ID,
          clientEmail: process.env.FB_CLIENT_EMAIL,
          privateKey: process.env.FB_PRIVATE_KEY,
        }),
        storageBucket: process.env.FB_STORAGE_BUCKET,
      })
    : getApp();

  authInstance = getAuth(app);
  storageInstance = getStorage(app).bucket();

  return { auth: authInstance, storage: storageInstance };
}

export const auth = () => authInstance ?? getAdmin().auth;
export const storage = () => storageInstance ?? getAdmin().storage;

Frederik N
  • 71
  • 1
  • 4