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의 적당한 위치에 웹스토리지에 저장하면 될 것 같다. 이상.
※ 오타자가 있을 수 있습니다. 잘못된 내용이 있을 수 있습니다. 더 좋은 방법이 있을 수 있습니다. 공유 부탁드립니다. 화이팅