avatar
screenshot
2023年12月〜

evodo/openapi

フロントとバックエンドが分離されている状態で、APIアクセスを型安全にするためのOpenAPIを試したくて作ったTodoリストアプリです。

概要

OpenAPIを試すために作ったTodoリストです。 型安全なAPIリクエストを実現する技術としてのOpenAPIを試してみたくて作りました。

Googleアカウントでログインすることができ、初回ログインではユーザー登録を行います。 クライアントサイドでのバリデーションも実装しています。

ログインページ ユーザー登録ページ

タスクの詳細はサイドパネルに表示されるようになっています。 タスクの説明を入力できたり、タスクにメモを追加していくことができます。

タスク詳細ページ

テーブルにタスクを表示しており、並び替えやフィルタリングを行うことができます。

タスクテーブル

使用した技術と実装の詳細

  • TypeScript
  • Hono
  • Drizzone ORM
  • Lucia・Oslo・Arctic
  • Vitest
  • React
  • openapi-zod-client
  • Cloudflare

バックエンド

バックエンドでは、HonoZod OpenAPIというミドルウェアを使っていて、 OpenAPIスキーマは手書きせず、コードファーストで開発を行っていました。 Honoには、クライアント側からバックエンド側のコードを関数のように呼び出せるような機能が存在するのですが、今回はOpenAPIを試したかったため使用していません。

Honoは、Expressに型がついたような薄いフレームワークで、TypeScriptとの相性が良いです。 Expressにはミドルウェアを使用するためにuseという関数が用意されていますが、 Honoはこのミドルウェアの中でcontextというオブジェクトをセットすることができます。 このContextに型を指定すると、そのミドルウェアを適用したハンドラの内部で型のついたContextを使用することができます。 これを使用することで、リクエストごとにインスタンスを作成してしてContextに渡しておくと、ハンドラからリクエストに紐づいたインスタンスを使用することができます。

Zod OpenAPIというミドルウェアでは、createRouteという関数にrequestのbodyのzodスキーマや、 responseのzodスキーマなどを渡してRouteを作ることができます。 それを使用してルートハンドラを実装すると、渡したスキーマの型がついたrequestのbodyのデータを取得することができます。 このcreateRouteに渡したオブジェクトをもとに、OpenAPIスキーマを生成してくれます。

また、AsyncLocalStorageというNode.jsのAPIも使用しています。 これは、関数でラップすることで、リクエストごとに独立したストレージにアクセスできるようになるというようなもので、 ReactのContextに似ているなぁと感じました。

シングルトンのLoggerの内部で、AsyncLocalStorageから開発環境か本番環境かの情報を取得するのに使っています。 HonoではContextからしか環境変数にアクセスできないため、ハンドラの深い場所から呼ばれている関数に Contextを渡す必要があるのですが、それを避けるためにこのような実装にしています。

認証

認証は、薄いライブラリを使用して、自分でOAuthのログインフローを実装しています。 Luciaというライブラリがセッション管理を担当していて、 ArcticがGoogleやGitHubなどの一般的なプロバイダーのためのOAuth 2.0クライアントです。 Osloは認証周りのユーティリティ集のようなもので、Arcticが内部的に使用しているOAuth2のクライアントなどがあります。

認証の流れは以下のようになります。

  1. ログインエンドポイントにアクセスされると、ArcticがGoogle認証用のURLを作成して、リダイレクトさせます。このとき、CSRF対策のstateや、PKCEの対応を行っています。
  2. ユーザーがリダイレクト先でログインを行うと、クエリパラメータに認可コードを含んだリダイレクトがサーバーに返ってきます。
  3. サーバー側はリダイレクトされると、stateを比較したあと、Googleに認可コードを検証してもらい、IDトークンを取得します。
  4. トークンの中からgoogleユーザーのIdを取得し、データベースのユーザーのgoogleユーザーIdと比較して、 一致しているユーザーをログイン状態にするため、セッションをセッションストアに作成し、sessionIdをcookieに含めます。

これでユーザーはセッションIDが手に入るので、ログイン状態になります。 Luciaはデフォルトでは、セッションストレージとユーザーの情報が入っているデータベースを同じものと想定しています。 それでも良かったのですが、セッションストアにキーバリューストアを試したかったため、 セッションストレージはCloudflare Workers KV、ユーザーのデータベースはCloudflare D1を使用しています。 そのため、Luciaがセッションを操作したり、ユーザー情報を取得するためのAdapterと呼ばれるものを独自に実装しています。

セッションストアとしてKVを使ってみて思ったのは、keyを指定してvalueを取得するだけだと不便なケースが多いということです。 はじめは、セッションIDをkeyとしてセッション情報を保存するだけで十分だと考えていましたが、 実際には、特定のユーザーのすべてのセッションの削除などが考えられます。 そのため、セッションを作るときには、セッションIdをkeyとする値だけでは不十分で、 ユーザーIdとセッションIdをつなげたkeyと、適当な文字列の値も作成しています。 Cloudflare Workers KVでは、値を検索するときに、keyの完全一致ではなく、prefixから始まるkeyの検索ができるので、 このようにkeyをもたせると、特定のユーザーのすべてのセッションを取得することができます。

デプロイ環境

バックエンドをCloudflare Workers、フロントエンドをCloudflare Pagesにデプロイしています。 ドメインを持っていないため、どちらもデフォルトのドメインを使用しており、セッションCookieがサードパーティークッキーになっています。 僕の環境ではChromeは正しく動いているように見えるのですが、FirefoxやSafariはサードパーティークッキーを許可しないと正しく動きません。

プロジェクトから学んだこと

このプロジェクトはOpenAPIを試すために作ったのですが、必要な設定がほとんどなく、 コードファーストで開発したのでOpenAPIスキーマを手書きするという必要もありませんでした。 OpenAPIスキーマから型定義を生成するツールさえあれば、手軽に型安全なAPIリクエストを実現することができるので、 GraphQLと比べるとシンプルになると感じました。

今回は、OpenAPIというより、OAuth 2/OIDCでの認証について多くのことを学びました。 OAuth 2クライアントを使用して認証フローを実装するのは今回が初めてで、 セキュリティのために考慮しなければいけないことが多いと感じました。 例えばstateの比較やPKCE、OIDCだとnonceパラメータなどもあります。

以前は、OAuthは認可の仕組みだから常に安全ではないと理解していたのですが、 サーバーサイドで認可コードからアクセストークンを取得し、アクセストークンからユーザー識別子を取得し、 その識別子で認証する場合には安全にやり取りすることが可能であると学びました。 stateやPKCEやnonceを怠った場合に危ないのはOAuthだけではなくOIDCでも同じなので、そこは関係ありません。

ただ、ユーザー識別子での認証は最低限の要件であり、複雑なことをしようとするためには、 認証のためのOIDCを使うほうが良いらしいです。 これまで複雑な認証の要件というのを実装したことがないのでイメージができないのですが、 実装する機会まで忘れないでおきたいです。