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を投げてもらうと解決できるかも