🔥

NestJS + GraphQL APIにFirebase Authenticationを実装してみた - JWT認証との比較も含めて

に公開

本記事のサマリ

以前の記事でNestJS + GraphQLアプリケーションにJWT認証を実装しましたが、今回はFirebase Authenticationを使って同じことをやってみました。結論から言うと、実装の手軽さとセキュリティの安心感がまるで違います。自前でJWTトークンの発行・検証・リフレッシュを管理していた頃と比べて、認証周りのコードがかなりシンプルになりました。

今回のコードはこちらです。
https://github.com/toto-inu/lab-202511-cloudrun-terraform/tree/fire-auth

はじめに - なぜFirebase Authenticationを選んだのか

前回の記事では、NestJSアプリケーションにJWT認証を一から実装しました。

https://zenn.dev/stellarcreate/articles/nestjs-graphql-nextjs-jwt-auth-part1

あの時は「認証の仕組みを理解するために自分で作ってみよう」という気持ちで取り組みましたが、実際にプロダクションで使うことを考えると、いくつか気になる点がありました。トークンのリフレッシュ処理、パスワードのハッシュ化など、認証周りは考慮すべきことが本当に多いんです。

そこで今回は、同じTodoアプリケーションにFirebase Authenticationを実装してみることにしました。Googleが提供する認証サービスなら、セキュリティ面の心配も少なく、実装工数も大幅に削減できるはずです。

構成は前回と同様、NestJS + GraphQLのバックエンドに、React + Viteのフロントエンドです。ただし認証部分だけをFirebaseに置き換えています。

JWT認証実装との比較

実際に両方実装してみると、違いは歴然でした。

実装の複雑さ

自前JWT実装では、以下のような処理を全て自分で書く必要がありました:

  • ユーザー登録・ログイン API
  • JWTトークンの生成・検証
  • リフレッシュトークンの管理
  • パスワードのハッシュ化・検証
  • トークンの有効期限チェック

一方、Firebase Authenticationでは:

  • Google Sign-inのボタンを配置するだけでログイン完了
  • トークン検証は firebaseAdmin.auth().verifyIdToken() の一行
  • リフレッシュは自動で処理される

コード量で言うと、3分の1程度になったという印象です。

セキュリティの考慮事項

自前実装だと「本当に大丈夫かな?」と不安になる場面が多々ありました。JWTの署名アルゴリズムの選択、トークンの保存場所、CSRF攻撃への対策など、一つ間違えると大きなセキュリティホールになりかねません。

Firebase Authenticationなら、こういった心配はGoogleに任せられます。二要素認証やアカウント乗っ取り対策なども、設定一つで有効化できるのは心強いですね。

運用コスト

自前実装では、パスワードリセット機能やアカウント確認メールの送信など、認証周りの運用機能も全て自分で作る必要がありました。Firebase Authenticationなら、これらの機能が最初から用意されています。

月間アクティブユーザー50,000人まで無料というのも、小規模なサービスには十分すぎる条件です!

Firebase Authenticationの基本

Firebase Authenticationは、Googleが提供する認証サービスです。今回はGoogle Sign-inを実装しましたが、メールアドレス・パスワード認証、Twitter、GitHub、Appleなど、様々な認証プロバイダーに対応しています。

https://firebase.google.com/docs/auth

基本的な流れは以下の通りです:

  1. フロントエンドでFirebase Client SDKを使ってログイン
  2. ログイン成功時にID Token(JWT)を取得
  3. GraphQLリクエストのAuthorizationヘッダーにトークンを付与
  4. バックエンドでFirebase Admin SDKを使ってトークンを検証

この仕組みなら、トークンの生成・検証はFirebaseが担当し、アプリケーションは検証済みのユーザー情報を受け取るだけで済みます。

バックエンド実装(NestJS)

まずはバックエンドから実装していきます。

パッケージのインストール

cd app
yarn add firebase-admin

FirebaseServiceの実装

Firebase Admin SDKを初期化し、トークン検証を行うサービスです。

// src/auth/firebase.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import * as admin from 'firebase-admin';

@Injectable()
export class FirebaseService implements OnModuleInit {
  private app: admin.app.App;

  onModuleInit() {
    const projectId = process.env.FIREBASE_PROJECT_ID;

    if (!projectId) {
      throw new Error('FIREBASE_PROJECT_ID environment variable is not set');
    }

    // Application Default Credentials (ADC) を使用
    this.app = admin.initializeApp({
      projectId: projectId,
    });

    console.log('Firebase Admin SDK initialized successfully with ADC');
  }

