FirebaaeEmulatorとHasuraを使用してJWT認証

## 今回の記事について

https://qiita.com/roll1226/items/3da8b75ffd1b486b248f

以前Hasuraについて学んだ内容を記事しました。
その後も引き続き学んでいる中でHasuraの認証機能を使うことでCRUDの権限を制御することが可能だと知りました。

自分がFirebaseを使う際は`Firebase Emulator`を使用して開発をするのですが、`Fireabase Emulator + Hasura環境`でのJWT認証を行う方法が全くなかったので自分なりに調べて実装した内容を記事に残して同じ環境で開発したい方の助けになればと思い書いていきます。

## JWT認証とは

https://jwt.io/

正式名称は「JSON Web Token」
`安全`かつ`管理しやすい`と言われている認証になります。

### 安全
JWT内には「署名」があるため、改竄を検知することが出来ます。

### 管理しやすい
BASE64URLエンコードされているためURLに直接記述出来るため使いやすいです。
認証に使う情報はJWT内にあるためユーザー認証情報を毎回データベースへアクセスして取得する必要がなくなります。

JWT内は「ヘッダー」「ペイロード」「署名」の3つに分かれています。
今回は「ペイロード」の部分が重要になっていきます。

### ペイロード
「クレーム情報」とも言われますが日本語だと「クレーム」は`お客さまから企業への不満や怒りを伴う、なんらかの要求や主張`という意味合いでよく使われています。

https://cs.oricon.co.jp/michitari/article/179/#:~:text=%E3%82%AF%E3%83%AC%E3%83%BC%E3%83%9E%E3%83%BC%E3%81%A8%E3%81%84%E3%81%86%E8%A8%80%E8%91%89%E3%81%AB%E4%BB%A3%E8%A1%A8,%E3%81%84%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E6%80%9D%E3%81%84%E3%81%BE%E3%81%99%E3%80%82

JWTでの「クレーム情報」はJSONデータ項目を指します。
この「クレーム情報」内にHasuraで使用する認証情報を入れていく形になります。

https://hasura.io/docs/latest/auth/authentication/jwt/

## ファイル構成
:::note warn
https://qiita.com/roll1226/items/3da8b75ffd1b486b248f
以前書いた記事とほぼ同じ構成になっています。
そのためファイ構成は記述しますが、環境構築は割愛します。
:::


```
├── backend
│   ├── dist
│   ├── src
│   └── test
├── commands
├── db_data
├── docker
│   ├── backend
│   └── frontend
├── envs
├── frontend
│   ├── public
│   └── src
├── functions
│   ├── lib
│   └── src
└── hasura
    ├── metadata
    ├── migrations
    └── seeds
```

### backendディレクトリ
Nest.jsディレクトリ

https://nestjs.com/

### commandsディレクトリ
シェルスクリプトを管理するディレクトリ

### db_data
PostgreSQLのデータを永続化するためのディレクトリ

https://www.postgresql.org/

### dockerディレクトリ
Dockerfileを管理するディレクトリ

### envs
.envを管理するディレクトリ(今回の記事では気にしない)

### frontend
Next.jsディレクトリ

https://nextjs.org/

### functions
Firebase Functionsを管理するディレクトリ

https://firebase.google.com/docs/functions?hl=ja

### hasuraディレクトリ
hasuraのテーブル定義やアクセス権限まわりなどのファイル郡を管理するためのディレクトリ

https://hasura.io/

### Firebase Functionsを整える
:::note warn
Firebase開発環境は多くの方がQittaに記事を載せている内容と変わらないため割愛します。
:::

```ts:functions/src/index.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import { Timestamp } from "firebase-admin/firestore";
import axios from "axios";
import { defineSecret } from "firebase-functions/params";

admin.initializeApp();

const hasuraGraphqlEndpoint = defineSecret("HASURA_GRAPHQL_ENDPOINT");
const hasuraGraphqlAdminSecret = defineSecret("HASURA_GRAPHQL_ADMIN_SECRET");

const query = `
  mutation InsertCustomer($id: String!, $email: String!, $username: String!) {
    insert_customers(objects: {id: $id, email: $email, username: $username}) {
      returning {
        id
        email
        created_at
      }
    }
  }
