🚀

Apollo Server/ClientでGraphQL利用時の認証エラーを処理する

2021/07/05に公開

Apollo Serverで認証エラーを掴むときの挙動。
RESTで組むときはhttpステータスコードで401としますが、GraphQLでは200で返し、レスポンスのerrorsキーに詳細情報をもたせるというのがベストプラクティス。
最近はRESTでも同じような実装にする流れがあるというのをどこかで聞いたような。

この記事では、ここの使用技術の解説は割愛して、全体の流れとコードを載せておきます。

利用する技術

Apollo Server

  • Custom Directives

Apollo Client

  • Reactive Variables
    • グローバルなストアで変数管理できる使用感
  • Apollo Link
    • リクエストを送る前とレスポンスを受け取る前に前処理するための機構

エラー発生時の流れ

  1. [Client]何かしらエラーになる通信呼び出し
  2. [Server]Directiveで認証チェック → エラー発生
  3. [Client]Error LinkでGraphQLエラーをキャッチ
  4. [Client]Reactive Variablesにエラーを格納
  5. [Client]4の変数をsubscribeしてサインイン画面に戻すなどの共通処理を実装

実装詳細

2. [Server]Directiveで認証チェック

GraphQLの仕様にあるDirectivesを使います。

schema.ts(一部抜粋)
  type ShopQueryAPI @auth(requires: MEMBER) {
    shop: Shop @auth(requires: ADMIN)
    myAccount: ShopUser
  }
  
  directive @auth(
    requires: Role = NONE,
  ) on OBJECT | FIELD_DEFINITION

  enum Role {
    ADMIN
    MEMBER
    NONE
  }

schemaでの活用の仕方。
typeだけでなく、field単位での設定が可能です。

authDirectiveTransformer.js
import { mapSchema, MapperKind, getDirective } from "@graphql-tools/utils";
import { AuthenticationError } from "apollo-server-errors";
import ShopAuthentication from "../domains/ShopAuthentication.js";
import { defaultFieldResolver, GraphQLSchema } from "graphql";

const executeAuth = (fieldConfig: any, authDirective: any) => {
  const { resolve = defaultFieldResolver } = fieldConfig;
  fieldConfig.resolve = async function (...args: any[]) {
    const requiredRole = authDirective.requires;

    if (!requiredRole) {
      return resolve.apply(this, args);
    }

    const context = args[2];
    if (!ShopAuthentication.hasRole(requiredRole, context.tokenData)) {
      throw new AuthenticationError("not authorized");
    }

    return resolve.apply(this, args);
  };

  return fieldConfig;
};

export default function authDirectiveTransformer(schema: GraphQLSchema) {
  const directiveName = 'auth';
  const typeDirectiveArgumentMaps: Record<string, any> = {};

  return  mapSchema(schema, {
    [MapperKind.TYPE]: (typeName: any) => {
      const authDirective = getDirective(schema, typeName, directiveName)?.[0];
      if (authDirective) {
        typeDirectiveArgumentMaps[typeName] = authDirective;
      }
      return typeName;
    },
    [MapperKind.OBJECT_FIELD]: (fieldConfig: any, _fieldName: any, typeName: any) => {
      const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0] ?? typeDirectiveArgumentMaps[typeName];
      if (authDirective) {
        return executeAuth(fieldConfig, authDirective);
      }
      return fieldConfig;
    },
  });
};

最後に、ApolloServerのコンストラクタに食わせます。

index.ts(一部抜粋)
  let schema = makeExecutableSchema({
    typeDefs,
    resolvers,
  });
  schema = authDirectiveTransformer(schema);

  const server = new ApolloServer({
    schema,
    dataSources,
    context,
    formatError: (error) => {
      console.error(error?.extensions);
      return error;
    },
    plugins: [
      logging,
    ]
  });
  await server.start();

3. [Client]Error LinkでGraphQLエラーをキャッチ, 4. [Client]Reactive Variablesにエラーを格納

globalVars.ts
import { makeVar, ServerError, ServerParseError } from '@apollo/client';
import { GraphQLError } from 'graphql';

type ApiErrorType = GraphQLError | Error | ServerError | ServerParseError | undefined
// eslint-disable-next-line import/prefer-default-export
export const apiError = makeVar<ApiErrorType[]>([]);
client.ts
import {
  createHttpLink, ApolloClient, InMemoryCache, from,
} from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { apiError } from './globalVars';

const httpLink = createHttpLink({
  uri: 'http://localhost:4000',
});
const errorLink = onError(({ graphQLErrors, networkError }) => {
  const errors = [];
  if (graphQLErrors) {
    graphQLErrors.forEach((err) => {
      errors.push(err);
    });
  }

  if (networkError) {
    errors.push(networkError);
  }

  apiError(errors);
});

const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: from([errorLink, httpLink]),
});

export default apolloClient;

Apollo Linkを使用するときは、ApolloServerのコンストラクタのほうでuriの指定が効かなくなる仕様なので、httpLinkも記述します。
エラーの中身をReactive Variablesに格納して、UI側で扱えるようにします。

5. [Client]4の変数をsubscribeしてサインイン画面に戻すなどの共通処理を実装

_app.tsx
import {
  Snackbar, Alert, Slide,
} from '@material-ui/core';
import { AppProps } from 'next/dist/next-server/lib/router/router';
import Head from 'next/head';
import React, { useEffect, useState } from 'react';
import { ApolloProvider, useReactiveVar } from '@apollo/client';
import { GraphQLError } from 'graphql';
import { useRouter } from 'next/router';
import apolloClient from '../lib/apollo/client';
import { apiError } from '../lib/apollo/globalVars';

const MyApp = (props: AppProps) => {
  const { Component, pageProps } = props;
  const router = useRouter();
  const [snackbarOpen, setSnackbarOpen] = useState(false);
  const apiErr = useReactiveVar(apiError);
  const [errorMessage, setErrorMessage] = useState('通信エラーが発生しました');
  const onCloseSnackbar = () => {
    apiError([]);
    setSnackbarOpen(false);
  };

  useEffect(() => {
    setSnackbarOpen(apiErr.length > 0);

    const isUnauthorized = apiErr.some((error) => {
      const e = error as GraphQLError;
      return e?.extensions?.code === 'UNAUTHENTICATED';
    });
    if (isUnauthorized) {
      setErrorMessage('認証エラーが発生しました。再ログインしてください。');
      router.push('/signin');
    }
  }, [apiErr]);

  return (
    <ApolloProvider client={apolloClient}>
      <>
        <Head>
          <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0" />
        </Head>
        <Component {...pageProps} />
        <Snackbar
          anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
          autoHideDuration={5000}
          TransitionComponent={(prps) => <Slide {...prps} direction="down" />}
          open={snackbarOpen}
          onClose={onCloseSnackbar}
        >
          <Alert
            severity="error"
            onClose={onCloseSnackbar}
          >
            {errorMessage}
          </Alert>
        </Snackbar>
      </>
    </ApolloProvider>
  );
};

export default MyApp;

Next.jsとMaterial-UIを使用している例です。
Reactive VariablesをHooksで扱うときはuseReactiveVarを用いるのが味噌です。
どの画面でもアラート上げるようにしたいので、_app.tsxに実装しました。

総括

気持ち的には、とてもスッキリ実装できた感覚です。
サーバーもクライアントもApolloで統一することで、エラーの型も同じように扱え、生産性の高さを感じました。

Discussion