🎲

Firebase AuthenticationでLogin with Discordを実現する

2024/04/21に公開

Firebase Authenticationでは様々な認証を簡単に導入することができ、Google、Apple、Faccebookといったよく利用されるサービスを統合した認証のほかに、カスタムの認証システムと統合することもできます。

これを利用して、Discordのアカウントを使ってログインするWebサービスを、Firebase HostingとFirebase Functionsを統合して作ってみたいと思います。Discordでは一般的なOAuth 2.0ののフローでサービスにログインすることができますので、これを使ってFirebase Authenticationにユーザーを登録してログインできるようにします。

なお「Identity Platform を使用する Firebase Authentication」というものもあり、こちらはSAMLやOpenID Connectなども簡単に設定でき様々な機能を利用でき、SAMLやOpenID Connectも簡単に実装できますが、課金額が結構高い[1]ので、無料枠超えない範囲とわかってる場合やコストが見合うかどうか、急にトラフィックが増大するリスクがあるかどうか等で検討すると良いでしょう。

使用するもの・必要なもの

  • クラウド: Firebase (Blazeプラン・従量課金)
  • フロントエンド: React + TypeScript
  • バックエンド: TypeScript

Firebase Functionsを使用するため、Blazeプラン(従量課金)が必要となります。
この方法で実装する場合、数百円以内に収まるものと思いますが、高額リソースを使ってしまったり急なユーザー数の増加などがあった場合はその限りではないため、事前に課金アラートや自動無効化などの設定をしておくと良いでしょう。[2]

Firebase Authentication カスタム認証の概念

Firebaseカスタム認証では、大まかに以下のステップを踏みます。
Firebase Function上でログインは完結せず「Firebase Authenticationにログインできるトークン」を作って、クライアント側でトークンを使い改めてログインを完了させる必要がある というのがポイントです。

  • 何らかの手段で、Firebase Functionsでカスタム認証先の認証を行う
  • カスタム認証に紐付いたFirebase Authenticationのカスタム認証トークンを作成し、クライアントに返す
  • クライアントでFirebase Functionsが作成したカスタム認証トークンを使い、Firebase Authenticationにログイン

これを今回使用するDiscordでのログインで例示すると、以下のようなシーケンスになります。

  • フロントエンドからAPIにDiscordの認証URLをリクエスト。APIは認証URLを返す。
    • 構成次第ではフロントエンドで完結できるが、設定値管理をFirebase Functionsに寄せるためこのようにしている
  • フロントエンドはDiscordの認証ページにリダイレクトし、OAuthの許可を表示する
  • Discord認証トークン(①)を持った状態でフロントエンドのページにリダイレクトするので、フロントエンドからAPIに送る
  • APIで認証トークン(①)をDiscordに送り、Discord Secret(②)を得る
    • Discord APIの呼び出しで必要になるなら、別途Secretを保存しておく
  • Discordのユーザー情報を取得するなどしてIDが確定できたら、API上でcreateCustomTokenを呼び出し、カスタム認証トークン(③)が得る
    • この時点ではまだログインは完了しておらず「DiscordのこのIDの人としてFirebase Authenticationにログインできるトークン」が入手できる
  • APIはカスタム認証トークン(③)を返し、フロントエンドがsignInWithCustomTokenでFirebaseへのログインを完了させる。

Discordアプリの作成

まず、Discordアプリの設定をします。Discord Developer PortalのApplicationsで、新規アプリケーションを作成してください。

次にOAuth2のページに移動して、CLIENT IDとCLIENT SECRETをメモしておきましょう。
このページには後ほどドメインの設定で戻ってきます。

Firebase Consoleでの初期化

次に、Firebaseプロジェクトを作成します。Firebase Consoleに行き、新規プロジェクトを作成します。

  • Firebaseプロジェクト名は適切に設定
  • Google Analyticsはプロジェクトの必要に応じて設定 (今回はOFFにする)

プロジェクトが作成されたら、Blazeプランを有効にするために課金設定をします。Firebase Console左下のSparkプランになっているところで「アップグレードを選択」します。

Blazeプランを選択します。

課金アカウントを設定します。これまでに使ったことがなければここでカードの入力などがあると思われます。

次に、Webアプリを追加しておきます。</>アイコン(Web)をクリックします。

名前を適切に設定します。また「このアプリのFirebase Hostingも設定する」を選択し、Hostingを作成しておきます。
以後、設定のウィザードが続きますが、そのまま「次へ」で進めます。

