NestJSのGraphQLサーバーにFirebase Authenticationを導入する
NestJSのGraphQLサーバーにFirebase Authenticationを導入したので、手順をまとめます。
Firebase Authenticationによる認証フロー
Firebase Authenticationはフロントエンドだけで認証を完了させる使い方ができるため、認証フローを比較的柔軟に組むことができます。本記事で想定している認証フローは以下になります。
- ユーザーがクレデンシャルを入力し、クライアントがログインリクエスト
- クライアントサイドFirebase SDKがFirebase Authentication Serverと通信して認証し、JWTを発行
- クライアントがJWTをAuthorizationヘッダーに含めてAPIリクエスト
- サーバーがFirebase Admin SDKを利用してJWTを検証
- サーバーは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を追加
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),
});
...
{
"compilerOptions": {
...
"resolveJsonModule": true
}
}
3. AuthModuleの実装
nest g module auth
cd auth
touch firebase-auth.guard.ts
touch 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をもたせたユーザーを保存しているので、ついでに引っ張ってきています。
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でこれらを束ねます。
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を実装します。
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に設定して、認証が必要なエンドポイントを保護します。
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は手軽に導入できる一方、適切に設計・実装を行わないとセキュリティホールになる可能性があります。以下の記事などを参考にしつつ、必要な対策を入れていこうと思います。
Discussion