この記事は Howtelevision Advent Calendar 2022 13日目の記事です。
12日目は @m-book さんの「Go 1.15 → 1.19」 でした。アップグレードは素敵ですね
はじめに: なぜ Hasura なのか
筆者は弊社にて Mond という Q&A サービスの開発を担当しています。弊社内では最も新しく、まだ立ち上げフェーズのサービスということもあり、技術的にもいろいろ試行錯誤をしながら日々開発を行っています。チームの特性として開発者の人数が少ないということがあり、技術選定にあたっては特に「コードをなるべく書かないで実装できる」というのを最重要視しています。その目的を達するため、バックエンドには Firebase を中心に据えたり、アプリ全体を通じて TypeScript や Monorepo を採用するなどしています。
2021年のリリース初期はデータベースにマネージド NoSQL データベースである Firestore を利用していたのですが、さすがにちょっと辛いことが多かったので2022年9月のリニューアルを機にデータベースを PostgreSQL に移行し、GraphQL バックエンドに Hasura を導入しました。
データベースは Firebase ファミリーを脱したわけなのですが、ユーザー認証には依然として Firebase Authentication (以下、Firebase Auth)を利用しています。Firestore の場合はセキュリティルール機能にて Firebase Auth との統合が標準で提供されていましたが、Hasura の場合は少し設定が必要になります。
そこで、今回の記事では Mond における Hasura の導入にあたり Firebase Auth との統合を行った手順についてご紹介したいと思います。
Hasura の認証方式
執筆時点(2022年12月)で Hasura には Webhook 方式と JWT 方式の2種類の認証方式があります。
Webhook 方式
認証が必要なロールのリクエストが来たとき、Hasura からリクエストヘッダ付きで事前に設定した Webhook のエンドポイントを呼び出し、そのレスポンスを利用して認証を行う方式です。
上図は Hasura のドキュメント ( https://hasura.io/docs/latest/auth/authentication/index/ ) から引用しています。
リクエストの都度認証を行う Webhook エンドポイントを呼び出すことになりますので、それなりのオーバヘッドがあるのと、その Webhook は自分で実装しなくてはならないというデメリットがあります。
この方式は JWT でないトークンをセッションIDとして利用する認証システムが既に構築済みのアプリケーションに Hasura を組み込む際に利用すべきものだと思います。
JWT 方式
もう一つの認証方式は JWT (JSON Web Token) を利用するもので、クライアントからリクエストヘッダに載せて送信された JWT を Hasura 内でデコードし、事前に設定した共通鍵または秘密鍵を用いて署名の検証を行うことで認証する仕組みです。
Webhook 方式と比較すると、認証にあたって外部のAPIを呼び出す必要がありませんのでオーバーヘッドが圧倒的に小さくなるという利点があります。
一方で、以下のようなデメリットもあります:
- JWT を発行する際に事前に Hasura 用のクレームを書き込んでおく必要がある
- 一度発行したトークンを個別に任意のタイミングで無効化することができない
どちらもトークン単体で検証とデータの埋め込みが可能という JWT の特徴に由来するものですので、仕方のないトレードオフだといえます。
Firebase Auth との統合は JWT 方式で
Firebase Auth との統合は Webhook 方式でも頑張れば実装できると思うのですが、Firebase Auth はユーザー認証用のトークンを JWT で発行するという既存の仕組みを持っています。よって、Firebase Auth により発行される認証トークン(以下、アクセストークン)をそのまま流用するのが一番の近道でしょう。実はこのことは Hasura のブログに書いてあるので、この記事は実はそれと同じことをつらつら書いているだけなのですが(小声)、もう少しお付き合いください 🙇
いざ、設定
Hasura の JWT 認証方式を利用するということがわかったところで、Hasura と Firebase Auth 双方の設定手順をご紹介します。
1. Hasura の設定
Hasura は起動時に環境変数を与えることで設定をカスタマイズすることができます。フルマネージドの Hasura Cloud を利用している場合は管理コンソールから設定でき、Docker イメージから起動する場合は普通に環境変数として定義すればOKです。
設定可能な環境変数のリストはこのページに掲載されており、JWT に関する設定は HASURA_GRAPHQL_JWT_SECRET
または HASURA_GRAPHQL_JWT_SECRETS
にて行います。
設定値は JSON になっており、サンプルは以下です:
{
"type": "<optional-type-of-key>",
"key": "<optional-key-as-string>",
"jwk_url": "<optional-url-to-refresh-jwks>",
"claims_namespace": "<optional-key-name-in-claims>",
"claims_namespace_path":"<optional-json-path-to-the-claims>",
"claims_format": "json|stringified_json",
"audience": "<optional-string-or-list-of-strings-to-verify-audience>",
"issuer": "<optional-string-to-verify-issuer>",
"claims_map": "<optional-object-of-session-variable-to-claim-jsonpath-or-literal-value>",
"allowed_skew": "<optional-number-of-seconds-in-integer>",
"header": "<optional-key-to-indicate-cookie-or-authorization-header>"
}
必須フィールド
全フィールドの詳細はこのページに記載されていますが、Firebase Auth との統合に必須となるのは下記の4つの値です:
type
JWT の署名アルゴリズムを指定します。Firebase Auth では RS256
(RSA と SHA-256 を利用した公開鍵方式)を指定します。
jwk_url
署名された JWT の公開鍵情報を提供している JWK (JSON Web Key) の URL を指定します。実体は JSON で、そのデータ構造は RFC 7517 にて定義されています。
Firebase Auth の場合はどのプロジェクトでも共通の鍵が用いられているようで、その URL は https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com
です。
audience
先ほど「どのプロジェクトでも共通の鍵」と書いたのですが、そのことによって Firebase Auth を利用しているプロジェクトであれば、他人のものであっても同じ鍵を用いて署名されたトークンが発行されることになります。よって、きちんと JWT 内でトークンの発行対象を示す audience クレームが自分の Firebase プロジェクト固有のものであることを確認しないと重大な脆弱性に繋がります。
Firebase Auth の場合は audience としてプロジェクトIDが設定されますので、確実にここを自分のプロジェクトIDに設定しましょう。
issuer
JWT のフィールドに Issuer という発行者を示すものもあり、Firebase Auth の場合は https://securetoken.google.com/<firebase-project-id>
が設定されるようなので、これも指定しておきましょう。
audience フィールドと署名を検証すればそのトークンは Firebase Auth による真正なものとみなしてよいかもしれないのですが、Hasura の公式ブログにはこのフィールドも指定するよう書いてあったので、念には念をいれましょう。
任意フィールド
上記の必須フィールドだけでもとりあえず統合は可能なのですが、JWT のクレームに Hasura が標準で使用する以下のようなネームスペースを利用する必要があります:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"https://hasura.io/jwt/claims": {
"x-hasura-allowed-roles": ["editor", "user", "mod"],
"x-hasura-default-role": "user",
"x-hasura-user-id": "1234567890",
"x-hasura-org-id": "123",
"x-hasura-custom": "custom-value"
}
}
この構造をそのまま利用しても良いのですが、個人的には認証対象のユーザーIDが sub
フィールドと https://hasura.io/jwt/claims.x-hasura-user-id
とで重複していることが気になってしまいます。
気になってしまう派の方もご安心ください。以下のフィールドを追加で設定することで、JWT のクレームをカスタムすることができます。
claims_map
JWT のクレームを Hasura が必要とするフィールド(ユーザーIDや権限ロール等)にマッピングする設定です。
例えば、以下のように設定したとします:
{
"x-hasura-allowed-roles":{
"path":"$.my_app.allowed_roles"
},
"x-hasura-default-role":{
"path":"$.my_app.default_role"
},
"x-hasura-user-id":{
"path":"$.sub"
}
}
そうすると、JWT のクレームを以下のような構造にカスタムできます:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"my_app": {
"allowed_roles": ["editor", "user", "mod"],
"default_role": "user",
}
}
ユーザーIDを示すフィールドの重複を解消し、Hasura 標準のネームスペースをアプリ固有のものにカスタムできました!
2. Firebase Auth 側の設定
前項でも記載の通り、Hasura で使用する JWT には許可されたロールのリストとデフォルトロールの情報を書き込まなくてはなりません。
Firebase Auth はアカウントを作成した時点ではこのようなクレームを持たせていませんので、別途 Firebase Admin SDK を利用してカスタムクレームを設定しなければなりません。実がこれが結構大変で、実際のアプリの実装でも苦労しているポイントの1つです。
実装方法は様々なのですが、筆者の実装では以下のようなフローを取っています:
クライアントアプリ上のフロー (1)
- ユーザーの操作により Firebase JavaScript SDK を使用してログイン
- ログイン状態の変化を
onIdTokenChanged
オブザーバーを利用して検知 - オブザーバー関数内で JWT をデコードし、カスタムクレームが存在するかチェック
- 存在しない場合、独自実装の Cloud Functions を呼び出し
Cloud Functions 上のフロー
- リクエストされたユーザーに対して許可されているロールの一覧を取得
- 許可されているロールの情報を含むカスタムクレームを作成し Firebase Admin SDK を利用して設定
- レスポンスを返す
クライアントアプリ上でのフロー (2)
- レスポンスを受け取ったら
getIdToken
をforceRefresh: true
で呼び出し、強制的に最新のトークンを取得 - 取得したトークンを利用して GraphQL クライアントを生成
ご覧の通り、結構大変なフローです...
Hasura の公式ブログでも同様の手法がとられていましたので、これは Firebase Auth と Hasura を統合するうえで避けては通れない難関のようです。
昨今、Auth0 や AWS Cognito などといった Firebase Auth と同様のサービスはたくさんありますが、もしアカウント作成と同時に任意のクレームを設定できるサービスがあれば、この面倒なフローは不要になるので Hasura との統合はより容易だと思います。そのようなサービスをご存じの方がいらっしゃったらぜひ教えていただきたいです!
3. Hasura のデータ毎の設定
上記で実際の統合に必要な設定は完了なのですが、現状ではどのデータ(テーブル)に対してもパーミッションが設定されていませんので、それらは個別に設定する必要があります。
この設定はアプリ固有ものですので本記事では省略しますが、以下のドキュメントが参考になると思います:
まとめ
Firebase Auth と Hasura を統合する利点
Firebase Auth と Hasura にはそれぞれ以下のような利点があります。
Firebase Auth の利点
- 様々な認証プロバイダの処理を自分で実装しなくてよい
- センシティブな情報を自分のDBで管理しなくてよい
- セキュリティのベストプラクティスが実装されている
Hasura の利点
- PostgreSQL にあるデータを安全に、フロントエンドから直接読み書きできる
- GraphQL のリゾルバを自分で書かなくて良い!
- コンテナ1つでポンと起動する
そうです。結局のところ、どちらも従来は開発者が書かないといけなかったよくあるコード(ログイン、ユーザー管理、CRUDのAPI)を一手に担ってくれるツールなのです。これらを統合することで「なるべくコードを書かずに」でもそれでいて「柔軟でイケてる」サービスを開発することができるのです。
つらい点
前章の終わりの方でも書いたのですが、Hasura が必要とするカスタムクレームを Firebase Auth のアカウント作成と同時に設定することができないのがとても辛い点だといえます。
筆者はこの構成でアプリにおいて、アクセストークンの状態を unauthenticated
, claimsNotReady
, authenticated
のような3段階でグローバルステートとして管理しており、それぞれ「未ログイン」「ログイン済みだがカスタムトークンが用意できていない」「ログイン済みでカスタムトークンも準備できている」という状態を表します。状態が authenticated
になるまでは GraphQL のリクエストが飛ばないように制限したりといった処理も実装しています。
claimsNotReady
状態になった場合、そのことを検知してバックエンドを呼び出し、それが済んだらトークンを更新するというピタゴラスイッチのような処理をどうしても実装せざるを得ず、これはもう少しなんとかならんものかと日々考えています。
それでも私は幸せです
若干ネガティブなことにも触れてしまいましたが、筆者は Firebase Auth と Hasura に心から感謝しています。足を向けて寝られないほどです。
Firebase Auth に触れる前は「認証なんて自分で実装できるんじゃい!!」と思っていましたし、Hasura を知る前は「GraphQL とかよくわからんし、自分で REST API 実装できるんじゃい!!」と思っていました。最初はただ人手が足りないからという理由で採用した「開発者の苦労を肩代わりしてくれる系サービス」ですが、今となっては「これらを使わないでサービス開発をしたくない」ほどにまで思想が変わってきました(笑)
「なるべくコード書かない」という点では、最近流行りの tRPC と Prisma を組み合わせた技術スタックもいいなあと思ったりします。結局人類のソフトウェア開発は一周まわってノーコード・ローコードに収束するのかもですね。
皆様もクリスマス・年末年始はぜひ「なるべくコードを書かない」おもしろプロダクト開発をされてみてはいかがでしょうか!
おわりに
ここまでお読みいただきありがとうございました。この記事へのご感想や誤りのご指摘などありましたらお気軽にお寄せください。
筆者が所属する株式会社ハウテレビジョンでは、いつも何度でもエンジニアを大募集しております。
いっぱいコードを書きたい派の方も、なるべく書きたくない派の方も大歓迎です。以下のサイトに募集中の職種が掲載されておりますので、ぜひご覧いただけると嬉しいです!!
ぜひ一緒におもしろプロダクト開発をしましょう! お待ちしております!