環境
ツール | 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
はまだすかすか。
{
"name": "MyWorkspace",
"packageManager": "yarn@4.1.1"
}
% touch .yarnrc.yml & echo "nodeLinker: node-modules" > .yarnrc.yml
Clientモジュールの追加
% yarn create vite modules/client --template vue-ts
? Package name: › client
% mv modules/client/.vscode .vscode
{
"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でインストールします。
% npm install -g @nestjs/cli
yarn は v2 から yarn global add
を廃止しました。これは yarn はあくまでプロジェクトを管理するもので、システムのライブラリ管理はしないよというスタンスだからとのこと。
代わりに yarn dlx
(ダウンロードして実行)が用意されていますが、残念ながら Nest CLI でのプロジェクト生成コマンドは内部でグローバルインストールされた Nest CLIを使うのでうまくいきませんでした。
% 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"
% nest new modules/server
% rm -rf modules/server/.git
{
"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として追加しておきます。
{
"name": "MyWorkspace",
+ "workspaces": [
+ "modules/*"
+ ],
"packageManager": "yarn@4.1.1"
}
% yarn add --dev typescript tsconfig-paths firebase-tools
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フォルダは削除します
% rm -rf functions
functionsにデプロイする対象を修正(ついでにfunctionsで動くnodeのバージョンをv20に指定)。
{
"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でトリガーされる関数を用意します。
{
"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",
...
+ 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を実行できます。
またエミュレータはそのままだとデータを保存(永続化)してくれないので、終了時にデータをエクスポートし、起動時にインポートする設定も入れる。
{
"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/ にアクセスすると、以下が表示される。
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の接続設定を記載し、ソースから読み出せるようにします。
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"
+ }
+ }
})
型定義を追加します。
/// <reference types="vite/client" />
+ declare var __FIREBASE_CONFIG__: { [x:string]: string }
+ interface ImportMetaEnv {
+ readonly MODE: string
+ }
+ interface ImportMeta {
+ readonly env: ImportMetaEnv
+ }
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モードで行われるようにしておきます。
{
"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 を 魔改造してサインイン/アウトの確認だけを行う。
<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
に追加した初期化部分を見直しましょう。
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を追加します。
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
を承認済ドメインとして登録する必要があります。
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)
有効にするのスイッチを入れて、プロジェクトのサポートメールを選択します。
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 を叩くようにします。
<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
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();
}
}
}
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 に格納されます。
- 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コンソール から取得します。
ダウンロードしたJSONから、project_id
、 private_key
、client_email
をコピーして使います(PrivateKeyは取り扱い注意です!)。
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 build
→ yarn 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
を追加します。
{
"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
を以下のように変更します。
{
"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 接続処理を追加します。
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
も修正します。
<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.
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 で認証を回避するようにします。
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',
};
});
}
}
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 {}
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 で動作するようにできました。