認証付きGraphQL APIサーバーを爆速で立てる。 Hasura + Firebase Authentication

[Hasura](https://hasura-firebase-auth-sample.herokuapp.com/console)はPostgreSQLからGraphQL APIサーバーを爆速で構築できるものの、認証については外部の認証基盤を使う必要があります。
今回は、認証基盤として[Firebase Authentication](https://firebase.google.com/docs/auth/?hl=ja)のJWT認証を使った例を紹介します。


# Hasuraの認証について
Hasuraの認証はWebhook方式と、JWT方式があり、今回はJWT方式を使います。
JWTは属性情報をJSONデータ構造で表現したトークンを使い認証を行う方法で、Firebase Authenticationにて採用されています。
Hasuraの認証でFirebase Authenticationを使う場合は以下のような流れとなります。

1. クライアントアプリでFirebase SDKを利用して認証サーバーにアクセスしてトークンを発行（ここでカスタムクレームにHasuraの属性情報も持たせておく）
2. リクエストヘッダーのAuthorizationにBearerスキームで、トークンを埋め込む
3. Hasuraから検証サーバーにアクセスしてトークンの検証を行う。

![無題のプレゼンテーション.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/d3af5091-c5d2-a3b3-c045-f32500f2e824.jpeg)


# Hasuraの設定
まずGraphQL APIサーバーの構築を行います。

## Hasura環境構築

以下のボタンをクリックしてHerokuにHasuraをデプロイします。

<a href="https://heroku.com/deploy?template=https://github.com/hasura/graphql-engine-heroku">![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/ee35cdf2-a30c-4b3a-a94d-6b339fb17784.png)</a>

アプリ名は適当に入力してDeploy App。

<img width="1408" alt="スクリーンショット 2020-01-12 11.41.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/4df49bd9-f1d0-8b2f-9fa2-d543307a9451.png">

完了したらView appからHasuraのコンソールへ。

<img width="1074" alt="スクリーンショット 2020-01-12 11.42.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/ee698efb-855f-2d95-cdcc-5620bb342efa.png">

これでHasuraの環境構築は完了です。
上部に表示されているアクセスポイントでHasuraのGraphQL APIが使用できます。

<img width="1401" alt="スクリーンショット 2020-01-12 11.44.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/c86d2172-c210-4390-f48b-9ab9ef7a1a99.png">


## アクセス制限、JWT検証サーバーの設定
Hasuraのコンソール・APIは今のままだと、URLさえ知れば誰でもアクセスできる状態です。それを防ぐため`ADMIN_SECRET`の設定をします。

Herokuのコンソールより、Hasura用に作成したAPPを選択してSettingsのConfig Varsから以下を設定してください。

|変数名|値|
|---|---|
|HASURA_GRAPHQL_ADMIN_SECRET|  Admin権限でアクセスする際に必要なパスワード（任意の値） |
|HASURA_GRAPHQL_JWT_SECRET|JWTのモード、検証サーバーの設定（[こちら](https://hasura.io/jwt-config )より生成）|

![スクリーンショット 2020-01-12 23.43.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/6dbadb93-5235-e670-70e8-468f1cd87856.png)

`HASURA_GRAPHQL_JWT_SECRET`は、https://hasura.io/jwt-config よりFirebase、Project IDを入力することで生成できます。

![スクリーンショット 2020-01-12 23.43.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/86ee2253-0a5f-f6f6-f39d-c5a149c69844.png)


これでHasura側の設定は完了です。
以降、Hasuraのコンソールにログインする際、パスワードを求められるようになるはずです。
`HASURA_GRAPHQL_ADMIN_SECRET`で設定したパスワードを入力してログインしてください。

![スクリーンショット 2020-01-12 23.53.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/c1d7bc26-88b4-f333-d2d4-c8aa20239fc2.png)

## テーブル構築

今回は簡易なメモアプリを作成します。テーブルはユーザー情報を保存する`users`と、メモを保存する`memos`の２つとします。
以下Hasuraのコンソール上でテーブルを作成します。
上部メニューDATA > Create tableより、まず`users`テーブルの作成。

|カラム名|構造|属性|
|---|---|---|
|id|Text|Primary key|
|name|Text||
|create_at|TimeStamp|default now()|


<img width="1404" alt="スクリーンショット 2020-01-12 12.00.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/5c468dce-3ebf-58d0-54c7-023013334e63.png">

次に`memos`テーブルの作成。

|カラム名|構造|属性|
|---|---|---|
|id|Integer(Auto-increment) |Primary key|
|user_id|Text|Foreign key user.id|
|content|Text||
|created_at|Timestamp| default now()|

<img width="1434" alt="スクリーンショット 2020-01-13 20.35.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/998e22a5-5b1c-c168-04cf-f38e33dbd513.png">


Foreign Keysでuser_idの関連キーとしてusersテーブルのidを指定します。

<img width="1245" alt="スクリーンショット 2020-01-13 20.35.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/ef35576c-ff54-36c2-0ee4-1d3533805b47.png">

これでテーブルの作成は完了です。

## 認可の設定
次にテーブルの操作・カラム単位での認可の設定をします。
DATA > サイドメニュー todos > Permissionsタブから新しいロールuserを追加します。
そしてuserロールでは自分のuser_idと関連するメモしかinsert, select, update, delete出来ないようにします。

ここで設定する`X-Hasura-User-Id`は後ほどFirebase Authenticationのカスタムクレームで設定します。

まずinsertの設定。
`Allow role user to insert rows`で、`With custom check`を選択肢し、`user_id eq X-Hasura-User-Id`を指定します。
その他、画像のように設定します。

<img width="1173" alt="スクリーンショット 2020-01-13 20.37.06.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/f44b7524-2714-26a4-c3e7-22a43291feee.png">


次にselectの設定。
`Allow role user to insert rows`は、`With same custom check insert`を選択します。
これでinsertと同じくuser_idがX-Hasura-User-Idと同様のものしかselect出来ないようになります。
ほかは画像のとおりです。

<img width="1196" alt="スクリーンショ<img width="1176" alt="スクリーンショット 2020-01-13 20.38.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/2aa61399-1b3e-e183-6fdc-6788bc54748f.png">

次にupdateの設定。
同じく`Allow role user to insert rows`は、`With same custom check insert`を選択肢します。
ほかは画像のとおりです。

<img width="1176" alt="スクリーンショット 2020-01-13 20.38.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/cecd8bd4-f52b-afc5-4000-77fed30b5216.png">


最後にdeleteの設定。
同じく`Allow role user to insert rows`は、`With same custom check insert`を選択肢します。

<img width="1201" alt="スクリーンショット 2020-01-13 20.38.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/2819c8b5-bd40-2d5c-0aba-5e9b13984a8b.png">

これで認可の設定は完了です。


# Firebaseの設定
続いて認証基盤として使うFirebaseの設定です。

## プロジェクトの作成
任意のディレクトリでFirebaseプロジェクトを作成してください。使うリソースはCloud FunctionsとFirestoreです。
拙著ですが、TypeScriptでESLint+Prettierを使う場合の設定例は[こちら](https://qiita.com/ryo2132/items/7612bd4d10572f49ee4f)をどうぞ。


```bash
firebase init
```

## 課金プランの設定
Cloud FunctionsからHasuraサーバーへユーザー作成のリクエストを投げため、外部サービスへのアクセスを有効にする必要があります。
initで設定したFirebaseプロジェクトのコンソールにログインして、課金プランをBlazeプランに変更してください。

![スクリーンショット 2020-01-13 7.11.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/a08e5aa9-44e3-fa03-536c-0c06cfce2661.png)

## Firebase Authenticationの設定
Firebase Authenticationを有効化します。
コンソールのサイドメニューよりAuthenticationを選択して、任意のログイン方法を有効化してください（デモだとGoogleとEmailログイン）。

![スクリーンショット 2020-01-13 7.20.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/1f375a92-8ce4-316b-1cb6-0c7fe201c593.png)


## Cloud Functionsの設定
最後にFirebase Cloud Functionsです。
Firebase Authenticationでのユーザー追加をフックに起動するFunctionsを設定します。
firebase initしたディレクトリの`functions`以下で依存モジュールを追加します。

```bash
npm i apollo-boost graphql graphql-tag node-fetch @types/node-fetch
```

次にコード内で参照する環境変数として、HasuraのエンドポイントURLとadmin_secreteを設定します。

```bash
firebase functions:config:set hasura.url="herokuのHasuraエンドポイントURL" hasura.admin_secret="HerokuのConfig varsで指定したHASURA_GRAPHQL_ADMIN_SECRETの値"
```

今回functionsのAuthenticationユーザー追加のトリガーにて以下を行います。

1. Hasura認証用のカスタムクレームの設定
2. Hasuraサーバーへのユーザー作成リクエストの送信
3. tokenリフレッシュのフック用にFirestoreへのmetaデータ追加

`functions/index.ts`に以下を記載してください。


```functions/index.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import ApolloClient from "apollo-boost";
import fetch from "node-fetch";
import gql from "graphql-tag";

admin.initializeApp();

const client = new ApolloClient({
  uri: functions.config().hasura.url,
  fetch: fetch as any,
  request: (operation): void => {
    operation.setContext({
      headers: {
        "x-hasura-admin-secret": functions.config().hasura.admin_secret
      }
    });
  }
});

export const setCustomClaims = functions.auth.user().onCreate(async user => {
　// Hasuraの検証用のカスタムクレーム（属性情報）
  const customClaims = {
    "https://hasura.io/jwt/claims": {
      "x-hasura-default-role": "user",
      "x-hasura-allowed-roles": ["user"],
      "x-hasura-user-id": user.uid
    }
  };

  try {
    // カスタムクレームの設定
    await admin.auth().setCustomUserClaims(user.uid, customClaims);

    // Hasuraサーバーへのユーザーデータの作成リクエスト
    await client.mutate({
      variables: { id: user.uid, name: user.displayName || "unknown" },
      mutation: gql`
        mutation InsertUsers($id: String, $name: String) {
          insert_users(objects: { id: $id, name: $name }) {
            returning {
              id
              name
              created_at
            }
          }
        }
      `
    });

    // 初回ログインの際にユーザー作成と、カスタムクレームの設定には遅延があるため、
    // tokenリフレッシュのフック用にFirestoreへのmetaデータ追加を行う
    await admin
      .firestore()
      .collection("user_meta")
      .doc(user.uid)
      .create({
        refreshTime: admin.firestore.FieldValue.serverTimestamp()
      });
  } catch (e) {
    console.log(e);
  }
});
```

これでdeployを実行するとfunctionsが作成されます。

```bash
npm run deploy
```

deploy完了後、コンソールにてFunctionsを確認できればOKです。

![スクリーンショット 2020-01-14 5.36.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114396/2413a82a-6df7-8c11-c450-58f8e53be6f0.png)


# クライアントの実装
最後にVue.jsでのクライントの実装を紹介します。
今回は認証がメインなので、細かい環境構築等は省き関連コードのみ紹介します。
以下で紹介するメモアプリの動作コードはこちらにあります。

https://github.com/kawamataryo/Hasura-with-firebaase-auth-tutorial

## Firebase Authenticationのログインフックの設定
main.tsにて、Vueの初期化の前にFirebase Authenticationのログインフックの設定を行っています。
ログイン後Hasuraのカスタムクレームがない場合は、FirestoreのonSnapshotにてuser_metaの変更を待ち、変更後再度tokenの取得、ログイン処理を行っている点がポイントです。

```main.ts
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import vuetify from "./plugins/vuetify";
import { apolloProvider, onLogin, onLogout } from "@/plugins/apollo";
import VueApollo from "vue-apollo";
import { auth, db } from "@/plugins/firebase";

const HASURA_TOKEN_KEY = "https://hasura.io/jwt/claims";

Vue.use(VueApollo);

Vue.config.productionTip = false;

let vue: Vue;
// firebaseの初期化が終わったあとにVueを初期化するようにする
auth.onAuthStateChanged(async user => {
  if (!vue) {
    new Vue({
      vuetify,
      apolloProvider,
      router,
      render: h => h(App)
    }).$mount("#app");
  }
  if (user) {
    const token = await user.getIdToken(true);
    const idTokenResult = await user.getIdTokenResult();
    const hasuraClaims = idTokenResult.claims[HASURA_TOKEN_KEY];

    if (hasuraClaims) {
      await onLogin(token);
    } else {
      // Tokenのリフレッシュを検知するためにコールバックを設定する
      const userRef = db.collection("user_meta").doc(user.uid);
      userRef.onSnapshot(async () => {
        const token = await user.getIdToken(true);
        await onLogin(token);
      });
    }
  } else {
    await onLogout();
  }
});

```

## Apolloクライアントの設定、ログイン・ログアウト処理
ApolloクライアントではBearerスキームで使うtokenをLocalStorage経由で設定しています。
ログイン、ログアウト処理では、tokenのLocalStorageへの追加・削除と、Apolloクライアントのリフレッシュを行っています。


```plugin/apollo.ts
import ApolloClient from "apollo-boost";
import VueApollo from "vue-apollo";

const AUTH_TOKEN = "hasura-auth-token";

const client = new ApolloClient({
  uri: process.env.VUE_APP_GRPHQL_HTTP,
  request: operation => {
    operation.setContext({
      headers: {
        Authorization: `Bearer ${localStorage.getItem(AUTH_TOKEN)}`
      }
    });
  }
});

// ログイン処理
export async function onLogin(token: string) {
  if (localStorage.getItem(AUTH_TOKEN) !== token) {
    localStorage.setItem(AUTH_TOKEN, token);
  }
  try {
    await client.resetStore();
  } catch (e) {
    // eslint-disable-next-line
    console.error(`Login Failed. ${e}`);
  }
}

// ログアウト処理
export async function onLogout() {
  if (typeof localStorage !== "undefined") {
    localStorage.removeItem(AUTH_TOKEN);
  }
  try {
    await client.resetStore();
  } catch (e) {
    // eslint-disable-next-line
    console.error(`Logout Failed. ${e}`);
  }
}

export const apolloProvider = new VueApollo({
  defaultClient: client
});

```

## Vue routerでの遷移制御
beforeEachにて、各routeへの遷移前にのmeta情報の判定とcurrent_userの有無で遷移制御を行っています。

```router/index.ts
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "@/views/Home.vue";
import Login from "@/views/Login.vue";
import { auth } from "@/plugins/firebase";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "home",
    component: Home,
    meta: { requireAuth: true }
  },
  {
    path: "/login",
    name: "login",
    component: Login
  }
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes
});

router.beforeEach((to, _from, next) => {
  const requireAuth = to.matched.some(record => record.meta.requireAuth);
  const currentUser = auth.currentUser;

  if (!requireAuth || currentUser) {
    next();
    return;
  }

  next({
    path: "/login",
    query: { redirect: to.fullPath }
  });
});

export default router;
```

## Firebase UIでのログインページ
ログインページでは[Firebase UI](https://github.com/firebase/firebaseui-web)を利用してログインフォームを構築しています。

```view/Login.vue
<template>
  <div class="about">
    <h2 class="text-center">Please login.</h2>
    <div id="firebaseui-auth-container"></div>
  </div>
</template>

<script>
import { auth } from "@/plugins/firebase";
import firebase from "firebase";
import * as firebaseui from "firebaseui";
import "firebaseui/dist/firebaseui.css";

export default {
  name: "login",
  beforeRouteEnter(to, from, next) {
    next(() => {
      const uiConfig = {
        signInSuccessUrl: "/",
        signInFlow: "popup",
        signInOptions: [
          firebase.auth.GoogleAuthProvider.PROVIDER_ID,
          firebase.auth.EmailAuthProvider.PROVIDER_ID
        ]
      };
      const ui =
        firebaseui.auth.AuthUI.getInstance() ||
        new firebaseui.auth.AuthUI(auth);
      ui.start("#firebaseui-auth-container", uiConfig);
    });
  }
};
</script>
```

## memoの取得・削除
アプリのメインのmemo追加・削除部分の実装はこちらです。
[vue-apollo](https://github.com/vuejs/vue-apollo)にて通信を行っています。

```view/Home.vue
<template>
  <div class="home">
    <v-card class="mb-5">
      <v-card-text>
        <v-textarea outlined label="Memo" single-line v-model="content" />
        <v-btn block @click="addMemo" color="primary">add Memo</v-btn>
      </v-card-text>
    </v-card>
    <template v-if="memos && memos.length > 0">
      <v-card v-for="memo in memos" :key="memo.id" class="mb-1">
        <v-card-text>
          {{ memo.content }}
        </v-card-text>
        <v-card-actions>
          <v-spacer/>
          <v-icon @click="deleteMemo(memo.id)">mdi-delete</v-icon>
        </v-card-actions>
      </v-card>
    </template>
  </div>
</template>

<script lang="ts">
import Vue from "vue";
import { FETCH_MEMOS } from "@/graphql/queries";
import { ADD_MEMO, DELETE_MEMO } from "@/graphql/mutations";
import { auth } from "@/plugins/firebase";

type Memo = {
  id: number;
  content: string;
  created_at: string;
};

type Data = {
  content: string;
  memos: Memo[];
};

export default Vue.extend({
  name: "home",
  data(): Data {
    return {
      content: "",
      memos: []
    };
  },
  methods: {
    async addMemo() {
      const res = await this.$apollo.mutate({
        mutation: ADD_MEMO,
        variables: {
          content: this.content,
          userId: (auth.currentUser as firebase.User).uid
        }
      });

      const insertResult = res.data.insert_memos.returning[0];
      this.memos.push({
        id: insertResult.id,
        content: insertResult.content,
        created_at: insertResult.created_at
      });

      this.clearField();
    },
    async deleteMemo(id: Number) {
      await this.$apollo.mutate({
        mutation: DELETE_MEMO,
        variables: {
          id
        }
      });

      this.memos = this.memos.filter(memo => memo.id !== id);
    },
    clearField() {
      this.content = "";
    }
  },
  apollo: {
    memos: {
      query: FETCH_MEMOS
    }
  }
});
</script>
```

# 最後に
以上、 「認証付きGraphQL APIサーバーを爆速で立てる。 Hasura + Firebase Authentication」でした。
Firebase AuthenticationをFirebaseのリソース以外の認証基盤として使うのは初めてだったので、JWTの認証方法などとても勉強になりました。Hasuraの日本語情報あまりなく、私もまだまだ勉強中です。もし誤表記や訂正などあればお気軽にコメントにて指摘願いします。



## 

# 参考URL
- [Authentication using JWT | Hasura 1.0 documentation](https://docs.hasura.io/1.0/graphql/manual/auth/authentication/jwt.html)
- [A tutorial for using Firebase to add authentication and authorization to a realtime Hasura app](https://blog.hasura.io/authentication-and-authorization-using-hasura-and-firebase/)
- [Firebase Authentication を使って得られた知見まとめ - トークンの仕様や注意点など - slideship.com](https://slideship.com/users/@iktakahiro/docs/2018/01/JnhLGehLsRw4dcZ42EY3Jf/)
- [JWT(JSON Web Token)の仕組みと使い方まとめ │ Web備忘録](https://webbibouroku.com/Blog/Article/jwt)
- [Firebase Authentication×Keycloak連携を実装してみた | 株式会社アイ・プライド](https://www.ipride.co.jp/blog/3541)
- [JWTを認証用トークンに使う時に調べたこと - Carpe Diem](https://christina04.hatenablog.com/entry/2016/06/07/123000)
- [Hasura GraphQLとAuth0で認証をする - Qiita](https://qiita.com/shirakiyo/items/72866f19484129d0f100)