🔒

自作の公開鍵と秘密鍵を使ってCI環境だけCognito依存なしでJWTの作成と検証を行う

2024/05/14に公開

背景

この記事はCI環境でCognitoに依存したくない特殊事情を抱えたエンジニアのために書きました。

私は顧客企業向けの社内フレームワークを開発しています。フロント・バックエンド・インフラ全てTypeScriptによるモノレポ構成なのですが、Cognitoなど一部のコンポーネントが顧客企業のセキュリティポリシーによりCDKで自動作成できません。

新規にCognitoが必要になった場合、専門の部署に依頼する必要があります。もちろん開発用のCognitoは用意されているのですが他のプロジェクトと相乗りなので自動テストで荒らしたくはありません。

Docker Imageを使う場合、Cognitoのスタブは2種類あるのですがLocalStackは有料のProライセンスが必要なので却下、MotoserverはECR Public Galleryに信頼できるイメージが無くてこれだけのためにECRを使うのは運用コストが高いなあ・・・と考えて自作の公開鍵と秘密鍵でJWTの作成と検証を行うことにしました。

アプリケーションサーバーの想定実装

環境変数です。 COGNITO_REGION ではなく COGNITO_POOL_ENDPOINT なのがポイントです。
テスト時にローカルサーバーのJWKSで検証できるようになります。

.env
COGNITO_POOL_ENDPOINT=https://cognito-idp.xxxxxxxxxxx.amazonaws.com
COGNITO_POOL_ID=xxxxxxxxxxx
COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxx

TypeScript+Fastifyです。CIのための固有処理は存在しません。
今回はCookieにJWTを乗せてますが、一般的にはAuthorizationヘッダを使うことが多いはずです。適宜読み替えてください。

initServer.ts
import type { TokenOrHeader } from '@fastify/jwt';
import fastifyJwt from '@fastify/jwt';
import assert from 'assert';
import type { FastifyRequest } from 'fastify';
import Fastify from 'fastify';
import buildGetJwks from 'get-jwks';

export const initServer = () => {
  const app = Fastify();
  const getJwks = buildGetJwks();

  app.register(fastifyJwt, {
    cookie: { cookieName: 'session', signed: false },
    decode: { complete: true },
    secret: (_: FastifyRequest, token: TokenOrHeader) => {
      assert('header' in token);
      assert(token.payload.aud === process.env.COGNITO_CLIENT_ID);

      return getJwks.getPublicKey({
        domain: `${process.env.COGNITO_POOL_ENDPOINT}/${process.env.COGNITO_POOL_ID}`,
        kid: token.header.kid,
        alg: token.header.alg,
      });
    },
  });

  return app;
};

認証が必要なAPIエンドポイントに

await req.jwtVerify({ onlyCookie: true });

を差し込むと検証されます。

秘密鍵と公開鍵の生成

秘密鍵はCIで使うのでGitに含めるのですが、拡張子がpemだとgitignoreされてたり色々不都合があるのでCI用だとわかる名前に変更しています。
公開鍵は後で削除するのでpemのままです。

$ openssl genpkey -algorithm RSA -out ci_private_key.pem.txt -pkeyopt rsa_keygen_bits:2048
$ openssl rsa -pubout -in ci_private_key.pem.txt -out public_key.pem

JWKSファイルの生成

サクッと使うバッチなのでTypeScriptを使わずNode.jsスクリプトを書きます。

$ npm install node-jose

以下はChatGPTの出力そのままです。

index.js
const fs = require('fs');
const jose = require('node-jose');

const main = async () => {
  const key = fs.readFileSync('public_key.pem', 'utf8');
  const keystore = jose.JWK.createKeyStore();

  await keystore.add(key, 'pem', {
    alg: 'RS256', // アルゴリズムの指定
    use: 'sig'    // このキーが署名用であることを示す
  });

  // keystore.toJSON(true) を使って、非公開の詳細も含める
  fs.writeFileSync('jwks.json', JSON.stringify(keystore.toJSON(true), null, 2));
};

main();
$ node index.js

jwks.json が生成されたら公開鍵 public_key.pem を使うことはないので削除します。

Vitestのセットアップ

COGNITO_POOL_ENDPOINT にローカルサーバーを指定することがポイントです。

vite.config.ts
import dotenv from 'dotenv';
import { defineConfig } from 'vite';

dotenv.config();

export default defineConfig({
  test: {
    env: {
      COGNITO_POOL_ENDPOINT: `http://localhost:${process.env.PORT}`,
    },
    setupFiles: ['setup.ts'],
  },
});

server.get~~ の部分でテスト時のみJWKSを返却するエンドポイントを追加していることがポイントです。

setup.ts
import type { FastifyInstance } from 'fastify';
import { readFileSync } from 'fs';
import { join } from 'path';
import { initServer } from './initServer';
import { afterAll, beforeAll } from 'vitest';

let server: FastifyInstance;

beforeAll(async () => {
  server = initServer();
  server.get(`/${process.env.COGNITO_POOL_ID}/.well-known/jwks.json`, (_, res) =>
    res.send(readFileSync(join(__dirname, 'jwks.json'), 'utf8')),
  );

  await server.listen({ port: process.env.PORT });
});

afterAll(async () => {
  await server.close();
});

テスト用JWTの生成

テストコード内で使うAxiosやUserを作成しています。

tests/apiClient.ts
import axios from 'axios';
import { randomUUID } from 'crypto';
import { createSigner } from 'fast-jwt';
import { readFileSync } from 'fs';
import { join } from 'path';

const jwk = JSON.parse(readFileSync(join(__dirname, '../jwks.json'), 'utf8')).keys[0];
const signer = createSigner({
  key: readFileSync(join(__dirname, '../ci_private_key.pem.txt'), 'utf8'),
  aud: process.env.COGNITO_CLIENT_ID,
  header: { kid: jwk.kid, alg: jwk.alg },
});

export const createApiClient = () => {
  const user = {
    sub: randomUUID(), // User ID
    email: `${randomUUID()}@example.com`,
  };
  const jwt = signer(user);
  const apiClient = axios.create({
    baseURL: `http://127.0.0.1:${process.env.PORT}`,
    headers: { cookie: `session=${jwt}` },
  });

  return { apiClient, user };
};

終わりに

上記のコードは業務コードを改変したものを検証せずに記載したものです。ファイル名や相対パスなど不整合は色々あるのでそこは適宜読み替えてください。

Discussion