🦊

Firebase Auth SignIn with MetaMask and React

2022/10/16に公開約13,000字3件のコメント

基本的に以下の記事がベース。

https://eliteionic.com/tutorials/creating-web3-login-with-ethereum-metamask-firebase-auth/

ただしこの記事では Angular を使っていたので、axiosを導入したりと少しカスタマイズを要した。

  • firebase-tools
  • React
  • axios

今回は Firebase の Atuhentication, FireStore, Cloud Functions の3つの機能を使います。認証の流れなどは上記の参考元に任せます。

大まかな構成

まず前提として、Next.js x TypeScript x FireStore を使ったプロジェクトがあるうえで、メールアドレスやTwitterログインでなくどうやったら MetaMesk を使った Web2.5 的なログインができるか、という部分だけ記述しています。状態管理ライブラリは Recoil を使ってますが、SignIn自体は関係しないかと。

# Frontend = Next.js
src
L pages/index.tsx
L components/ConnectWalletButton.tsx
L lib/firebaseConfig.ts
L models/FirebaseUser.ts
L hooks
  L AuthService.ts // ホントは hook 化したいけど現状 class
  L useFirebaseUser.ts

# Backend = Cloud Functions
functions
L index.ts // getNonceToSign, verifySignedMessage

Firebase の設定

割愛。基本的な Firebase Authentication と FireStore のアプリを作成していれば、あとは「匿名ログイン」を有効にするくらいです。

Frontend

今回フロントエンドは Next.js です。

package installation

yarn add @metamask/detect-provider rxjs axios

Firebase Config

これは今回に限らず、FireStoreやFirebase Authentication を使うならいる設定。Next.jsを使っている関係もあり少しだけ他の人の書き方とは違うかも。

// firebaseConfig.ts
import firebase from 'firebase/app';
import 'firebase/auth';
import 'firebase/firestore';

const config = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

!firebase.apps.length ? firebase.initializeApp(config) : firebase.app();

const auth = firebase.auth();
const db = firebase.firestore();

export { firebase, auth, db }

AuthService.ts

ここが今回のフロント側の肝。hooks 化したかったが取り敢えずクラスの形で実装した。@metamask/detect-providerは web3.js とか ethers.js とか、AlchemySDKとかで代用できるはずだけど、今回は参考元に従って入れました。

// AtuhService.ts
import detectEthereumProvider from '@metamask/detect-provider';
import axios from 'axios';
import firebase from 'firebaseConfig';
import { from } from 'rxjs';
import { switchMap } from 'rxjs/operators';

interface NonceResponse {
  nonce: string;
}

interface VerifyResponse {
  token: string;
}

export class AuthService {
  constructor(private auth: firebase.auth.Auth) { }
  
  public signOut() {
    return firebase.auth().signOut();
  }

  public signInWithMetaMask() {
    let ethereum: any;
    return from(detectEthereumProvider()).pipe(
      // Step 1: Request (limited) access to users ethereum account
      switchMap(async (provider) => {
        if (!provider) {
          throw new Error('Please install MetaMask');
        }
        ethereum = provider;
        return await ethereum.request({ method: 'eth_requestAccounts' });
      }),
      // Step 2: Retrieve the current nonce for the requested address
      switchMap(async () => {
        const response = await axios.post<NonceResponse>(
          `${process.env.NEXT_PUBLIC_FIREBASE_FUNCTIONS_ENDPOINT}/getNonceToSign`,
          {
            address: ethereum.selectedAddress,
          }
        )
        return response;
      }
      ),
      // Step 3: Get the user to sign the nonce with their private key
      switchMap(
        async (response) =>
          await ethereum.request({
            method: 'personal_sign',
            params: [
              `0x${this.toHex(response.data.nonce)}`,
              ethereum.selectedAddress,
            ],
          })
      ),
      // Step 4: If the signature is valid, retrieve a custom auth token for Firebase
      switchMap(async (sig) => {
        const response = await axios.post<VerifyResponse>(
          `${process.env.NEXT_PUBLIC_FIREBASE_FUNCTIONS_ENDPOINT}/verifySignedMessage`,
          { address: ethereum.selectedAddress, signature: sig }
        ).catch(err=>console.error(err))
        return response;
      }
      ),
      // Step 5: Use the auth token to auth with Firebase
      switchMap(
        async (response) => {
          firebase.auth().signInWithCustomToken(response.data.token)
            .then((userCredential) => {
              // Signed in
              var user = userCredential.user;
              console.log('Successfully signed in')
              console.log('user: ', user)
              // ...
            })
            .catch((error) => {
              var errorCode = error.code;
              var errorMessage = error.message;
              console.log('errorCode: ', errorCode)
              console.log('errorMessage: ', errorMessage)
              // ...
            });
        }      
      )
    );
  }

