GAミント至上主義

Web Monomaniacal Developer.

TypeORMのEntityでGetterを使うのをやめてAfterLoadでセットするようにした

TypeORMを使っているプロジェクトで、よくやるEntityのfirstNameとlastNameをくっつけてfullnameを返す方法として、getterを使って実装していました。

getterはTypeScriptというかJavaScriptの標準機能なのでまあ良いかと思っていましたが、 TypeScript: Documentation - Classes

↓この仕様があるので

プロパティが参照された時に関数が呼び出されるようにします ゲッター - JavaScript | MDN

EntityをそのままJSONレスポンスとして返すときには付いておらず、いちいちサーバー側で明示的に参照してセットする必要がありました。

擬似コードですがこんな感じ。特にUserが配列のとき、他のEntityに含まれている時などは特に面倒なことになってました。

before

user.entity.ts

@Entity()
export class User {
  @Column({ comment: '姓' })
  lastName: string

  @Column({ comment: '名' })
  firstName: string

  public get fullname(): string {
    return `${this.lastName} ${this.firstName}`
  }
}

コントローラーで使うとき

// fullnameは入ってない
const user = await userRepository.findOne({ id })
return { ...user, fullname: user.fullname }

試してはないけど、おそらくEntityのtoJSONなどを書き換えれば自動でつくようにもできそうだけどやりたくない。

そこでこの記事にもあるAfterLoadを使った形式にしてみたところ、今回の用途では問題なく使えそうでした。

javascript - How I can use getter and setter in TypeORm - Stack Overflow

typeorm/listeners-and-subscribers.md at master · typeorm/typeorm · GitHub

after

user.entity.ts

@Entity()
export class User {
  @Column({ comment: '姓' })
  lastName: string

  @Column({ comment: '名' })
  firstName: string

  fullname = ''

  @AfterLoad
  updateVirtualField {
    this.fullname = `${this.lastName} ${this.firstName}`
  }
}

コントローラーで使うとき

// fullname入ってる!
return await userRepository.findOne({ id })

DB側にカラムはいらないので@Columnはつけずにただのメンバー変数として定義しておきます。 そこにAfterLoadをつけたメソッドで必要な値をセットするだけ。

デメリットとして、名前の通りロード時にセットするだけなので、変更に対してリアルタイム性はないというのがあります。

が、表示以外の処理で使う予定もなさそうなので問題なさそう。

今のプロジェクトでは、姓名をくっつけたフルネームのほか、enum値の表示用ラベルなどをこの形式で追加するようにしています。

今回はSetterはまだ使ってないけど、同じように@BeforeInsertを使ってやれば問題なさそう

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