🚀

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

7 min read

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単位での設定が可能です。

auth_directives.js
import { AuthenticationError, SchemaDirectiveVisitor } from 'apollo-server';
import { defaultFieldResolver } from 'graphql';
import Authentication from '../domains/authentication';

class AuthDirective extends SchemaDirectiveVisitor {
  visitObject(type) {
    this.ensureFieldsWrapped(type);
    type._requiredAuthRole = this.args.requires;
  }
  // Visitor methods for nested types like fields and arguments
  // also receive a details object that provides information about
  // the parent and grandparent types.
  visitFieldDefinition(field, details) {
    this.ensureFieldsWrapped(details.objectType);
    field._requiredAuthRole = this.args.requires;
  }

  ensureFieldsWrapped(objectType) {
    // Mark the GraphQLObjectType object to avoid re-wrapping:
    if (objectType._authFieldsWrapped) return;
    objectType._authFieldsWrapped = true;

    const fields = objectType.getFields();

    Object.keys(fields).forEach(fieldName => {
      const field = fields[fieldName];
      const { resolve = defaultFieldResolver } = field;
      field.resolve = async function (...args) {
        // Get the required Role from the field first, falling back
        // to the objectType if no Role is required by the field:
        const requiredRole =
          field._requiredAuthRole ||
          objectType._requiredAuthRole;

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

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

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

export default AuthDirective;

Directiveの中身の実装は、ほとんどこちらにならっています。

index.ts(一部抜粋)
  const server = new ApolloServer({
    typeDefs,
    schemaDirectives: {
      auth: AuthDirective,
    },
    resolvers,
    dataSources,
    context,
  });
  await server.start();

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

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

ログインするとコメントできます