  private toHex(stringToConvert: string) {
    return stringToConvert
      .split('')
      .map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
      .join('');
  }
}

Component

// ConnectWalletButton.tsx
import { Login } from "../hooks/useFirebaseUser";

export default function ConnectWalletButton() {
  return (
    <button
      onClick={() => Login()}
    >
      Sign in with MetaMask
    </button>
  )
}

hooks

今回は Firebase のログイン中ユーザーを返してくれる hook, useFirebaseUser.ts を用意しています。ステート管理ライブラリとして Recoil を使っていますが、これはもっと上手いやり方があるはず。

// useFirebaseUser.ts
import router from 'next/router';
import { useEffect } from 'react';
import { atom, useRecoilState } from 'recoil';
import { firebase, auth, db } from '../lib/firebase';
import { FirebaseUser } from '../models/FirebaseUser';
import { AuthService } from './AuthService';

const userState = atom<FirebaseUser>({ // Recoil ゆえの定義
  key: 'firebaseUser',
  default: null,
});

export function useFirebaseUser() {
  const [firebaseUser, setFirebaseUser] = useRecoilState(userState);

  useEffect(() => {
    const unsubscribe = auth.onAuthStateChanged(function (firebaseUser) {
      if (firebaseUser) {
        console.log('Firebase auth state changed');
        console.log('Set firebaseUser');
        const loginUser: FirebaseUser = {
          uid: firebaseUser.uid,
          providerId: firebaseUser.providerId,
          displayName: firebaseUser.displayName, // 匿名ログインなので undefined になる
          photoURL: firebaseUser.photoURL, // 匿名ログインなので undefined になる
        };
        setFirebaseUser(loginUser);
      } else {
        console.log('User is signed out');
        setFirebaseUser(null);
      }
    });
    return () => unsubscribe();
  }, [setFirebaseUser]);

  return firebaseUser;
}

export const Login = () => {
  console.log('Login..')
  const authService = new AuthService(auth);
  authService.signInWithMetaMask().subscribe({
    next() {
      // Handle success
      // router.push('/dashboard')
    },
    error(err) {
      console.error(err)
    },
  })
};

export const Logout = () => {
  auth
    .signOut()
    .then(() => {
      window.location.reload();
    });
};

Backend (Cloud Functions)

まず npm, firebase-tools を最新の安定版にアップデートしておいた方が良いと思う。自分は最初バージョンが古く firebase init functions で入るデフォオルトの firebase-tools, firebase-admin のバージョンが食い違い、deploy時にエラーが出たりしました。最終的には以下で安定した。

- Node.js: v14.19.0
- npm: 8.19.2
- firebase-tools: 11.14.4

公式サイトはこちら

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

Function を作っていく

firebase init functions
// 省略。流れに従う
// 今回は TypeScript を使ってます

Install library..

npm install @metamask/eth-sig-util cors
// index.ts
import * as functions from "firebase-functions";
import * as firebaseAdmin from "firebase-admin";
import * as corsLib from "cors";
import {recoverPersonalSignature} from "@metamask/eth-sig-util";
import {randomUUID} from "crypto";

const admin = firebaseAdmin.initializeApp();
const cors = corsLib({
  origin: true,
});

export const getNonceToSign = functions.https.onRequest((request, response) =>
  cors(request, response, async () => {
    try {
      if (request.method !== "POST") {
        return response.sendStatus(403);
      }
      if (!request.body.address) {
        return response.sendStatus(400);
      }
      // Get the user document for that address
      const userDoc: FirebaseFirestore.DocumentSnapshot = await admin
          .firestore()
          .collection("users")
          .doc(request.body.address)
          .get();
      if (userDoc.exists) {
        // The user document exists already, so just return the nonce
        const existingNonce = userDoc.data()?.nonce;
        return response.status(200).json({nonce: existingNonce});
      } else {
        // The user document does not exist, create it first
        const generatedNonce = randomUUID(); // 元記事の Math.random() * 1000000 から変更
        // Create an Auth user
        const createdUser = await admin.auth().createUser({
          uid: request.body.address,
        });
        // Associate the nonce with that user
        await admin.firestore().collection("users").doc(createdUser.uid).set({
          nonce: generatedNonce,          
        });
        return response.status(200).json({nonce: generatedNonce});
      }
    } catch (err) {
      console.log(err);
      return response.sendStatus(500);
    }
  })
);

