[Next.js] GraphQL을 활용한 refresh token 적용 방법

woohyuk·2023년 12월 28일
0
post-custom-banner

graphql을 활용해서 프로젝트에 accessToken과 refreshToken을 어떻게 적용하였는지 작성해 보도록 하겠다.

Flow

일단 어떤식으로 적용할지에 대한 플로우는 다음과 같다.

  1. 로그인 후 액세스 토큰과 리프레쉬 토큰 발급 받음
  2. 액세스 토큰과 리프레쉬 토큰 둘다 로컬스토리지에 저장
  3. 인가가 필요한 api 요청 시 액세스 토큰이 만료라면(Unauthorized) 리프레쉬 토큰으로 액세스 토큰 재발급 api 요청
  4. (리프레쉬 토큰 유효할 시) 재발급받은 액세스 토큰으로 리젝된 요청을 다시 재요청
  5. (리프레쉬 토큰 만료 시) 로컬스토리지 토큰 삭제 및 로그아웃 진행

여기서 주의깊게 봐야 할 단계는 3번과 4번이다.

액세스 토큰 만료 시 리프레쉬 토큰요청과
그 이후 로직을 어떠한 방식으로 처리하였는지 작성하겠다.

ApolloClient 초기 세팅

현재 ApolloClient 초기 세팅 값은 다음과 같다.

import {
  ApolloClient,
  InMemoryCache,
  createHttpLink,
  ApolloLink,
} from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

const httpLink = createHttpLink({
  // eslint-disable-next-line
  uri: `${process.env.SERVER_URI}/graphql`,
});

const authLink = setContext((_, { headers }: { headers: Headers }) => {
  if (typeof window === "undefined") return;

  const accessToken = localStorage.getItem(
    process.env.ACCESS_TOKEN_KEY as string
  );

  return {
    headers: {
      ...headers,
      "x-jwt": accessToken ? accessToken : "",
    },
  };
});

const client = new ApolloClient({
  link: ApolloLink.from([authLink, httpLink]),
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: "no-cache",
      errorPolicy: "ignore",
    },
    query: {
      fetchPolicy: "no-cache",
      errorPolicy: "all",
    },
  },
});

export default client;
  • httpLink를 선언하여 요청 보낼 서버 uri 작성
  • authLink를 선언하여 로컬스토리지에 accessToken이 있다면 요청시 항상 헤더에 토큰을 넣을수 있도록 함.

이 설정값들은 기본적으로 useApolloClient를 통해 요청을 보낼때 항상 실행이 된다고 볼 수 있다.

위에서 언급한 3번과 4번과정을 실현시킬려면 요청과 응답사이에 처리할수 있는 미들웨어 역할을 하는 로직이 필요하다.
axios.interceptors 처럼 말이다.

apolloClient에서는 onError라는 top level에서 에러를 가로채 중간 작업을 할 수있도록 제공하는 기능이 있다.

import {
  ApolloClient,
  InMemoryCache,
  createHttpLink,
  ApolloLink,
  Observable,
} from "@apollo/client";
import { onError } from "@apollo/client/link/error";
import { setContext } from "@apollo/client/link/context";
import { getRestoreAuthToken } from "@libs/getRestoreAuthToken";
import { setAuthToken } from "./utils";
import { logout } from "@libs/logout";

const httpLink = createHttpLink({
  // eslint-disable-next-line
  uri: `${process.env.SERVER_URI}/graphql`,
});

const errorLink = onError(({ graphQLErrors, operation, forward }) => {
  if (graphQLErrors) {
    for (const err of graphQLErrors) {
      if (err.extensions.code === "UNAUTHENTICATED") {
        void getRestoreAuthToken()
          .then((response) => {
            const newAccessToken = response?.token;
            const newRefreshToken = response?.refreshToken;

            if (!newAccessToken || !newRefreshToken) return;

            operation.setContext({
              headers: {
                ...operation.getContext().headers, // 기존 헤더는 그대로 가져오기
                "x-jwt": newAccessToken, //  accessToken만 바꿔치기
              },
            });

            // 로컬 스토리지 토큰 값 갱신
            setAuthToken(newAccessToken, newRefreshToken);

            // 변경된 operation 재요청하기
            forward(operation);
          })
          .catch(async (err) => {
            console.log(`err`, err);

            // refreshToken이 만료이기에 로그아웃 진행
            await logout();
          });
      }
    }
  }
});

