🛡️

NestJSのGraphQLサーバーにFirebase Authenticationを導入する

2024/07/07に公開

NestJSのGraphQLサーバーにFirebase Authenticationを導入したので、手順をまとめます。

Firebase Authenticationによる認証フロー

Firebase Authenticationはフロントエンドだけで認証を完了させる使い方ができるため、認証フローを比較的柔軟に組むことができます。本記事で想定している認証フローは以下になります。

  1. ユーザーがクレデンシャルを入力し、クライアントがログインリクエスト
  2. クライアントサイドFirebase SDKがFirebase Authentication Serverと通信して認証し、JWTを発行
  3. クライアントがJWTをAuthorizationヘッダーに含めてAPIリクエスト
  4. サーバーがFirebase Admin SDKを利用してJWTを検証
  5. サーバーはJWTが有効な場合にリクエストを処理しレスポンス

NestJSにおける認証の実装方法

上記フローの通り、APIサーバーでは自前でJWTの発行や検証を行わず、Firebase Admin SDKにそれらを委譲します。この場合、APIサーバーで認証に関してやることはあまり多くなく、以下の3点くらいです。

  • 適切なタイミングでFirebase Admin SDKを呼び出す
  • (必要があれば)認証されたユーザー情報に紐づくユーザーデータをDBから取得
  • (必要があれば)ユーザー情報やユーザーデータに対し、追加のバリデーションを実施(例: カスタムクレームや特定のRoleを持っていること)

そのため、NestJSにおける実装もいくつか方法があるように思います。

PassportとAuthGuard

Node.jsの認証ライブラリとして一般的なPassportを使い、Strategyの実装でFirebase Admin SDKを噛ませた認証を行います。

MiddlewareとContext

NestJSのMiddlewareを実装し、Firebase Admin SDKで検証したユーザー情報をRequestオブジェクトに追加します。後続処理ではGraphQL Contextからユーザー情報を取得して利用します。

この他にも、GraphQL ContextやカスタムGuardで認証を行うといった方法も考えられます。

Firebase Authenticationに引っ張られた実装にしたくないという思いから、本記事ではNestJSの公式ドキュメントに記載のあるPassportでの実装方法をFirebase Authentication向けに簡略化して実装していきます。

Firebase Authentication with Passport

Firebase Authenticationのセットアップが終わっていることを前提として書いていきます。

1. ライブラリのインストール

まず必要なライブラリをインストールします。

npm install @nestjs/passport passport passport-http-bearer firebase-admin
npm install -D @types/passport-http-bearer

passportはPassportのライブラリ、@nestjs/passportはPassportの認証パターンをNestJSの構成パターンで利用するラッパーです。

PassportはStrategyといって認証の実装パターンを抽象化しており、今回はpassport-http-bearerを利用します。passport-jwtでも良さそうですが、上述のとおりAPIサーバーではJWTを直接扱わないので、シンプルなhttp-bearerを選びました。

2. Firebaseのセットアップ

Firebase Admin SDKを利用するには、initializeApp関数を呼びます。GCP環境(Firebase環境含む)であれば引数なしでいい感じにしてくれるんですが、それ以外の場合はクレデンシャルを渡す必要があります。以下のような手順で行いました。

  • Firebaseコンソールからサービスアカウントの秘密鍵を生成し、取得したJSONファイルをルートに配置
  • .gitignoreに該当JSONを追加
main.ts
import admin from "firebase-admin";
import * as serviceAccount from "<pass to your credential json>";

async function bootstrap() {
...
  admin.initializeApp({
    credential: admin.credential.cert(serviceAccount as admin.ServiceAccount),
  });
...
tsconfig.json
{
  "compilerOptions": {
  ...
  "resolveJsonModule": true
  }
}

3. AuthModuleの実装

nest g module auth
cd auth
touch firebase-auth.guard.ts
touch firebase-auth.strategy.ts
auth/firebase-auth.strategy.ts
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import * as admin from "firebase-admin";
import { Strategy } from "passport-http-bearer";
import { PrismaService } from "src/prisma.service";

@Injectable()
export class FirebaseAuthStrategy extends PassportStrategy(
  Strategy,
  "firebase-auth"
) {
  constructor(private readonly prismaService: PrismaService) {
    super();
  }

  async validate(token: string) {
    try {
      const decodedToken = await admin.auth().verifyIdToken(token);
      const user = await this.prismaService.user.findUnique({
        where: { firebaseUid: decodedToken.uid },
      });
      if (user) {
        return user;
      }
      throw new UnauthorizedException("No user found for this Firebase token");
    } catch (error) {
      throw new UnauthorizedException("Invalid Firebase token");
    }
  }
}

まずStrategyです。extends PassportStrategy(Strategy, "firebase-auth"とあるように、passport-http-bearerをベースにfirebase-authという名前でStrategyを作成します。

validateを実装すればいいので、このなかでadmin.auth().verifyIdToken(token);を呼びます。passport-http-bearerがAuthrizationヘッダーからBearerトークンを取得して引数にstring型で渡してくれます。

私はDBにfirebaseUidをもたせたユーザーを保存しているので、ついでに引っ張ってきています。

auth/firebase-auth.guard.ts
import { ExecutionContext, Injectable } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class FirebaseAuthGuard extends AuthGuard("firebase-auth") {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }
}

次にAuthGuardです。extends AuthGuard("firebase-auth")とすることで、先程のStrategyに対応するAuthGuardであることを明示します。GraphQLContextからreqをとって返すだけのお決まりの実装です。

最後に、AuthModuleでこれらを束ねます。

auth/auth.module.ts
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
import { PrismaService } from "src/prisma.service";
import { FirebaseAuthGuard } from "./firebase-auth.guard";
import { FirebaseAuthStrategy } from "./firebase-auth.strategy";

@Module({
  imports: [PassportModule.register({ defaultStrategy: "firebase-auth" })],
  providers: [FirebaseAuthStrategy, FirebaseAuthGuard, PrismaService],
  exports: [FirebaseAuthGuard],
})
export class AuthModule {}

4. Decoratorの実装

Strategyの実装で取得したユーザー情報に簡単にアクセスできるようにDecoratorを実装します。

current-user.decorator.ts
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";

export const CurrentUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req.user;
  }
);

5. Resolverへの適用

最後に、作ったものをResolverに設定して、認証が必要なエンドポイントを保護します。

home/home.resolver.ts
import { FirebaseAuthGuard } from "src/auth/firebase-auth.guard";
import { CurrentUser } from "src/common/current-user.decorator";
...

@Resolver(() => HomeData)
@UseGuards(FirebaseAuthGuard)
export class HomeResolver {
  ...
  @Query(() => HomeData)
  async getHomeData(
    @CurrentUser() user: User,
    @Args("input") input: GetHomeInput
  ) {
    console.log("user", user);
    return this.homeService.getHomeData(user.id, input.groupId);
  }
...

以上です。1点気になっているのは、Stategyのvalidate関数でDBアクセスしているので、パフォーマンスに影響がある可能性があります。

気になるようであればキャッシュを入れるか、userIdだけが必要なら、ユーザー登録時にFirebase AuthenticationのカスタムクレームとしてuserIdを保持しても良いのかもしれません。

また、Firebase Authenticationは手軽に導入できる一方、適切に設計・実装を行わないとセキュリティホールになる可能性があります。以下の記事などを参考にしつつ、必要な対策を入れていこうと思います。

https://blog.flatt.tech/entry/firebase_authentication_security
https://firebase.google.com/support/guides/security-checklist

Discussion