현재로서는 자동 Refresh Token 갱신을 위한 내장 솔루션이 없습니다. 이 가이드는 여러분의 애플리케이션에서 이를 구현하는 데 도움을 줄 것입니다. 우리의 목표는 내장 프로바이더에 대해 설정 없이도 사용할 수 있는 기능을 추가하는 것입니다. 도움을 주고 싶다면 저희에게 알려주세요.
리프레시 토큰 회전이란?
리프레시 토큰 회전은 사용자의 재인증 없이도 access_token
을 업데이트하는 방법입니다.
access_token
은 일반적으로 제한된 시간 동안만 유효합니다. 만료되면 서비스에서 이를 무시하게 되고, access_token
은 더 이상 사용할 수 없게 됩니다.
사용자에게 다시 로그인을 요청하는 대신, 많은 프로바이더들은 초기 로그인 시 더 긴 유효 기간을 가진 refresh_token
을 발급합니다.
Auth.js 라이브러리는 이 refresh_token
을 사용해 사용자의 재로그인 없이도 새로운 access_token
을 얻도록 설정할 수 있습니다.
구현
다음 가이드에는 보안상의 이유로 refresh_token
이 일반적으로 한 번만 사용 가능하다는 사실에서 비롯된 고유한 제한이 있습니다. 즉, 성공적으로 리프레시한 후에는 refresh_token
이 무효화되어 다시 사용할 수 없습니다. 따라서 여러 요청이 동시에 토큰을 리프레시하려고 시도할 경우 경쟁 조건이 발생할 수 있습니다. Auth.js 팀은 이 문제를 인지하고 있으며, 향후 해결책을 제공할 계획입니다. 이는 여러 요청이 동시에 토큰을 리프레시하지 못하도록 하는 “락” 메커니즘을 포함할 수 있지만, 이는 애플리케이션에서 병목 현상을 일으킬 가능성이 있습니다. 또 다른 가능한 해결책은 인증된 요청 중에 토큰이 만료되지 않도록 백그라운드 토큰 리프레시를 사용하는 것입니다.
먼저, 사용하려는 프로바이더가 refresh_token
을 지원하는지 확인하세요. 자세한 내용은 OAuth 2.0 인증 프레임워크 스펙을 참조하세요. 세션 전략에 따라 refresh_token
은 쿠키 내부의 암호화된 JWT에 저장되거나 데이터베이스에 저장될 수 있습니다.
JWT 전략
refresh_token
을 쿠키에 저장하는 방법은 더 간단하지만, 보안 측면에서는 덜 안전합니다. strategy: "jwt"
를 사용할 때 발생할 수 있는 위험을 줄이기 위해, Auth.js 라이브러리는 refresh_token
을 암호화된 JWT로 변환한 후 HttpOnly
쿠키에 저장합니다. 하지만 여러분의 요구사항에 따라 어떤 전략을 선택할지 평가해야 합니다.
jwt와 session 콜백을 사용하면 OAuth 토큰을 유지하고, 토큰이 만료될 때 이를 갱신할 수 있습니다.
아래는 Google을 사용하여 access_token
을 갱신하는 예제 구현입니다. refresh_token
을 얻기 위한 OAuth 2.0 요청은 각 프로바이더마다 다를 수 있지만, 나머지 로직은 비슷하게 유지됩니다.
import NextAuth, { type User } from "next-auth"
import Google from "next-auth/providers/google"
export const { handlers, auth } = NextAuth({
providers: [
Google({
// Google은 `refresh_token`을 제공하기 위해 "offline" access_type을 요구합니다.
authorization: { params: { access_type: "offline", prompt: "consent" } },
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
// 첫 로그인 시, `access_token`, 만료 시간, `refresh_token`을 저장합니다.
return {
...token,
access_token: account.access_token,
expires_at: account.expires_at,
refresh_token: account.refresh_token,
}
} else if (Date.now() < token.expires_at * 1000) {
// 이후 로그인 시, `access_token`이 아직 유효한 경우
return token
} else {
// 이후 로그인 시, `access_token`이 만료된 경우, 갱신을 시도합니다.
if (!token.refresh_token) throw new TypeError("Missing refresh_token")
try {
// `token_endpoint`는 프로바이더의 문서에서 찾을 수 있습니다. 또는 OIDC를 지원하는 경우,
// `/.well-known/openid-configuration` 엔드포인트에서 확인할 수 있습니다.
// 예: https://accounts.google.com/.well-known/openid-configuration
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: new URLSearchParams({
client_id: process.env.AUTH_GOOGLE_ID!,
client_secret: process.env.AUTH_GOOGLE_SECRET!,
grant_type: "refresh_token",
refresh_token: token.refresh_token!,
}),
})
const tokensOrError = await response.json()
if (!response.ok) throw tokensOrError
const newTokens = tokensOrError as {
access_token: string
expires_in: number
refresh_token?: string
}
return {
...token,
access_token: newTokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + newTokens.expires_in),
// 일부 프로바이더는 refresh 토큰을 한 번만 발급하므로, 새로운 토큰이 없으면 기존 토큰을 유지합니다.
refresh_token: newTokens.refresh_token
? newTokens.refresh_token
: token.refresh_token,
}
} catch (error) {
console.error("Error refreshing access_token", error)
// 토큰 갱신에 실패한 경우, 페이지에서 처리할 수 있도록 에러를 반환합니다.
token.error = "RefreshTokenError"
return token
}
}
},
async session({ session, token }) {
session.error = token.error
return session
},
},
})
declare module "next-auth" {
interface Session {
error?: "RefreshTokenError"
}
}
declare module "next-auth/jwt" {
interface JWT {
access_token: string
expires_at: number
refresh_token?: string
error?: "RefreshTokenError"
}
}
데이터베이스 전략
데이터베이스 세션 전략을 사용하는 것도 비슷하지만, 대신 제공자(provider)에 대한 access_token
, expires_at
, refresh_token
을 account
에 저장합니다.
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
authorization: { params: { access_type: "offline", prompt: "consent" } },
}),
],
callbacks: {
async session({ session, user }) {
const [googleAccount] = await prisma.account.findMany({
where: { userId: user.id, provider: "google" },
})
if (googleAccount.expires_at * 1000 < Date.now()) {
// 액세스 토큰이 만료된 경우, 토큰을 새로고침 시도
try {
// https://accounts.google.com/.well-known/openid-configuration
// `token_endpoint`가 필요합니다.
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: new URLSearchParams({
client_id: process.env.AUTH_GOOGLE_ID!,
client_secret: process.env.AUTH_GOOGLE_SECRET!,
grant_type: "refresh_token",
refresh_token: googleAccount.refresh_token,
}),
})
const tokensOrError = await response.json()
if (!response.ok) throw tokensOrError
const newTokens = tokensOrError as {
access_token: string
expires_in: number
refresh_token?: string
}
await prisma.account.update({
data: {
access_token: newTokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + newTokens.expires_in),
refresh_token:
newTokens.refresh_token ?? googleAccount.refresh_token,
},
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: googleAccount.providerAccountId,
},
},
})
} catch (error) {
console.error("액세스 토큰 새로고침 중 오류 발생", error)
// 토큰 새로고침에 실패한 경우, 페이지에서 처리할 수 있도록 오류 반환
session.error = "RefreshTokenError"
}
}
return session
},
},
})
declare module "next-auth" {
interface Session {
error?: "RefreshTokenError"
}
}
에러 처리
토큰 갱신이 실패한 경우, 강제로 재인증을 진행할 수 있습니다.
import { useEffect } from "react"
import { auth, signIn } from "@/auth"
export default async function Page() {
const session = await auth()
if (session?.error === "RefreshTokenError") {
await signIn("google") // 새로운 액세스 토큰과 리프레시 토큰을 얻기 위해 강제로 로그인
}
}