`;

exports.processSignUp = functions.auth.user().onCreate(async (user) => {
  const customClaims = {
    "https://hasura.io/jwt/claims": {
      "x-hasura-default-role": "customer",
      "x-hasura-allowed-roles": ["customer", "anonymous"],
      "x-hasura-user-id": user.uid,
    },
  };

  return await admin
    .auth()
    .setCustomUserClaims(user.uid, customClaims)
    .then(async () => {
      const variables = {
        id: user.uid,
        email: user.email,
        username: "test",
      };

      await axios
        .post(
          hasuraGraphqlEndpoint.value(),
          {
            operationName: "InsertCustomer",
            query,
            variables,
          },
          {
            headers: {
              "Content-Type": "application/json",
              "x-hasura-admin-secret": hasuraGraphqlAdminSecret.value(),
            },
          }
        )
        .catch((err) => {
          console.error("success");
          console.log(err);
        });

      // NOTE:クレーム情報の設定に遅延がある時のためにmetaデータをFirestoreで管理
      await admin.firestore().collection("user_meta").doc(user.uid).create({
        refreshTime: Timestamp.now(),
      });
    })
    .catch((error) => {
      console.log(error);
    });
});
```
コードはユーザー作成時をトリガーとしているものです。
`customClaims`, `admin.auth().setCustomUserClaims()`について説明します。

### customClamis
```ts
const customClaims = {
  "https://hasura.io/jwt/claims": {
    "x-hasura-default-role": "customer",
    "x-hasura-allowed-roles": ["customer"],
    "x-hasura-user-id": user.uid,
  },
};
```

Hasuraの認証で使用するクレーム情報になります。

* `https://hasura.io/jwt/claims`
    * デフォルトのネームスペースとして設定されているもの
* `x-hasura-default-role`
    * 何も指定してなければデフォルトで使用されるRole
* `x-hasura-allowed-roles`
    * Roleを複数付与出来る
    * `x-hasura-allowed-roles`内にあるPermissionであれば`x-hasura-role`ヘッダーで渡して使用することが可能
* `x-hasura-user-id`
    * Rowに設定されている`ユーザーID`を`query`, `mutation`で渡さなくてもHasuraが自動的に入れてくれる

`x-hasura-〇〇`の形で渡して上げればHasuraが良しなに認識してくれて値を使用することが出来ます。

### admin.auth().setCustomUserClaims()

```ts
return await admin
  .auth()
  .setCustomUserClaims(user.uid, customClaims)
```
Firebase AuthのユーザーにHasuraで使用するクレーム情報をsetしてあげます。 
これにより、ログイン時にユーザー情報からクレーム情報を取得することが出来ます。

## envを整える
自分が一番ハマって進歩なし状態が数日続いた箇所になります。
`Firebase Emulator`ではなく実際の`Firebase`を使用すればハマることはなかった？とは思います。
ただ、開発するならEmulatorを使いたいという気持ちがあったので自分なりに調査・実装した内容を記述していきます。

```ts:.env
HASURA_GRAPHQL_JWT_SECRET='{"type":"HS256", "audience": {Firebaseプロジェクト名}, "issuer": "https://securetoken.google.com/{Firebaseプロジェクト名}", "allowed_skew": 86400, "key": {JWTに使用する鍵}}'
```

`HASURA_GRAPHQL_JWT_SECRET`をHasuraに渡してあげると自動でJWT認証モードになり、Hasuraが良しなにJWTを読み取ってくれます。
重要になるのは`key`になります。

### key
公式・エンジニアの方々が記述しているのは`jwt_url`になっています。
```ts
"jwk_url": "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com"
```
通常のFirebaseではればこちらで問題ないのですが、Emulatorでは鍵として上記を使用していない感じでした。
そこで`jwt_url`ではなく`key`を使用するようにしました。
JWT認証する場合は一度FirebaseAuthから取得したJWTをデコードをして`key`に指定した鍵情報を使用してエンコード、Hasuraに渡してあげるようにしてあげます。
ただ、上記のやり方はローカル環境のみでしか使用しないコードになるのでビルド時に除外などをしてあげると良いかと思います。

## AuthProvider、Emulator用JWTを整える

```ts:frontend/src/utils/lib/jwt/Jwt.ts
import { env } from "@/env/dotEnv";
import * as jwt from "jsonwebtoken";

// HACK: FIrebase Functionsに移す予定
export const Jwt = {
  /**
   * Firebase Emulator起動時のみ使用する
   *
   * @param token string
   * @return string
   */
  getEmulatedSignedToken: (token: string) => {
    return jwt.sign(
      jwt.decode(token) as string,
      env.getHasuraGraphQLJwtSecret()
    );
  },
} as const;
```

