TechBlog masa

  • Top
  • Posts
  • Profile
2025-02-12
SWE
[2025年] Github ActionsからCloud Functions for firebase上のAPIをJWT認証付きで叩く[2025年] Github ActionsからCloud Functions for firebase上のAPIをJWT認証付きで叩く
TS / NodeJSGit / GitHubfirebase...

likes: 1

Unsupported Block: table_of_contents
  • Cloud Functions for firebase上に構築したAPIを、JWT認証を用いて、(定刻実行の)Github Actionsからのみ叩けるようにする(FunctionsのAPIはFirestoreの操作を行う)。APIへの認証されていないリクエストは全て`401 Unauthorized`または`403 Forbidden`を返す。
  • JWT認証用のミドルウェアを作成し、特定のエンドポイントのみにJWT認証を付与できる。例えば`POST /api/aaa`のみJWT認証を必須にして`GET /api/aaa`は認証なしにできる。
  • Actionsではなく手動でFirestoreを操作したい時は、firebase-adminを用いてローカルから実行できる(ちょっとだけの操作ならfirebase consoleからでも可能)。
Unsupported Block: heading_2

ここでは、Githubのコードの要旨のみを抽出。

Unsupported Block: link_preview
Unsupported Block: heading_2

言語:TS

インフラ:Cloud Functions for firebase, Cloud Firestore (for firebase)

 

バージョンなど:

arm64 (M2 mac air)
"firebase-tools": "13.29.2"

"firebase": "^11.1.0",
"typescript": "^5.7.3",

"express": "^4.21.2",
"firebase-admin": "^12.6.0",
"firebase-functions": "^6.0.1",
"jsonwebtoken": "^9.0.2",
"node-jose": "^2.2.0"

 

Unsupported Block: heading_2
  1. Actions
    1. OIDCトークンを取得(JWTなので<header>.<payload>.<signature>の形式)
    2. jsファイルにトークンを渡す
    3. jsファイル内でAuthorizationヘッダにBearerでトークンをつけて、 Functions上のAPIを叩く
  2. firebase Functions
    1. Github OIDC用の公開鍵のデータを返すAPIにアクセスして、公開鍵として使用するデータ(複数ある)を取得
    2. 上記で取得したデータのうち、Actionsからのリクエストのkidと同じkidを持つデータを選択
    3. そのデータを元にPEM形式の公開鍵を生成
    4. Actionsからのトークン・上記の公開鍵を用いてJWTを検証
    5. 検証に成功すれば、JWTのpayloadを取得する
    6. (ついでに、実行元が指定されたユーザor組織のレポジトリであることを検証)
    7. API EPの処理を実行
Unsupported Block: heading_2
  • aud (audience) には`https://github.com/<userId>`を指定する。audはJWTの受信者である。受信者はFuctionsのAPIかのような気もするが、github actionsでOIDCによってJWTトークンを受け取っているので、受信者はgithub側である。
    • 実際にactions上でJWTの値を見るには(Productionではログはセキュリティ上の理由で消す):
    • # トークンのデバッグ出力
      echo "OIDC Token (Full): $oidcToken"
      echo ""
      echo "OIDC Token Header:"
      echo "$oidcToken" | cut -d "." -f 1 | base64 -d 2>/dev/null || base64 --decode | jq
      echo ""
      echo "OIDC Token Payload:"
      echo "$oidcToken" | cut -d "." -f 2 | base64 -d 2>/dev/null || base64 --decode | jq
  • iss (issuer) には`https://token.actions.githubusercontent.com`を指定する。今回は、JWTトークンを発行しているのはgithubであり、そのgithubが提示しているUriがこれ。
    • 参照:https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect
  • kid (key ID) : JWT headerにあり、鍵の識別に使用する。
  • alg (algorithm) は RS256 を指定する。
Unsupported Block: heading_2

https://qiita.com/asagohan2301/items/cef8bcb969fef9064a5c

 