export const verifySignedMessage = functions.https.onRequest(
    (request, response) =>
      cors(request, response, async () => {
        try {
          if (request.method !== "POST") {
            return response.sendStatus(403);
          }
          if (!request.body.address || !request.body.signature) {
            return response.sendStatus(400);
          }
          const address = request.body.address;
          const sig = request.body.signature;
          // Get the nonce for this address
          const userDocRef = admin.firestore().collection("users").doc(address);
          const userDoc = await userDocRef.get();
          if (userDoc.exists) {
            const existingNonce = userDoc.data()?.nonce;
            // Recover the address of the account
            // used to create the given Ethereum signature.
            const recoveredAddress = recoverPersonalSignature({
              data: `0x${toHex(existingNonce)}`,
              signature: sig,
            });
            // See if that matches the address
            // the user is claiming the signature is from
            if (recoveredAddress === address) {
            // The signature was verified - update the nonce to prevent replay attacks
            // update nonce
              await userDocRef.update({
                nonce: randomUUID(), // 元記事の Math.random() * 1000000 から変更
              });
              // Create a custom token for the specified address
              const firebaseToken = await admin.auth().createCustomToken(address);
              // Return the token
              return response.status(200).json({token: firebaseToken});
            } else {
            // The signature could not be verified
              return response.sendStatus(401);
            }
          } else {
            console.log("user doc does not exist");
            return response.sendStatus(500);
          }
        } catch (err) {
          console.log(err);
          return response.sendStatus(500);
        }
      })
);

const toHex = (stringToConvert: string) =>
  stringToConvert
      .split("")
      .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0"))
      .join("");

And deploy..

firebase deploy --only functions --project [your_project_id]

ハマりどころ

Cloud Functions for Firebase は Admin SDK を実行する時は以下のプリンシパル(≒サービスアカウント?)を使うようで、それにロール2つを付与しないといけなかった。

## Cloud Functions が使うプリンシパル
- App Engine default service account
- [project_Id]@appspot.gserviceaccount.com

## 付与するロール
- Firebase Admin SDK 管理者サービス エージェント
- サービス アカウント トークン作成者

ロールを付与して変更が保存されても、実際に Cloud Functions の挙動に反映されるまでは5分くらいかかるので注意(Firebase あるある)。

注意点

UID は小文字アドレスとなる

この方法で Firebase Authentication に Ethereum Address をUIDとするユーザーアカウントが、FireStore の /users 配下に ethereum address を Document ID とするドキュメントが作成されますが、これらは小文字になってます。Ethereumではアドレスの大文字小文字を区別しないそうですが、何かデータの Update などをする際の Path 指定では、(本来のアドレスの形式である)大文字混在ではなく、全て小文字にしないと FireStore はエラーを吐くのでご注意ください。

FireStore内の nonce は消しちゃダメ

/users配下にできたユーザーのドキュメントに nonce: string のフィールドが入ってますが、このフィールド値は消してはダメです。僕もまだ正確に説明しきれないので理由は省きますが、この FireStore のデータを削除すると2回目以降の SignIn ができなくなります。

もしデータを上書きなどして消してしまって、nonce値も忘れてしまった場合は Authentication からユーザーアカウントを削除し、改めてフロントエンドから SignIn し直すことで同じ Ethereum Address を UID としてアカウントを作成できます。

終わりに (Bug Bounty)

思ったより難しく、またネット上にも定番のフレームワーク React を使っている知見が少なかったので書きました。DApps ではブロックチェーンを DB として活用したり(フルオンチェーン)、Moralis などの Web3.0 版Firebase を使っているからかもしれません。

今回 RxJs なども入ってきたので正直自分も挙動に自身がなく、もし「この書き方はセキュリティーリスクを孕んでるよ」「これは不具合を起こすよ」などありましたら、コメントか @maztak_ までご連絡いただけると幸いですm(__)m

typoとかはあれですが、重大なものには Bug Bounty として 0.01 ETH をお支払いさせていただきます!

参考リンク

公式のカスタムトークンによる認証
https://firebase.google.com/docs/auth/web/custom-auth

FireStoreから取得したデータを型付けようという話
https://www.gixo.jp/blog/15372/

Discussion


OpenSeaのログイン画面はこんな感じになってました。隠してますが MetaMesk のメッセージ欄の一番下に nonce の表示もありました。

ログインするとコメントできます