GAミント至上主義

Web Monomaniacal Developer.

NestJSのテストで Firebase AuthenticationのGoogle認証を使用する

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でログインしたことになっていることが確認できる

f:id:uyamazak:20211028123551p:plain

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認証を使ったアプリケーション開発ができそう。