NestJSでユーザー認証にFirebase Authentication + Google認証を使うこととなり、なんとかテスト環境を構築したのメモ。
認証はセキュリティ的にも怖いので、これできなかったらつらいなぁというレベルだったけどできてよかった。
Firebase Local Emulatorを使うことで、実在しないGoogleアカウントで認証したFirebaseユーザーをでっちあげることができ、エミュレータ内でもGoogle認証済みのアカウントとして認識されるので便利。
Firebase Local Emulator Suite の概要 | Firebase Documentation
前提とか
Firebaseのプロジェクトを作成済みで、上記Firebase Local Emulatorを起動していること。 関係しそうなpackage.json抜粋
"dependencies": { "@firebase/app": "^0.7.4", "@firebase/auth": "^0.18.3", "@nestjs/common": "^8.0.9", "@nestjs/config": "^1.0.1", "@nestjs/core": "^8.0.9", "@tfarras/nestjs-firebase-admin": "^2.0.1", "firebase-admin": "^10.0.0",
動くやつ
開発中のコードから必要なやつだけ抜き出したもの。
src/admin/google-auth.spec.ts
import { Test, TestingModule } from '@nestjs/testing' import { INestApplication } from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' import * as admin from 'firebase-admin' import { initializeApp } from '@firebase/app' import { getAuth, Auth, connectAuthEmulator, signInWithCredential, GoogleAuthProvider, } from '@firebase/auth' import { FirebaseAdminModule, FirebaseAdminSDK, FIREBASE_ADMIN_INJECT, } from '@tfarras/nestjs-firebase-admin' describe('Google Auth', () => { let app: INestApplication let firebaseAdmin: FirebaseAdminSDK let auth: Auth let idToken: string beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ FirebaseAdminModule.forRootAsync({ imports: [ConfigModule.forRoot()], useFactory: (config: ConfigService) => { return { credential: admin.credential.cert( config.get('GOOGLE_APPLICATION_CREDENTIALS'), ), } }, inject: [ConfigService], }), ], }).compile() app = module.createNestApplication() firebaseAdmin = module.get<FirebaseAdminSDK>(FIREBASE_ADMIN_INJECT) // FireaseAppが必要 const firebaseApp = initializeApp({ apiKey: process.env.FIREBASE_API_KEY, projectId: process.env.FIREBASE_PROJECT_ID, }) auth = getAuth(firebaseApp) // 明示的にエミュレータに接続しないとエラーになる connectAuthEmulator( auth, `http://${process.env.FIREBASE_AUTH_EMULATOR_HOST}`, ) // テスト前に全ユーザー削除する処理 const { users } = await firebaseAdmin.auth().listUsers() const userIds = users.map((u) => { return u.uid }) await firebaseAdmin.auth().deleteUsers(userIds) }) it('Googleアカウントでログインできる', async () => { const result = await signInWithCredential( auth, GoogleAuthProvider.credential( '{"sub": "abc1234", "email": "uyamzak@senior-job.co.jp", "email_verified": true}', ), ) idToken = await result.user.getIdToken() const verified = await firebaseAdmin.auth().verifyIdToken(idToken) console.log(verified) expect(verified.email).toBe('uyamzak@senior-job.co.jp') const { users } = await firebaseAdmin.auth().listUsers() expect(users[0].email).toBe('uyamzak@senior-job.co.jp') console.log(users[0]) expect(users.length).toBe(1) }) afterAll(async () => { await app.close() }) })
実行結果
$ yarn test ./src/admin/google-auth.spec.ts yarn run v1.22.10 $ jest ./src/admin/google-auth.spec.ts console.info WARNING: You are using the Auth Emulator, which is intended for local testing only. Do not use with production credentials. at emitEmulatorWarning (../node_modules/@firebase/auth/src/core/auth/emulator.ts:140:13) PASS src/admin/google-auth.spec.ts (6.709 s) AdminService ✓ Googleでログイン (42 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 6.861 s Ran all test suites matching /.\/src\/admin\/google-auth.spec.ts/i. console.log { email: 'uyamzak@senior-job.co.jp', email_verified: true, auth_time: 1635392247, user_id: 'yS6S2pHbRkcBzA80BAhSq3EB1ltw', firebase: { identities: { email: [Array], 'google.com': [Array] }, sign_in_provider: 'google.com' }, iat: 1635392247, exp: 1635395847, aud: 'your-project', iss: 'https://securetoken.google.com/your-project', sub: 'yS6S2pHbRkcBzA80BAhSq3EB1ltw', uid: 'yS6S2pHbRkcBzA80BAhSq3EB1ltw' } at Object.<anonymous> (admin/google-auth.spec.ts:72:13) console.log UserRecord { uid: 'yS6S2pHbRkcBzA80BAhSq3EB1ltw', email: 'uyamzak@senior-job.co.jp', emailVerified: true, displayName: undefined, photoURL: undefined, phoneNumber: undefined, disabled: false, metadata: UserMetadata { creationTime: 'Thu, 28 Oct 2021 03:37:27 GMT', lastSignInTime: 'Thu, 28 Oct 2021 03:37:27 GMT', lastRefreshTime: 'Thu, 28 Oct 2021 03:37:27 GMT' }, providerData: [ UserInfo { uid: 'abc1234', displayName: undefined, email: 'uyamzak@senior-job.co.jp', photoURL: undefined, providerId: 'google.com', phoneNumber: undefined } ], passwordHash: undefined, passwordSalt: undefined, tokensValidAfterTime: undefined, tenantId: undefined } at Object.<anonymous> (admin/google-auth.spec.ts:76:13) ✨ Done in 8.45s.
Local EmulatorのUIでも、Googleでログインしたことになっていることが確認できる
connectAuthEmulatorが必要なのがハマりどころ
ドキュメントにはFIREBASE_AUTH_EMULATOR_HOST
を指定すればいいような感じで、実際メールアドレス認証では問題なかったが、Google認証ではエミュレータに向いてないのが原因と思われるエラーがでた。
Firebase Local Emulator Suite の概要 | Firebase Documentation
connectAuthEmulatorのためにAuthが必要となり、
auth = getAuth(firebaseApp)
getAuthにはFirebaseAppが必要となるため、initializeAppをしている。admin.initializeAppとは別物。 他にも項目あるけどAuthだけなら最低限この2つがあれば良さそう。別途自分で環境変数設定が必要です。
const firebaseApp = initializeApp({ apiKey: process.env.FIREBASE_API_KEY, projectId: process.env.FIREBASE_PROJECT_ID, })
流れ
ここでデタラメでもなんでもいいメールアドレスなどの情報をGoogleAuthProvider.credentialに渡すと、エミュレータだけで有効なcredentialを取得できる。 subはGoogle側のIDとして使われる。 それをsignInWithCredentialに渡すと、ログイン or 新規登録されて、認証情報が返ってくる
const result = await signInWithCredential( auth, GoogleAuthProvider.credential( '{"sub": "abc1234", "email": "uyamzak@senior-job.co.jp", "email_verified": true}', ), )
result.userからidToken(いわゆるJWT文字列)を取得し
idToken = await result.user.getIdToken()
verifyIdToken()を使うと、JWTの中身をオブジェクトで取得できる
const verified = await firebaseAdmin.auth().verifyIdToken(idToken)
渡したメールアドレスの他に発行されたuidも付いてくるので、いろいろ使うことになる。 Google認証を使ったこともfirebase.sign_in_providerで確認できる。
{ email: 'uyamzak@senior-job.co.jp', email_verified: true, auth_time: 1635392247, user_id: 'yS6S2pHbRkcBzA80BAhSq3EB1ltw', firebase: { identities: { email: [Array], 'google.com': [Array] }, sign_in_provider: 'google.com' }, iat: 1635392247, exp: 1635395847, aud: 'your-project', iss: 'https://securetoken.google.com/your-project', sub: 'yS6S2pHbRkcBzA80BAhSq3EB1ltw', uid: 'yS6S2pHbRkcBzA80BAhSq3EB1ltw' }
Firebase Admin SDK側からそのユーザーを取得してみると
await firebaseAdmin.auth().listUsers()
こんな中身
UserRecord { uid: 'yS6S2pHbRkcBzA80BAhSq3EB1ltw', email: 'uyamzak@senior-job.co.jp', emailVerified: true, displayName: undefined, photoURL: undefined, phoneNumber: undefined, disabled: false, metadata: UserMetadata { creationTime: 'Thu, 28 Oct 2021 03:37:27 GMT', lastSignInTime: 'Thu, 28 Oct 2021 03:37:27 GMT', lastRefreshTime: 'Thu, 28 Oct 2021 03:37:27 GMT' }, providerData: [ UserInfo { uid: 'abc1234', displayName: undefined, email: 'uyamzak@senior-job.co.jp', photoURL: undefined, providerId: 'google.com', phoneNumber: undefined } ], passwordHash: undefined, passwordSalt: undefined, tokensValidAfterTime: undefined, tenantId: undefined }
ここまでできたのでテスト付きで快適なNestJS + Firebase Auth + Google認証を使ったアプリケーション開発ができそう。