1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ViteとNestとYarn Workspacesで作ったClient/Serverモノリポを Firebaseエミュレータ上でHostingとFunctionsに乗せてFirebase Authenticationを使ってGoogle OAuthさせるまで

Last updated at Posted at 2024-04-05

環境

ツール version 前提
macOS Ventura 13.4.1
VSCode 1.87.0
node v20.11.1 nodebrew で管理
yarn v4.1.1 corepack のを使う。 node-moduesモードで使う
Firebase CLI
(firebase-tools)
13.4.0 プロジェクト毎のインストール推奨
firebase v10.8.0
Cloud Functions v10.8.0 第2世代

構成

構成
MyWorkspace (親プロジェクト)
 ├─ modules
 │    ├─ client (Clientモジュール)
 │    │    ├─ dist          <--- ここにできるファイルを Hosting する
 │    │    ├─ src
 │    │    └─ package.json  <--- clientのビルドスクリプト
 │    └─ server (Serverモジュール)
 │         ├─ dist          <--- ここにできるファイルを Functions で動かす
 │         ├─ src     
 │         └─ package.json  <--- serverのビルドスクリプト
 ├─ firebase.json
 └─ package.json            <--- 各モジュールのビルドスクリプト と Firebaseエミュレータの起動

個人的な好みで workspaces でも repos でなく modules としています。

本記事ではMyWorkspace全体レベルを親プロジェクト、Client/Server をそれぞれClientモジュールServerモジュールと呼びます。

yarnのセットアップ

yarnのセットアップは以下の記事をご参考に。ここでは v4 を使いますが、 Cloud Functions のエミュレータが pnpモードだと動かないので node-modulesモードを使います。

ワークスペースの作成

ワークスペースの作成
% mkdir MyWorkspace
% cd MyWorkspace
% yarn init
できるファイル
.editorconfig
.git
.gitattributes
.gitignore
.pnp.cjs
.yarn
README.md
package.json
yarn.lock

package.json はまだすかすか。

package.json
{
  "name": "MyWorkspace",
  "packageManager": "yarn@4.1.1"
}
yarnがnode-modulesモードになるように.yarnrc.ymlを作る
% touch .yarnrc.yml & echo "nodeLinker: node-modules" > .yarnrc.yml

Clientモジュールの追加

clientモジュールをviteで作成
% yarn create vite modules/client --template vue-ts

? Package name: › client
vscodeの設定をプロジェクトルートへ移動
% mv modules/client/.vscode .vscode
modules/client/package.json
{
  "name": "client",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.4.21"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.4",
    "typescript": "^5.2.2",
    "vite": "^5.2.0",
    "vue-tsc": "^2.0.6"
  }
}

Serverモジュールの追加

Nest CLI をグローバルインストール

npmでインストールします。

NestCLIのグローバルインストール
% npm install -g @nestjs/cli

yarn は v2 から yarn global add を廃止しました。これは yarn はあくまでプロジェクトを管理するもので、システムのライブラリ管理はしないよというスタンスだからとのこと。

代わりに yarn dlx(ダウンロードして実行)が用意されていますが、残念ながら Nest CLI でのプロジェクト生成コマンドは内部でグローバルインストールされた Nest CLIを使うのでうまくいきませんでした。

dlxではNestの新規プロジェクトが作れない
% yarn dlx @nestjs/cli new nest-demo

➤ YN0000: · Yarn 4.1.1
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @nestjs/cli@npm:10.3.2, @angular-devkit/core@npm:17.1.2, @angular-devkit/schematics-cli@npm:17.1.2, @angular-devkit/schematics@npm:17.1.2, and 330 more.
➤ YN0000: └ Completed in 1s 248ms
➤ YN0000: ┌ Fetch step
➤ YN0013: │ 334 packages were added to the project (+ 104.43 MiB).
➤ YN0000: └ Completed in 0s 265ms
➤ YN0000: ┌ Link step
➤ YN0000: │ ESM support for PnP uses the experimental loader API and is therefore experimental
➤ YN0000: └ Completed
➤ YN0000: · Done with warnings in 1s 695ms

⚡  We will scaffold your app in a few seconds..

? Which package manager would you ❤️  to use? yarn
Error: Collection "@nestjs/schematics" cannot be resolved.