  async verifyIdToken(token: string): Promise<admin.auth.DecodedIdToken> {
    try {
      return await this.app.auth().verifyIdToken(token);
    } catch (error) {
      throw new Error(`Invalid Firebase ID token: ${error.message}`);
    }
  }
}

ここでポイントなのは、Application Default Credentials(ADC)を使っていることです。サービスアカウントキーのJSONファイルを直接コードに含める必要がなく、Cloud Runでは自動的に適切な認証情報が使われます。ローカル開発では gcloud auth application-default login で設定した認証情報を使用します。

FirebaseAuthGuardの実装

GraphQLリクエストの認証チェックを行うガードです。

// src/auth/guards/firebase-auth.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { FirebaseService } from '../firebase.service';

@Injectable()
export class FirebaseAuthGuard implements CanActivate {
  constructor(private readonly firebaseService: FirebaseService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const gqlContext = GqlExecutionContext.create(context);
    const { req } = gqlContext.getContext();

    const authHeader = req.headers.authorization;
    if (!authHeader) {
      throw new UnauthorizedException('Authorization header is missing');
    }

    const [bearer, token] = authHeader.split(' ');
    if (bearer !== 'Bearer' || !token) {
      throw new UnauthorizedException('Invalid authorization header format');
    }

    try {
      const decodedToken = await this.firebaseService.verifyIdToken(token);
      req.user = decodedToken;
      return true;
    } catch (error) {
      throw new UnauthorizedException(
        `Authentication failed: ${error.message}`,
      );
    }
  }
}

自前JWT実装と比べると、トークン検証の部分がとてもシンプルになりました。verifyIdToken の一行で済むのは本当に楽です。

CurrentUser Decoratorの実装

認証済みユーザー情報を簡単に取得できるカスタムデコレーターです。

// src/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import * as admin from 'firebase-admin';

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

TodoResolverへの適用

認証が必要なMutationに @UseGuards(FirebaseAuthGuard) を適用します。

// src/todo/todo.resolver.ts
@Resolver(() => Todo)
export class TodoResolver {
  constructor(private readonly todoService: TodoService) {}

  // 公開Query: 認証不要
  @Query(() => [Todo], { name: 'todos' })
  findAll() {
    return this.todoService.findAll();
  }

  // 認証必須Mutation
  @Mutation(() => Todo)
  @UseGuards(FirebaseAuthGuard)
  createTodo(
    @Args('input') createTodoInput: CreateTodoInput,
    @CurrentUser() user: admin.auth.DecodedIdToken,
  ) {
    console.log('Authenticated user:', user.uid, user.email);
    return this.todoService.create(createTodoInput);
  }
}

@CurrentUser() デコレーターで、認証済みユーザーの情報(uid、email、nameなど)を簡単に取得できます。これは前回の自前実装よりもかなり使いやすくなりました。

フロントエンド実装(React + Vite)

次にフロントエンドです。

パッケージのインストール

cd frontend
yarn add firebase @apollo/client graphql

Firebase Client SDKのセットアップ

// src/firebase.ts
import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();

Apollo Clientの認証ヘッダー自動付与

これが特に気に入っている部分です。Apollo Clientのリンク機能を使って、自動的にAuthorizationヘッダーを付与する仕組みです。

// src/apollo.ts
import { ApolloClient, InMemoryCache, HttpLink, ApolloLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { auth } from './firebase';

const httpLink = new HttpLink({
  uri: import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:3000/graphql',
});

const authLink = setContext(async (_, { headers }) => {
  const user = auth.currentUser;
  let token = '';

  if (user) {
    try {
      token = await user.getIdToken();
    } catch (error) {
      console.error('Error getting ID token:', error);
    }
  }

  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

export const apolloClient = new ApolloClient({
  link: ApolloLink.from([authLink, httpLink]),
  cache: new InMemoryCache(),
});

この設定により、全てのGraphQLリクエストに自動的にトークンが付与されます。ログインしていない場合は空文字列になるので、Queryは正常に動作し、Mutationは401エラーになるという想定通りの動作になります。

Auth Componentの実装

Google Sign-inとログアウトを行うUIコンポーネントです。

// src/components/Auth.tsx
import { signInWithPopup, signOut, type User } from 'firebase/auth';
import { auth, googleProvider } from '../firebase';
import { useState, useEffect } from 'react';

interface AuthProps {
  onAuthChange: (user: User | null) => void;
}

export function Auth({ onAuthChange }: AuthProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const unsubscribe = auth.onAuthStateChanged((user) => {
      setUser(user);
      setLoading(false);
      onAuthChange(user);
    });

    return () => unsubscribe();
  }, [onAuthChange]);

  const handleLogin = async () => {
    try {
      await signInWithPopup(auth, googleProvider);
    } catch (error) {
      console.error('Login error:', error);
    }
  };

  const handleLogout = async () => {
    try {
      await signOut(auth);
    } catch (error) {
      console.error('Logout error:', error);
    }
  };

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      {user ? (
        <div>
          <img src={user.photoURL || ''} alt="User avatar" />
          <span>{user.displayName}</span>
          <button onClick={handleLogout}>Logout</button>
        </div>
      ) : (
        <button onClick={handleLogin}>Sign in with Google</button>
      )}
    </div>
  );
}

