nextauthjs / next-auth

Authentication for the Web.
https://authjs.dev
ISC License
23.13k stars 3.15k forks source link

Does Next Auth handle automatic JWT refresh? #5652

Open iWanheda opened 1 year ago

iWanheda commented 1 year ago

Question 💬

Not saying my provider's token, my own generated JWT token with: image

It doesn't seem to be handling it automatically, since I wait 30s and then get logged out

(I'm using Discord as auth provider)

How to reproduce ☕️

None

Contributing 🙌🏽

No, I am afraid I cannot help regarding this

noxify commented 1 year ago

No, but there is a tutorial which explains how to implement it: https://next-auth.js.org/tutorials/refresh-token-rotation

lantaozi commented 1 year ago

Yes, it does.

30s is too short, but no problem. Just set proper refetchInterval less than 30s, and it will renew the cookie token automatically.

The value for refetchInterval should always be lower than the value of the session maxAge session option.

Ref: https://next-auth.js.org/getting-started/client#refetch-interval

agusterodin commented 1 year ago

I don't believe this library supports out-of-the-box token refresh. I believe refetchInterval lets you set how often your client reaches out to Next Auth backend. It is still up to you to handle token refresh logic on the Next Auth backend.

I had to implement refresh tokens myself for Google identity platform and it has weird issues. It doesn't help that Google tokens in particular are so short lived and expire quickly (probably intentional design by Google).

In my opinion, authentication is not a solved problem (and is a damn nightmare) until refresh tokens fully seamlessly work. Would love to see that in a future version of this library 🙏

Zero disrespect intended. Thank you to the maintainers of this awesome library.

ThangHuuVu commented 1 year ago

We don't have out-of-the-box support yet, but we absolutely want to do this 🙌 Things are a bit hectic at the moment for the core team, so PR is welcome!

iWanheda commented 1 year ago

You've got a PR that is almost working from what I've seen, can you not spare a few mates from the team to actually implement it? I'm not hating just asking :)

hichemfantar commented 1 year ago

I def agree that JWT auth is not complete without a token refresh strategy. Would love to see some movement on this. Love to the team working on this project 🚀

byanes commented 1 year ago

This would be amazing to have. Thank you for the amazing work!

vwatel commented 1 year ago

Hey guys, Any update on this one? I'm also looking for refresh token strategy in order to have end-to-end integration using this great library. Than you :)

nreh commented 1 year ago

I also followed https://next-auth.js.org/tutorials/refresh-token-rotation to implement token refreshing

However, for some reason the re-fetch interval doesn't seem to line up with my token's expiration time, (my guess is that the token is acquired from some cache or cookies so it might already be close to expiration). As a result there is a small window between token expiration and refetching where the token is invalid for making API calls.

In addition, I assumed that the JWT callback function would fire every time useSession is called, as indicated by the documentation:

https://next-auth.js.org/configuration/callbacks#jwt-callback

Requests to /api/auth/signin, /api/auth/session and calls to getSession(), unstable_getServerSession(), useSession() will invoke this function, but only if you are using a JWT session.

However, I found this issue https://github.com/nextauthjs/next-auth/issues/4227 which indicates that this is not the case:

There is no session refetching by default, useSession uses what's in the cache. You can poll the session if you need to trigger the JWT callback. https://next-auth.js.org/getting-started/client#refetch-interval Another option is using getSession which always visits the backend: https://next-auth.js.org/getting-started/client#getsession

I'm trying to find a way to modify the useSession hook so that if the token is expired, it automatically requests a new one but I'm not sure how to override it nor do I know how to manually trigger a repoll of the token.

At this point I'm thinking of making the refetchInterval to a short timespan and making the token lifespan longer, but that seems like a very wasteful workaround.

stale[bot] commented 1 year ago

It looks like this issue did not receive any activity for 60 days. It will be closed in 7 days if no further activity occurs. If you think your issue is still relevant, commenting will keep it open. Thanks!

andrei-ivanov commented 1 year ago

no activity doesn't mean it's not needed

stale[bot] commented 1 year ago

It looks like this issue did not receive any activity for 60 days. It will be closed in 7 days if no further activity occurs. If you think your issue is still relevant, commenting will keep it open. Thanks!

andrei-ivanov commented 1 year ago

no activity doesn't mean it's not needed

stale[bot] commented 11 months ago

It looks like this issue did not receive any activity for 60 days. It will be closed in 7 days if no further activity occurs. If you think your issue is still relevant, commenting will keep it open. Thanks!

andrei-ivanov commented 11 months ago

no activity doesn't mean it's not needed

TheCarioca commented 11 months ago

I have been trying to remove the expired tokens, according to this topic, but it is not re-populated. What I am trying to do now is checking it manually, changing the session callback. First I verify if the expires_at > Date.now() from the account stored in my mongodb and fetching to https://discord.com/api/v10/oauth2/token. However I am not able to get the token to use the grant_type: "authorization_code". By the way I am using the database strategy, but in case it helps, here is my attempt so far:

import NextAuth from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import clientPromise from "@lib/mongodb";
import { MongoDBAdapter } from "@next-auth/mongodb-adapter";
import { ObjectId } from "mongodb";

export const authOptions = {
  providers: [
    DiscordProvider({
      clientId: process.env.DISCORD_CLIENT_ID,
      clientSecret: process.env.DISCORD_CLIENT_SECRET,
      authorization: { params: { scope: "identify email guilds" } },
    }),
  ],
  adapter: MongoDBAdapter(clientPromise),
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    session: async ({ session, user }) => {
      const client = await clientPromise;
      const db = client.db();
      const { expires_at, refresh_token } = await db
        .collection("accounts")
        .findOne({
          userId: new ObjectId(user.id),
        });

      // if (expires_at > Date.now()) {
      if (expires_at < Date.now()) {
        try {
          const response = await fetch(
            "https://discord.com/api/v10/oauth2/token",
            {
              method: "POST",
              headers: {
                "Content-Type": "application/x-www-form-urlencoded",
              },
              body: new URLSearchParams({
                client_id: process.env.DISCORD_CLIENT_ID,
                client_secret: process.env.DISCORD_CLIENT_SECRET,
                grant_type: "authorization_code",
                refresh_token: refresh_token,
                redirect_uri: "http://localhost:3000/",
                scope: "identify email guilds",
              }),
            }
          );

          const data = await response.json();
          console.log(data);

          if (!response.ok) throw data;

          await db.collection("accounts").updateOne(
            { userId: new ObjectId(user.id) },
            {
              $set: {
                access_token: data.access_token,
                expires_at: Date.now() + data.expires_in * 1000,
                refresh_token: data.refresh_token || user.refreshToken,
              },
            },
            { upsert: false }
          );
        } catch (error) {
          console.error("Error refreshing access token", error);
        }
      }

      session.user.id = user.id;
      return session;
    },
  },
};
export default NextAuth(authOptions);
KalleV commented 11 months ago