Firebase Authenticationを有効化します。メニューの構築から「Authentication」を選び「始める」をクリックしておきます。

viteプロジェクトを作成し、httpsの設定をする

viteでプロジェクトを新規作成します。

$ npm create vite@latest
✔ Project name: … discord-auth-demo
✔ Select a framework: › React
✔ Select a variant: › TypeScript

Scaffolding project in /Users/ebina/Projects/discord-auth-demo...

Done. Now run:

  cd discord-auth-demo
  npm install
  npm run dev

$ cd discord-auth-demo
$ npm install

次に、このViteをhttpsでアクセスできるようにします。mkcertを使用してローカルホストにマルチドメインの環境を作るを参考に設定を加えます。今回はDiscordに認証URLの設定を加えるため、起動ポートも明示的に固定します。

$ mkdir certs/
$ cd certs
$ mkcert discord-auth-demo.local.example.com
$ mv discord-auth-demo.local.example.com-key.pem key.pem
$ mv discord-auth-demo.local.example.com.pem cert.pem
$ cd ..
$ echo certs/ >> .gitignore

$ npm install --save-dev @types/node
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import * as fs from "fs";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
/* ↓↓↓↓↓↓ ここから ↓↓↓↓↓↓ */
  server: {
    host: '127.0.0.1',  // IPv4での起動を強制する
    port: 6200, // ポートがずれるとOAuthの設定が手間になるので、起動ポートも固定しておく
    https: {
      key: fs.readFileSync("certs/key.pem"),
      cert: fs.readFileSync("certs/cert.pem"),
    },
  },
/* ↑↑↑↑↑↑ ここまで ↑↑↑↑↑↑ */
})

npm run dev で起動を確認しておきましょう。

Firebaseの設定をする

次に、viteで作ったこのフロントエンドにFirebaseの設定を加えていきます。

$ npm install firebase-tools
$ npx firebase 

? Which Firebase features do you want to set up for this directory? Press Space to select features, then Enter to 
confirm your choices.
→以下2つを選択
- Functions: Configure a Cloud Functions directory and its files
- Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Action deploys

? Please select an option: 
→ Use an existing project を選択

? Select a default Firebase project for this directory: 
→ 作成したプロジェクトを選択

=== Functions Setup
Let's create a new codebase for your functions.
A directory corresponding to the codebase will be created in your project
with sample code pre-configured.

See https://firebase.google.com/docs/functions/organize-functions for
more information on organizing your functions using codebases.

Functions can be deployed with firebase deploy.

? What language would you like to use to write Cloud Functions?
→ TypeScript

? Do you want to use ESLint to catch probable bugs and enforce style?
→ Yes (デフォルト値)

? Do you want to install dependencies with npm now?
→ Yes (デフォルト値)

=== Hosting Setup

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

? What do you want to use as your public directory? 
→ dist と入力(viteの出力ディレクトリ)

? Configure as a single-page app (rewrite all urls to /index.html)?
→ Yes (yを指定する)

? Set up automatic builds and deploys with GitHub? No
→ No (デフォルト値)

今回はローカルで動いているviteでFirebase FunctionsおよびFirebase Authenticationのローカルエミュレーターを使って開発できるようにしたいので、追加の設定をしていきます。

まず、Firebaseエミュレーターを起動する設定を加えます。
ポイントとしては、Hosting Emulatorのポートを5000から5100に変更することです。これはmacOS Sonomaから導入されたAirPlay Receiverのポートと重複しており、環境によって起動に難があるからです。[3]
また、Hostingのエミュレーターも起動します。viteを使用するためこちらにブラウザでアクセスすることはないのですが、Firebaseの設定ファイルをダウンロードする機能をviteを経由して使用できるようにするためです。

$ npx firebase init emulators

? Which Firebase emulators do you want to set up? Press Space to select emulators, then Enter to confirm your choices. 
→以下を選択 (Authentication Emulator を追加選択)
- Authentication Emulator
- Functions Emulator
- Hosting Emulator

? Which port do you want to use for the auth emulator? 
→ 9099 (デフォルト値)
? Which port do you want to use for the functions emulator?
→ 5001 (デフォルト値)
? Which port do you want to use for the hosting emulator? 
→ 5100 (5000から変更)
? Would you like to enable the Emulator UI?
→ Yes (デフォルト値)
? Which port do you want to use for the Emulator UI (leave empty to use any available port)? 
→  (デフォルト値)
? Would you like to download the emulators now?
→ Yes (デフォルト値)