自前実装の時は、ユーザー登録フォーム、ログインフォーム、パスワード忘れた時の処理など、結構な量のUIコンポーネントが必要でした。Firebase Authenticationなら、ボタン一つでGoogle Sign-inが完了するのは本当に楽ですね!

ローカル開発環境のセットアップ

環境変数の設定

バックエンド用(.env):

FIREBASE_PROJECT_ID=your-firebase-project-id

フロントエンド用(frontend/.env):

VITE_FIREBASE_API_KEY=AIza...
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id
VITE_FIREBASE_STORAGE_BUCKET=your-project.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789
VITE_FIREBASE_APP_ID=1:123456789:web:abc123
VITE_GRAPHQL_ENDPOINT=http://localhost:3000/graphql

Firebase Consoleのプロジェクト設定画面から、これらの値を取得できます。

Google Cloud認証の設定

Application Default Credentialsを設定します。

gcloud auth application-default login
gcloud config set project your-firebase-project-id

これで、ローカル開発時もFirebase Admin SDKが適切に動作します。

Docker Compose設定

version: '3.8'
services:
  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    volumes:
      - ${HOME}/.config/gcloud/application_default_credentials.json:/tmp/application_default_credentials.json:ro
    environment:
      - FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID:-demo-project}
      - GOOGLE_APPLICATION_CREDENTIALS=/tmp/application_default_credentials.json

ADCファイルをマウントすることで、コンテナ内でも認証が正常に動作します。

動作確認

実際に動作確認してみると、想定通りに動きました👍

未認証時

  • Todo一覧は表示される(Queryは認証不要)
  • 「Sign in with Google」ボタンが表示される
  • Todoの作成・編集ボタンは表示されない

Google Sign-in後

  • ユーザー名とアバターが表示される
  • Todoの作成・編集・削除が可能になる
  • GraphQLリクエストにBearerトークンが自動付与される

ブラウザの開発者ツールでNetworkタブを見ると、Authorization: Bearer <token>ヘッダーが正しく付与されていることが確認できます。

トラブルシューティング

実装中に遭遇したいくつかの問題と解決方法をまとめておきます。

CORSエラー

フロントエンドからのリクエストが拒否される場合は、NestJSのmain.tsでCORSを有効化する必要があります。

app.enableCors({
  origin: true,
  credentials: true,
});

Firebase認証エラー

auth/unauthorized-domain エラーが出る場合は、Firebase Consoleの「Authentication > Settings > Authorized domains」に localhost を追加してください。

環境変数が読み込まれない

NestJSで環境変数が読み込まれない場合は、@nestjs/config パッケージがインストールされていることと、AppModuleConfigModule.forRoot() が呼ばれていることを確認してください。

まとめ - 実際に使ってみての感想

Firebase Authenticationを使った認証実装は、想像以上に簡単でした。自前JWT実装と比べて、コード量は大幅に減り、セキュリティ面の不安もなくなりました。

特に良かった点:

  • 実装が圧倒的にシンプル
  • Googleが提供するセキュリティの安心感
  • トークンのリフレッシュが自動
  • 二要素認証などの高度な機能も簡単に追加可能

気になる点:

  • Firebase依存になるため、将来的な移行が難しくなる可能性
  • 細かい認証ロジックのカスタマイズには限界がある

とはいえ、多くのWebアプリケーションにとって、Firebase Authenticationは十分すぎる機能を提供してくれます。「認証は本質的な機能ではないので、できるだけ簡単に済ませたい」という場合には、まさにピッタリの選択肢だと感じました✨

次回は、このアプリケーションをCloud Runにデプロイする過程を書いてみようと思います。Terraformを使ったインフラ管理も含めて、実際のプロダクション環境での運用を想定した内容にしたいと思っています。

株式会社StellarCreate | Tech blog📚

Discussion