N

Next.js 身份验证 - 使用 NextAuth.js 进行 JWT 刷新令牌轮换

2025-06-09

Next.js 身份验证 - 使用 NextAuth.js 进行 JWT 刷新令牌轮换

最近,我正在一个 Next.js 应用中实现身份验证。在权衡了多个方案之后,我最终选择了 NextAuth.js,因为它是为 Next.js 量身定制的,并且支持多种身份验证服务提供商。

虽然只使用访问令牌,但身份验证流程实现起来相当简单。当我添加刷新令牌并尝试静默验证用户身份时,问题出现了。

在撰写本文时,尚无关于如何在 NextAuth.js 中实现令牌轮换的官方最佳实践。未来可能会有内置的 JWT 轮换解决方案,因此最好先查看文档。这里有一个教程,可能足以满足您的用例。

本简短教程是我对这个问题的理解,希望对大家有所帮助。我使用的是 Next.js 12.1.0、NextAuth.js 4.2.1 以及一个带有独立后端的凭证提供程序,该后端负责签发令牌。不过,文中提出的概念也适用于其他提供程序。

身份验证流程

当用户输入其凭证时,后端会验证它们并返回accessTokenaccessTokenExpiryrefreshToken

  • 它们accessToken的寿命应该相对较短,比如说 24 小时。
  • 另一方面refreshToken, 应该是长期有效的,有效期比如说 30 天。它将用于获取新的accessTokens
  • accessTokenExpiry令牌失效的时间戳。它也可以嵌入到令牌accessToken本身中,稍后解码以获取到期时间戳。

我们可以设置refetchInterval,定期向后端请求新令牌。该调用应在accessToken过期之前进行,以确保用户保持身份验证状态。如果调用发生在accessToken过期之后,只要 仍然有效,我们仍然有机会刷新令牌refreshToken。但是,如果调用发生在refreshToken过期之后,我们应该注销用户。

服务器端

我们创建一个文件,pages/api/auth/[…nextauth].js用于存放令牌轮换的后端逻辑。文件中包含一个CredentialsProvider,用于实现凭证身份验证。返回的对象将传递给jwt回调函数。

jwt回调是我们决定令牌是否可以刷新的地方。回调是我们用session指定客户端上可用内容的地方useSession()getSession()

关于刷新时间,我决定在令牌过期前为其设置一个刷新窗口。如果我们直接这样做token.accessTokenExpiry - Date.now() > 0,要么会refetchInterval调用会话刷新过早,导致下一个时间间隔内令牌无法刷新;要么会调用会话刷新过晚,导致用户在一段时间内无法获得有效的身份验证令牌。

[…nextauth].js

import axios from 'axios';
import NextAuth from 'next-auth';
import CredentialsProvider from "next-auth/providers/credentials";

async function refreshAccessToken(tokenObject) {
    try {
        // Get a new set of tokens with a refreshToken
        const tokenResponse = await axios.post(YOUR_API_URL + 'auth/refreshToken', {
            token: tokenObject.refreshToken
        });

        return {
            ...tokenObject,
            accessToken: tokenResponse.data.accessToken,
            accessTokenExpiry: tokenResponse.data.accessTokenExpiry,
            refreshToken: tokenResponse.data.refreshToken
        }
    } catch (error) {
        return {
            ...tokenObject,
            error: "RefreshAccessTokenError",
        }
    }
}

const providers = [
    CredentialsProvider({
        name: 'Credentials',
        authorize: async (credentials) => {
            try {
                // Authenticate user with credentials
                const user = await axios.post(YOUR_API_URL + 'auth/login', {
                    password: credentials.password,
                    email: credentials.email
                });

                if (user.data.accessToken) {
                    return user.data;
                }

                return null;
            } catch (e) {
                throw new Error(e);
            }
        }
    })
]

const callbacks = {
    jwt: async ({ token, user }) => {
        if (user) {
            // This will only be executed at login. Each next invocation will skip this part.
            token.accessToken = user.data.accessToken;
            token.accessTokenExpiry = user.data.accessTokenExpiry;
            token.refreshToken = user.data.refreshToken;
        }

        // If accessTokenExpiry is 24 hours, we have to refresh token before 24 hours pass.
        const shouldRefreshTime = Math.round((token.accessTokenExpiry - 60 * 60 * 1000) - Date.now());

        // If the token is still valid, just return it.
        if (shouldRefreshTime > 0) {
            return Promise.resolve(token);
        }

        // If the call arrives after 23 hours have passed, we allow to refresh the token.
        token = refreshAccessToken(token);
        return Promise.resolve(token);
    },
    session: async ({ session, token }) => {
        // Here we pass accessToken to the client to be used in authentication with your API
        session.accessToken = token.accessToken;
        session.accessTokenExpiry = token.accessTokenExpiry;
        session.error = token.error;

        return Promise.resolve(session);
    },
}

