본문 바로가기

React/Nextjs

next-auth 를 활용한 jwt 인증 및 refresh 토큰 순환

반응형

next-auth는 사실 공식홈페이지에서도 소개된 것처럼 서버리스를 지원하도록 처음부터 설계된 오픈소스 인증 솔루션이다.

하지만 실무에서 서버리스로 개발하는 경우는 드물다.

그럼에도 필자가 next-auth를 도입해보게 된 이유는 프론트엔드에서 페이지 라우트별로 접근을 보다 쉽게 제한할 수 있으며, 그래도 상당히 나쁘지않은 보안적인 측면을 기본적으로 보다 쉽게 제공해주는 느낌을 받았기 때문이다.

 

예를 들면, 서버를 통해 accessToken을 넘겨받은 뒤, 해당 accessToken이 만료가 된다면 서버에서는 api 호출이 되지 않을 것이다. 하지만 각 라우트는 별도의 작업이 없다면 해당 페이지에 접근할 수 있게된다. 

물론 HOC(High Order Component)나 각 페이지를 래핑하는 Provider 를 제공하거나, 혹은 프론트에서 다양한 방법으로 인증체크를 추가로 작업해야만 라우트(페이지)에 접근할 수 없게 된다.

 

하지만 next-auth는 보다 쉽게 로그인 및 페이지 인증을 해결할 수 있어보인다.

 

일단 당연히 next-auth를 사용하기 위해 패키지를 설치한다.

# yarn
yarn add next-auth

# npm
npm i next-auth

 

이 후에 환경변수 파일(.env)에 해당 변수를 추가해줘야만 한다.

NEXTAUTH_URL=http://localhost:3000/ # HOST
NEXTAUTH_SECRET=test # 매우 중요!

NEXTAUTH_URL : 기본적으로 next-auth의 인증 api는 nextjs 의 api 라우트를 사용하기 때문에 서버 api URL이 아닌 웹 프론트 서버의 URL이 되어야만 한다.

NEXTAUTH_SECRET : next-auth의 비밀키로 next-auth를 통해 인증 후 로그인을 하게되면 브라우저에 http only 의 브라우저 쿠기 (next-auth.session-token) 가 생성되는데, 해당 토큰을 디코드 하는데 사용하는 것으로 보여진다. (추측) 그래서 NEXTAUTH_SECRET 이 일치한다면 도메인이 같을 때, 쿠키 공유를 통한 로그인 세션 공유도 가능하다. (뒤에서 다루겠지만, 공유받은 앱에서 토큰이나 세션에 저장한 값도 읽어올 수 있다.)

 

이 후에, api/auth 경로에 [...nextauth].ts 파일을 생성한다.

import axios from 'axios';
import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth';
import type { CallbacksOptions, PagesOptions } from 'next-auth';
import type { JWT } from 'next-auth/jwt';
import type { Provider } from 'next-auth/providers';
import CredentialsProvider from 'next-auth/providers/credentials';

class NextAuthOptions {
  req: NextApiRequest;
  res: NextApiResponse;

  constructor(req: NextApiRequest, res: NextApiResponse) {
    this.req = req;
    this.res = res;
  }

  // 쿠키 셋팅
  setCookieToken(accessToken: string) {
    this.res.setHeader('Set-Cookie', `Authorization=Bearer ${accessToken}`);
  }

  // refresh token 을 통한 accessToken 재발급
  async refreshAccessToken(tokenObject: JWT) {
    try {
      // refreshToken 을 통한 새로운 토큰 발급
      const data = await axios.post(REFRESH_토큰_API_SERVER_URL, {
        refreshToken: tokenObject.refreshToken as string
      });

      this.setCookieToken(data.result?.accessToken);

      return {
        ...tokenObject,
        accessToken: data.result?.accessToken,
        expiration: data.result?.expiration,
        refreshToken: data.result?.refreshToken,
      };
    } catch (error) {
      return {
        ...tokenObject,
        error: 'RefreshAccessTokenError',
      };
    }
  }

  getProviders(): Provider[] {
    return [
      CredentialsProvider({
        id: 'sb-credentials',
        name: 'sb-credentials',
        // sign에서 넘겨받을 값
        credentials: {
          email: {},
          password: {},
        },
        // 로그인(인증)
        async authorize(credentials) {
          try {
            const data = await axios.post(로그인_API_SERVER_URL, {
              email: credentials?.email as string,
              password: credentials?.password as string,
            });

            if (data.result?.accessToken && data.result?.refreshToken) {
              return data.result;
            }

            return Promise.reject(data);
          } catch (e: unknown) {
            // console.log(e.response.data)
            throw new Error(e as string);
          }
        },
      }),
    ];
  }

