shwld.io

NestJSでGraphQL利用時にFirebase認証結果をデコレーター@CurrentUserで取得できるようにする


タイトルのとおりですが、Next.jsのGraphQL利用時に、Firebase Authenticationの idToken を使って認証する実装方法について考えました。

前提

以下のようなデータベーステーブルを定義しています。

// Firebaseアカウントと同期されるテーブル
model Account {
  id              String  @default(uuid()) @id
  firebaseAuthUid String  @unique // Firebase Authenticationのuidをセットする
  email           String?  @unique
  user            User?

  @@index([firebaseAuthUid])
}

model User {
  id        String  @default(uuid()) @id
  name      String
  tasks     Task[]
  account   Account @relation(fields: [accountId], references: [id])
  accountId String @unique
}

  1. まずはfirebase-adminを初期化

bootstrap時に初期化を行っておきます。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { initializeFirebaseAdmin } from './_initializers/initialize-firebase-admin';
import { LoggerService } from './common/logger/logger.service';
import { ServiceAccount, getApps, initializeApp } from 'firebase-admin/app';
import { credential } from 'firebase-admin';

function initializeFirebaseAdmin() {
  const cert: ServiceAccount = {
    projectId: process.env.FIREBASE_PROJECT_ID,
    clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
    privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
  };
  if (getApps().length === 0) {
    initializeApp({
      credential: credential.cert(cert),
    });
  }
}

async function bootstrap() {
  initializeFirebaseAdmin();
  const app = await NestFactory.create(AppModule, {
    logger: new LoggerService(),
  });
  await app.listen(3002);
}
bootstrap();

  1. 次にFirebase Authenticationで取得できるidTokenをAuthorizationヘッダから受け取りdecodeするためのMiddlewareを書きます。

MiddlewareはNestJSがルーティングを行う前に処理を挿入でき、Requestを拡張することができます。

これを使って、RequestからAuthorization headerを読み取り、idTokenを取得してDBからユーザーレコードを引っ張り、Requestに詰めてあげます。

import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Response } from 'express';
import { getAuth } from 'firebase-admin/auth';
import { RequestWithCurrentUser } from '../interfaces/request-with-current-user';
import { AccountRepository } from '../repositories/account.repository';

@Injectable()
export class FirebaseAuthMiddleware implements NestMiddleware {
  constructor(private readonly accountRepository: AccountRepository) {}

  async use(req: RequestWithCurrentUser, _: Response, next: NextFunction) {
    const { authorization } = req.headers;
    req.currentFirebaseUser = null;
    if (authorization) {
      const [scheme, token] = req.headers.authorization?.split(' ') ?? [];
      if (scheme === 'Bearer') {
        const decodedIdToken = await getAuth().verifyIdToken(token);
        const currentFirebaseUser = {
          uid: decodedIdToken.uid,
          email: decodedIdToken.email ?? null,
        };
        const account =
          await this.accountRepository.findOrCreateByFirebaseAuthUid(
            currentFirebaseUser,
          );
        req.currentFirebaseUser = currentFirebaseUser;
        req.currentAccount = account;
        req.currentUser = account.user ?? null;
      }
    }
    next();
  }
}

  1. Middlewareを登録する

FirebaseAuthMiddlewareを利用するためにAppModuleに設定を書きます。

ルーティング毎に設定できますが、今回は /graphql のみ反映できれば良いですが、他にルートもないのでforRoutes('*')に設定しています。

// ...略
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(FirebaseAuthMiddleware).forRoutes('*');
  }
}

  1. 現在のユーザーを取得するデコレータを作成する

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { RequestWithCurrentUser } from '../interfaces/request-with-current-user';
import { User } from '~/user/entities/user.entity';

export type CurrentUser = User | null;

export const CurrentUser = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): CurrentUser => {
    const gqlContext = GqlExecutionContext.create(ctx);
    const req: RequestWithCurrentUser = gqlContext.getContext().req;
    return req.currentUser;
  },
);

  1. 作ったデコレータを利用して、現在のユーザーを取得するには以下のようにします。

import { CurrentUser } from '~/auth/current-user.decorator';

@ResolveField(() => [Task], { nullable: false })
async tasks(@CurrentUser() user: CurrentUser) {
  if (user == null) return [];

  const tasks = await this.taskRepository.findMany({
    ownerId: user.id,
  });

  return tasks;
}

これでResolveFieldやMutationなどで、CurrentUserを手軽に扱えるようになります

今回載せたコードを含むサンプルはこちらにあります。

https://github.com/shwld/nest-js-todo-example

NestJSのGraphQLサーバにApplication Insightsのトレースを仕込む
shwld
2024/02/19
GitHub Copilotを効率よく使うために
shwld
2024/01/29
2024年。今年の抱負
shwld
2024/01/18
DevOps活動の半年間の振り返り:2つのプロジェクトの経験と教訓
shwld
2023/12/07