iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

我們的 Angular 應用程式目前已部署至 Firebase App Hosting,但尚未設定 Firebase API Key、Auth Domain、專案 ID、應用程式 ID、Storage Bucket URL、訊息傳送者 ID (Message sender ID) 以及 reCAPTCHA Enterprise 金鑰。

因為我們尚未在前端撰寫任何 Firebase 邏輯,所以部署能夠順利成功。但只要我們加入第一個 Firebase 呼叫,正式環境建置(Production build)就會立刻中斷。

複製貼上的惡夢

我們可以在 Firebase 使用者介面 (UI) 中手動將環境變數新增至後端。如果這是一次性的操作,對大多數人來說應該不算太麻煩。

但作為一個經常建立新展示後端的內容創作者,每次都要複製並貼上 7 個獨立的數值既繁瑣又容易出錯。(沒錯,我以前就曾不小心將金鑰貼到錯誤的欄位中)。

為什麼不直接提交到 apphosting.yaml?

我可以將環境變數直接寫入 apphosting.yaml,但 GitHub 會針對金鑰外洩發出警示。處理這些誤報的安全警示是沒有人想要的麻煩事。

我在 Antigravity CLI 中向 Gemini 提出了我的問題,它建議了一個結合 Firebase CLI、apphosting.yaml 以及 Husky 的 pre-push hook 的解決方案。

零憑證自動化解決方案

以下是我在 Antigravity CLI 對話中提供給 grill-with-docs 技能的需求。

需求

  1. apphosting.yaml 中新增環境變數,但不能是純文字。否則 GitHub 會針對密鑰外洩向我發出警示
  2. .env 至少包含 Web 應用程式名稱以及 reCAPTCHA Enterprise 金鑰。
  3. 使用 Firebase CLI、預設專案 ID 和應用程式名稱來查詢 SDK 設定。該 SDK 設定與 reCAPTCHA Enterprise 金鑰即為應用程式所需的環境變數
  4. 不要要求我在每次推送程式碼前手動執行指令碼。當我將程式碼推送到 main 分支時,Firebase App Hosting 應該要擷取最新的環境變數來建置程式碼,並部署到雲端
  5. 當缺少任何環境變數時,指令碼必須擲出錯誤並中止
  6. apphosting.yaml 應該作為單一事實來源(Single source of truth)。不要在 Yaml 檔案和 Node.js 指令碼中重複定義環境變數名稱

Gemini 與該技能產生了這份架構決策記錄 (ADR) 並提出了以下解決方案。

解決方案

  1. 建立一個新的 Node.js 指令碼,使用 Firebase CLI 來設定 Secret(密鑰)
  2. package.json 中新增一個執行該指令碼的指令碼
  3. 建立一個 Husky 的 pre-push hook,當新的程式碼變更推送到 main 分支時觸發同步

透過 AI,我得以開發出一個具備擴展性、精密且符合企業級標準的解決方案。

在接下來的幾個小節中,我們將展示 apphosting.yaml 以及同步 Secret 的 Node.js 指令碼之關鍵實作。

App Hosting Yaml 檔案中的環境變數

runConfig:
  minInstances: 0
  maxInstances: 2  # Prevents Google Cloud from scaling up infinitely during an attack

env:
  - variable: APP_FIREBASE_API_KEY
    secret: firebase_api_key
  - variable: APP_FIREBASE_AUTH_DOMAIN
    secret: firebase_auth_domain
  - variable: APP_FIREBASE_PROJECT_ID
    secret: firebase_project_id
  - variable: APP_FIREBASE_STORAGE_BUCKET
    secret: firebase_storage_bucket
  - variable: APP_FIREBASE_MESSAGING_SENDER_ID
    secret: firebase_messaging_sender_id
  - variable: APP_FIREBASE_APP_ID
    secret: firebase_app_id
  - variable: APP_FIREBASE_RECAPTCHA_ENTERPRISE_KEY
    secret: firebase_recaptcha_enterprise_key

apphosting 檔案新增了 env 區段,用來維護 Firebase API Key、Auth Domain、專案 ID、應用程式 ID、Storage Bucket、訊息傳送者 ID 以及 reCAPTCHA Enterprise 金鑰。

這些 Secret 並非寫死在程式碼中(Hardcoded),而是透過 Secret 名稱表示,例如 firebase_api_keyfirebase_project_id

環境變數

APP_FIREBASE_WEB_APP_NAME="<Firebase Web App Display Name>"
APP_FIREBASE_RECAPTCHA_ENTERPRISE_KEY="<Recaptcha Enterprise Key>"
APP_FIREBASE_APPCHECK_DEBUG_TOKEN="<App Check debug token>"

.env 檔案包含三個變數,但 App Hosting 並不需要 APP_FIREBASE_APPCHECK_DEBUG_TOKEN。如果不小心部署了 APP_FIREBASE_APPCHECK_DEBUG_TOKEN,將會是嚴重的安全性漏洞。這意味著 App Check 會被繞過,任何請求都能存取 Firebase 資源。

同步 Secret 的實作

const execAsync = promisify(exec);

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const firebaseDir = path.resolve(__dirname, '..');
const projectRootDir = path.resolve(firebaseDir, '..');
const envPath = path.join(firebaseDir, '.env');
const appHostingYamlPath = path.join(projectRootDir, 'apphosting.yaml');

