Scottish Smile
Scottish Smile

Reputation: 1141

Next Auth Refresh Tokens Produces Multiple Callbacks

I copied the Next-Auth suggested Refresh Token code from their github:

Next-Auth Refresh Tokens

However, my backend API is getting 2 or 3 JWT callback requests one after the other, milliseconds of a difference.

This means my backend API processes the first request, issues a new refresh/access token. As part of this process it deletes the old token as it is not current anymore. 200 OK returned.

The next two Next-Auth requests both see that the token they are supplying is no longer current, and get 400 Bad Request returned. (As intended, this is refresh token rotation)

This overwrites the original "good" request and Next Auth logs the user out.

Next Auth shouldn't be sending multiple requests like this.

Is there a way to only send one request?

I'm thinking maybe you can lock the thread/ callback to wait until a response is received before doing any more calls?

Thanks!

dashboard.js :

const Dashboard = () => {
  const isAuthenticated = useAuth(true); // true means we should redirect to login page if the user is not authenticated
  const { data: session } = useSession(); // useAuth ALSO has a useSession hook so maybe this is duplicate?

  return;
  <>
    {isAuthenticated ? (
      <Layout title="Dashboard" content="Members Area Dashboard">
        <div>
          <h2>Welcome {session?.user.username} to the Members Area!</h2>
        </div>
      </Layout>
    ) : (
      <div>
        <p>Error: User Signed Out! Login to access this page.</p>
        <p>
          <Link href="/members/login">Login</Link>
        </p>
      </div>
    )}
  </>;
};

export default Dashboard;

useAuth.js :

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

  useEffect(() => {
    if (session?.error === "RefreshAccessTokenError") {
      signOut();
    }

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

  return isAuthenticated;
}

[...nextauth].js :

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { API_URL } from "@/constants/constants";
import { signOut } from "next-auth/react";

async function refreshAccessToken(tokenObject) {
  try {
    const tokenData = {
      refreshToken: tokenObject.refreshToken,
      userName: tokenObject.username,
    };

    const data = JSON.stringify(tokenData);

    const options = {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: data,
    };

    const response = await tokenResponse.json();

    if (tokenResponse.status === 200) {
      // Success 200 OK
      return {
        ...tokenObject,
        accessToken: response.accessToken,
        accessTokenExpiry: response.accessTokenExpiry,
        refreshToken: response.refreshToken,
        refreshTokenExpiry: response.refreshTokenExpiry,
      };
    } else {
      // Fail. Return the API error message.
      return {
        ...tokenObject,
        error: "RefreshAccessTokenError",
      };
    }
  } catch (error) {
    return {
      ...tokenObject,
      error: "RefreshAccessTokenError",
    };
  }
}

const providers = [
  // Add Your Providers Here
  CredentialsProvider({
    async authorize(credentials, req) {
      const { Username, Password } = credentials;

      const body = JSON.stringify({
        Username,
        Password,
      });

      const res = await fetch(`${API_URL}/Login`, {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json; charset=utf-8",
        },
        body: body,
      }).catch((error) => {
        const fetchError = {
          success: false,
          message: "Sorry, something is wrong with our servers...",
        };

        return fetchError;
      });

      let data = "";
      if (res.ok) {
        data = await res.json();
      } else if (res.status === 400 || res.status === 500) {
        // If we get a 400 or 500 response we still want to map the error message.
        data = await res.json();
      } else {
        // Incase there's an exception in the fetch
        data = res; // The fetchError is not json.
      }

      const user = {
        success: data.success,
        message: data.message,
        id: data.id,
        email: data.email,
        username: data.username,
        accessToken: dataaccessToken,
        accessTokenExpiry: data.accessTokenExpiry,
        refreshToken: data.refreshToken,
        refreshTokenExpiry: data.refreshTokenExpiry,
        roles: data.roles,
      };

      if (res.ok && user) {
        return user;
      } else {
        return user;
      }
    },
  }),
];

const callbacks = {
  async signIn({ user }) {
    if (user.success) {
      return true;
    } else {
      let url = `/members/login?error=${user.message}`;
      return url;
    }
  },
  async jwt({ token, user }) {
    if (user) {
      token.accessToken = user.accessToken;
      token.accessTokenExpiry = user.accessTokenExpiry;
      token.refreshToken = user.refreshToken;
      token.refreshTokenExpiry = user.refreshTokenExpiry;
      token.username = user.username;
      token.user = user;
      token.roles = user.roles;
    }

    let utcDateNow = new Date().toISOString();

    // If the token is still valid, just return it.
    if (token.accessTokenExpiry > utcDateNow) {
      return token;
    }

    // If the access token has expired, refresh it.
    token = await refreshAccessToken(token);

    if (token?.error === "RefreshAccessTokenError") {
      signOut();
    }

    return token;
  },
  async session({ session, token }) {
    session.user = token.user;
    session.accessToken = token.accessToken;
    session.accessTokenExpiry = token.accessTokenExpiry;
    session.refreshToken = token.refreshToken;
    session.refreshTokenExpiry = token.refreshTokenExpiry;
    session.error = token.error;

    return session;
  },
};

export const options = {
  providers,
  callbacks,

  pages: {
    signIn: "/members/login",
    signOut: "members/signout",
    error: "/members/auth-error",
  },
};

const Auth = (req, res) => NextAuth(req, res, options);
export default Auth;

Upvotes: 0

Views: 3347

Answers (1)

Ahmed Sbai
Ahmed Sbai

Reputation: 16219

I think the issue is that the useEffect in useAuth.js is running twice because of strictMode.
One solution is to make the code only run the second time:

const run = useRef(0);
useEffect(() => {
  if (run.current !== 0) {
    //your code
  }
  run.current++;
}, [session]);

Note: this not recommended but in this case you can make it as temporary fix, it will not harm
In production you don't have to do that strictMode is disabled

Upvotes: 2

Related Questions