Firebase FunctionsとDeno + Oakを組み合わせて認証が通ったリクエストのみデータを返すようにしたい話
Firebase FunctionsをBFFとして実装し、Deno + Oakで作成したAPIサーバーからデータを取得してみる。
今回はJWTを使った認証を行いたいので、認証が成功したリクエストのみにデータを返したい。
内容
- 両方の環境に共通のSecretを持たせて、Firebase Functions側で署名したjwtをOak側で認証させる。
/weather
ルートをお互いの環境に用意して、JWT経由で都市名を渡してその都市の天気が最終的にクライアント側に返ってくるようにする。
キーを生成する
CryptoKey型の値を生成したあと、raw形式でエクスポートする。 その内容をArrayBufferに変換して、jwt.keyファイルに書き出す。
// generateKey.ts // キーを生成する const key = await crypto.subtle.generateKey( { name: "HMAC", hash: "SHA-256" }, true, ["sign", "verify"] ); // raw形式でkeyの中身をエクスポートする const result = await crypto.subtle.exportKey("raw", key); // 生データを含むArrayBufferに変換する const data = new Uint8Array(result); // ./secretsフォルダが存在しない場合は作成する try { await Deno.mkdir("./secrets"); } catch (_e: unknown) { console.error("secretsディレクトリは作成しませんでした") } // secrets/jwt.keyファイルに書き込む await Deno.writeFile("./secrets/jwt.key", data);
Denoで実行する
deno run --allow-write ./generateKey.ts
生成されたファイルの中身を文字列として取得する
作成したjwt.keyファイルの中身をエディターで開いても、なんだかよくわからない文字列になっているので、これを読める形に変換する。
denoを立ち上げる
deno
jwt.keyの中身を読み取る。
> const rawKey = await Deno.readFile("./jwt.key");
rawKeyはUint8Array
なので、これを文字列に変換する。
> rawKey.toString() "219,49,91,3,59,139,24,63,52,98,16,9,220,236,62,34,135,0,42 ......"
表示された,
区切りの文字列をコピーして、環境変数JWT_RAW_KEY
に設定する。
Node側のjwt周りの実装
必要なパッケージは次の通り
- jwt-decode
- jsonwebtoken
- @types/jsonwebtoken
- @peculiar/webcrypto
- dotenv
functionsフォルダ内でインストールする。
# /functions yarn add jwt-decode jsonwebtoken @peculiar/webcrypto dotenv yarn add -D @types/jsonwebtoken
トークンを生成する関数を作成
jsonwebtokenパッケージを使って署名する。
jwtの解説記事等では、文字列のシークレットを使って署名する例を見かけるが、今回はBufferを使って署名してみる。
// src/utils/createJwt.ts import { sign } from "jsonwebtoken"; export function createJwt(payload: { [key: string]: string | number }, key: Buffer) { const token = sign(payload, key, { algorithm: "HS256", }) return token; }
先ほどCryptoKeyを生成した際にHMAC-SHA256で署名しているので、algorithm: "HS256"
をオプションとして指定する。
第二引数のkeyを準備する
環境変数に設定したJWT_RAW_KEY
をUint8Array
に変換する。
const rawKeyStr = process.env.JWT_RAW_KEY ?? "" const rawKey = Uint8Array.from(JWT_KEY.split(",").map(x => parseInt(x)));
そしてそれをBuffer
に変換する。
const key = Buffer.from(rawKey)
Deno側の実装
フレームワークはOakを利用する。
/weather
ルートを実装し、Firebase Functions側からはhttp://localhost:8000/weather
に対してリクエストを送信する。
/weather
ルートでは、Authorization
ヘッダーが設定されていてかつ認証に成功したリクエストのみ天気の情報を返すようにする。それ以外の場合はHTTPErrorを発生させる。
ペイロードのcity
フィールドには天気の情報が知りたい都市の名前を入れる必要がある。
// server.ts import { bold, yellow } from "https://deno.land/std@0.131.0/fmt/colors.ts"; import { Router } from "https://deno.land/x/oak@v10.5.1/mod.ts"; import { Middleware } from "https://deno.land/x/oak@v10.5.1/mod.ts"; import { Application } from "https://deno.land/x/oak@v10.5.1/mod.ts"; import { verify } from "https://deno.land/x/djwt@v2.7/mod.ts"; const router = new Router(); // secrets/jwt.keyファイルから生データのキーを取得する const rawKey = await Deno.readFile("./secrets/jwt.key"); // importKeyメソッドを使用した生データのキーをCryptoKey型に変換する export const key = await crypto.subtle.importKey( "raw", rawKey, { name: "HMAC", hash: "SHA-256" }, true, ["sign", "verify"] ); const getWeather: Middleware = async (ctx) => { const headers = ctx.request.headers; const token = headers.get("Authorization"); if (!token) { ctx.throw(400, "トークンが存在しません"); return; } // Bearer 部分を取り除く const jwt = token.replaceAll("Bearer ", "").trim(); let city: string | undefined = undefined; try { // jwtトークンとして有効かどうかを調べる const result = await verify(jwt, key); city = result.city as string; } catch (e: unknown) { if (e instanceof Error) { ctx.throw(400, e.message); return; } ctx.throw(400, "エラーが発生しました"); return; } if (!city) { ctx.throw(400, "都市が存在しません"); return; } ctx.response.body = { weather: `${city}の天気は晴れです`, } return; }; router.get("/weather", getWeather); const app = new Application(); app.use(router.allowedMethods()); app.use(router.routes()); app.addEventListener("listen", ({ hostname, port, serverType }) => { console.log(bold("Start listening on ") + yellow(`${hostname}:${port}`)); console.log(bold(" using HTTP server: " + yellow(serverType))); }); await app.listen({ hostname: "127.0.0.1", port: 8000 }); console.log(bold("Finished."));
実際に立ち上げてみる。
deno run --allow-net --allow-env --allow-read --allow-write server.ts
Firebase Functions側の実装
HTTPリクエスト経由で関数を呼び出せる様にする。
// src/index.ts import * as functions from 'firebase-functions'; import * as admin from 'firebase-admin'; import fetch from 'node-fetch'; import * as dotenv from 'dotenv'; import {createJwt} from './utils/createJwt'; dotenv.config(); admin.initializeApp(); export const weather = functions.https.onRequest(async (req, res) => { // POSTメソッド以外は受け付けない if (req.method !== 'POST') { res.status(403).send('Forbidden!'); return; } const jwtRawKey = process.env.JWT_RAW_KEY ?? ''; // 文字列をUint8Arrayに変換 const rawKey = Uint8Array.from(jwtRawKey.split(',').map((x) => parseInt(x))); // payloadを準備する const payload = { city: '札幌市', }; const token = createJwt(payload, Buffer.from(rawKey)); const response = await fetch('http://localhost:8000/weather', { method: 'GET', headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, }, }); const data = await response.json(); res.json({ status: 'Success', ...data, }); });
エミュレーターを使ってローカルで実行
エミュレーターで実行する前にビルドを行う。
yarn build
Cloud Functionsエミュレータを実行する
firebase emulators:start --only functions
実際にリクエストを送ってみる
ここまでで二つのURLが作成された状態。
実際に動くかを検証するために今回はPostmanを使用する。
PostmanでFirebase Functions側にリクエストを作成して送信する。
リクエスト先はFirebase Functionsの/weather
ルート。
http://localhost:5001/project-name/us-central1/weather
次のように出力されれば成功。
{ "status": "Success", "weather": "札幌市の天気は晴れです" }
この仕組みは色々と応用できそうでわくわくしている。