인증을 위한 오픈소스
next-auth에서 버전이 올라가면서 이름을 바꿨다. 현재 v5는 beta이고 버그가 좀 많은 편이다.
인증을 위해 Auth.js (v5)를 프로젝트(Next.js 14 앱라우터 사용)에 적용하며 느낀 Auth.js의 장점, 단점 그리고 문제점과 해결방법을 정리해 봤다.
장점
CSR 환경에 비해 Next.js를 활용한 SSR환경은 클라이언트단에 추가로 서버단 까지 인증을 다루기가 까다롭다. 이 때 인증을 다루기 편하게 해준다. 인증 관련 로직들이 잘 추상화되어 있다.
다양한 Provider, Adapter들을 통해 github, kakao등 소셜 로그인, DB 접근 로그인을 지원한다.
단점
v5가 beta 버전이다보니 버그가 많다. 적용하면서 커스텀해줘야 하는 부분이 많고 오픈소스 코드를 직접 보거나 issue를 확인하여 수정해줘야 해서 적용하는데 시간이 많이 걸린다.
적용시 문제점과 해결방법
1) 클라이언트에서 useSession으로 얻는 사용자 세션이 서버쪽 세션과 일치하지 않음
문제점 찾기
웹사이트에서 사용자 로그인 후에 클라이언트 쪽에서 사용자 세션을 제대로 받아오지 못하는 버그가 있었다. > 공식문서에서 Next.js 앱 라우터
auth.js issue들을 찾아보니 나와 같은 경우가 몇 명 있었다. https://github.com/nextauthjs/next-auth/issues/9504
문제가 무엇인지 알아보기 위해 auth.js 코드를 살펴봤다.
Auth.js useSession 코드를 보면, sessionProvider에 전달한 session을 리턴하도록 구현되어 있다.
// packages/next-auth/src/react.tsx
export function useSession<R extends boolean>(
options?: UseSessionOptions<R>
): SessionContextValue<R> {
if (!SessionContext) {
throw new Error("React Context is unavailable in Server Components")
}
// @ts-expect-error Satisfy TS if branch on line below
const value: SessionContextValue<R> = React.useContext(SessionContext)
if (!value && process.env.NODE_ENV !== "production") {
throw new Error(
"[next-auth]: `useSession` must be wrapped in a <SessionProvider />"
)
}
const { required, onUnauthenticated } = options ?? {}
const requiredAndNotLoading = required && value.status === "unauthenticated"
React.useEffect(() => {
if (requiredAndNotLoading) {
const url = `${__NEXTAUTH.basePath}/signin?${new URLSearchParams({
error: "SessionRequired",
callbackUrl: window.location.href,
})}`
if (onUnauthenticated) onUnauthenticated()
else window.location.href = url
}
}, [requiredAndNotLoading, onUnauthenticated])
if (requiredAndNotLoading) {
return {
data: value.data,
update: value.update,
status: "loading",
}
}
return value
}
SessionProvider는 props로 받은 session을 초기값으로 상태에 저장하고, 다음 경우에만 해당 session 상태를 업데이트한다.
- 'storage' 이벤트 발생시
- 탭 활성화시 ('visibilitychange' 이벤트 발생시)
- broadcastChannel에 'message' 이벤트 발생시
- refetchInterval 설정해놨을 경우 interval마다
// packages/next-auth/src/react.tsx
export function SessionProvider(props: SessionProviderProps) {
// session fetch.. 길어서 생략
const value: any = React.useMemo(
() => ({
data: session,
status: loading
? "loading"
: session
? "authenticated"
: "unauthenticated",
async update(data: any) {
if (loading || !session) return
setLoading(true)
const newSession = await fetchData<Session>(
"session",
__NEXTAUTH,
logger,
typeof data === "undefined"
? undefined
: { body: { csrfToken: await getCsrfToken(), data } }
)
setLoading(false)
if (newSession) {
setSession(newSession)
broadcast().postMessage({
event: "session",
data: { trigger: "getSession" },
})
}
return newSession
},
}),
[session, loading]
)
return (
// @ts-expect-error
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
)
}
로그인시에는 next-auth의 signIn이라는 서버 액션을 사용해서 로그인하도록 구현해놨기 때문에 로그인 후에 redirect되고 session이 변경되었음에도
- 'storage' 이벤트 발생시
- 탭 활성화시 ('visibilitychange' 이벤트 발생시)
- broadcastChannel에 'message' 이벤트 발생시
- refetchInterval 설정해놨을 경우 interval마다
위 4가지 경우에 해당되지 않기 때문에
클라이언트쪽에서는 세션이 업데이트되지 않는 것이었다.
해결
session provider와 usesession 훅을 새로 구현하여 기존의 session provider와 useSession 훅 대신 사용했다.
https://github.com/nextauthjs/next-auth/issues/9504
위 이슈에서 코드를 고대로 가져와서 썼다.
// SessionDataProvider.tsx (replace the current SessionProvider from next-auth/react)
"use client"
import type { Session } from "next-auth"
import { getCsrfToken } from "next-auth/react"
import { usePathname } from "next/navigation"
import {
Context,
createContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from "react"
/**
* Provider props
*/
type TSessionProviderProps = PropsWithChildren<{
session?: Session | null
}>
/**
* Type of the returned Provider elements with data which contains session data, status that shows the state of the Provider, and update which is the function to update session data
*/
type TUpdateSession = (data?: any) => Promise<Session | null | undefined>
export type TSessionContextValue = {
data: Session | null
status: string
update: TUpdateSession
}
/**
* React context to keep session through renders
*/
export const SessionContext: Context<TSessionContextValue | undefined> =
createContext?.<TSessionContextValue | undefined>(undefined)
export function SessionDataProvider({
session: initialSession = null,
children,
}: TSessionProviderProps) {
const [session, setSession] = useState<Session | null>(initialSession)
const [loading, setLoading] = useState<boolean>(!initialSession)
const pathname: string = usePathname()
const fetchSession = async () => {
if (!initialSession) {
// Retrive data from session callback
const fetchedSessionResponse: Response = await fetch("/api/auth/session")
const fetchedSession: Session | null = await fetchedSessionResponse.json()
setSession(fetchedSession)
setLoading(false)
}
}
useEffect(() => {
const onTabActive = () => {
if (document.visibilityState === "visible") {
fetchSession()
}
}
document.addEventListener("visibilitychange", onTabActive)
return () => {
document.removeEventListener("visibilitychange", onTabActive)
}
}, [])
useEffect(() => {
fetchSession().finally()
}, [initialSession, pathname])
const sessionData = useMemo(
() => ({
data: session,
status: loading
? "loading"
: session
? "authenticated"
: "unauthenticated",
async update(data?: any) {
if (loading || !session) return
setLoading(true)
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
},
}
if (data) {
fetchOptions.method = "POST"
// That is possible to replace getCsrfToken with a fetch to /api/auth/csrf
fetchOptions.body = JSON.stringify({
csrfToken: await getCsrfToken(),
data,
})
}
const fetchedSessionResponse: Response = await fetch(
"/api/auth/session",
fetchOptions
)
let fetchedSession: Session | null = null
if (fetchedSessionResponse.ok) {
fetchedSession = await fetchedSessionResponse.json()
setSession(fetchedSession)
setLoading(false)
}
return fetchedSession
},
}),
[loading, session]
)
return (
<SessionContext.Provider value={sessionData}>
{children}
</SessionContext.Provider>
)
}
// useSessionData.ts (replace useSession hook)
"use client"
import type { Session } from "next-auth"
import { useContext } from "react"
import {
SessionContext,
type TSessionContextValue,
} from "./session-data-provider"
/**
* Retrieve session data from the SessionContext for client side usage only.
* Content:
* ```
* {
* data: session [Session | null]
* status: 'authenticated' | 'unauthenticated' | 'loading'
* update: (data?: any) => Promise<Session | null | undefined>
* }
* ```
*
* @throws {Error} - If React Context is unavailable in Server Components.
* @throws {Error} - If `useSessionData` is not wrapped in a <SessionDataProvider /> where the error message is shown only in development mode.
*
* @returns {TSessionContextValue} - The session data obtained from the SessionContext.
*/
export function useSessionData(): TSessionContextValue {
if (!SessionContext) {
throw new Error("React Context is unavailable in Server Components")
}
const sessionContent: TSessionContextValue = useContext(SessionContext) || {
data: null,
status: "unauthenticated",
async update(): Promise<Session | null | undefined> {
return undefined
},
}
if (!sessionContent && process.env.NODE_ENV !== "production") {
throw new Error(
"[auth-wrapper-error]: `useSessionData` must be wrapped in a <SessionDataProvider />"
)
}
return sessionContent
}
Auth.js의 코드와 달라진 점은 useSession hook에서 pathname이 변경될 경우에 session을 fetch하는 것이다. 이를 통해 redirect시 pathname 변경 > 세션 다시 받아오므로 버그가 해결된다.
2) 로그인 후 redirect시 AUTH_URL로 redirect 안함
로그인 후 AUTH_URL로 지정해놓은 주소(도메인)로 redirect안하고, IP주소로 redirect하는 문제점이 있었다.
문제점 찾기
https://github.com/nextauthjs/next-auth/issues/10928
처음에는 위의 이슈와 같은 문제인지 알았으나 아니었다. 우리 프로젝트의 경우 기업별로 도메인을 분리해 두었다. 도메인 별로 환경변수를 분리하기 위해 환경변수 파일을 다음과 같이 분리했다.
- 메인 (사내 오픈) - .env.production
- 외부 POC용 - .env.poc
그리고 배포시에 환경변수를 다르게 적용하기 위하여 env-cmd 활용하여 각 도메인에 맞는 환경변수를 주입해 줬다.
{
"scripts": {
"build:prod": "env-cmd -f .env.production next build"
"build:poc": "env-cmd -f .env.poc next build"
}
}
이렇게하면 -f 뒤에 준 파일이 최우선순위로 주입이 된다. 이 경우에 next.js 동작에는 이상이 없으나 auth.js 동작에는 이상이 생긴다.
이 때는 시간이 없어서 코드를 파보지는 못했지만 next.js의 기본동작이 빌드시 .env.proudction을 포함하도록 되어 있어 build:poc
했을 때 .env.production도 주입되고 환경변수가 꼬인게 아닌가 생각이 되었다.
해결
환경변수 파일 이름을 .env.production
-> env.prod.[회사이름]
으로 변경한 후 테스트했더니 AUTH_URL로 redirect되었다.
정리
- Auth.js를 활용해 인증을 간편하게 구현할 수 있지만 현 버전은 beta여서 버그가 꽤 있다.
- 오픈소스 사용시 오픈소스 자체 버그가 있다면 이를 해결하는데 오랜 시간이 걸린다.
- issue란을 확인하여 같은 문제 있는 사람 찾고, 소스를 직접 열어보고, 특정 부분을 직접 구현하여 대체하는 데 시간이 많이 걸린다.
- 해서, 오픈소스를 선택할 때는 사람들이 많이 쓰는지에 추가로.. 현재 버전이 안정적인지도 확인할 필요가 있다.
- 그런데 오픈소스를 사용해보기 전에는 문제점을 잘 알기가 힘들다. 그냥 많이 쓰고 유지보수 잘되는 걸로 골라서 쓰자
'프론트엔드 > Next.js' 카테고리의 다른 글
데이터 목록 ui 공통 컴포넌트, 훅으로 관리하기 (0) | 2024.05.19 |
---|---|
Next.js 14 기초 활용법 (1) | 2024.02.24 |