I'm eager to see built-in support for refresh tokens too! Here's the relevant refresh token-related snippets for how I set it up with the Keycloak provider.

Helpers

const TOKEN_REFRESH_THRESHOLD_IN_SECONDS = 30;
const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;

/**
 * Takes a token, and returns a new token with updated
 * `accessToken` and `accessTokenExpires`. If an error occurs,
 * returns the old token and an error property
 */
async function refreshAccessToken({refresh_token}: {refresh_token: string}) {
  const params = new URLSearchParams({
    client_id: process.env.KEYCLOAK_CLIENT_ID as string,
    grant_type: 'refresh_token',
    refresh_token,
  });

  const url = `${keycloakIssuer}/protocol/openid-connect/token`;

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: params,
  });

  const refreshedTokens: TokenSet = await response.json();

  if (!response.ok) {
    throw refreshedTokens;
  }

  return refreshedTokens;
}

// ...

/**
 * Checks if the access token is expired or not. Goal is try to refresh the token before it expires.
 *
 * Simplified version of this function:
 * https://github.com/keycloak/keycloak/blob/67c64c37df4c33a90f4db1873620da06fb751cd4/js/libs/keycloak-js/src/keycloak.js#L575
 *
 * @param minValidityInSeconds The minimum validity time in seconds for the access token.
 * @returns A boolean indicating whether the access token is expired or not.
 */
const isTokenExpired = (minValidityInSeconds: number, expiresAt: number) => {
  return Date.now() + minValidityInSeconds * 1000 > expiresAt * 1000;
};

Auth Options

Session Callback

{
  // other options
  async session({ session, user }) {
    /* Retrieve "userAccount" from adapter */

    if (
      userAccount &&
      isTokenExpired(TOKEN_REFRESH_THRESHOLD_IN_SECONDS, userAccount.expires_at!)
    ) {
      try {
        const refreshedTokens = await refreshAccessToken({
          refresh_token: userAccount?.refresh_token as string,
        });

        await myAdapter.linkAccount({
          ...userAccount,
          access_token: refreshedTokens.access_token,
          expires_at: Math.floor(
            Date.now() / 1000 + (refreshedTokens.expires_in as number),
          ),
          refresh_token:
            refreshedTokens.refresh_token ?? userAccount.refresh_token,
        });

        session.accessToken = refreshedTokens.access_token as string;
        session.idToken = refreshedTokens.id_token as string;
      } catch (error) {
        // The error property will be used client-side to handle the refresh token error
        session.error = 'RefreshAccessTokenError';
        return session;
      }
    } else {
      session.accessToken = userAccount?.access_token as string;
      session.idToken = userAccount?.id_token as string;
    }

    /* Assign attributes to session then return it for the frontend */

    return session;
  }
}
TheCarioca commented 11 months ago

@KalleV just 30 seconds of threshold is enough? I have no idea :)

stale[bot] commented 8 months ago

It looks like this issue did not receive any activity for 60 days. It will be closed in 7 days if no further activity occurs. If you think your issue is still relevant, commenting will keep it open. Thanks!

krzysztof-cislo commented 7 months ago

"no activity doesn't mean it's not needed" 🙂

stale[bot] commented 4 months ago

It looks like this issue did not receive any activity for 60 days. It will be closed in 7 days if no further activity occurs. If you think your issue is still relevant, commenting will keep it open. Thanks!

krzysztof-cislo commented 4 months ago

Anyone plans to solve this?

dominikjenik commented 3 months ago

https://stackoverflow.com/questions/78260818/set-expiration-and-automaticaly-refresh-jwt-token-in-nextauth-js/78260819#78260819 Looks like I have found a way how to implement it.

karar-shah commented 1 month ago

You've got a PR that is almost working from what I've seen, can you not spare a few mates from the team to actually implement it? I'm not hating just asking :)

Can yo please mention the PR, as i am also stuck with the same issue, i want the session token to expire after 1 day instead of 30 days.