2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

FirebaaeEmulatorとHasuraを使用してJWT認証

Posted at

今回の記事について

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

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

JWT認証とは

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

安全

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

管理しやすい

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

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

ペイロード

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

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

ファイル構成

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ディレクトリ

commandsディレクトリ

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

db_data

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

dockerディレクトリ

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

envs

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

frontend

Next.jsディレクトリ

functions

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

hasuraディレクトリ

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

Firebase Functionsを整える

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

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

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に設定されているユーザーIDquery, mutationで渡さなくてもHasuraが自動的に入れてくれる

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

admin.auth().setCustomUserClaims()

return await admin
  .auth()
  .setCustomUserClaims(user.uid, customClaims)

Firebase AuthのユーザーにHasuraで使用するクレーム情報をsetしてあげます。
これにより、ログイン時にユーザー情報からクレーム情報を取得することが出来ます。

envを整える

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

.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になっています。

"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を整える

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に移行予定です。

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>
  );
};
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を生成するコードについて。

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-secretx-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

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

delete

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

tasksテーブル

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

実際に動かす

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

ブラウザ上

  1. ログイン後、タスクページに遷移する
  2. 遷移後にtaskを追加を行う
    CleanShot 2024-04-28 at 21.17.37.gif
  3. ログアウトを行い別アカウントでログイン
  4. タスクページに遷移すると先程追加したタスクが表示されていない
  5. タスクが追加出来ることを確認後、HasuraConsoleに移動
  6. 2アカウントで追加したtaskが存在することを確認
    CleanShot 2024-04-28 at 21.22.48.gif

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

今回のコード

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

まとめ

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

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?