今回の記事について
以前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に記事を載せている内容と変わらないため割愛します。
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に設定されている
ユーザーID
をquery
,mutation
で渡さなくてもHasuraが自動的に入れてくれる
- Rowに設定されている
x-hasura-〇〇
の形で渡して上げればHasuraが良しなに認識してくれて値を使用することが出来ます。
admin.auth().setCustomUserClaims()
return await admin
.auth()
.setCustomUserClaims(user.uid, customClaims)
Firebase AuthのユーザーにHasuraで使用するクレーム情報をsetしてあげます。
これにより、ログイン時にユーザー情報からクレーム情報を取得することが出来ます。
envを整える
自分が一番ハマって進歩なし状態が数日続いた箇所になります。
Firebase Emulator
ではなく実際のFirebase
を使用すればハマることはなかった?とは思います。
ただ、開発するならEmulatorを使いたいという気持ちがあったので自分なりに調査・実装した内容を記述していきます。
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を整える
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に移行予定です。
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>
);
};
"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>
);
};
一般的な書き方なので説明は割愛します。
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-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
から取得するデータを絞り込める
update
With same custom check as select, pre update
項目を選択すると{"id":{"_eq":"X-Hasura-User-Id"}}
と記述される
Column update permissions
ではid
,created_at
はupdateする必要がないのでチェックを外す
delete
delete出来ない状態にする(何も設定しなし)
tasksテーブル
customersテーブルと同じように設定していけば良いので説明は割愛していきます。
実際に動かす
今回はFirebase Emulatorを起動しての実際になります。
ブラウザ上
- ログイン後、タスクページに遷移する
- 遷移後にtaskを追加を行う
- ログアウトを行い別アカウントでログイン
- タスクページに遷移すると先程追加したタスクが表示されていない
- タスクが追加出来ることを確認後、HasuraConsoleに移動
- 2アカウントで追加したtaskが存在することを確認
2アカウントで追加したtaskがあればJWT認証を使った環境が整っております。
今回のコード
今回書いたコード全体は下記のリポジトリにあるのでもし良かったら確認してみてください。
https://github.com/roll1226/study-next-nest
まとめ
FirebaseEmulatorとHasuraを使用したJWT認証は詰まってしまったが分かってしまえば簡単に実装することが出来ます。
今回はテーブルに対してPermissionを追加していきましたが、RemoteSchemasで追加したNest.jsのQueryにもPermissionを付与出来るので開発のしやすさはずば抜けているかと思います。今後はHasuraを使ってマルチテナントを試してみたいです。
開発チーム環境にもよりますが、是非Hasuraを使ってより多くのサービスを出してほしいです。