ReactでFirebaseのリダイレクト認証とidToken運用の設定

ReactでFirebase自動ログイン

ども!今回は、改めてReactでFirebaseのリダイレクトログインについてまとめました。社内システムでは、すべてがログインユーザである必要があるので、ログインしていない場合は自動でログインするようにしています。BFFでフロントとバックを分離するためのステップ1

最初の雑談

ども!3日ぶりに外に出たら、雪が積もっていてびっくりした龍ちゃんです。まだ残ってると思っていませんでしたが、かっちこちに凍っていて滑りそうになりました。

さて、今回はフロントエンドの話になります。過去記事で、フロントのみで構築していたアプリケーションをBFFを意識して分割していこうとしています。今回は実践編その1になります。

改めてフロントエンドをFirebaseのクライアントとしてセットアップしていきたいと思います。今回の記事でわかることは以下になります。

  • ユーザーがログインしていない場合は、Firebaseを用いてGoogle認証画面にリダイレクト
  • axiosを用いて自動でidTokenをHeaderに付与

それでは内容に移っていきます。

お願い!Firefoxでは、cookieがブロックされるため無限リダイレクトが発生します。ChromeかEdgeで検証してください。この回避方法に関しては、また調べます。

事前準備

事前準備として、Firebaseのアプリケーション設定と環境変数を保存しておきます。環境としては、React +Viteで環境を作成しています。Firebase Console側の設定は以下の記事を参考にしてもらえればと思います。

コンソールから取得した情報は、環境変数として保存します。

VITE_FB_APIKEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
VITE_FB_AUTHDOMAIN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
VITE_FB_PROJECT_ID="nxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
VITE_FB_STORAGE_BUCKET="nexxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
VITE_FB_MESSAGEING_SENDER_ID="xxxxxxxxxxxxx"
VITE_FB_APP_ID="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

あとは、開発に使用するモジュールのインストールをしておきます。

npm install firebase axios swr

それでは、構築に入っていきます。

FirebaseのログインとidTokenの取り回し

今回目指す構築の全体像について共有していきます。フロントエンドの実装としては、Firebase Autenticationで認証して、idTokenaxiosheader[”google-certification”]として付与して送信します。今回はGoogleの認証のみを利用して、認可は実装しないのでアクセストークンではなくidTokenを利用します。

MVP開発詳細

段階含めて設定していきたいと思います。今回のディレクトリ構造は以下になります。

src/
├── auth
│   └── authFirebase.ts
├── hooks
│   └── useAuth.ts
├── pages
│   ├── layout
│   │   └── ParentLayout.tsx
│   └── TopPage.ts
├── utilities
│   ├── AxiosConfig.ts
│   └── FirebaseInit.ts
├── App.css
├── App.tsx
├── index.css
├── main.tsx
└── vite-env.d.ts

以下のステップで解説していきます。

  • Firebaseの初期設定とFirebase周りのhooks化
  • Firebaseで自動リダイレクトログインの作成
  • axiosのintersepterでheaderにidTokenを挿入する

それでは、解説していきます。

Firebaseの初期設定とFirebase周りのhooks化

まずは、Firebase SDKをローカルで使用することができるように初期設定を行っていきます。auth配下にauthFirebase.tsに以下の内容をコピーしてください。

import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';

// Initialize Firebase
const app = initializeApp({
  apiKey: import.meta.env.VITE_FB_APIKEY,
  authDomain: import.meta.env.VITE_FB_AUTHDOMAIN,
  projectId: import.meta.env.VITE_FB_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FB_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FB_MESSAGEING_SENDER_ID,
  appId: import.meta.env.VITE_FB_APP_ID,
});

const auth = getAuth(app);
const provider = new GoogleAuthProvider();
provider.setCustomParameters({
  hd: 'sios.com',
});

export { auth, provider };

こちらでは、Firebase SDKの初期化とプロバイダーの設定を行っています。GoogleAuthProviderに追加設定を行うことでログイン周りの追加で制約を縛ることができます。具体的なパラメータはこちらにあります

こちらでエクスポートしたものを利用することで、Firebaseの機能を扱うことができます。次はhooks配下のuseAuth.tsに以下の内容をコピーしてください。

import { signInWithRedirect, signOut } from 'firebase/auth';
import useSWR from 'swr';

import { auth, provider } from '@/auth/authFirebase';
import { firebaseAuthType } from '@/types/firebaseAuthType';

export const useAuthAction = () => {
  const signInAction = () => {
    signInWithRedirect(auth, provider).catch((err) => {
      alert(err);
    });
  };
  const singOutAction = () => {
    signOut(auth);
  };
  return { signInAction, singOutAction };
};

export const useAuthenticated = () => {
  const uid: string = auth.currentUser ? auth.currentUser.uid : '';

  const { data: idToken, isLoading: isLoadingIDToken } = useSWR('idToken', async () => {
    if (auth.currentUser === null) return '';
    const token = await auth.currentUser.getIdToken();
    return token;
  });

  return { idToken, isLoadingIDToken, uid };
};

こちらのhooksでは、以下の二つがあります。

  • ログイン周りのアクション
  • ログイン後にユーザー情報にアクセスするアクション

idTokenの取得などもまとめて処理として入れています。idTokenの取得では、非同期処理が走るためSWRを用いて、情報の状態を管理しています。

Firebaseで自動リダイレクトログイン

ログイン処理はutilitiesFirebaseInit.tsxにまとめてあります。

import { useEffect, useState } from 'react';