const authLink = setContext((_, { headers }: { headers: Headers }) => {
  if (typeof window === "undefined") return;

  const accessToken = localStorage.getItem(
    process.env.ACCESS_TOKEN_KEY as string
  );

  return {
    headers: {
      ...headers,
      "x-jwt": accessToken ? accessToken : "",
    },
  };
});

const client = new ApolloClient({
  link: ApolloLink.from([authLink, errorLink, httpLink]),
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: "no-cache",
      errorPolicy: "ignore",
    },
    query: {
      fetchPolicy: "no-cache",
      errorPolicy: "all",
    },
  },
});

export default client;

위에 코드를 설명하자면
err.extensions.code가 "UNAUTHENTICATED" 즉
accessToken이 만료된 상태를 말한다.

그렇기에 getRestoreAuthToken을 호출하여 refreshToken 으로 accessToken과 refreshToken을 갱신하는 작업을 한후 갱신된 토큰으로 기존에 거절되었던 요청을 다시 재요청 하는 것이다.

한마디로
1. 유저정보 요청
2. UNAUTHENTICATED 에러
3. 리프레쉬 토큰으로 토큰 갱신
4. 유저정보 재요청
이런식의 흐름을 갖게된다.

재호출 함수 작동 에러

위에서 재요청을 담당하는 함수는 forward(operation)이다.
하지만 이상하게도 위에 코드에서 forward(operation)은 작동하지 않는다.

그 이유를 생각해보니 getRestoreAuthToken()이 비동기 작업을 진행하기에 그 결과를 기다리지 않고 onError 함수가 끝나버려서 그런거지 않나 싶다.

그래서 코드를 다음과 같이 수정하였다.

import {
  ApolloClient,
  InMemoryCache,
  createHttpLink,
  ApolloLink,
  Observable,
} from "@apollo/client";
import { onError } from "@apollo/client/link/error";
import { setContext } from "@apollo/client/link/context";
import { getRestoreAuthToken } from "@libs/getRestoreAuthToken";
import { setAuthToken } from "./utils";
import { logout } from "@libs/logout";

const httpLink = createHttpLink({
  // eslint-disable-next-line
  uri: `${process.env.SERVER_URI}/graphql`,
});

const errorLink = onError(({ graphQLErrors, operation, forward }) => {
  if (graphQLErrors) {
    for (const err of graphQLErrors) {
      if (err.extensions.code === "UNAUTHENTICATED") {
        return new Observable((observer) => {
          void getRestoreAuthToken()
            .then((response) => {
              const newAccessToken = response?.token;
              const newRefreshToken = response?.refreshToken;

              if (!newAccessToken || !newRefreshToken) return;

              operation.setContext({
                headers: {
                  ...operation.getContext().headers, // 기존 헤더는 그대로 가져오기
                  "x-jwt": newAccessToken, //  accessToken만 바꿔치기
                },
              });

              // 로컬 스토리지 토큰 값 갱신
              setAuthToken(newAccessToken, newRefreshToken);

              const subscriber = {
                next: observer.next.bind(observer),
                error: observer.error.bind(observer),
                complete: observer.complete.bind(observer),
              };

              // 변경된 operation 재요청하기
              forward(operation).subscribe(subscriber);
            })
            .catch(async (err) => {
              console.log(`err`, err);

              // refreshToken이 만료이기에 로그아웃 진행
              await logout();
            });
        });
      }
    }
  }
});

const authLink = setContext((_, { headers }: { headers: Headers }) => {
  if (typeof window === "undefined") return;

  const accessToken = localStorage.getItem(
    process.env.ACCESS_TOKEN_KEY as string
  );

  return {
    headers: {
      ...headers,
      "x-jwt": accessToken ? accessToken : "",
    },
  };
});

const client = new ApolloClient({
  link: ApolloLink.from([authLink, errorLink, httpLink]),
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: "no-cache",
      errorPolicy: "ignore",
    },
    query: {
      fetchPolicy: "no-cache",
      errorPolicy: "all",
    },
  },
});

export default client;

Observable 함수를 리턴해 줌으로써 비동기 작업을 처리하고 그 결과를 옵저버에게 전달하게 하였더니 정상적으로 재호출을 진행하였다.

profile
기록하는 습관을 기르자
post-custom-banner

0개의 댓글