export const options = {
    providers,
    callbacks,
    pages: {},
    secret: 'your_secret'
}

const Auth = (req, res) => NextAuth(req, res, options)
export default Auth;
Enter fullscreen mode Exit fullscreen mode

客户端

_app.js我们用 包装我们的应用。然后<SessionProvider>我们将 设置refetchInterval为以秒为单位的特定值。这里的问题是,如果设置一个常量值,每次用户刷新页面时,计数器都会重新启动。因此,如果我们将 设置为refetchInterval23 小时 30 分钟,当用户离开页面并在 12 小时后返回时,计数器会重新启动。因此,在Date.now()+12 小时到Date.now()+23 小时 30 分钟之间,我们得到的是无效令牌。

_app.js

import { SessionProvider } from 'next-auth/react';
import { useState } from 'react';
import RefreshTokenHandler from '../components/refreshTokenHandler';

function MyApp({ Component, pageProps }) {
    const [interval, setInterval] = useState(0);

    return (
        <SessionProvider session={pageProps.session} refetchInterval={interval}>
            <Component {...pageProps} />
            <RefreshTokenHandler setInterval={setInterval} />
        </SessionProvider>
    )
}

export default MyApp;
Enter fullscreen mode Exit fullscreen mode

为了解决这个问题,我创建了一个RefreshTokenHandler组件,它必须放置在 内部,<SessionProvider>以便我们能够访问useSession钩子,从而获取访问令牌的到期时间。然后,我们计算距离到期的剩余时间,减去 30 分钟的余量。现在,每次用户刷新页面时,间隔都会设置为正确的剩余时间。

refreshTokenHandler.js

import { useSession } from "next-auth/react";
import { useEffect } from "react";

const RefreshTokenHandler = (props) => {
    const { data: session } = useSession();

    useEffect(() => {
        if(!!session) {
            // We did set the token to be ready to refresh after 23 hours, here we set interval of 23 hours 30 minutes.
            const timeRemaining = Math.round((((session.accessTokenExpiry - 30 * 60 * 1000) - Date.now()) / 1000));
            props.setInterval(timeRemaining > 0 ? timeRemaining : 0);
        }
    }, [session]);

    return null;
}

export default RefreshTokenHandler;
Enter fullscreen mode Exit fullscreen mode

令牌刷新现在应该可以正常工作了。我们可以创建一个钩子,当刷新令牌过期时,它会将用户注销。如果需要,我们可以进行重定向,并保存用户是否已通过身份验证的状态。

useAuth.js

import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";

export default function useAuth(shouldRedirect) {
    const { data: session } = useSession();
    const router = useRouter();
    const [isAuthenticated, setIsAuthenticated] = useState(false);

    useEffect(() => {
        if (session?.error === "RefreshAccessTokenError") {
            signOut({ callbackUrl: '/login', redirect: shouldRedirect });
        }

        if (session === null) {
            if (router.route !== '/login') {
                router.replace('/login');
            }
            setIsAuthenticated(false);
        } else if (session !== undefined) {
            if (router.route === '/login') {
                router.replace('/');
            }
            setIsAuthenticated(true);
        }
    }, [session]);

    return isAuthenticated;
}
Enter fullscreen mode Exit fullscreen mode

我们可以在我们的页面中使用这个钩子,如果用户未经身份验证则显示一条消息,或者让应用程序将用户重定向到登录页面。

const isAuthenticated = useAuth(true);

差不多就是这样了。现在我们可以用中的signIn()signOut()方法测试这个机制了index.js。至于我们在这个例子中设置的时间裕度,它们相当宽裕。这是因为我发现,在某些极端情况下,refetchInterval可能会略微滞后。

index.js

import { signIn, signOut } from 'next-auth/react';

export default function Home() {
  return (
    <>
      <button onClick={() => signIn('credentials', { email: 'example@example.com', password: 'example' })}>
        Sign in
      </button>
      <button onClick={() => signOut()}>
        Sign out
      </button>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

包起来

我们介绍了一种使用 NextAuth.js 实现 JWT 令牌轮换的方法。感谢您的关注,如果您有更好的 NextAuth.js 中 JWT 令牌轮换解决方案,欢迎在下方留言或在Twitter上联系我。

鏂囩珷鏉ユ簮锛�https://dev.to/mabaranowski/nextjs-authentication-jwt-refresh-token-rotation-with-nextauthjs-5696
PREV
使用 React 和 Intersection Observer 创建部分导航 使用 React 和 Intersection Observer 创建部分导航
NEXT
了解二进制系统如何工作的最简单方法。