たかぎとねこの忘備録

プログラミングに関する忘備録を自分用に残しときます。マサカリ怖い。

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_KEYUint8Arrayに変換する。

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": "札幌市の天気は晴れです"
}

この仕組みは色々と応用できそうでわくわくしている。