Unsupported Block: heading_2
.
│
├── dist
│   └── github-actions
│       └── dataTypes
├── functions
│   ├── lib
│   │   ├── middlewares
│   │   ├── prodFirestore
│   │   │   └── dataForSave
│   │   ├── routers
│   │   └── utils
│   └── src
│       ├── middlewares
│       ├── prodFirestore
│       │   └── dataForSave
│       ├── routers
│       └── utils
├── public
└── src
    └── github-actions
        └── dataTypes
Unsupported Block: heading_2
// tsconfig.ts
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src", "public"]
}
// functions/tsconfig.ts
{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "outDir": "lib",
    "sourceMap": true,
    "strict": true,
    "target": "es2017",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "rootDir": "src"
  },
  "compileOnSave": true,
  // ESLintを含める必要がある。
  "include": ["src", ".eslintrc.js"],
  "exclude": ["node_modules"]
}
// firebase.json
{
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  },
  "functions": [
    {
      "source": "functions",
      "codebase": "default",
      "ignore": [
        "node_modules",
        ".git",
        "firebase-debug.log",
        "firebase-debug.*.log",
        "*.local"
      ],
      "predeploy": [
        "npm --prefix \"$RESOURCE_DIR\" run lint",
        "npm --prefix \"$RESOURCE_DIR\" run build"
      ]
    }
  ],
  "hosting": {
    "public": "public",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"]
  },
  "emulators": {
    "functions": {
      "port": 5001
    },
    "firestore": {
      "port": 8080
    },
    "hosting": {
      "port": 5002
    },
    "ui": {
      "enabled": true
    },
    "singleProjectMode": true
  }
}

 

Unsupported Block: heading_2
// src/firebase.ts

// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
// Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries

// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
  apiKey: process.env.API_KEY,
  authDomain: process.env.AUTH_DOMAIN,
  projectId: process.env.PROJECT_ID,
  storageBucket: process.env.STORAGE_BUCKET,
  messagingSenderId: process.env.MESSAGING_SENDER_ID,
  appId: process.env.APP_ID,
  measurementId: process.env.MEASUREMENT_ID,
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);

export { app, analytics };
# .env
API_KEY=
AUTH_DOMAIN=
PROJECT_ID=
STORAGE_BUCKET=
MESSAGING_SENDER_ID=
APP_ID=
MEASUREMENT_ID=