わかる方にはわかるのですが、使用してる`jsonwebtoken`はv9からブラウザ上では使用出来なくなっていますが、現状ダウングレードして使用出来るようにしています。今回は一先ずブラウザ上で動くようにしています。
ダウングレードして使用するのはセキュリティ的にダメなので追々Firebase Functionsに移行予定です。

```tsx:frontend/src/providers/AuthProvider.tsx
import React, {
  FC,
  createContext,
  useState,
  useContext,
  useEffect,
  ReactNode,
} from "react";
import { onAuthStateChanged, User } from "firebase/auth";
import {
  firebaseAuth,
} from "@/utils/lib/firebase/FirebaseInitialize";
import { LocalStorages } from "@/utils/LocalStorages";
import { env } from "@/env/dotEnv";
import { onSnapshot } from "firebase/firestore";
import { FirebaseFirestore } from "@/utils/lib/firebase/FirebaseFirestore";

type AuthContextProps = {
  currentUser: User | null;
  loading: boolean;
};

type Props = {
  children: ReactNode;
};

const AuthContext = createContext<AuthContextProps>({
  currentUser: null,
  loading: false,
});

export const useAuthContext = () => {
  return useContext(AuthContext);
};

export const AuthProvider: FC<Props> = ({ children }) => {
  const [loading, setLoading] = useState(false);
  const [currentUser, setCurrentUser] = useState<User | null>(null);
  const value = { currentUser, loading };

  useEffect(() => {
    const unsubscribed = onAuthStateChanged(firebaseAuth, async (user) => {
      setLoading(true);
      if (user === null) {
        firebaseAuth.signOut();
        LocalStorages.removeAuthToken();
        return;
      }

      setCurrentUser(user);

      const token = await user.getIdToken(true);
      const idTokenResult = await user.getIdTokenResult();
      const hasuraClaims = idTokenResult.claims[env.getHasuraTokenKey()];

      if (hasuraClaims) return LocalStorages.setAuthToken(token);

      // NOTE:ユーザー情報にクレーム情報が登録されるまでの遅延を担保
      onSnapshot(
        FirebaseFirestore.getAuthTokenRefreshDoc(user.uid),
        async (doc) => {
          const token = await user.getIdToken(true);
          LocalStorages.setAuthToken(token);
        }
      );
    });

    return () => {
      unsubscribed();
    };
  }, []);

  return (
    <AuthContext.Provider
      value={{ currentUser: value.currentUser, loading: value.loading }}
    >
      {children}
    </AuthContext.Provider>
  );
};
```



```tsx:frontend/src/providers/AppProvider.tsx
"use client";

import { client } from "@/apolloClient";
import { ApolloProvider } from "@apollo/client";
import { FC, ReactNode } from "react";
import { AuthProvider } from "./AuthProvider";

type Props = { children: ReactNode };

export const AppProvider: FC<Props> = ({ children }) => {
  return (
    <ApolloProvider client={client}>
      <AuthProvider>{children}</AuthProvider>
    </ApolloProvider>
  );
};
```

一般的な書き方なので説明は割愛します。

```frontend/src/apolloClient.ts
import {
  ApolloClient,
  InMemoryCache,
  ApolloLink,
  createHttpLink,
} from "@apollo/client";
import { onError } from "@apollo/client/link/error";
import { WebSocketLink } from "@apollo/client/link/ws";
import { getMainDefinition } from "@apollo/client/utilities";
import { env } from "@/env/dotEnv";
import { Logger } from "./utils/debugger/Logger";
import { HasuraLogger } from "./utils/debugger/HasuraLogger";
import { LocalStorages } from "./utils/LocalStorages";
import { Jwt } from "./utils/lib/jwt/Jwt";

const errorLink = onError((errors) => {
  const { graphQLErrors, networkError } = errors;
  if (graphQLErrors)
    graphQLErrors.forEach(({ message, locations, path }) =>
      Logger.debug(
        `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
      )
    );
  if (networkError) Logger.debug(`[Network error]: ${networkError}`);
});

const authToken = LocalStorages.getAuthToken();
// TODO: LocalStorageに保存する際にデコード・エンコードを行う予定
const apolloHeader: Record<string, string> = authToken
  ? {
      Authorization: `Bearer ${
        env.isDevelopment() ? Jwt.getEmulatedSignedToken(authToken) : authToken
      }`,
    }
  : {
      "x-hasura-admin-secret": `${env.getHasuraGraphQLAdminSecret()}`,
    };