  getCallbacks(): Partial<CallbacksOptions> {
    return {
      // authorize 가 성공하면 jwt 콜백이 실행되고, 해당 콜백을 리턴하면 session 콜백이 실행됨.
      // token: 저장 토큰, user: 위 authorize에서 넘겨받은 결과 값.
      // token 에 값을 직접적으로 넣을 수도 있지만, token.user 에 값을 넣을 수도 있다.
      // 선언을 통한 모듈타입 재정의로 값을 자유롭게 넣을 수도 있음!!
      jwt: async ({ token, user }) => {
        if (user) {
          token.email = user.email;
          token.name = user.name;
          token.accessToken = user.accessToken;
          token.expiration = user.expiration;
          token.refreshToken = user.refreshToken;
        }


        // client 의 SessionProvider의 옵션 refetchInterval 을 통해 jwt 콜백 재실행
        // expiration 시간이 지나기 전에 refreshToken 발급 (만료시간보다 좀 더 빠른시간에 갱신: 30분전)
        const shouldRefreshTime = Math.round(
          (token.expiration as number) - 30 * 60 * 1000 - Date.now()
        );

        // accessToken 유효하면, 기존 토큰을 반환
        if (shouldRefreshTime > 0) {
          this.setCookieToken(token.accessToken);
          return Promise.resolve(token);
        }

        // accessToken 이 유효하지 않다면 refreshToken 을 통해 재발급
        token = await this.refreshAccessToken(token);
        return Promise.resolve(token);
      },
      // session에 저장하면 client에서 값을 가져올 수 있음.( ex: useSession / getSession )
      session: async ({ session, token }) => {
        session.email = token.email;
        session.name = token.name;
        session.expiration = token.expiration;
        session.error = token.error;
        return Promise.resolve(session);
      },
    };
  }

  // next-auth에서 기본적으로 제공하는 페이지를 변경 처리
  getPages(): Partial<PagesOptions> {
    return {
      // login 페이지를 직접 만든 페이지로 변경 처리 (기본적으로 제공하는 로그인 인증 페이지가 있음)
      signIn: '/auth/sign-in',
    };
  }

  getOptions() {
    return {
      providers: this.getProviders(),
      callbacks: this.getCallbacks(),
      pages: this.getPages(),
    }
  }
}

const AUTH = (req: NextApiRequest, res: NextApiResponse) => {
  const nextAuthOptions = new NextAuthOptions(req, res);
  return NextAuth(req, res, nextAuthOptions.getOptions())
};
export default AUTH;

소스코드가 길어서 주석으로 간단하게 설명을 대체하고자 한다.

하지만 여기서 중요한 포인트는 [...nextauth].ts 는 api 라우트 이기 때문에 쿠키를 직접 저장할 수 있으며, 

개인적으로 실행순서를 직접 로그확인하면서 확인하면 좋을 것 같다. 간단하게 설명하면,,

  1. CredentialsProvider의 authorize
  2. callbacks 의 jwt (token의 값이 할당되고나서 한번 더 실행되는 것 같음!!!!!!)
  3. callbacks 의 session

그리고 뒤에서도 다시 언급은 하겠지만 jwt callback의  shouldRefreshTime 은 토큰의 만료시간(token.expiration) 과 현재시간(Date.now()) 의 차이가 양수와 음수인지 체크하여 현재 토큰을 계속 사용할지, 재발급을 받을지 결정한다. 이 때, 클라이언트에서 만약에 정상적으로 갱신되지 않을 수 있는 사이드이펙트를 방지하고자 SessionProvider에서 refetchInterval 값은 조금 더 작게한다. 

(예를 들면, 13시에 발급받은 토큰의 만료시간이 14시라면 jwt callback 의 shouldRefreshTime 은 13시 30분 이후 부터 재발급 받도록 하고 SessionProvider에서 refetchInterval 은 13시 40분에 jwt callback 을 실행시킬 수 있도록 처리한다는 뜻이다. 혹여나 refetch가 실행되서 jwt callback이 실행되었는데 sholudRefreshTime이 0보다 크게 될 수 있는 아주아주만약의 상황을 대비하는 것이다.)

 

이렇게 api 라우트 작업이 완료되었다면, 클라이언트에서 마무리 작업을 해야한다

pages/_app.tsx 에 

import { SessionProvider } from 'next-auth/react';
import type { AppProps } from 'next/app';
import { useState } from 'react';
import RefreshTokenHandler from '@/components/auth/RefreshTokenHandler';

const MpApp = (appProps: AppProps) => {
  const { Component, pageProps } = appProps;
  
  const [interval, setInterval] = useState(0); 
  
  return (
    <SessionProvider session={pageProps.session} refetchInterval={interval}>
      <RefreshTokenHandler setInterval={setInterval} />
      <Component {...pageProps} />
    </SessionProvider>
  );
};