Failed to execute command: node @nestjs/schematics:application --name=nest-demo --directory=undefined --no-dry-run --no-skip-git --no-strict --package-manager="yarn" --collection="@nestjs/schematics" --language="ts"
serverモジュールをnestで作成
% nest new modules/server
NestCLIが作るgitファイルを削除
% rm -rf modules/server/.git
modules/server/package.json
{
  "name": "server",
  "version": "0.0.1",
  "description": "",
  "author": "",
  "private": true,
  "license": "UNLICENSED",
  "scripts": {
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },
  "dependencies": {
    "@nestjs/common": "^10.0.0",
    "@nestjs/core": "^10.0.0",
    "@nestjs/platform-express": "^10.0.0",
    "reflect-metadata": "^0.2.0",
    "rxjs": "^7.8.1"
  },
  "devDependencies": {
    "@nestjs/cli": "^10.0.0",
    "@nestjs/schematics": "^10.0.0",
    "@nestjs/testing": "^10.0.0",
    "@types/express": "^4.17.17",
    "@types/jest": "^29.5.2",
    "@types/node": "^20.3.1",
    "@types/supertest": "^6.0.0",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "eslint": "^8.42.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.0",
    "jest": "^29.5.0",
    "prettier": "^3.0.0",
    "source-map-support": "^0.5.21",
    "supertest": "^6.3.3",
    "ts-jest": "^29.1.0",
    "ts-loader": "^9.4.3",
    "ts-node": "^10.9.1",
    "tsconfig-paths": "^4.2.0",
    "typescript": "^5.1.3"
  },
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node"
  }
}

親プロジェクトの設定

modules以下をworkspacesとして追加しておきます。

package.json
 {
   "name": "MyWorkspace",
+  "workspaces": [
+    "modules/*"
+  ],
   "packageManager": "yarn@4.1.1"
 }
Client/Server共通の依存関係を追加
% yarn add --dev typescript tsconfig-paths firebase-tools

Firebaseの導入

Firebaseの導入
% yarn firebase init
     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  /Users/yusuke/Workspaces/MyWorkspace

? Which Firebase features do you want to set up for this directory? Press Space to select features, then Enter to confirm your choices. (Press <space> to select, <a> to toggle all, 
<i> to invert selection, and <enter> to proceed)
 ◉ Functions: Configure a Cloud Functions directory and its files
 ◉ Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Action deploys
 ◯ Hosting: Set up GitHub Action deploys
❯◯ Storage: Configure a security rules file for Cloud Storage
 ◉ Emulators: Set up local emulators for Firebase products
 ◯ Remote Config: Configure a template file for Remote Config
 ◯ Extensions: Set up an empty Extensions manifest
(Move up and down to reveal more choices)
=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we'll just set up a default project.

? Please select an option: (Use arrow keys)
❯ Use an existing project 
  Create a new project 
  Add Firebase to an existing Google Cloud Platform project 
  Don't set up a default project 

WebのFirebaseコンソールで適宜プロジェクトを作るなりしておきましょう。

=== 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? 
  JavaScript 
❯ TypeScript 
  Python 

? Do you want to use ESLint to catch probable bugs and enforce style? (Y/n) n
✔  Wrote functions/package.json
✔  Wrote functions/tsconfig.json
✔  Wrote functions/src/index.ts
✔  Wrote functions/.gitignore


? Do you want to install dependencies with npm now? (Y/n) n
=== 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? modules/client/dist

? Configure as a single-page app (rewrite all urls to /index.html)? (y/N) n

? Set up automatic builds and deploys with GitHub? (y/N) n
✔  Wrote modules/client/dist/404.html
✔  Wrote modules/client/dist/index.html

公開するディレクトリはどれと訊かれますが、Viteで yarn build した結果はデフォルトでは dist 以下にできるので、modules/client/distと回答します。

=== Emulators Setup
? Which Firebase emulators do you want to set up? Press Space to select emulators, then Enter to confirm your choices. (Press <space> to select, <a> to toggle all, <i> to invert 
selection, and <enter> to proceed)
 ◉ Authentication Emulator
 ◉ Functions Emulator
 ◯ Firestore Emulator
❯◯ Database Emulator
 ◉ Hosting Emulator
 ◯ Pub/Sub Emulator
 ◯ Storage Emulator
(Move up and down to reveal more choices)

? 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? (5000) 5050

? Would you like to enable the Emulator UI? (Y/n) y

? 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? (Y/n) y

