nextauthjs / next-auth

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

Newly refreshed JWT token does not get returned properly #6680

Open carledwardfp opened 1 year ago

carledwardfp commented 1 year ago

Provider type

Spotify

Environment

 System:
    OS: macOS 13.0.1
    CPU: (8) arm64 Apple M1
    Memory: 46.77 MB / 8.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 16.14.2 - ~/.nvm/versions/node/v16.14.2/bin/node
    Yarn: 1.22.19 - ~/.nvm/versions/node/v16.14.2/bin/yarn
    npm: 8.5.0 - ~/.nvm/versions/node/v16.14.2/bin/npm
  Browsers:
    Brave Browser: 110.1.48.158
    Chrome: 109.0.5414.119
    Safari: 16.1

Reproduction URL

-

Describe the issue

Since Spotify access tokens expire in 1 hour after generation, I implemented Refresh Token Rotation following this guide

refreshToken:

const SPOTIFY_REFRESH_TOKEN_URL = 'https://accounts.spotify.com/api/token'
const CLIENT_ID = serverEnv.SPOTIFY_CLIENT_ID
const CLIENT_SECRET = serverEnv.SPOTIFY_CLIENT_SECRET

async function refreshAccessToken(token: JWT): Promise<JWT> {
  try {
    const basicAuth = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString(
      'base64'
    )
    const { data } = await axios.post(
      SPOTIFY_REFRESH_TOKEN_URL,
      {
        grant_type: 'refresh_token',
        refresh_token: token.refreshToken,
      },
      {
        headers: {
          Authorization: `Basic ${basicAuth}`,
          'Content-Type': 'application/x-www-form-urlencoded',
        },
      }
    )
    return {
      ...token,
      accessToken: data.access_token,
      accessTokenExpires: Date.now() + data.expires_in * 1000,
    }
  } catch (error) {
    return {
      ...token,
      error: 'RefreshAccessTokenError',
    }
  }
}

authOptions:

export const authOptions: NextAuthOptions = {
   ...
   providers: [
      SpotifyProvider({
         clientId: serverEnv.SPOTIFY_CLIENT_ID,
         clientSecret: serverEnv.SPOTIFY_CLIENT_SECRET,
         authorization: `https://accounts.spotify.com/authorize?scope=${spotifyScopes}`,
      }),
   ],
   callbacks: {
      async jwt({ token, account, user }) {
         if (account && user) {
            console.log(`jwt:signin account:${account.expires_at * 1000}`)
            return {
               accessToken: account.access_token,
               refreshToken: account.refresh_token,
               accessTokenExpires: account.expires_at * 1000,
               user,
            }
         }
         if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
            console.log(`jwt:session token:${token.accessTokenExpires}`)
            return token
         }
         const newToken = await refreshAccessToken(token)
         console.log(`jwt:refresh token:${newToken.accessTokenExpires}`)
         return newToken
      }
      async session({ session, token }) {
         session.accessToken = token.accessToken
         session.error = token.error
         session.user = token.user
         return session
      },
   },
   session: {
      strategy: 'jwt',
   },
   ...
}

When you are past the expiration time (1 hour for spotify). The jwt calllback should refresh the token

What happens is that after refreshing the access token, the token returned in the jwt callback does not change the accessTokenExpires value causing it to refresh the token on every request. Since it always fails this checker if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) { return token }

How to reproduce

  1. Follow the files above.

  2. Login and note the accessTokenExpires returned

  3. Make sure that you have gone past the initial expiry (1 hour)

    • I change the function token.accessTokenExpires && Date.now() < token.accessTokenExpires to token.accessTokenExpires && Date.now() > token.accessTokenExpires to simulate expiration
  4. It should successfully request a new access token. Make sure to note the accessTokenExpires again

// this is where it fails

  1. refresh the page --> it tries to refresh the token instead of using the newly refreshed token object
  2. note the accessTokenExpires returned
  3. The accessTokenExpires is not changed

sample logs: I added console logs in the example above so you can check the ff log values

Expected behavior

logs should look like this:

pauldunn commented 1 year ago

I am also seeing this exact issue but when using Credentials provider.

However when you call getServerSession to get the token in a server component it does return the updated token. Odd.

nicklascarnegie commented 1 year ago

Related to/duplicate of #6447 ?

babblebey commented 1 year ago

Hi @carledwardfp,

Few observations....

I think you'd better append your client_id and client_secret to your post params object rather than using them in the headers. This should be safe to do considering the code runs in the API route i.e. serverSide. Hence your axios.post param should look like so..

const { data } = await axios.post(
    SPOTIFY_REFRESH_TOKEN_URL,
    {
          client_id: CLIENT_ID,
          client_secret: CLIENT_SECRET,
          grant_type: 'refresh_token',
          refresh_token: token.refreshToken,
    },
    {
          headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
          },
    }
)

Consider simplifying your token expiration checker within your callback or separating the evaluations. i.e. the checker should be in one of both ways.

  1. Simplify

    if (Date.now() < token.accessTokenExpires) {
      return token;
    }
  2. Seperate

    if (token.accessTokenExpires && (Date.now() < token.accessTokenExpires)) {
      return token
    }

I am also working with a Spotify Provider in NextAuth and here's my configuration ...nextauth.ts

javsco commented 1 year ago

getting the same error using the credentials provider.

javsco commented 1 year ago

I implemented the refresh token rotation in my repo six months ago and it was working as expected, but now the access token is not been updated from the refresh token endpoint, when I logged the endpoint response it looks fine, it returns a new valid access token but it is not updated in the JWT returned object.

callbacks: {
        async jwt({ token, user }) {
            // check if user is logged but accessToken is expired
            if (token.accessToken) {
                const isExpired = isTokenExpired(token.accessToken);

                if (isExpired) {
                    try {
                        const response = await API.post(API_URL.REFRESH_TOKEN, {
                            refreshToken: token.refreshToken,
                        });

                        const newAccessToken = response.data.access_token;

                        return {
                            ...token,
                            accessToken: newAccessToken,
                        };
                    } catch (error) {
                        console.log(error.response.data);
                        return {
                            ...token,
                            error: 'invalidRefreshToken',
                        };
                    }
                }
            }

            if (user) {
                return {
                    ...token,
                    accessToken: user.data.access_token,
                    refreshToken: user.data.refresh_token,
                };
            }

            return {
                ...token,
                ...user,
            };
        },

        async session({ session, token }) {
            session.user = token.user;
            session.accessToken = token.accessToken;
            session.error = token.error;

            return session;
        },
}
DaviPolita commented 1 year ago

Same problem, I cant update token after the first login. Any news on this issue?

Redemption198 commented 11 months ago

+1