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

Firebase AuthエミュレータをCircleCIで動かすためにDockerイメージを作って公開した

NestJSを使ったプロジェクトで、認証だけFirebase Authenticationを使う。 そのためローカルのエミュレータ環境とそれを使った簡単なテストを作った。

ローカルで動かすとUIはこんなので超便利↓ CIではもちろんいらないので無効化してる f:id:uyamazak:20211025192620p:plain

でもCircleCI上でそのテストを実行させようと思ったら、ちょうどいいDockerイメージが見つからなかったので作った。

github.com

Authしか動かないけどfirebase-toolsとOpen JDKがでかすぎるのでイメージサイズはでかい。

Introduction to Firebase Local Emulator Suite  |  Firebase Documentation

シンプルなのでDockerイメージはすぐに作れたけど、CircleCIから呼び出すためにDockerレポジトリが必要。

認証も面倒なのでPublicで、更にできれば無料いい。

最初はDocker Hubを使ったけど懐かしのダイヤルアップ接続並みにアップロードが遅く断念。

GitHubのPublicレポジトリならコンテナレジストリも無料で使えそう?なので、ひとまず個人レポジトリでGithub Actionsを使ってアップロードまでしてみた。

コンテナレジストリの利用 - GitHub Docs

使い方

Firebaseのプロジェクト名をcommandで渡すだけ デフォルトポート9099で起動します

.circleci/config.yml

version: 2.1

jobs:
  test-server:
    resource_class: medium
    docker:
      - image: ghcr.io/uyamazak/firebase-auth-emulator:latest
        command: --project your-project-name

CircleCIでfirebase emulators:exec {テストコマンド} でテストを実行するって方法もあったけど、エミュレータに必要なfirebase-toolsでかすぎるし、こっちの方がいろいろ分離されて良さそう。

Run functions locally  |  Firebase Documentation

ひとまずローカルで動いてたテストが通ったのでここまで。 GitHub Actions + コンテナレジストリのテンプレみたいのできたので活用していきたい。

NestJSのTest.createTestingModuleの基本的な使い方。ServiceのMock化とかforRoot系の指定とか

NestJSの新規開発で、テストを有効活用しながら開発を進めてるけどTest.createTestingModuleでいろいろハマったのでポイントをメモ。 使い慣れてくると超便利。

今作ってるe2eテストの場合、createTestingModuleはこんな感じ。コメントでメモ。 beforeAll, もしくはbeforeEachでテスト用のNestアプリケーションをつくってそれぞれのケースで使うことになる。

import { Repository } from 'typeorm'
import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication, ValidationPipe } from '@nestjs/common'
import { getRepositoryToken } from '@nestjs/typeorm'
import { SendGridService } from '@anchan828/nest-sendgrid'
// 自分で作ったモック
import { SendGridServiceMock } from './test.lib'

describe('User Authorization (e2e)', () => {
  // 複数のメソッド内で使うやつはここらへんでletしておく
  let app: INestApplication
  let token: string
  let userRepository: Repository<User>
  let usersService: UsersService
  let userEmailVerificationRepository: Repository<UserEmailVerification>

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [],
      imports: [
        UsersModule,
        // テストではこのモジュールがRootになるのでHogeModule.forRoot系はだいたい必要になる
        // 接続名にtestのものを使用するようにしてる、いろんなテストで使うので実際は共通化してimportして使ってる。
        TypeOrmModule.forRootAsync({
          useFactory: async () => {
            return {
              ...(await getConnectionOptions('test')),
              // nameが残ってるとtestConnectionを生成してしまってエラーになるので消してる
              name: null,
            }
          },
        })
        // Entityに紐付いたRepositoryを書き出してくれる。Moduleでやってるときは不要。カスタムレポジトリの場合も別
        TypeOrmModule.forFeature([User, UserProfile, /* 省略*/]),
      ],
    })
    // メール送信に@anchan828/nest-sendgridを使用したけどテスト中は送られると困るのでMockに差し替える 
      .overrideProvider(SendGridService)
      .useClass(SendGridServiceMock)
      .compile()
    app = module.createNestApplication()
    // テスト中に使いたいサービス、レポジトリなどはこの形で抜き出せる
    userEmailVerificationRepository = app.get<
      Repository<UserEmailVerification>
    >(getRepositoryToken(UserEmailVerification))
    userRepository = app.get<Repository<User>>(getRepositoryToken(User))
    usersService = app.get<UsersService>(UsersService)
    // モックのメソッドが呼ばれたか判定したいのでspyを仕込んでおく
 // expect(sendGridService.send).toHaveBeenCalled() のような感じでチェックできる
    sendGridService = app.get<SendGridService>(SendGridService)
    jest.spyOn(sendGridService, 'send')
    // バリデーションなどのPipeも設定が必要
    app.useGlobalPipes(
      new ValidationPipe({
        transform: true,
      }),
    )

imports

そのテストで使うModuleを指定する。自分で作成したModuleの他、 あとテスト中はこのモジュールがRootになるのでTypeOrmModule.forRootAsync()(もしくはforRoot())や、 よく使うConfigModule、EventEmitterModuleなどもそれぞれforRoot()があるので必要な場合は追加する。 テストファイルが増えてくるとだいたい同じのを何度も書くことになるのでサービス単位などで共通化しておくと良さそう。

providers

サービスなどの@Injectable()なClassを指定するけど、基本的に上記のModuleで指定しているものが多いと思うので、サービスの単体テスト等でなければ指定する機会がないかも。 importのModule内と、ここで2重で読み込んだりするとなかなか分かりにくいエラーが出たりするので注意。

サービスなどのMock化

この箇所で行っている。

      .overrideProvider(SendGridService)
      .useClass(SendGridServiceMock)

元のサービスの型をみて今回はこんな感じのにした。送らないけど中身もちょっと目視したいので一旦console.logで出してる。

export class SendGridServiceMock {
  send(data: Partial<MailDataRequired>) {
    console.log(
      `SendGridServiceMock.send() from:${data.from} to:${data.to} subject:${data.subject}`,
    )
  }
  sendMultiple(data: Partial<MailDataRequired>) {
    console.log(
      `SendGridServiceMock.sendMultiple() from:${data.from} to:${data.to} subject:${data.subject}`,
    )
  }
}