i  firestore: downloading cloud-firestore-emulator-v1.19.4.jar...
Progress: ===========================================================================================================================================================> (100% of 66MB
i  ui: downloading ui-v1.11.8.zip...

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...

✔  Firebase initialization complete!
Progress: ============================================================================================================================================================> (100% of 4MB

hostingのエミュレータのPortだけ 5000 から 5050 へ変えています。これは mac の場合、5000番はControl CenterがAirPlayの受信用に使っているためです。

ポートが使われていると、エミュレータの起動時に以下のようにエラーが出ます。

ポートが使われているとエラーに
i  emulators: Starting emulators: auth, hosting
⚠  hosting: Port 5000 is not open on localhost (127.0.0.1,::1), could not start Hosting Emulator.
⚠  hosting: To select a different host/port, specify that host/port in a firebase.json config file:
      {
        // ...
        "emulators": {
          "hosting": {
            "host": "HOST",
            "port": "PORT"
          }
        }
      }
i  emulators: Shutting down emulators.

Error: Could not start Hosting Emulator, port taken.

使っているプロセスを探すには以下のようにします。

使っているのは誰?
% lsof -P -i:5000
COMMAND   PID   USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
ControlCe 624 yusuke    7u  IPv4 0xe62d81438a5d612f      0t0  TCP *:5000 (LISTEN)
ControlCe 624 yusuke    8u  IPv6 0xe62d8151ef804ce7      0t0  TCP *:5000 (LISTEN)

Serverモジュールの修正

modules/serverをそのままFunctionsにデプロイするのでfunctionsフォルダは削除します

functionsフォルダは削除
% rm -rf functions

functionsにデプロイする対象を修正(ついでにfunctionsで動くnodeのバージョンをv20に指定)。

firebase.json
 {
   "functions": [
     {
-      "source": "functions",
+      "source": "modules/server",
+      "runtime": "nodejs20",
       "codebase": "default",
       "ignore": [
         "node_modules",
         ".git",
         "firebase-debug.log",
         "firebase-debug.*.log"
       ],
       "predeploy": [
         "npm --prefix \"$RESOURCE_DIR\" run build"
       ]
     }
   ],
   "hosting": {
     "public": "modules/client/dist",
     "ignore": [
       "firebase.json",
       "**/.*",
       "**/node_modules/**"
     ]
   },
   "emulators": {
     "auth": {
       "port": 9099
     },
     "functions": {
       "port": 5001
     },
     "hosting": {
       "port": 5050
     },
     "ui": {
       "enabled": true
     },
     "singleProjectMode": true
   }
 }

依存関係の追加

yarn workspacesでは yarn workspace [workspace名] [command] で指定したワークスペースに対して yarn を実行できます。

% yarn workspace server add firebase-admin firebase-functions
% yarn workspace server add --dev firebase-functions-test

functions向けのエントリポイントを追加

ローカルでの起動について
Serverモジュールは Nest CLI を使った起動も、firebase emulatorを使った起動もできるようにしたいと思います。

どちらで起動されたかで実行すべきコードが異なる場合も出てくるため、環境変数で判断するようにします。

具体的には、Nest CLI での起動時には NODE_ENV = nest とします。

NestCLIで自動生成されたエントリポイントの main.ts とは別に、functions上で動かされた時のためのエントリポイント、Firebase風に言うと、Httpでトリガーされる関数を用意します。

modules/server/package.json
 {
   "name": "server",
   "version": "0.0.1",
   "description": "",
   "author": "",
   "private": true,
   "license": "UNLICENSED",
+  "main": "dist/index.js",
   "scripts": {
     "build": "nest build",
     "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
-    "start": "nest start",
-    "start:dev": "nest start --watch",
-    "start:debug": "nest start --debug --watch",
+    "start": "NODE_ENV=nest nest start",
+    "start:dev": "NODE_ENV=nest nest start --watch",
+    "start:debug": "NODE_ENV=nest nest start --debug --watch",
     "start:prod": "node dist/main",
     ...
modules/server/src/index.ts
+ import { NestFactory } from '@nestjs/core';
+ import { ExpressAdapter } from '@nestjs/platform-express';
+ import * as express from 'express';
+ import { AppModule } from './app.module';

+ import { onRequest } from 'firebase-functions/v2/https';
+ import { setGlobalOptions } from 'firebase-functions/v2/options';

+ const server = express();
+ setGlobalOptions({ region: 'asia-northeast1' });

+ export const api = onRequest({ minInstances: 5 }, async (req, res) => {
+   return NestFactory.create(AppModule, new ExpressAdapter(server))
+     .then((app) => app.init())
+     .then(() => server(req, res));
+ });

起動を確認する

親プロジェクトのscriptsでビルド、エミュレータの起動ができるようにします。

yarn workspaces foreach --all run [command] で各ワークスペースのscriptsを実行できます。

またエミュレータはそのままだとデータを保存(永続化)してくれないので、終了時にデータをエクスポートし、起動時にインポートする設定も入れる。

package.json
  {
    "name": "MyWorkspace",
    "workspaces": [
      "modules/*"
    ],
+   "scripts": {
+     "build": "yarn workspaces foreach --all run build",
+     "serve": "firebase emulators:start --import .emulator-data --export-on-exit .emulator-data"
  },
    "packageManager": "yarn@4.1.1",
    "devDependencies": {
      "firebase-tools": "^13.6.1",
      "tsconfig-paths": "^4.2.0",
      "typescript": "^5.4.3"
    }
  }
ビルドしてみる
% yarn build
[client]: Process started
[client]: vite v5.2.7 building for production...
[client]: transforming...
[client]: ✓ 18 modules transformed.
[client]: rendering chunks...
[client]: computing gzip size...
[client]: dist/index.html                  0.46 kB │ gzip:  0.30 kB
[client]: dist/assets/index-B87vROlf.css   1.27 kB │ gzip:  0.65 kB
[client]: dist/assets/index-CfLvyDuu.js   55.38 kB │ gzip: 22.48 kB
[client]: ✓ built in 533ms
[client]: Process exited (exit code 0), completed in 2s 507ms

[server]: Process started
[server]: Process exited (exit code 0), completed in 2s 89ms
Done in 4s 599ms
起動してみる
% yarn serve
i  emulators: Starting emulators: auth, functions, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: firestore, database, pubsub, storage
i  auth: Importing config from /Users/yusuke/Workspaces/MyWorkspace/.emulator-data/auth_export/config.json
i  auth: Importing accounts from /Users/yusuke/Workspaces/MyWorkspace/.emulator-data/auth_export/accounts.json
i  hosting[myproject-4649]: Serving hosting files from: modules/client/dist
✔  hosting[myproject-4649]: Local server: http://127.0.0.1:5050
i  ui: Emulator UI logging to ui-debug.log
i  functions: Watching "/Users/yusuke/Workspaces/MyWorkspace/modules/server" for Cloud Functions...
✔  functions: Using node@20 from host.
Serving at port 8447

✔  functions: Loaded functions definitions from source: api.
✔  functions[asia-northeast1-api]: http function initialized (http://127.0.0.1:5001/myproject-4649/asia-northeast1/api).

┌─────────────────────────────────────────────────────────────┐
│ ✔  All emulators ready! It is now safe to connect your app. │
│ i  View Emulator UI at http://127.0.0.1:4000/               │
└─────────────────────────────────────────────────────────────┘

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ 127.0.0.1:9099 │ http://127.0.0.1:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ 127.0.0.1:5001 │ http://127.0.0.1:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ 127.0.0.1:5050 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘
  Emulator Hub running at 127.0.0.1:4400
  Other reserved ports: 4500

Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files.

ブラウザで http://127.0.0.1:4000/ にアクセスすると、以下が表示される。
EmulatorSuite.png

Hostingエミュレータの View website から Vite + Vue の画面が表示され、コンソールに表示された http://127.0.0.1:5001/myproject-4649/asia-northeast1/api にアクセスして Hello World! が表示されれば Client モジュールと Serverモジュール をエミュレータ上で動作させるところまではOK。

Clientモジュール に Firebase Authentication + Google OAuth でのサインイン/サインアウトを実装

% yarn workspace client add firebase

 ここでは vite.config.ts に Firebaseの接続設定を記載し、ソースから読み出せるようにします。

modules/client/vite.config.ts
import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'

 // https://vitejs.dev/config/
 export default defineConfig({
   plugins: [vue()],
+   define: {
+     __FIREBASE_CONFIG__: {
+       apiKey: "AIzaSyAv9VnvR6YK4ljBjPe7UZjnBV9N4h0c7qE",
+       authDomain: "myproject-4649.firebaseapp.com",
+       projectId: "myproject-4649",
+       storageBucket: "myproject-4649.appspot.com",
+       messagingSenderId: "539906050176",
+       appId: "1:539906050176:web:24e2561daf2e7c4b785973"
+     }
+   }
 })

型定義を追加します。

modules/client/src/vite-env.d.ts
 /// <reference types="vite/client" />
+ declare var __FIREBASE_CONFIG__: { [x:string]: string }

+ interface ImportMetaEnv {
+     readonly MODE: string
+ }

+ interface ImportMeta {
+     readonly env: ImportMetaEnv
+ }
modules/client/src/main.ts
  import { createApp } from 'vue'
  import './style.css'
  import App from './App.vue'
  
+ import { initializeApp } from 'firebase/app'
+ import { connectAuthEmulator, getAuth } from 'firebase/auth'
  
+ const firebaseApp = initializeApp(__FIREBASE_CONFIG__)
  
+ // Initialize Firebase Authentication and get a reference to the service
+ if (import.meta.env.MODE === 'development') {
+     const auth = getAuth()
+     connectAuthEmulator(auth, 'http://127.0.0.1:9099')
+ } else {
+     getAuth(firebaseApp)
+ }
  
  createApp(App).mount('#app')

Firebase Hosting はビルドしたものが使われるので、clientモジュール の yarn buildが Vite の developmentモードで行われるようにしておきます。

modules/client/package.json
  {
    "name": "client",
    "private": true,
    "version": "0.0.0",
    "type": "module",
    "scripts": {
      "dev": "vite",
-     "build": "vue-tsc && vite build",
+     "build": "vue-tsc && vite build --mode development",
      "preview": "vite preview"
    },
    "dependencies": {
      "vue": "^3.4.21"
    },
    "devDependencies": {
      "@vitejs/plugin-vue": "^5.0.4",
      "typescript": "^5.2.2",
      "vite": "^5.2.0",
      "vue-tsc": "^2.0.6"
    }
  }

ここではアーキテクチャは考えず、初めからある HelloWorld.vue を 魔改造してサインイン/アウトの確認だけを行う。

modules/client/src/components/HelloWorld.vue
  <script setup lang="ts">
- import { ref } from 'vue'
+ import { reactive } from 'vue'
+ import { AuthError, getAuth, GoogleAuthProvider, onAuthStateChanged, signInWithPopup, signOut, User, UserCredential } from 'firebase/auth'

  defineProps<{ msg: string }>()

- const count = ref(0)
+ const state = reactive<{ user: User | null }>({
+   user: null
+ })

+ // サインインステータスを観測
+ onAuthStateChanged(getAuth(), (user: User | null) => {
+     if (user) {
+         console.log('onAuthStateChanged: signIn', user)
+         state.user = user
+     } else {
+         console.log('onAuthStateChanged: signOut')
+         state.user = null
+     }
+ })

+ const requiredScope: string[] = ['https://www.googleapis.com/auth/contacts.readonly']

+ const signin = () => {
+     const provider = new GoogleAuthProvider()
+     requiredScope.forEach(scope => provider.addScope(scope))
+     return signInWithPopup(getAuth(), provider)
+         .then((result: UserCredential) => {
+             // This gives you a Google Access Token. You can use it to access the Google API.
+             const credential = GoogleAuthProvider.credentialFromResult(result)
+             console.log('credential', credential)
+         })
+         .catch((error: AuthError) => console.error('Google Sign-In Error:', error))
+ }

+ const signout = () => {
+     return signOut(getAuth())
+         .then(() => console.log('signOut'))
+         .catch((error: AuthError) => console.error('Google Sign-Out Error:', error))
+ }
  </script>

  <template>
    <h1>{{ msg }}</h1>

    <div class="card">
-     <button type="button" @click="count++">count is {{ count }}</button>
+     <button v-if="state.user === null" type="button" @click="signin()">Sign In</button>
+     <button v-else type="button" @click="signout()">Sign Out [{{ state.user.email }}] </button>
      ...

再び yarn build -> yarn serve して Sign Inボタンを押下してサインインできるか試します。

ブラウザのコンソールに以下のエラーが出た場合
エミュレータでなく Cloud の Firebase Authentication に繋ぎにいっています。main.ts に追加した初期化部分を見直しましょう。

エラー(auth/configuration-not-found)
Google Sign-In Error: FirebaseError: Firebase: Error (auth/configuration-not-found).
    at ds (index-Bkf71-lp.js:377:758)
    at Ce (index-Bkf71-lp.js:377:30)
    at jo (index-Bkf71-lp.js:482:1213)
    at async Vf (index-Bkf71-lp.js:1247:141)

エミュレータでなく、 Cloud の Firebase Authentication で利用する場合は Firebaseコンソール の方で以下の設定が必要です。

auth/configuration-not-found が出た場合

Firebaseコンソールに接続し、接続情報で指定したプロジェクトにAuthenticationを追加します。

Authenticationの利用開始.png

auth/unauthorized-domain が出た場合

エラー(auth/unauthorized-domain)
Google Sign-In Error: FirebaseError: Firebase: Error (auth/unauthorized-domain).
    at ds (index-C4VdVVTk.js:377:758)
    at Ce (index-C4VdVVTk.js:377:30)
    at Vf (index-C4VdVVTk.js:1247:197)
警告も出る
Info: The current domain is not authorized for OAuth operations. This will prevent signInWithPopup, signInWithRedirect, linkWithPopup and linkWithRedirect from working. Add your domain (127.0.0.1) to the OAuth redirect domains list in the Firebase console -> Authentication -> Settings -> Authorized domains tab.

hostingエミュレータが動く 127.0.0.1 を承認済ドメインとして登録する必要があります。

承認済ドメインの追加-1.png
承認済ドメインの追加-2.png

auth/operation-not-allowed が出た場合

Google Sign-In Error: FirebaseError: Firebase: Error (auth/operation-not-allowed).
    at ds (index-Bkf71-lp.js:377:758)
    at _e (index-Bkf71-lp.js:377:68)
    at Uf.sendToConsumer (index-Bkf71-lp.js:1217:961)
    at index-Bkf71-lp.js:1217:647
    at Set.forEach (<anonymous>)
    at Uf.onEvent (index-Bkf71-lp.js:1217:594)
    at _.bo.<anonymous> (index-Bkf71-lp.js:1322:1009)
    at yn (cb=gapi.loaded_0?le=scs:196:92)
    at Object.<anonymous> (cb=gapi.loaded_0?le=scs:196:265)
    at Sl (cb=gapi.loaded_0?le=scs:175:307)

ログイン方法の追加.png
ログイン方法の追-2.png

有効にするのスイッチを入れて、プロジェクトのサポートメールを選択します。

Cross-Origin-Opener-Policy のエラー

Cross-Origin-Opener-Policy policy would block the window.closed call.

これは signInWithPopup() を使うならば仕方なし。

Clientモジュールが OAuth で取得したトークンを Serverモジュールで認証する

構成
Clientモジュールでトークンを取得したら、axios を使って Serverモジュールの API を叩きます。
その際、Authorizationヘッダーに Bearer を設定します。

Serverモジュールでは認証OKの場合、NGの場合でレスポンスを変えて返します。

Clientモジュールにaxiosを追加

% yarn workspace client add axios

Serverモジュールの認証を試すため、Sign In 成功時に API を叩くようにします。

modules/client/src/components/HelloWorld.vue
  <script setup lang="ts">
  import { reactive } from 'vue'
  import { AuthError, getAuth, GoogleAuthProvider, onAuthStateChanged, signInWithPopup, signOut, User, UserCredential } from 'firebase/auth'
+ import axios, { AxiosError, AxiosResponse } from 'axios'
  
  defineProps<{ msg: string }>()
  
  const state = reactive<{ user: User | null }>({
    user: null
  })
  
  // サインインステータスを観測
  onAuthStateChanged(getAuth(), (user: User | null) => {
      if (user) {
          console.log('onAuthStateChanged: signIn', user)
          state.user = user
      } else {
          console.log('onAuthStateChanged: signOut')
          state.user = null
      }
  })
  
  const requiredScope: string[] = ['https://www.googleapis.com/auth/contacts.readonly']
+ const url = 'http://127.0.0.1:5001/myproject-4649/asia-northeast1/api'
  
  const signin = () => {
      const provider = new GoogleAuthProvider()
      requiredScope.forEach(scope => provider.addScope(scope))
      return signInWithPopup(getAuth(), provider)
          .then((result: UserCredential) => {
              // This gives you a Google Access Token. You can use it to access the Google API.
              const credential = GoogleAuthProvider.credentialFromResult(result)
              console.log('credential', credential)
+             return result.user.getIdToken(true)
          })
+         .then((token: string) => {
+             const id = 'hogehoge'
+             return axios.get(`${url}/auth/${id}`, {
+                 headers: {
+                     Authorization: `Bearer ${token}`,
+                 },
+                 params: {
+                     key1: 'fuga',
+                     key2: 'piyo'
+                 },
+             })
+         })
+         .then((res: AxiosResponse) => {
+             const { data, status } = res
+             console.log('Response:', data, status)
+         })
          .catch((error: AuthError) => console.error('Google Sign-In Error:', error))
  }

Serverモジュールに認証機構を追加

Passport を使います。

% yarn workspace server add @nestjs/passport passport passport-http-bearer
% yarn workspace server add --dev @types/passport-http-bearer

NestCLI で認証モジュールの雛形を作ります。

% yarn workspace server run nest g module auth 
modules/server/src/auth/firebase-auth.strategy.ts
import { Strategy } from 'passport-http-bearer';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { DecodedIdToken, getAuth } from 'firebase-admin/auth';

export const FIREBASE_AUTH = 'firebase-auth';

@Injectable()
export class FirebaseAuthStrategy extends PassportStrategy(Strategy, FIREBASE_AUTH) {
  constructor() {
    super();
  }

  async validate(token: string): Promise<DecodedIdToken> {
    try {
      return getAuth().verifyIdToken(token);
    } catch (err) {
      console.error(err);
      throw new UnauthorizedException();
    }
  }
}
modules/server/src/auth/auth.module.ts
  import { Module } from '@nestjs/common';
+ import { FirebaseAuthStrategy } from './firebase-auth.strategy';
+ import { PassportModule } from '@nestjs/passport';

- @Module({})
+ @Module({
+   imports: [PassportModule],
+   providers: [FirebaseAuthStrategy],
+ })
  export class AuthModule {}

Strategy にて validate されたオブジェクトは Request.user に格納されます。

modules/server/src/app.controller.ts
- import { Controller, Get } from '@nestjs/common';
+ import { Controller, Get, Param, Query, Request, UseGuards } from '@nestjs/common';
  import { AppService } from './app.service';
+ import { AuthGuard } from '@nestjs/passport';
+ import { FIREBASE_AUTH } from './auth/firebase-auth.strategy';
  
- @Controller()
+ @Controller('auth')
+ @UseGuards(AuthGuard(FIREBASE_AUTH))
  export class AppController {
    constructor(private readonly appService: AppService) {}
  
-   @Get()  
+   @Get(':id')
-   getHello(): string {
+   getHello(@Request() request: any, @Param() params: any, @Query() query: any): string {
+     console.log('Request:', request.user, params, query);
      return this.appService.getHello();
    }
  }

Serverモジュールに Firebase の初期化処理を追加

サービスアカウントの設定を Firebaseコンソール から取得します。

サービスアカウント情報-1.png

サービスアカウント情報-2.png

サービスアカウント情報-3.png

ダウンロードしたJSONから、project_idprivate_keyclient_emailをコピーして使います(PrivateKeyは取り扱い注意です!)。

modules/server/src/index.ts
  import { NestFactory } from '@nestjs/core';
  import { ExpressAdapter } from '@nestjs/platform-express';
  import * as express from 'express';
  import { AppModule } from './app.module';
  
  import { onRequest } from 'firebase-functions/v2/https';
  import { setGlobalOptions } from 'firebase-functions/v2/options';
  
+ import { ServiceAccount, credential } from 'firebase-admin';
+ import { initializeApp } from 'firebase-admin/app';
+ 
+ const serviceAccount: ServiceAccount = {
+   projectId: 'myproject-4649',
+   privateKey: '-----BEGIN PRIVATE KEY-----\nMIIEv...ezs=\n-----END PRIVATE KEY-----\n',
+   clientEmail: 'firebase-adminsdk-f7pvg@myproject-4649.iam.gserviceaccount.com',
+ };
+ 
+ initializeApp({
+   credential: credential.cert(serviceAccount),
+ });
  
  const server = express();
  setGlobalOptions({ region: 'asia-northeast1' });
  
  export const api = onRequest({ minInstances: 5 }, async (req, res) => {
    return NestFactory.create(AppModule, new ExpressAdapter(server))
      .then((app) => app.init())
      .then(() => server(req, res));
    },
  );

念の為、modules/cleint/dist、modules/server/dist を削除し、 yarn buildyarn serve します。

Sign Inボタンでサインインした後、ターミナルのログにデコードされたユーザ情報、URLパラメータ(id)、クエリパラメータ(key1, key2)が表示され、

>  Request: {
>    name: 'Algae Otter',
>    email: 'algae.otter.118@example.com',
>    email_verified: true,
>    auth_time: 1712216457,
>    user_id: 'ae54hlPjHpfgOdP6YV3Evrd6YSDa',
>    firebase: {
>      identities: { email: [Array], 'google.com': [Array] },
>      sign_in_provider: 'google.com'
>    },
>    iat: 1712216457,
>    exp: 1712220057,
>    aud: 'myproject-4649',
>    iss: 'https://securetoken.google.com/myproject-4649',
>    sub: 'ae54hlPjHpfgOdP6YV3Evrd6YSDa',
>    uid: 'ae54hlPjHpfgOdP6YV3Evrd6YSDa'
>  } { id: 'hogehoge' } { key1: 'fuga', key2: 'piyo' }

ブラウザのコンソールに以下が表示されれば成功です。

Response: Hello World! 200

ClientモジュールをViteで動かす

hosting では build が必要なのでホットリローディングができません(似たようなことはやろうとすればできますが)。なので開発中は Vite で動かしたいです。

親プロジェクトの package.json のスクリプトに serve:local を追加します。

package.json
  {
    "name": "MyWorkspace",
    "workspaces": [
      "modules/*"
    ],
    "scripts": {
      "build": "yarn workspaces foreach --all run build",
+     "serve:local": "firebase emulators:start --only auth,functions --import .emulator-data --export-on-exit .emulator-data & yarn workspace client run dev",
      "serve": "firebase emulators:start --import .emulator-data --export-on-exit .emulator-data"
    },
    ...

Serverモジュールを NestCLI で動かす

functions では build が必要なのでホットリローディングができません(以下略

serve:local を以下のように変更します。

package.json
  {
    "name": "MyWorkspace",
    "workspaces": [
      "modules/*"
    ],
    "scripts": {
      "build": "yarn workspaces foreach --all run build",
-     "serve:local": "firebase emulators:start --only auth,functions --import .emulator-data --export-on-exit .emulator-data & yarn workspace client run dev",
+     "serve:local": "firebase emulators:start --only auth --import .emulator-data --export-on-exit .emulator-data & yarn workspace server run start:dev & yarn workspace client run dev",
      "serve": "firebase emulators:start --import .emulator-data --export-on-exit .emulator-data"
    },
    ...

NestCLI 起動時のエントリポイントにも Firebase 接続処理を追加します。

modules/server/src/main.ts
  import { NestFactory } from '@nestjs/core';
  import { AppModule } from './app.module';
  
+ import { ServiceAccount, credential } from 'firebase-admin';
+ import { initializeApp } from 'firebase-admin/app';
+  
+ const serviceAccount: ServiceAccount = {
+   projectId: 'myproject-4649',
+   privateKey: '-----BEGIN PRIVATE KEY-----\nMIIEv...ezs=\n-----END PRIVATE KEY-----\n',
+   clientEmail: 'firebase-adminsdk-f7pvg@myproject-4649.iam.gserviceaccount.com',
+ };
+ 
+ initializeApp({
+   credential: credential.cert(serviceAccount),
+ });
  
  async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    await app.listen(3000);
  }
  bootstrap();

Serverモジュール の接続先が変わるので HelloWorld.vue も修正します。

modules/client/src/components/HelloWorld.vue
  <script setup lang="ts">
  import { reactive } from 'vue'
  import { AuthError, getAuth, GoogleAuthProvider, onAuthStateChanged, signInWithPopup, signOut, User, UserCredential } from 'firebase/auth'
  import axios, { AxiosError, AxiosResponse } from 'axios'
  
  defineProps<{ msg: string }>()
  
  const state = reactive<{ user: User | null }>({
    user: null
  })
  
  // サインインステータスを観測
  onAuthStateChanged(getAuth(), (user: User | null) => {
      if (user) {
          console.log('onAuthStateChanged: signIn', user)
          state.user = user
      } else {
          console.log('onAuthStateChanged: signOut')
          state.user = null
      }
  })
  
  const requiredScope: string[] = ['https://www.googleapis.com/auth/contacts.readonly']
- const url = 'http://127.0.0.1:5001/myproject-4649/asia-northeast1/api'
+ const url = 'http://127.0.0.1:3000' 

これで起動してサインインを行うと、API部分で以下のエラーが起きます。クロスオリジンでのリソース共有を可能にするように、CORSのスイッチを true にします。

Access to XMLHttpRequest at 'http://127.0.0.1:3000/auth/hogehoge?key1=fuga&key2=piyo' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
modules/server/src/main.ts
  
  async function bootstrap() {
-   const app = await NestFactory.create(AppModule);
+   const app = await NestFactory.create(AppModule, { cors: true })
    await app.listen(3000);
  }
  bootstrap();

ホットリローディングが利いているのでそのまま試します。すると別のエラーが出ます。

ERROR [ExceptionsHandler] Firebase ID token has no "kid" claim. See https://firebase.google.com/docs/auth/admin/verify-id-tokens for details on how to retrieve an ID token.
[2] Error: Firebase ID token has no "kid" claim. See https://firebase.google.com/docs/auth/admin/verify-id-tokens for details on how to retrieve an ID token.
[2]     at FirebaseTokenVerifier.verifyContent (/Users/yusuke/Workspaces/MyWorkspace/node_modules/firebase-admin/lib/auth/token-verifier.js:239:19)
[2]     at /Users/yusuke/Workspaces/MyWorkspace/node_modules/firebase-admin/lib/auth/token-verifier.js:160:18
[2]     at processTicksAndRejections (node:internal/process/task_queues:95:5)
[2]     at FirebaseAuthStrategy.callback [as _verify] (/Users/yusuke/Workspaces/MyWorkspace/node_modules/@nestjs/passport/dist/passport/passport.strategy.js:11:44)

どうやら verifyIdToken()は現時点ではCloud Functionからでないと動かないとのこと。NestCLI で動かす際にはダミーの Strategy で認証を回避するようにします。

modules/src/server/auth/dummy.strategy.ts
import { Strategy } from 'passport-http-bearer';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { DecodedIdToken } from 'firebase-admin/auth';

export const DUMMY = 'dummy';

@Injectable()
export class DummyStrategy extends PassportStrategy(Strategy, DUMMY) {
  constructor() {
    super();
  }

  async validate(idToken: string): Promise<DecodedIdToken> {
    console.log('DummyStrategy.validate', idToken);
    return Promise.resolve().then(() => {
      return {
        name: 'Algae Otter',
        email: 'algae.otter.118@example.com',
        email_verified: true,
        auth_time: 1712216457,
        user_id: 'ae54hlPjHpfgOdP6YV3Evrd6YSDa',
        firebase: {
          identities: { email: [Array], 'google.com': [Array] },
          sign_in_provider: 'google.com',
        },
        iat: 1712216457,
        exp: 1712220057,
        aud: 'myproject-4649',
        iss: 'https://securetoken.google.com/myproject-4649',
        sub: 'ae54hlPjHpfgOdP6YV3Evrd6YSDa',
        uid: 'ae54hlPjHpfgOdP6YV3Evrd6YSDa',
      };
    });
  }
}
modules/server/src/auth/auth.module.ts
  import { Module } from '@nestjs/common';
  import { FirebaseAuthStrategy } from './firebase-auth.strategy';
  import { PassportModule } from '@nestjs/passport';
+ import { DummyStrategy } from './dummy.strategy';
  
  @Module({
    imports: [PassportModule],
-   providers: [FirebaseAuthStrategy],
+   providers: [FirebaseAuthStrategy, DummyStrategy],
  })
  export class AuthModule {}
modules/server/src/app.controller.ts
  import { Controller, Get, Param, Query, Request, UseGuards } from '@nestjs/common';
  import { AppService } from './app.service';
  import { AuthGuard } from '@nestjs/passport';
  import { FIREBASE_AUTH } from './auth/firebase-auth.strategy';
+ import { DUMMY } from './auth/dummy.strategy';

+ const isNest = process.env.NODE_ENV === 'nest';
  
  @Controller('auth')
- @UseGuards(AuthGuard(FIREBASE_AUTH))
+ @UseGuards(AuthGuard(isNest ? DUMMY : FIREBASE_AUTH))
  export class AppController {
    constructor(private readonly appService: AppService) {}
  
    @Get(':id')
    getHello(@Request() request: any, @Param() params: any, @Query() query: any): string {
      console.log('Request:', request.user, params, query);
      return this.appService.getHello();
    }
  }

これで yarn serve:local で起動したとき(NODE_ENV=nest を付けて NestCLI で起動した時)は dummy のStrategy で、yarn serve でエミュレータで起動した時は Firebase Authentication 向けの Strategy で動作するようにできました。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?