function parseAppHostingSecrets(yamlPath) {
  const content = fs.readFileSync(yamlPath, 'utf8');
  const secretEntries = [];
  const regex = /-\s+variable:\s*([^\s]+)\s+secret:\s*([^\s]+)/g;

  let match;
  while ((match = regex.exec(content)) !== null) {
    secretEntries.push({
      variableName: match[1],
      secretName: match[2],
    });
  }

  return secretEntries;
}

function resolveSecretMappings(declaredSecrets, sdkConfig) {
  return declaredSecrets.map(({ variableName, secretName }) => {
    const sdkKey = variableName
        .replace(/^APP_FIREBASE_/, '')
        .toLowerCase()
        .replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
    if (sdkConfig && sdkConfig[sdkKey]) {
      return { secretName, secretValue: sdkConfig[sdkKey] };
    }

    const envValue = process.env[variableName];
    if (envValue && !envValue.startsWith('<')) {
      return { secretName, secretValue: envValue };
    }
  });
}

async function fetchSdkConfigViaCli(webAppName) {
  const { stdout: listStdout } = await execAsync('npx firebase apps:list WEB --json --project default', {
    cwd: firebaseDir,
  });

  const apps = JSON.parse(listStdout)?.result || [];
  const targetApp = apps.find((app) => app.displayName === webAppName);

  const { stdout: sdkStdout } = await execAsync(`npx firebase apps:sdkconfig WEB ${targetApp.appId} --json --project default`, {
    cwd: firebaseDir,
  });

  return JSON.parse(sdkStdout)?.result?.sdkConfig;
}

function runSpawnCommand(secretName, secretValue, cwd) {
  return new Promise((resolve, reject) => {
    const child = spawn(
      'npx',
      ['firebase', 'apphosting:secrets:set', secretName, '--data-file', '-', '--force', '--project', 'default'],
      { cwd, stdio: ['pipe', 'inherit', 'inherit'] },
    );

    child.on('close', () => resolve());
    child.stdin.write(secretValue);
    child.stdin.end();
  });
}

async function syncAllSecrets(mappings, cwd) {
  await Promise.all(
    mappings.map(({ secretName, secretValue }) => runSpawnCommand(secretName, secretValue, cwd)),
  );

  const allSecretNames = mappings.map((m) => m.secretName);
  const secretList = allSecretNames.join(',');
  await execAsync(`npx firebase apphosting:secrets:grantaccess ${secretList} -b ng-test --project default`, {
    cwd,
  })
}

/* ... load and validate environment variables  ... */
const declaredSecrets = parseAppHostingSecrets(appHostingYamlPath);
const sdkConfig = await fetchSdkConfigViaCli(process.env.APP_FIREBASE_WEB_APP_NAME);
const mappings = resolveSecretMappings(declaredSecrets, sdkConfig);
await syncAllSecrets(mappings, firebaseDir);

parseAppHostingSecrets 會剖析 apphosting.yaml 以擷取變數與 Secret 名稱。fetchSdkConfigViaCli 擷取 SDK 設定並將該物件傳遞給 resolveSecretMappings,藉此建立 Secret 名稱與 SDK 設定值之間的對應關係。最後,syncAllSecrets 將 Secret 名稱設定為正確的值,並授予 ng-test 後端存取這些 Secret 的權限。

我們需要一個 pre-push hook 來執行程式碼以同步 Secret。

Pre-push Hook

"scripts": {
    "config:secrets": "node firebase/scripts/deploy-apphosting-secrets.mjs",
}

package.json 中新增 config:secrets 指令碼以供 pre-push hook 執行

while read -r local_ref local_oid remote_ref remote_oid; do
  if [ "$remote_ref" = "refs/heads/main" ] && [ "$local_oid" != "(delete)" ] && [ "$local_oid" != "0000000000000000000000000000000000000000" ]; then
    npm run config:secrets
    break
  fi
done

if 判斷式可確保不會在功能分支(feature branch)上執行該指令碼,以節省時間。在我們將功能分支合併到 main 分支後,推送 main 分支即可同步 Secret,並在線上站台驗證新的變更。

這個解決方案保證了無需手動複製貼上、在 apphosting.yaml 中沒有寫死的數值,並且能使用最新的環境變數進行部署。

推送到功能分支會略過 Secret 同步以節省時間。一旦合併後,推送到 main 分支即會部署您的變更並將最新的 Secret 同步到線上站台。

這種方法徹底消除了複製貼上的繁瑣步驟、避免了在 apphosting.yaml 中寫死 Secret,並確保了乾淨且安全的部署。

今天就到這裡。明天我們將在 Angular 應用程式中撰寫程式碼,以初始化 Firebase App、App Check 並啟用 Remote Config。

相關資源

Firebase App Hosting 官方文件


上一篇
Day 13 - 建置 Firebase 基礎設施 - 將 GitHub 存放庫連結至 Firebase App Hosting
下一篇
Day 15 - Firebase 與 Angular 整合 - 初始化 Firebase App、App Check 並取得 Remote Config
系列文
2026年,如何利用 Antigravity CLI、Gemini、各項技能及 MCP Server 建構基於 Firebase 的 Angular 應用20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言