跳至内容
从 NextAuth.js v4 迁移?阅读 我们的迁移指南.
指南刷新令牌轮换
💡

截至目前,还没有内置的自动刷新令牌轮换解决方案。本指南将帮助您在应用程序中实现此目标。我们的目标是最终为内置提供商添加零配置支持。告诉我们如果您想提供帮助。

什么是刷新令牌轮换?

刷新令牌轮换是在不需用户交互(即重新验证)的情况下更新用户access_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可以保存在 cookie 中的加密 JWT 中或保存在数据库中。

JWT 策略

💡

虽然使用 cookie 来存储refresh_token更简单,但它安全性较低。为了降低使用strategy: "jwt"的风险,Auth.js 库将refresh_token存储在 cookie 中的加密 JWT 中,并使用HttpOnly cookie。但您仍需根据自己的需求评估选择哪种策略。

使用jwtsession回调,我们可以持久化 OAuth 令牌并在它们过期时刷新它们。

以下是用 Google 刷新access_token的示例实现。请注意,获取refresh_token的 OAuth 2.0 请求在不同提供商之间会有所不同,但其余逻辑应该相似。

./auth.ts
import NextAuth, { type User } from "next-auth"
import Google from "next-auth/providers/google"
 
export const { handlers, auth } = NextAuth({
  providers: [
    Google({
      // Google requires "offline" access_type to provide a `refresh_token`
      authorization: { params: { access_type: "offline", prompt: "consent" } },
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        // First-time login, save the `access_token`, its expiry and the `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) {
        // Subsequent logins, but the `access_token` is still valid
        return token
      } else {
        // Subsequent logins, but the `access_token` has expired, try to refresh it
        if (!token.refresh_token) throw new TypeError("Missing refresh_token")
 
        try {
          // The `token_endpoint` can be found in the provider's documentation. Or if they support OIDC,
          // at their `/.well-known/openid-configuration` endpoint.
          // i.e. 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
          }
 
          token.access_token = newTokens.access_token
          token.expires_at = Math.floor(
            Date.now() / 1000 + newTokens.expires_in
          )
          // Some providers only issue refresh tokens once, so preserve if we did not get a new one
          if (newTokens.refresh_token)
            token.refresh_token = newTokens.refresh_token
          return token
        } catch (error) {
          console.error("Error refreshing access_token", error)
          // If we fail to refresh the token, return an error so we can handle it on the page
          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"
  }
}

数据库策略

使用数据库会话策略类似,但我们将access_tokenexpires_atrefresh_token保存在给定提供商的account上。

./auth.ts
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()) {
        // If the access token has expired, try to refresh it
        try {
          // https://accounts.google.com/.well-known/openid-configuration
          // We need the `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 refreshing access_token", error)
          // If we fail to refresh the token, return an error so we can handle it on the page
          session.error = "RefreshTokenError"
        }
      }
      return session
    },
  },
})
 
declare module "next-auth" {
  interface Session {
    error?: "RefreshTokenError"
  }
}

错误处理

如果令牌刷新失败,我们可以强制重新验证。

app/dashboard/page.tsx
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") // Force sign in to obtain a new set of access and refresh tokens
  }
}
Auth.js © Balázs Orbán 和团队 -2024