Apollo Graphql で Firebase authentication を使った認証つきリクエスト

apollographqlfirebase-authentication

最近、Graphql を使い始めた。まだ手探り状態で理解は浅いが、「Apollo Graphql で認証つきリクエスト」を実装したので、まとめておく。

構成

フロントエンドは React (Next.js) を利用。認証は Firebase authentication を利用。Graphql ライブラリとして、Apollo を採用。

実装内容

1. ApolloProvider

まず、_app.tsxApolloProvider を設置。

pages/_app.tsx
import { ApolloProvider } from "@apollo/client";
import { apolloClient } from "@src/graphqlClient/apollo";
import "@src/styles/globals.scss";
import type { AppProps } from "next/app";

function MyApp({ Component, pageProps, router }: AppProps) {
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

export default MyApp;

client

ApolloProvider に渡す client は別ファイルで管理している(記載量がそこそこあるので)。

設定ファイル内の実装方法について、まずは下記の流れで試してみた。

  1. getIdToken で、idToken を取得する
  2. idToken をヘッダーにセットする。

これだけだと、初期ロード時・リロード時は auth.currentUser が null となって、idToken が取得されず、usersQuery 的な認証リクエストを叩いた時にデータが取得できない問題にぶち当たった。

そこで、ログイン時に localStorage に idToken をセットすることにして、「idToken を取得できなかった場合に、localStorage に存在する idToken を利用する」形に変更した。

これで、リロードをしても問題なくデータを取得できるようになった。

src/graphqlClient/apollo.ts
import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { config } from "@site.config";
import { getFirebase } from "@src/lib/firebase/client";

const httpLink = createHttpLink({
  uri: `${config.siteRoot}/api/graphql`,
});

const authLink = setContext(async (_, { headers }) => {
  let idToken: string | null = null;

  // 👇 localStorage から idToken を取得(一度ログイン済みであれば、セットされている)
  const localIdToken = localStorage.getItem("idToken");

  const { auth, getIdToken } = await getFirebase(); // 👈 firebase を動的 import しているだけ

  if (auth && auth?.currentUser) {
    try {
      idToken = await getIdToken(auth.currentUser, true);
      localStorage.setItem("idToken", idToken);
    } catch {
      idToken = null;
    }
  }

  // 👇 idToken が取得できなければ、localStorage から取得した idToken を利用する
  if (!idToken) {
    idToken = localIdToken;
  }

  // 👇 header にセット
  return {
    headers: {
      ...headers,
      authorization: idToken ? `Bearer ${idToken}` : "",
    },
  };
});

const _client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
});

export const apolloClient = _client;

api/graphql

残りはpages/api/graphql ページで、「idToken を検証して、カスタムトークンを取得する」処理を書くだけ。 Access-Control などの設定は他サイトを参考にしただけで、理解が浅いので、また今度調べたい。

pages/api/graphql.ts
import admin from "@src/lib/firebase/server"; // 👈 初期化済の admin を import している
import { ApolloServer, gql } from "apollo-server-micro";
import { join } from "path";
import { readFileSync } from "fs";
import { resolvers } from "@src/graphqlServer/resolvers";
import type { NextApiRequest, NextApiResponse } from "next";

// node を実行している環境からのパス
const schemaFilePath = join(process.cwd(), "graphql", "schema.graphql");
const typeDefs = gql(readFileSync(schemaFilePath).toString());

const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  // 👇 context: 実行時に全ての resolve function に渡される
  context: async ({ req }) => {
    const idToken = getIdTokenFromReq(req);

    if (idToken) {
      // idToken の検証
      const decoded = await admin.auth().verifyIdToken(idToken);
      const uid = decoded?.uid;

      return { idToken, uid };
    } else {
      return { idToken: null, uid: null };
    }
  },
});

const startServer = apolloServer.start();

export default async function (req: NextApiRequest, res: NextApiResponse) {
  // CORS リクエストには Cookie が自動で付与されないので、Cookie の付与を許可
  res.setHeader("Access-Control-Allow-Credentials", "true");

  // 許可する Origin の設定
  res.setHeader(
    "Access-Control-Allow-Origin",
    "https://studio.apollographql.com"
  );

  // Access-Control-Allow-Headers は Access-Control-Request-Headers を含むプリフライトリクエストへのレスポンスで、実際のリクエストの間に使用できる HTTP ヘッダーを指定する
  res.setHeader(
    "Access-Control-Allow-Headers",
    "Origin, X-Requested-With, Content-Type, Accept"
  );
  if (req.method === "OPTIONS") {
    res.end();
    return false;
  }

  await startServer;
  await apolloServer.createHandler({
    path: "/api/graphql",
  })(req, res);
}

const getIdTokenFromReq = (req: NextApiRequest) => {
  const idToken = req.headers["authorization"] as string;
  return idToken?.replace(/^Bearer (.*)/, "$1");
};

export const config = { api: { bodyParser: false } };

query

こんな感じで、uid を利用している。

resolvers/query

import admin from "@src/lib/firebase/server";
import type {
  QueryResolvers,
  Post
} from "@src/types/generated/serverGraphql";

export const queryResolvers: QueryResolvers = {
  posts: async (parent, args, { uid }) => {
    const db = admin.firestore();
    const collectionRef = db.collection("users").doc(uid).collection("posts");
    const querySnap = await collectionRef.get();
    const posts = querySnap.docs.map(doc => doc.data()) as Post[];

    return posts;
  },
};