【Firebase + SPA + Basic認証】FirebaseでBasic認証を掛けながらReactアプリを配信する

こんにちは。
Firebase Hostingで展開しているSPAに、Basic認証を掛ける方法を紹介します。

# 注意
ここではReactやFirebaseについては解説しません。\
SPAをFirebase Hostingで公開している状態から開始して、Functionsを使ってSPAへのアクセスにBasic認証を要するように変更します。

また、Functionsを利用するには[従量課金制のBlazeプラン](https://firebase.google.com/pricing?hl=ja)にプロジェクトを移行する必要があるので、事前に移行しておいてください。

# 環境
今回はReactを使っていますが、Basic認証を掛ける仕組みは変わらないのでVueやAngularでも同じように出来るはずです。\
また、Firebaseプロジェクトは`firebase init`でTypeScriptテンプレートかつSPA用の設定を有効化して作成しました。

諸々の環境の違いは適宜読み替えてもらえると助かります。\
(コマンドの例: `yarn --cwd functions add express` -> `npm --prefix functions install express`)

- [React](https://ja.reactjs.org/)
  - [Create React App](https://create-react-app.dev/)
- [TypeScript](https://www.typescriptlang.org/)
- [Yarn 3](https://github.com/yarnpkg/berry)

- `package.json`

```json
{
  "devDependencies": {
    "firebase-tools": "^11.13.0"
  }
}
```

- `functions/package.json`

```json
{
  "dependencies": {
    "firebase-functions": "^3.24.1"
  },
  "devDependencies": {
    "typescript": "^4.8.4"
  },
}
```

# 概要

どのようにしてBasic認証を掛けながらSPAを配信するかを簡単に書いておきます。

1. Basic認証を掛けながらファイルを配信するExpressアプリをFunctionとして公開
2. Hostingへのアクセスを上記のFunctionにリライト

記載するコマンドは全てプロジェクトのルートディレクトリで実行する想定で記述します。\
また、ソースコードは既に存在するソースコードに組み合わせる前提なので、全体ではなく一部を抜粋しています。\
ファイルツリーについても、ある程度抜粋した状態の物を記載しています。

```bash
$ ls
# -> functions/ ... src/ ... firebase.json ...
```

# 必要なパッケージのインストール

まず、必要なパッケージをインストールします。\
一部のチュートリアルでは`basic-auth-connect`をBasic認証ミドルウェアとして使用しますが、古くてTypeScriptに対応していないのでここでは`express-basic-auth`を使用します。

```bash
$ yarn --cwd functions add express express-basic-auth
$ yarn --cwd functions add -D @types/express
```

コマンドを実行したら、`functions/package.json`には以下のような項目が追加されました。

```json
{
  "dependencies": {
    "express": "^4.18.1",
    "express-basic-auth": "^1.2.1"
  },
  "devDependencies": {
    "@types/express": "^4.17.14"
  },
}
```

# Basic認証付き静的ファイル配信Functionの定義

Cloud Functions for Firebase上に、Basic認証を掛けながら`functions/lib/static`ディレクトリのファイルを配信する`basic`関数を定義します。

- `functions/src/index.ts`

```typescript
// インポート周りはTypeScriptの設定に左右されるので必要に応じて調整する。
const path = require('path');
const express = require('express');
const basicAuth = require('express-basic-auth');

/** `basic`エンドポイントをハンドリングするExpressアプリケーション */
const basic = express();
/* 【ミドルウェア】Basic認証 */
basic.use(
  basicAuth({
    challenge: true, // ブラウザのBasic認証プロンプトを表示する。
    users: { username: 'password' },  // ユーザー名／パスワードの組を入れる。
  })
);
/* 【ミドルウェア】静的配信(`static`) */
basic.use(express.static(path.join(__dirname, 'static')));
/* 【ミドルウェア】全てのリクエストに対して`index.html`(SPA)を送信する処理 */
basic.use((_, res) => {
  res.sendFile(path.join(__dirname, 'static', 'index.html'));
});
// `basic`エンドポイントとして公開する。
exports.basic = functions.https.onRequest(basic);
```

## 補足

`basic`関数を処理するExpressアプリは以下のように3つのミドルウェアから構成されます。

1. Basic認証ミドルウェア
2. 静的配信ミドルウェア
3. 全てのリクエストに`index.html`を返すミドルウェア

こうすると全てのリクエストに対して(1)でBasic認証を掛けながら、`index.html`及び`index.html`に記述された`.css`や`.js`ファイルリクエストは(2)が応答、ルーティングを指定してのSPAへのアクセスには(3)が応答するので、Basic認証を掛けながらSPAを配信できるようになります。

# Functionsのビルド

適当なコマンドでFunctionsをビルドします。\
今回は`yarn --cwd functions run build`でビルドすると`functions/lib`ディレクトリに結果が出力される事とします。

```
- functions/
  - lib/ # このディレクトリが生成された。
  - src/
- src/
- firebase.json
```

# SPAのビルド

適当なコマンドでSPAをビルドします。\
今回は`yarn run build`でビルドすると`build`ディレクトリに結果が出力される事とします。

```bash
# ここでは以下のコマンドでビルドを行う。
$ yarn run build
```

以下のようなファイルが出力されると思います。

```
- build/ # このディレクトリが生成された。
  - static/
    - css/
      - main.f2bef1c1.css
    - js/
      - main.b8bf8203.js
  - index.html
- functions/
- src/
- firebase.json
```

# ビルド済みSPAの`functions/lib`への移動

先ほどの`build`ディレクトリを`static`ディレクトリにリネームして、`functions/lib`に移動します。\
こうすると`basic`関数を処理するExpressアプリから、ビルド結果のファイル群を配信できるようになります。\
(Create React Appのビルドでは移動後のツリーが`functions/lib/static/static`になってややこしいですが正しいです。)

```
- functions/
  - lib/
    - static/
      - static/
        - css/
          - main.f2bef1c1.css
        - js/
          - main.b8bf8203.js
      - index.html
  - src/
- src/
- firebase.json
```

# Firebase Hostingへのリクエストのリライト

最後に、Firebase Hostingへのリクエストを全て`basic`関数が処理するようにリライトします。

- `firebase.json`

```json
"hosting": {
  "rewrites": [
    {
      "source": "**",
      "function": "basic"
    }
  ]
}
```

# 【おまけ1】FirebaseエミュレーターでのBasic認証付きSPA配信

エミュレーターではHostingのリライトが機能しないので、直接`basic`関数にアクセスする必要があります。

例として、以下のようなプロジェクトを使っているとします。

- FirebaseプロジェクトID: `basic-auth-spa`

この場合、`basic`関数に直接アクセスする場合は以下のURLに接続します。

```
http://localhost:5001/basic-auth-spa/us-central1/basic
```

しかし、アクセスしても画面は真っ白のまま何も表示されません。\
何故ならば、`index.html`内に記載された`.css`や`.js`のパス変換が上手く機能しないからです。

## エミュレーター向けに`index.html`を変更する

ビルド結果の`index.html`には、SPAの実体である`.css`や`.js`は以下のように読み込むように記述されています。

```html
<!-- Create React Appの場合 -->
<script defer="defer" src="/static/js/main.b8bf8203.js"></script>
<link href="/static/css/main.f2bef1c1.css" rel="stylesheet" />

<!-- Vite + Reactの場合 -->
<script type="module" crossorigin src="/assets/index.19d121b8.js"></script>
<link rel="stylesheet" href="/assets/index.8d16f29f.css" />
```

これらの`src`属性に注目してください。

`src`属性は、ルートからのパスが記述されているので、`http://localhost:5001/basic-auth-spa/us-central1/basic`へのリクエストで得た`index.html`から読み込まれる場合は以下のようにパスが変換されます。

- `/static/js/main.b8bf8203.js` -> `http://localhost:5001/static/js/main.b8bf8203.js`
- `/static/css/main.f2bef1c1.css` -> `http://localhost:5001/static/css/main.f2bef1c1.css`
- `/assets/index.19d121b8.js` -> `http://localhost:5001/assets/index.19d121b8.js`
- `/assets/index.8d16f29f.css` -> `http://localhost:5001/assets/index.8d16f29f.css`

これらのファイルは当然見つかりません。\
そもそも`basic`関数はおろかFunctionsへのリクエストすらしていないので探してすらいません。

これらにアクセスするためには、URLが以下のように`basic`関数のエンドポイントを基準としたパスになっている必要があります。

- `http://localhost:5001/static/js/main.b8bf8203.js` -> `http://localhost:5001/basic-auth-spa/us-central1/basic/static/js/main.b8bf8203.js`
- `http://localhost:5001/static/css/main.f2bef1c1.css` -> `http://localhost:5001/basic-auth-spa/us-central1/basic/static/css/main.f2bef1c1.css`
- `http://localhost:5001/assets/index.19d121b8.js` -> `http://localhost:5001/basic-auth-spa/us-central1/basic/assets/index.19d121b8.js`
- `http://localhost:5001/assets/index.8d16f29f.css` -> `http://localhost:5001/basic-auth-spa/us-central1/basic/assets/index.8d16f29f.css`

### `index.html`を直接編集する

`index.html`に記述されているパスを`basic`関数基準の物に変更すると、エミュレーター環境でも正しくSPAが表示されるようになります。

最も素朴な方法としては、`index.html`を直接編集します。

```html
<!-- Create React Appの場合 -->
<script defer="defer" src="/basic-auth-spa/us-central1/basic/static/js/main.b8bf8203.js"></script>
<link href="/basic-auth-spa/us-central1/basic/static/css/main.f2bef1c1.css" rel="stylesheet" />

<!-- Vite + Reactの場合 -->
<script type="module" crossorigin src="/basic-auth-spa/us-central1/basic/assets/index.19d121b8.js"></script>
<link rel="stylesheet" href="/basic-auth-spa/us-central1/basic/assets/index.8d16f29f.css" />
```

### Create React Appの場合は環境変数で設定する

Create React Appでは`PUBLIC_URL`環境変数を設定すると、上記の変更を自動で行います。

- `.env`

```
PUBLIC_URL="/basic-auth-spa/us-central1/basic/"
```

#### 開発時のみ有効化する

開発環境としてビルドする場合には以下のように、特定のビルドコマンドにのみ適用されるようにします。

- `package.json`

```json
{
  "scripts": {
    "build:development": "PUBLIC_URL=/basic-auth-spa/us-central1/basic/ react-scripts build"
  }
}
```

### Viteの場合も設定が存在する

`base`設定がこれにあたります。

- [共通オプション | Vite](https://ja.vitejs.dev/config/shared-options.html#mode)

# 【おまけ2】Reactアプリのビルド結果の出力先を変更する

ビルドした後にいちいちリネームと移動をするのが面倒な場合は設定でそもそもの出力先を変更しましょう。

## 汎用的な方法

シンプルに、ビルド後に`mv`コマンドでディレクトリを丸ごとリネーム＆移動します。

```
$ yarn run build && mv build functions/lib/static
```

## Create React Appの場合

`BUILD_PATH`環境変数で出力ディレクトリを変更できます。

- `.env`

```
BUILD_PATH="functions/lib/static"
```


## Viteの場合

`outDir`設定が対応します。

- [ビルドオプション | Vite](https://ja.vitejs.dev/config/build-options.html)

# まとめ
以上の設定を行うと、HostingのURLにアクセスしたときにBasic認証を求められるようになります。\
Firebase Hostingには標準で閲覧制限を掛ける手段が無いので、限られた人にのみ一時的に公開する場合に便利です。