GAミント至上主義

Web Monomaniacal Developer.

このブログについて(先頭固定)

【お約束】 投稿内容は個人の見解であり、所属する(していた)組織の公式見解ではありません。

名前:uyamazak(昔いた会社で上司が開発用Linuxサーバーのユーザー名に「yuyamazaki」が長いので勝手に作ってくれた。読み方わからないけどウヤマザク)

高校あたりからWEBサイトをやったり趣味の延長でWEB系を仕事にした感じの人間。

2020年1月から株式会社シニアジョブにジョイン。
2021/1現在、まだ開発者は3人、これからの会社なのでビジネス貢献しつつ、きれいな設計をしたい

アズールレーン@竹敷でモバイルのUI、UX研究中(初嫁ジャベリン)
フレンド&大艦隊メンバー募集してます ID:939524678 @竹敷



GitHub: https://github.com/uyamazak/

これまでの主なプロジェクト

続きを読む

axiosでinterceptorsを使ってリクエスト直前にヘッダーをセットする

Firebase AuthのidToken(JWT)を、サーバー側(NestJS)へのリクエストで使う場面で、 サーバーへのリクエスト時にAuthorizationヘッダーにaccess_tokenを付与する必要があった。

FirebaseのユーザーはNuxt3のuseStateで入れてuseFirebaseUserで使っているけど今回は省略。

最初はAPIリクエスト用のaxiosインスタンスを作って、baseURLと一緒にAuthorizationヘッダーもセットしていて、最初は動きはする。

before 期限切れになる

const makeIntance = async () => {
    const instance = axios.create({
      baseURL
    });
    const user = useFirebaseUser()
    if (user.value) {
      const token = await user.value.getIdToken()
      instance.defaults.headers.common['Authorization'] = `bearer ${token}`
    }
    return instance
  }

でも、トークンの有効期限は1時間程度なので、ページを1時間以上開きっぱなしの状態でリクエストすると401エラーになってしまう。 postとかgetとかリクエストの直前にセットすることできないかなぁと探したら、まさにそれ用のInterceptorsという機能があった。 リクエスト、レスポンス、どちらにも介入することができる。

axiosのinterceptorsで、リクエストの前処理を共通して行う - Qiita

axios-http.com

今回はリクエストの前にgetIdToken()を実行してセットするようにした。 getIdToken()はPromiseを返すので動くかな?と思ったけど問題なかった。

after

const makeIntance = async () => {
    const instance = axios.create({
      baseURL
    });
    const user = useFirebaseUser()
    if (user.value) {
      instance.interceptors.request.use(async (request) => {
        const token = await user.value.getIdToken()
        request.headers.common['Authorization'] = `bearer ${token}`
        return request
      })
    }
    return instance
  }

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 + コンテナレジストリのテンプレみたいのできたので活用していきたい。