SessionProvider 의 refetchInterval은 클라이언트가 세션 상태를 업데이트 하기 위한 시간 값이다. (https://next-auth.js.org/getting-started/client#refetch-interval)

 

RefreshTokenHandler 에서 interval 값을 핸들링한다.

 

src/component/auth/RefreshTokenHandler.tsx

import { signOut, useSession } from 'next-auth/react';
import type { FC } from 'react';
import { useEffect } from 'react';

type Props = {
  shouldRedirect?: boolean;
  setInterval: (interval: number) => void;
};

const RefreshTokenHandler: FC<Props> = ({ setInterval }) => {
  const { data: session } = useSession();

  useEffect(() => {
    if (session) {
      // refresh 토큰을 통한 토큰 갱신 중 에러가 발생하면 로그아웃
      if (session?.error === 'RefreshAccessTokenError') {
        signOut();
      }
      const timeRemaining = Math.round(
        ((session.expiration as number) - 20 * 60 * 1000 - Date.now()) / 1000
      );
      setInterval(timeRemaining > 0 ? timeRemaining : 0);
    }
  }, [session, setInterval]);

  return null;
};

export default RefreshTokenHandler;

서버에서보다 더 늦게 인증 업데이트를 실행하도록 구현했으며, 해당 핸들러를 통해 세션상태가 업데이트 된다면 accessToken이 서버 api 라우트에서 쿠키에 갱신될 것이다. (해당 핸들러는 _app.tsx 에 위치하며, 모든 페이지에 접속할 때 핸들러에서 남은시간이 체크된다!)

 

로그인과 로그아웃은 next-auth/react의 signIn과 signOut을 사용하면 된다.

import type { NextPage } from 'next';
import { signIn, signOut } from 'next-auth/react';

const SignTest: NextPage = () => {
  return (
    <>
      <button
        onClick={() =>
          signIn('sb-credentials', {
            email: 'sbjang@example.com',
            password: 'sbjang123',
            redirect: false,
          })
        }
      >
        로그인
      </button>
      <button onClick={() => signOut({ redirect: false })}>로그아웃</button>
    </>
  );
};

export default SignTest;

로그인할 때 체크해야할 것은 api route([...nextauth].ts) 에서 지정한 CredentialsProvider의 id와 credentials의 값이다.

signIn 함수를 사용할 때 지정한 id 를 첫번째 매개변수에 넣어야 하며, credentials에 기입한 값만 받아올 수 있음을 체크하자!

 

마지막으로 session 체크에 따른 route url 변경처리는 nextjs >=12.2 의 middleware 를 활용하였다.

최신버전의 nextjs 에서는 middlware의 위치는 pages와 동일레벨에 있어야한다고 한다. 

https://nextjs.org/docs/advanced-features/middleware

https://nextjs.org/docs/messages/middleware-upgrade-guide

 

middleware.ts

import { getToken } from 'next-auth/jwt';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { dynamicRoute } from '@/lib/dynamicRoutes';

export const middleware = async (req: NextRequest) => {
  const response = NextResponse.next();
  const { pathname } = req.nextUrl;

  // route 경로가 아닌 경로는 제외
  if (pathname.startsWith('/api') || pathname.startsWith('/static') || pathname.includes('.')) {
    return response;
  }

  const baseUrl = req.url;
  const token = await getToken({ req });

  // 토큰이 없는데, url이 로그인 path 일 때 메인페이지로 이동
  if (!token) {
    if (!pathname.startsWith('/auth/sign-in')) {
      return NextResponse.redirect(new URL('/auth/sign-in', baseUrl));
    }
  } else {
    if (pathname.startsWith('/auth/sign-in')) {
      return NextResponse.redirect(new URL('/', baseUrl));
    }
  }
  return response;
};

// 특정 경로만 middleware 체크 가능
// export const config = {
//   matcher: [
//     '/admin/:path*',
//   ],
// };

middleware를 통해 토큰 여부에 따라 해당 페이지에 인증이 안된 유저나 인증된 유저가 다시 로그인페이지에 접근하는 것 등등을 처리할 수 있다.

 

위 처럼 작업하면 accessToken은 쿠키에 정상적으로 셋팅하고 accessToken이 만료되기 전에 refreshToken을 통하여 토큰은 재발급 받을 수 있게 된다. 추가로 middleware에서는 쿠키값을 핸들링이 간편한데 잘 커스텀하면 여기서도 문제없이 쿠키를 셋팅할 수 있을 것으로 보여진다.

혹여나 쿠키로 저장하고 싶지 않다면 RefreshTokenHandler의 적당한 위치에 웹스토리지에 저장하면 될 것 같다. 이상.

 

※ 오타자가 있을 수 있습니다. 잘못된 내용이 있을 수 있습니다. 더 좋은 방법이 있을 수 있습니다. 공유 부탁드립니다. 화이팅

반응형