Firebase Emulatorをnpm run経由で起動できるようにスクリプトを追加します。

package.json
{
  ...
  "scripts": {
    ...
    "emulators": "firebase emulators:start"
  },
  ...
}

次に、Viteのhttpsサーバーを経由して、Firebase Emulatorで起動したHosting(のFirebase設定配信)、Firebase Functionsのhttpsハンドラにアクセスできるようにプロキシ設定を加えます。
.firebaserc に書かれているプロジェクト名が必要になるのでファイルを読み込んでそこからプロジェクトIDを得ます。実際にプロキシを実現するserver.proxyの設定には、以下の設定を加えています。

  • /__ 以下: Firebase Hostingにプロキシ。Firebase Hostingの/__/firebase/init.json/__/firebase/init.jsにはFirebase初期化用の設定が配置されているので、firebaseConfigのハードコーディングが不要になる。
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import * as fs from 'fs';

/* ↓↓↓↓↓↓ ここから ↓↓↓↓↓↓ */
// .firebaserc から現在のプロジェクト名を取得する
const firebaseRc = JSON.parse(fs.readFileSync(".firebaserc", "utf-8")) as { projects: { default: string } };
const projectId = firebaseRc.projects.default;
/* ↑↑↑↑↑↑ ここまで ↑↑↑↑↑↑ */

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    host: '127.0.0.1',  // IPv4での起動を強制する
    port: 6200, // ポートがずれるとOAuthの設定が手間になるので、起動ポートも固定しておく
    https: {
      key: fs.readFileSync("certs/key.pem"),
      cert: fs.readFileSync("certs/cert.pem"),
    },
/* ↓↓↓↓↓↓ ここから ↓↓↓↓↓↓ */
    proxy: {
      "/__": {
        target: 'http://127.0.0.1:5100/',
        changeOrigin: true,
        secure: false,
      }
    }
  },
/* ↑↑↑↑↑↑ ここまで ↑↑↑↑↑↑ */
})

ReactアプリケーションでFirebaseを使えるように設定を加えます。まずFirebaseのライブラリをインストールします。

$ npm install firebase

main.tsxを編集し、Reactアプリケーションを起動する前にFirebaseを初期化するようにします。また、ローカルでの起動を検出したらFirebase Authenticationはエミュレータを使うようにします。

main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { initializeApp } from "firebase/app";
import {connectAuthEmulator, getAuth} from "firebase/auth";

fetch('/__/firebase/init.json').then(async response => {
  const config = await response.json()
  initializeApp(config);
  
  // ローカル起動の場合はAuthentication Emulatorを使用する
  if (import.meta.env.DEV) {
    const auth = getAuth();
    connectAuthEmulator(auth, 'http://localhost:9099');
  }

  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
  )
});

ここまでで起動して確認しておきましょう。ターミナルが2つ必要です。

ターミナル1
$ npm run emulators
ターミナル2
$ npm run dev

Firebase Functionsの設定をする

APIとなるFirebase Functionsの設定をします。まずは、Firebase Functionsに簡単なHTTPS APIハンドラを作成します。

functions/src/index.ts
/**
 * Import function triggers from their respective submodules:
 *
 * import {onCall} from "firebase-functions/v2/https";
 * import {onDocumentWritten} from "firebase-functions/v2/firestore";
 *
 * See a full list of supported triggers at https://firebase.google.com/docs/functions
 */

import {onRequest} from "firebase-functions/v2/https";
// import * as logger from "firebase-functions/logger";

const region = "asia-northeast1";

// noinspection JSUnusedGlobalSymbols
export const httpHandler = onRequest({region}, async (request, response) => {
  response.send({message: "Hello from Firebase!"});
});

ここで注意したいのが、リージョンを指定する場合はハンドラを作成するときに明示的に指定することです。[4]

これでエミュレーターを再起動して http://127.0.0.1:5001/${FIREBASE_PROJET_ID}/asia-northeast1/httpHandler (${FIREBASE_PROJET_ID}は各自のFirebaseプロジェクトIDに置き換え)を開くと、APIのレスポンスが見えます。

ただ、このままだとエミュレーターの再起動が都度必要になってしまい手間となってしまいます。Firebase Functionsの変更をホットリロードする処理もあわせて起動しておきます。[5]

