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 라우트 이기 때문에 쿠키를 직접 저장할 수 있으며,
개인적으로 실행순서를 직접 로그확인하면서 확인하면 좋을 것 같다. 간단하게 설명하면,,
- CredentialsProvider의 authorize
- callbacks 의 jwt (token의 값이 할당되고나서 한번 더 실행되는 것 같음!!!!!!)
- 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의 적당한 위치에 웹스토리지에 저장하면 될 것 같다. 이상.
※ 오타자가 있을 수 있습니다. 잘못된 내용이 있을 수 있습니다. 더 좋은 방법이 있을 수 있습니다. 공유 부탁드립니다. 화이팅