import { onAuthStateChanged, User } from 'firebase/auth';

import { auth } from '@/auth/authFirebase';
import { useAuthAction } from '@/hooks/useAuth';

type Props = {
  children: React.ReactNode;
};

export const FirebaseInit = (props: Props) => {
  const { children } = props;
  const { signInAction } = useAuthAction();

  const [loading, setLoading] = useState<boolean>(true);

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, async (getAuthUser: User | null) => {
      if (getAuthUser == null) signInAction();
      setLoading(false);
    });
    return unsubscribe;
  });

  if (loading || auth.currentUser == null) return <div>loading</div>;

  return <>{children}</>;
};

こちらでは、先ほど作成したuseAuthAction()を使用してログイン処理を行っています。ユーザーの状態を監視して、ログインユーザーがいない場合はログイン処理に飛ばしています。また、ログインユーザーがいるまでLoading画面を表示しています。

axiosのinterceptorでheaderにidTokenを挿入する

ここでは、フロントとバックの通信をセキュアに担保するためにトークンのやり取りを自動でHeaderに挿入しています。すべてのAPI通信において、Headerを挿入することで対応漏れを防ぐことができます。なぜ?トークンでの運用が必要かについては以下の記事でまとめています。

アクセストークンを正しく使おう

それでは実際のコードです。こちらの処理はutilitiesAxiosConfig.tsxにまとめてあります。

import React, { useEffect, useState } from 'react';
import { useErrorBoundary } from 'react-error-boundary';

import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';

import { useAuthenticated } from '@/hooks/useAuth';

export const axiosClient = axios.create({
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

type Props = {
  children: React.ReactNode;
};

export const AxiosConfig = (props: Props) => {
  const { children } = props;

  const { showBoundary } = useErrorBoundary();
  const { idToken } = useAuthenticated();
	const [isTokenSet, setIsTokenSet] = useState<boolean>(false);

  useEffect(() => {
    const requestInterceptor = axiosClient.interceptors.request.use(
      (config: InternalAxiosRequestConfig) => {
        config.headers['google-certification'] = `${idToken}`;
        return config;
      },
      (error: AxiosError) => showBoundary(error),
    );
    const responseInterceptor = axiosClient.interceptors.response.use(
      (response: AxiosResponse) => {
        return response;
      },
      (error: AxiosError) => {
				return Promise.reject(error);
      },
    );
		if (idToken) setIsTokenSet(true);
    
		return () => {
      axiosClient.interceptors.response.eject(responseInterceptor);
      axiosClient.interceptors.request.eject(requestInterceptor);
    };
  }, [idToken]);

  if (!isTokenSet) return <div>loading</div>;
  return <>{children}</>;
};

useEffect内の処理が実際に対応している内容になります。今回は、headers[’google-certification']に情報を付与して送信しています。Authorizationに送付して送ろうかと悩んでいたのですが、ざっくりと調べた限り認可用のAccessTokenを付与するほうが正しいようなので、新しく作りました。

重要な点としては、idTokenがセットされるまで子要素を描画しない点です。useEffectの起動タイミングとidTokenの取得が競合して、Headerが付与されていないまま送付されるとエラーになるので、描画をisTokenSetidTokenのセットと同期をとっています。

使用方法としては、axiosを使用したい場面で上部でエクスポートされているaxiosClientを使用して処理を記述するだけです。axiosで書いたら機能しないので注意してください。

全体の描画ファイル

こちらでは、全体の設定ファイルを一つのファイルとしてまとめていきます。ファイルとしては、pages内のlayoutになります。概念図を以下に出しておきます。

ParentLayout

FirebaseInitが一番外にある必要があります。これは、ログイン処理が済んでいる状態じゃないとidTokenの取得ができないためです。また、描画要素内で情報の取得を行うため描画要素とFirebaseInitの間にAxiosConfigを挟んでいます。

実際のコードです。

import { AxiosConfig } from '@/utilities/AxiosConfig';
import { FirebaseInit } from '@/utilities/FirebaseInit';

type Props = {
  children: React.ReactNode;
};

export const ParentLayout = (props: Props) => {
  const { children } = props;
  return (
    <>
      <section>
        <main>
          <FirebaseInit>
            <AxiosConfig>{children}</AxiosConfig>
          </FirebaseInit>
        </main>
      </section>
    </>
  );
};

最終的に以下のように使用すれば、すべて機能します。

export const App = ()=>{
	return (
    <ParentLayout>
      <適当なコンポーネント/>
    </ParentLayout>
);

【適当なコンポーネント】部分で、axiosClientを使用して通信することでログイン処理とAPI通信部分に自動でHeaderが付与されます。検証のために適当なコンポーネントを置いておきます。

import { axiosClient } from '@/utilities/AxiosConfig';

export const TopPage = () => {
  axiosClient.get('/api');

  return <div className="bg-white">TopPage</div>;
};

終わり

お疲れ様です。Firebaseでアクセストークンを取得しようとして挫折しました。まだまだ勉強不足を実感させられます。Google側で認可を作成するか、BFFでカスタムトークンを作成して認可をするべきなのか?という疑問が発生します。また、OIDCについて勉強してきましょう。

とりあえず、idTokenがあることで誰からのアクセスか?という特定と担保はできます。実際にトークン検証をnest.jsで実装してみました。header上から取得して不正なidTokenははじいてくれます。

nest.jsでFirebase idTokenの検証Guard

ではまた!

Twitterのほうもよろしくお願いします。

 

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です