ターミナル3
$ cd functions
$ npm run build:watch

これをFirebase Hostingを経由して使用できるようにします。まず、firebase.jsonに設定を加えます。

firebase.json
{
  ...
  "hosting": {
    ...
    "rewrites": [
      {
        "source": "/api/**",
        "function": {
          "functionId": "httpHandler",
          "region": "asia-northeast1"
        }
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  },
  ...
}

rewritesの先頭に /api/ 以下に来たリクエストをすべてFunctionsのhttpHandlerに送るようにします。リージョンをus-central1から変更している場合はリージョンの指定が必要なので忘れずに設定します。

次にviteからも使えるようにプロキシの設定を追加します。

vite.config.ts
...
export default defineConfig({
  ...
  server: {
  	...
    proxy: {
      "/__": {
        ...
      },
      "/api": {
        target: `http://127.0.0.1:5100/`,
        changeOrigin: true,
        secure: false,
      }
    }
  },
})

環境変数を設定する

Firebase FunctionでAPIが動作する際に必要な環境変数を定義します。
環境変数はおおまかにホスト名など機密性がそれほど高くない情報と、APIキーなどの機密性が高い情報に分類できます。

非機密値を設定する: パラメータ化された構成

ホスト名などの一般的な設定値は、パラメータ化された構成を使って設定することができます。
まず、.env ファイルにデプロイされるときの設定値、.env.local にローカルで動作させるときの設定値を書いておきます。

functions/.env
APP_HOST={FIREBASE_PROJET_ID}.web.app
functions/.env.local
APP_HOST=discord-auth-demo.local.example.com:6200

これを読み取るには、defineStringを使います。

import {defineString} from "firebase-functions/params";

const host = defineString("APP_HOST");

機密値を設定する: Secret Manager

DiscordのClient ID/Client SecretをFirebase Functionのパラメーターとして与えます。機密情報でもあるので、Secret Managerを使って管理します。[6]
Firebase FunctionsのSecret Managerはアクティブなシークレットバージョンが6個まで無料ですので、なるべく1つにまとめてJSONに書き込むようにしようと思います。

まず、ローカルで使用する値を記述します。functions/.env.local に以下のように値を記述します。
YOUR_DISCORD_CLIENT_IDYOUR_DISCORD_CLIENT_SECRETはDiscord Developer Portalから取得した値を指定してください。

functions/.env.local
...
APP_SECRET_CONFIG={"discordClientId":"YOUR_DISCORD_CLIENT_ID", "discordClientSecret":"YOUR_DISCORD_CLIENT_SECRET"}

次に、Firebaseに書き込みます。firebase functions:secrets:set をプロジェクトルートで実行します。
APP_SECRET_CONFIGの中身を入力するプロンプトが表示されますが、入力は隠れて見えないため、クリップボードにコピーしておいて貼り付けます。

$ firebase functions:secrets:set APP_SECRET_CONFIG
? Enter a value for APP_SECRET_CONFIG

APP_SECRET_CONFIGを都度パースするのは手間なので、キャッシュするためのクラスを作成しておきます。
functions/src/secretConfig.tsに以下の内容で保存します。

functions/src/secretConfig.ts
type AppSecretConfig = {
  discordClientId: string;
  discordClientSecret: string;
};

/**
 * AppSecretConfig の取得を行うクラス
 */
export class AppSecretConfigHandler {
  private config: AppSecretConfig | undefined;

  /**
   * AppSecretConfig を取得する
   * @return {AppSecretConfig}
   */
  get(): AppSecretConfig {
    if (!this.config) {
      const configString = process.env.APP_SECRET_CONFIG;
      if (!configString) {
        throw new Error("APP_SECRET_CONFIG is not defined");
      }
      this.config = JSON.parse(configString) as AppSecretConfig;
    }
    return this.config;
  }
}

では、このクラスを使ってFunctionsから参照できるようにします。
onRequestの第1引数で、使用するシークレットの名前を与える必要があることに注意します。

import {onRequest} from "firebase-functions/v2/https";
import {defineString} from "firebase-functions/params";
import {AppSecretConfigHandler} from "./secretConfig";
// import * as logger from "firebase-functions/logger";

const region = "asia-northeast1";
const host = defineString("APP_HOST");

// APP_SECRET_CONFIG を取得して保持する
const appSecretConfig = new AppSecretConfigHandler();

// noinspection JSUnusedGlobalSymbols
export const httpHandler = onRequest({
  region,
  secrets: ["APP_SECRET_CONFIG"], // Secret Manager から取得するシークレットのリスト
}, async (request, response) => {
  response.send({
    message: "Hello from Firebase!",
    host: host.value(),
    discordClientId: appSecretConfig.get().discordClientId, // 例としてClient IDを返す
  });
});

デプロイの構成を追加する

デプロイ用の構成を追加しましょう。package.jsonにデプロイコマンドを追加します。

package.json
{
  ...
  "scripts": {
    ...
    "deploy": "firebase deploy"
  },
  ...
}

また、deployコマンドを実行したときにviteのビルドがされるようにもしておきます。

firebase.json
{
  ...
  "hosting": {
    ...
    "predeploy": [
      "npm --prefix \"$PROJECT_DIR\" run build"
    ]
  },
  ...
}

Google Cloudの機能の有効化

Firebaseのデプロイでは、最低限必要なGoogle CloudのAPIを有効化してくれますが、Firebase Admin SDKから使用するAPIのうち一部の機能は有効にしてくれないため、手動で有効にする必要があります。
今回はcreateCustomTokenのためにIAM Service Account Credentials APIと、これを使うための権限「サービスアカウントトークン作成者」がCloud Functionsを動作させるサービスアカウントに必要となります。

IAM Service Account Credentials APIの有効化

まず、APIを有効化します。
Google Cloudのコンソールを開き、APIとサービスにあるライブラリを開きます。

IAM Service Account Credentials API を検索します。

有効にするで有効化します。

サービスアカウントトークン作成者の権限追加

次にサービスアカウントトークン作成者の権限を追加します。

Google Cloudのコンソールを開き、IAMと管理→IAMを選びます。

Default compute service account[7]の編集をクリックします。

サービス アカウント トークン作成者を検索して追加します。

コールバックURLの設定

OAuth2では、コールバック先のURLを事前に連携先のサービスに登録しておく必要があります。今回のコールバック先は/auth/discord/completionというパスにすることにします。

DiscordではDeveloper Portalのアプリケーション設定のOAuth2の画面のRedirectsに設定があるので追加しておきます。ローカル開発環境とFirebase Hostingのアドレスを両方とも指定しておきましょう。

APIを実装する

ここまでで設定は終わりです。あとは実装をしていきます。まずはFunctionsにAPIを実装します。

Firebase Admin SDKを使用しますので、admin.initializeApp()でAdmin SDK を初期化するのを忘れないようにしてください。

APIの実装
functions/src/index.ts
import {onRequest} from "firebase-functions/v2/https";
import {defineString} from "firebase-functions/params";
import * as admin from "firebase-admin";
import {AppSecretConfigHandler} from "./secretConfig";
// import * as logger from "firebase-functions/logger";

const region = "asia-northeast1";
const host = defineString("APP_HOST");
const requestScopes = ["identify", "guilds"];

// APP_SECRET_CONFIG を取得して保持する
const appSecretConfig = new AppSecretConfigHandler();

admin.initializeApp();

// noinspection JSUnusedGlobalSymbols
export const httpHandler = onRequest({
  region,
  secrets: ["APP_SECRET_CONFIG"], // Secret Manager から取得するシークレットのリスト
}, async (request, response) => {
  const path = request.path;

  const secretConfig = appSecretConfig.get();
  const redirectUri = `https://${host.value()}/auth/discord/completion`;

  switch (path) {
  case "/api/auth/discord": {
    const scopesString = requestScopes.join(" ");
    response.send({
      authorizeUri: "https://discord.com/api/oauth2/authorize?" +
          `client_id=${secretConfig.discordClientId}` +
          `&redirect_uri=${redirectUri}` +
          `&response_type=code&scope=${encodeURIComponent(scopesString)}`,
    });
    break;
  }
  case "/api/auth/discord/completion": {
    // code を得る
    const code = request.body.code;
    if (!code) {
      response.status(400).send("Bad Request");
      return;
    }

    // Discord からトークンを得る
    const authResult = await fetch("https://discord.com/api/oauth2/token", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        client_id: secretConfig.discordClientId,
        client_secret: secretConfig.discordClientSecret,
        grant_type: "authorization_code",
        code,
        redirect_uri: redirectUri,
      }),
    });
    const authJson = await authResult.json() as {
        access_token: string,
        refresh_token: string,
        error?: string
      };
    if (authJson.error) {
      response.status(500).send(authJson.error);
      return;
    }
    const accessToken = authJson.access_token;
    // const refreshToken = authJson.refresh_token;

    // Discord からユーザー情報を得る
    const userResult = await fetch("https://discord.com/api/users/@me", {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    const userJson = await userResult.json() as {
        id: string,
        username: string,
      };

    // Firebase Authentication でカスタムトークンを発行する
    const token = await admin.auth().createCustomToken(
      `discord:${userJson.id}`,
      {
        "discord:id": userJson.id,
        "discord:username": userJson.username,
      });

    // TODO: 必要があれば、ここで accessToken/refreshToken を保存する

    response.send({token});
    break;
  }
  default:
    response.status(404).send("Not Found");
    break;
  }
});

GET /api/auth/discord: Discordの認証URLを取得する

環境変数値などを用いて、URLを作成してレスポンスとして返しているだけです。

POST /api/auth/discord/completion: Discordの認証を行い、Firebase Authenticationの認証トークンを返す

  • // code を得る のブロックでは、パラメーターとして、OAuth2.0のAuthorization Codeを得ます。
  • // Discord からトークンを得る のブロックでは、DiscordのToken Endpointを呼んでAccess Token/Refresh Tokenを得ます。
  • // Discord からユーザー情報を得る のブロックでは、Discordのユーザー情報取得APIを呼び出し、トークンの有効性確認の上、ユーザーIDとユーザー名を取得しています。
  • // Firebase Authentication でカスタムトークンを発行する が今回の要です。Firebase Authenticationで使用するカスタムトークンを作成します。
    • 第1引数には、このユーザーのFirebase Authentication上でのIDを指定します。DiscordのIDをそのまま使うでも良いのですが、ほかのサービスと混在させることも考慮してプレフィクスを指定しました。
    • 第2引数には、claimsの追加値を指定します。Firestoreのセキュリティルールなどで使用できる値を指定します。

フロントエンドの実装

ルーターを使用したいのでreact-router-domをインストールしておきます。

$ npm install react-router-dom

App.tsx

ルーターと、ログイン状態に応じた状態の変化を扱います。onAuthStateChangedを使うことでログイン状態が変わったときにFirebaseのユーザーオブジェクトのインスタンスを引数に読んでもらえますので、それをコンポーネントのstateにセットしています。
大きなプロジェクトであればProviderにすると良いと思います。

App.tsx
App.tsx
import {useEffect, useState} from 'react'
import './App.css'
import {BrowserRouter, Route, Routes} from "react-router-dom";
import {getAuth, onAuthStateChanged} from 'firebase/auth';
import LoginPage from "./LoginPage.tsx";
import TopPage from "./TopPage.tsx";
import LoginCompletionHandlePage from "./LoginCompletionHandlePage.tsx";

function App() {
  const [loggedIn, setLoggedIn] = useState(false);

  useEffect(() =>
    onAuthStateChanged(getAuth(), (user) => {
      setLoggedIn(Boolean(user));
    })
  );

  return (
    <BrowserRouter>
      <Routes>
        <Route path="/auth/discord/completion" element={<LoginCompletionHandlePage method="discord" />} />
        {loggedIn ? (
          <Route path="/" element={<TopPage />} />
        ) : (
          <Route path="/" element={<LoginPage />} />
        )}
      </Routes>
    </BrowserRouter>
  )
}

export default App

LoginPage.tsx, TopPage.tsx

LoginPage.tsxはログイン前のページです。ログインボタンをクリックするとAPIを呼んでURLを取得し、取得されたURLに遷移します。

LoginPage.tsx
LoginPage.tsx
function LoginPage() {

  const navigateToAuthorize = async () => {
    const response = await fetch("/api/auth/discord");
    const data = await response.json() as { authorizeUri: string };
    window.location.href = data.authorizeUri;
  };

  return (
    <>
      <h1>Login</h1>
      <div className="card">
        <button onClick={() => navigateToAuthorize()}>
          Navigate to authorize
        </button>
      </div>
    </>
  )
}

export default LoginPage;

TopPageはログイン後のページの例です。signOutを呼ぶことでログアウトします。

TopPage.tsx
TopPage.tsx
import {getAuth, signOut} from "firebase/auth";

function TopPage() {

  const logout = async () => {
    await signOut(getAuth());
  };

  return (
    <>
      <h1>Top Page</h1>
      <div className="card">
        <button onClick={() => logout()}>
          Logout
        </button>
      </div>
    </>
  )
}

export default TopPage;

LoginCompletionHandlePage.tsx

ここが肝要の部分です。

LoginCompletionHandlePage.tsx
LoginCompletionHandlePage.tsx
import { getAuth, signInWithCustomToken } from "firebase/auth";
import {useEffect} from "react";
import {useNavigate} from "react-router-dom";

const runningCompletion: Record<string, boolean> = {};

const handleLoginCompletion = async (method: string, code: string | null, navigate: (path: string) => void) => {
  if (runningCompletion[method]) return;
  runningCompletion[method] = true;

  if (!code) {
    console.error("No code provided");
    runningCompletion[method] = false;
    navigate('/');
    return;
  }

  const response = await fetch(`/api/auth/${method}/completion`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({code}),
  });
  const resultJson = (await response.json()) as { token: string };
  const auth = getAuth();
  const result = await signInWithCustomToken(auth, resultJson.token);
  console.log(result);
  runningCompletion[method] = false;
  navigate('/');
};