const httpLink = createHttpLink({
  uri: env.getHasuraGraphQLEndpoint(),
  headers: apolloHeader,
});

const wsLink = new WebSocketLink({
  uri: `${env.getHasuraGraphQLWebsocketEndpoint()}`,
  options: {
    reconnect: true,
    connectionParams: {
      headers: apolloHeader,
    },
  },
});

// TODO:開発環境のみ読み出されるようにする
HasuraLogger.Messages();

const link = ApolloLink.from([errorLink]).split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === "OperationDefinition" &&
      definition.operation === "subscription"
    );
  },
  wsLink,
  httpLink
);

export const client = new ApolloClient({
  ssrMode: typeof window === "undefined",
  cache: new InMemoryCache(),
  link,
});
```

以前の記事に記述したコードとあまり変わりませんが、headerを生成するコードについて。


```ts
const authToken = LocalStorages.getAuthToken();
const apolloHeader: Record<string, string> = authToken
  ? {
      Authorization: `Bearer ${
        env.isDevelopment() ? Jwt.getEmulatedSignedToken(authToken) : authToken
      }`,
    }
  : {
      "x-hasura-admin-secret": `${env.getHasuraGraphQLAdminSecret()}`,
      "x-hasura-role": "anonymous",
    };
```

FirebaseAuthから取得してきたJWTを取得、その後にローカル環境か確認してローカル環境の場合はデコード・エンコードを行ってあげてローカル環境出ない場合はそのままJWTを渡してあげいます。
ログインしていない場合は`x-hasura-admin-secret`と`x-hasura-role`を渡してあげます。

## HasuraにRole追加

`DATA ＞ Roleを追加したいテーブル ＞ Permissions`に移動。
`Enter new role`に追加したいRoleを追加。(今回は`customer`, `anonymous`)
クレーム情報の`x-hasura-user-id`で取得するデータを絞り込める

### cusotmersテーブル

#### customer Role

> ### insert
> insert出来ない状態にする(何も設定しなし)

> ### select
> `Row select permissiions`で`{"id":{"_eq":"X-Hasura-User-Id"}}`を設定する
> 設定することで前述したクレーム情報の`x-hasura-user-id`から取得するデータを絞り込める
>> ![CleanShot 2024-04-28 at 09.46.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/09a800c9-cfbb-21c1-7b44-ce646e17c593.png)

> ### update
> `With same custom check as select, pre update`項目を選択すると`{"id":{"_eq":"X-Hasura-User-Id"}}`と記述される
> `Column update permissions`では`id`,`created_at`はupdateする必要がないのでチェックを外す
>> ![CleanShot 2024-04-28 at 09.46.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/c3cf0693-e102-c33b-3408-78ac4850dee2.png)

> ### delete
> delete出来ない状態にする(何も設定しなし)

### tasksテーブル
customersテーブルと同じように設定していけば良いので説明は割愛していきます。

## 実際に動かす
今回はFirebase Emulatorを起動しての実際になります。

### ブラウザ上
1. ログイン後、タスクページに遷移する
1. 遷移後にtaskを追加を行う
    ![CleanShot 2024-04-28 at 21.17.37.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/2b0fb5a6-361d-54b7-338c-541c4a8d36ed.gif)
1. ログアウトを行い別アカウントでログイン
1. タスクページに遷移すると先程追加したタスクが表示されていない
1. タスクが追加出来ることを確認後、HasuraConsoleに移動
1. ２アカウントで追加したtaskが存在することを確認
    ![CleanShot 2024-04-28 at 21.22.48.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/eee4edf7-9393-1e5c-afa7-884699a2857a.gif)

:::note info
２アカウントで追加したtaskがあればJWT認証を使った環境が整っております。
:::

## 今回のコード

今回書いたコード全体は下記のリポジトリにあるのでもし良かったら確認してみてください。
https://github.com/roll1226/study-next-nest

## まとめ
FirebaseEmulatorとHasuraを使用したJWT認証は詰まってしまったが分かってしまえば簡単に実装することが出来ます。
今回はテーブルに対してPermissionを追加していきましたが、RemoteSchemasで追加したNest.jsのQueryにもPermissionを付与出来るので開発のしやすさはずば抜けているかと思います。今後はHasuraを使ってマルチテナントを試してみたいです。
開発チーム環境にもよりますが、是非Hasuraを使ってより多くのサービスを出してほしいです。