likes: 1
- 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からでも可能)。
ここでは、Githubのコードの要旨のみを抽出。
言語: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"
- Actions
- OIDCトークンを取得(JWTなので<header>.<payload>.<signature>の形式)
- jsファイルにトークンを渡す
- jsファイル内でAuthorizationヘッダにBearerでトークンをつけて、 Functions上のAPIを叩く
- firebase Functions
- Github OIDC用の公開鍵のデータを返すAPIにアクセスして、公開鍵として使用するデータ(複数ある)を取得
- 上記で取得したデータのうち、Actionsからのリクエストのkidと同じkidを持つデータを選択
- そのデータを元にPEM形式の公開鍵を生成
- Actionsからのトークン・上記の公開鍵を用いてJWTを検証
- 検証に成功すれば、JWTのpayloadを取得する
- (ついでに、実行元が指定されたユーザor組織のレポジトリであることを検証)
- API EPの処理を実行
- 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 を指定する。
https://qiita.com/asagohan2301/items/cef8bcb969fef9064a5c
. │ ├── dist │ └── github-actions │ └── dataTypes ├── functions │ ├── lib │ │ ├── middlewares │ │ ├── prodFirestore │ │ │ └── dataForSave │ │ ├── routers │ │ └── utils │ └── src │ ├── middlewares │ ├── prodFirestore │ │ └── dataForSave │ ├── routers │ └── utils ├── public └── src └── github-actions └── dataTypes
// 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 } }
// 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
これは何: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); });
// 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";
// 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;
// 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;
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;
※データ形式に特に意味はない。
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;
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 }}
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を投げてもらうと解決できるかも