function LoginCompletionHandlePage({ method }: { method: string }) {
  const navigate = useNavigate();

  // onMount Handling
  useEffect(() => {
    const code = new URLSearchParams(window.location.search).get('code');
    handleLoginCompletion(method, code, navigate).then();
  }, [method, navigate]);

  return (
    <>
      <div>Processing...</div>
    </>
  )
}

export default LoginCompletionHandlePage;
  • handleLoginCompletion がメインの処理です
    • Discordからリダイレクトで渡されたcodeをAPIにリクエストし、Firebase Authenticationにログインするためのトークンを得ます。
    • 得られたトークンを使い、signInWithCustomToken でログインを完了させます。
    • ログインが完了したら / 、すなわちトップに戻ります。
  • LoginCompletionHandlePage の useEffect ハンドラでは、遷移後すぐ上記の処理が行われるようにしています。

ローカルで動作を確認する

それでは動作を確認しましょう。まずはローカルで以下のサービスが立ち上がっていることを再度確認します。

ターミナル1
$ npm run emulators
ターミナル2
$ npm run dev
ターミナル3
$ cd functions
$ npm run build:watch

設定したローカル認証用のドメイン (コード中の例はhttps://discord-auth-demo.local.example.com:6200ですが使用したドメインによって変わります)にアクセスします。

  • Navigate to authorizeでDiscordの認証画面に遷移する
  • Discordで認証をOKにすると、ログイン後画面になる
  • ログアウトをクリックすることで、ログアウトが完了する

デプロイして確認する

それではデプロイして確認してみましょう。

$ npm run deploy

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/..../overview
Hosting URL: https://.....web.app

最後のHosting URLを開いて、動作を確認してみてください。

この後の展開

今回はプロジェクトを作成してログインを成功させるところまでを取り扱いました。
この後は以下のような実装を加えて、実用的なアプリにしていくことができるでしょう。

  • Firestoreなどのデータベースにアクセストークン/リフレッシュを保存し、DiscordのAPIを呼び出す
  • DiscordのAPIを使って、Discordと連携する
脚注
  1. 1ヶ月のアクティブユーザーが無料枠超えると、1人あたりで課金。Firebase Authenticationで標準対応されているメールアドレス・ソーシャルログインで無料枠5万AU以後は0.4円/AU、OIDC/SAMLだと無料枠50AUで以後2.5円/AUほど。うっかりバズってAU10万とかなると、月の課金額が25万円に!個人ではちょっと無理… ↩︎

  2. Firebase の課金で爆死しないための設定方法などを参考にしてみてください。 ↩︎

  3. AirPlay Receiverをオフにする方法もある Macでport:5000が使えないときの対処 ↩︎

  4. 長らくus-central1しか使えなかったが、現在はリージョン指定できるようになったらしい。Geminiによると、2021年3月頃からとのことらしいが、裏付けがある資料は見つからなかった。 ↩︎

  5. FirebaseのFunctionsのHotReload ↩︎

  6. 機密性の高い構成情報の保存とアクセス ↩︎

  7. 今回使用しているFirebase Functions第2世代の場合。第1世代の場合はApp Engine default service accountに設定する。 ↩︎

Discussion