FUNCTIONS_URL={firebase consoleから見れる}
# functions/.env
FUNCTIONS_URL={firebase consoleから見れる}
FUNCTIONS_REGION={ここから選ぶ: https://firebase.google.com/docs/functions/locations?hl=ja}
AUDIENCE={https://github.com/<userId>}
ACTIONS_REPOSITORY={Actionsを実行するレポジトリ: <userId>/<repoName>}

https://firebase.google.com/docs/functions/locations?hl=ja

Unsupported Block: heading_2
Unsupported Block: heading_3

これは何:firebase-adminの初期化、expressの設定、ルーティング、API定義

serviceAccountKey.jsonを取得するには:https://zenn.dev/protoout/articles/28-firebase-realtimedb-nodejs#%E7%A7%98%E5%AF%86%E9%8D%B5%E3%82%92%E7%94%9F%E6%88%90%E3%81%97%E6%BA%96%E5%82%99

// functions/src/index.ts
import express from "express";
import helmet from "helmet";
import cors from "cors";
import { rateLimit } from "express-rate-limit";
import admin from "firebase-admin";
import serviceAccountKey from "./serviceAccountKey.json";
import { onRequest } from "firebase-functions/v2/https";
import authJwt from "./middlewares/jwt";
import { config } from "dotenv";
config();

admin.initializeApp({
  credential: admin.credential.cert(serviceAccountKey as admin.ServiceAccount),
});

const region = process.env.FUNCTIONS_REGION;

const app = express();
app.use(cors({ origin: true }));
app.use(helmet());
app.use(express.json());
// firebaseのemulator suiteのために必要
app.set("trust proxy", 1);

import forecastRouter from "./routers/forecast";
import temperatureRouter from "./routers/temperature";

// POSTにauthJwtミドルウェアを適用
app.post("/forecast", authJwt());
app.post("/temperature", authJwt());

app.use("/forecast", forecastRouter);
app.use("/temperature", temperatureRouter);

export const firestore = onRequest({ region: region }, (req, res) => {
  app(req, res);
});
Unsupported Block: heading_3
// functions/src/constants.ts

// Githubの公開鍵が置いてあるURL
export const githubPublicKeyUrl =
  "https://token.actions.githubusercontent.com/.well-known/jwks";

export const jwtIssuer = "https://token.actions.githubusercontent.com";
Unsupported Block: heading_3
// functions/src/middlewares/jwt.ts
import verifyToken from "../utils/verifyToken";
import { Request, Response, NextFunction } from "express";

const authJwt = () => {
  return async (req: Request, res: Response, next: NextFunction) => {
    const authHeader = req.headers.authorization || "";
    if (!authHeader.startsWith("Bearer ")) {
      res.status(401).json({ error: "Unauthorized" });
      return;
    }

    const token = authHeader.split(" ")[1];
    try {
      const payload = await verifyToken(token);
      if (payload.repository !== process.env.ACTIONS_REPOSITORY) {
        throw new Error("Invalid repository");
      }
      next();
    } catch (error) {
      console.error("JWT validation failed:", error);
      res.status(403).json({ error: "Forbidden" });
    }
  };
};

export default authJwt;
Unsupported Block: heading_3
// functions/src/utils/verifyToken.ts
import jwt from "jsonwebtoken";
import jose from "node-jose";
import { githubPublicKeyUrl, jwtIssuer } from "../constants";

interface ExtendedJwtPayload extends jwt.JwtPayload {
  repository: string;
}

const publicKeyUrl = githubPublicKeyUrl;

const verifyToken = async (token: string): Promise<ExtendedJwtPayload> => {
  const response = await fetch(publicKeyUrl);
  const jwks = await response.json();

  // トークンヘッダーから`kid`を取得
  const decodedHeader = JSON.parse(
    Buffer.from(token.split(".")[0], "base64").toString("utf8")
  );
  const kid = decodedHeader.kid;

  // 公開鍵を選択
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const key = jwks.keys.find((key: any) => key.kid === kid);
  if (!key) {
    throw new Error("No matching key found");
  }

  // node-joseを使用してPEM形式の公開鍵を生成
  const keyStore = await jose.JWK.asKeyStore({ keys: [key] });
  const publicKey = keyStore.get(kid).toPEM();

  // JWTの検証
  try {
    const payload = jwt.verify(token, publicKey, {
      algorithms: ["RS256"],
      audience: process.env.AUDIENCE,
      issuer: jwtIssuer,
    }) as ExtendedJwtPayload;
    return payload;
  } catch (err) {
    console.error("JWT verification failed:", err);
    throw err;
  }
};

export default verifyToken;
Unsupported Block: heading_3

GET: firestoreからデータ取得

POST: JWT認証必須でfirestoreにデータ保存

// functions/src/routers/forecast.ts
import express, { Request, Response } from "express";
import getFromFirestore from "../utils/getFromFirestore";
import { DataFromApi, DataType } from "../types";
import saveToFirestore from "../utils/saveToFirestore";

const dataType: DataType = "forecast";
const router = express.Router();

router.route("/").get(async (req: Request, res: Response) => {
  const data: DataFromApi = await getFromFirestore(dataType);
  res.send(data);
});

router.route("/").post(async (req: Request, res: Response) => {
  saveToFirestore(dataType, req.body);
  const sending = {
    dataType: dataType,
    data: req.body,
  };
  res.send(sending);
});

export default router;
// functions/src/routers/temperature
import express, { Request, Response } from "express";
import getFromFirestore from "../utils/getFromFirestore";
import { DataFromApi, DataType } from "../types";
import saveToFirestore from "../utils/saveToFirestore";

const dataType: DataType = "temperature";
const router = express.Router();

router.route("/").get(async (req: Request, res: Response) => {
  const data: DataFromApi = await getFromFirestore(dataType);
  res.send(data);
});

router.route("/").post(async (req: Request, res: Response) => {
  saveToFirestore(dataType, req.body);
  const sending = {
    dataType: dataType,
    data: req.body,
  };
  res.send(sending);
});

export default router;
Unsupported Block: heading_2

※データ形式に特に意味はない。

Firestoreの構造 → /jwt-actions-test/{forecast, temperature}

Firestoreのフィールド → ”1-29”: 10.2, “1-30”: 13.1 …

// functions/src/utils/getFromFirestore.ts
import admin from "firebase-admin";
import { DataFromApi, DataType } from "../types";

const db = admin.firestore();

const getFromFirestore = async (type: DataType): Promise<DataFromApi> => {
  const firestorePath = `jwt-actions-test/${type}`;
  const docRef = db.doc(firestorePath);
  const doc = await docRef.get();

  if (!doc.exists) {
    throw new Error(`No such document: ${firestorePath}`);
  } else {
    const data = doc.data();
    if (!data) throw new Error(`no data found: ${firestorePath}`);
    const dataFromApi = {
      dataType: type,
      data: sortedDictByDateKey(data as DataFromApi["data"]),
    };
    return dataFromApi;
  }
};

const sortByDateKey = (pre: string, post: string): number => {
  const [preMonth, preDate] = pre.split("-").map(Number);
  const [postMonth, postDate] = post.split("-").map(Number);

  return preMonth - postMonth || preDate - postDate;
};
const sortedDictByDateKey = (dict: {
  [key: string]: number;
}): { [key: string]: number } => {
  return Object.fromEntries(
    Object.entries(dict).sort(([preKey], [postKey]) => sortByDateKey(preKey, postKey))
  );
};

export default getFromFirestore;

// functions/src/utils/saveToFirestore.ts
import admin from "firebase-admin";
import { DataType } from "../types";

const db = admin.firestore();

const saveToFirestore = async (
  type: DataType,
  data: { [key: string]: number }
): Promise<void> => {
  const firestorePath = `jwt-actions-test/${type}`;
  const docRef = db.doc(firestorePath);

  try {
    await docRef.set(
      {
        ...data,
      },
      { merge: true }
    );
  } catch (error) {
    throw new Error(`failed to save to ${firestorePath}: ${error}`);
  }
};

export default saveToFirestore;

 

Unsupported Block: heading_2

Actionsに環境変数を設定する(FUNCTIONS_URL): Actionsでは.envから取得するのではなくActionsのSecertsまたはVariablesに設定するのが一般的。

https://zenn.dev/kou_pg_0131/articles/gh-actions-configurations-variables#%E3%83%AA%E3%83%9D%E3%82%B8%E3%83%88%E3%83%AA%E3%83%AC%E3%83%99%E3%83%AB%E3%81%AE%E5%A4%89%E6%95%B0%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B

ちなみに、JWT_TOKENはActions環境変数や.envに書く必要はない。これはActionsの実行中に設定される値。

# .github/workflows/postToFunctions.yml
name: update firestore from functions api

on:
  schedule:
    - cron: '0 17 * * *'  # 毎日 UTC 17:00 = JST 02:00 に実行
  workflow_dispatch:  # 手動実行を有効化

jobs:
  run-ts-and-call-api:
    runs-on: ubuntu-latest

    permissions:
      id-token: write # OIDC トークン発行を許可
      contents: read

    steps:
      # リポジトリのコードを取得
      - name: Checkout code
        uses: actions/checkout@v4

      # Node.js の設定
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ^18

      # 必要なパッケージのインストール
      - name: Install dependencies
        run: npm install

      - name: Request OIDC token
        run: |
          oidcToken=$(curl --silent \
            -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
            "${ACTIONS_ID_TOKEN_REQUEST_URL}" | jq -r '.value')

          echo "JWT_TOKEN=$oidcToken" >> $GITHUB_ENV

			# FunctionsのAPIを叩くファイルを実行
      - name: Run NodeJS
        run: |
          npx tsc
          node dist/github-actions/postToFunctions.js
        # ファイル内で使う環境変数を設定
        env:
          FUNCTIONS_URL: ${{ vars.FUNCTIONS_URL }}
Unsupported Block: heading_3

Actionsで実行するための、FunctionsのAPIを叩くファイル

// src/github-actions/postToFunctions.ts
import { config } from "dotenv";
import path from "path";
import postData from "./postData";
import { fetchForecast, parseForecast } from "./dataTypes/forecast";
import { fetchTemperature, parseTemperature } from "./dataTypes/temperature";
config();

async function postToFunctions() {
  type FuncsType = {
    [key: string]: {
      fetch: () => Promise<{ [key: string]: number }>;
      parse: (data: { [key: string]: number }) => Promise<{ [key: string]: number }>;
    };
  };
  // 各dataTypeについて、fetchは叩く外部APIが違う・parseはデータが違うことを想定し、異なる処理にしている。
  // postDataは全て同じ処理の想定。
  const funcs: FuncsType = {
    forecast: { fetch: fetchForecast, parse: parseForecast },
    temperature: { fetch: fetchTemperature, parse: parseTemperature },
  };

  try {
    for (const dataType in funcs) {
      console.log("--------------------------------\n" + dataType);
      await postData(dataType, funcs[dataType].fetch, funcs[dataType].parse);
    }
  } catch (error) {
    throw new Error("Error Posting Functions API" + error);
  }
}
postToFunctions();
// src/github-actions/postData.ts
import path from "path";
import { config } from "dotenv";
config();

export default async function postData(
  dataType: string,
  fetchData: () => Promise<any>,
  processData: (data: any) => Promise<any>
): Promise<void> {
  const data = await fetchData();
  const dataToSend = await processData(data);

  try {
    const jwtToken = process.env.JWT_TOKEN;
    if (!jwtToken) {
      console.error("JWT_TOKEN is not set");
      process.exit(1);
    }
    const baseUrl = process.env.FUNCTIONS_URL;
    if (!baseUrl) {
      console.error("FUNCTIONS_URL is not set");
      process.exit(1);
    }
    const url = path.join(baseUrl, dataType);

    const res = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${jwtToken}`,
      },
      body: JSON.stringify(dataToSend),
    });
    if (!res.ok) {
      const errRes = await res.text();
      throw new Error(`Failed to save data:\n${res.statusText},\nDetails: ${errRes}`);
    }
    console.log("successfully saved to Firestore:", await res.json());
  } catch (error) {
    console.error("Error saving data:", error);
    process.exit(1);
  }
}

外部APIを叩く想定で、適当な定数を渡している。

また、外部APIを叩いて取得した値に対して適当な処理(parse)をする想定。

// src/github-actions/dataTypes/forecast.ts
export async function fetchForecast() {
  try {
    const data = {
      "1-12": 5,
      "1-13": 6,
      "1-14": 7,
      "1-15": 8,
      "1-16": 9,
      "1-17": 10,
      "1-18": 11.1,
    };
    console.log("successfully fetched data");
    return data;
  } catch (err) {
    console.error("Error fetching data:", err);
    process.exit(1);
  }
}

export async function parseForecast(data: {
  [key: string]: number;
}): Promise<{ [key: string]: number }> {
  try {
    const dataToSend: { [key: string]: number } = data;
    console.log("successfully processed data:", dataToSend);
    return dataToSend;
  } catch (err) {
    console.error("Error processing data:", err);
    process.exit(1);
  }
}
// src/github-actions/dataTypes/temperature.ts
export async function fetchTemperature() {
  try {
    const data = {
      "1-12": 5,
      "1-13": 6,
      "1-14": 7,
      "1-15": 8,
      "1-16": 9,
      "1-17": 10,
      "1-18": 11.1,
    };
    console.log("successfully fetched data");
    return data;
  } catch (err) {
    console.error("Error fetching data:", err);
    process.exit(1);
  }
}

export async function parseTemperature(data: {
  [key: string]: number;
}): Promise<{ [key: string]: number }> {
  try {
    const dataToSend: { [key: string]: number } = data;
    console.log("successfully processed data:", dataToSend);
    return dataToSend;
  } catch (err) {
    console.error("Error processing data:", err);
    process.exit(1);
  }
}

 

GithubにIssueを投げてもらうと解決できるかも

Unsupported Block: link_preview
breadcrumb予定地
profileCard予定地

SideBarPage

共有ボタン予定地
他ボタン予定地