Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FIX] Example NextJS - Fixing JWT Refresh token rotation #84

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

matmont
Copy link

@matmont matmont commented Oct 9, 2023

Problem

I'm working on some personal side project using Spotify Web API, and I was checking out how to handle authentication in a correct way. I'm using NextJS so I checked the Next example of the SDK as long as the NextAuth.js documentation. Checking in the code I found something that seemed a bit off to me, in the jwt callback of the authOptions.

Looking at the code, we can see that the first check done is on the definition of account. As per NextAuth.js documentation:

The arguments user, account, profile and isNewUser are only passed the first time this callback is called on a new session, after the user signs in. In subsequent calls, only token will be available.

In the code we are now just returning the token as it is if account is not present, and checking for validity in the other case. But I would suggest to invert this flow. As above, if account is present, the session is brand new, so we don't need to check for validity. In all the cases where account is not defined, we then should check for validty, refreshing the token if necessary (proactively strategy).

Solution

I changed the code to resemble the flow suggested above. The idea is to check if the session is brand new:

  • if so, nothing to do here, just returning the brand new token;
  • otherwise, we should check if the current token is expired or not:
    • if so, let's refresh it
    • otherwise, just return the current token, it's still valid

I hope that my understanding about the issue is right.

Sources/References:

@matmont matmont changed the title Example NextJS - Fixing JWT Refresh token rotation [FIX] Example NextJS - Fixing JWT Refresh token rotation Oct 9, 2023
@jcbraz
Copy link

jcbraz commented Nov 1, 2023

I gave this solution a try since it looks much clearer.

When testing it, I was getting redirected in an infinite loop to the authentication page since the authentication was rejected and the response was HTML with the authentication page, triggering the error

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
    at JSON.parse (<anonymous>)
    at parseJSONFromBytes (node:internal/deps/undici/undici:6662:19)
    at successSteps (node:internal/deps/undici/undici:6636:27)
    at node:internal/deps/undici/undici:1236:60
    at node:internal/process/task_queues:140:7
    at AsyncResource.runInAsyncScope (node:async_hooks:206:9)
    at AsyncResource.runMicrotask (node:internal/process/task_queues:137:8)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5).

Solved this problem by changing the fetch call in the refreshAccessToken function in the SpotifyProfile.ts file:

const response = await fetch("https://accounts.spotify.com/api/token", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${Buffer.from(
          `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`
        ).toString("base64")}`,
      },
      body: `grant_type=refresh_token&refresh_token=${token.refresh_token}`,
      cache: "no-cache",
    });

However, the main problem for the token rotation remains when calling the sdk, even with this new change.
For example, when calling for the sdk to create a playlist on the server side:

async function createPlaylist(title: string, prompt: string) {
    try {
        const playlist = await sdk.playlists.createPlaylist(process.env.SPOTIFY_USER_ID || '', {
            name: title,
            public: false,
            collaborative: true,
            description: prompt
        });

        return {
            id: playlist.id,
            url: playlist.external_urls.spotify
        } as Playlist;
    } catch (error) {
        console.error('Error creating playlist', error);
    }
}

The error in the server logs comes as

Error creating playlist Error: Bad or expired token. This can happen if the user revoked a token or the access token has expired. You should re-authenticate the user.
    at DefaultResponseValidator.validateResponse (webpack-internal:///(action-browser)/./node_modules/@spotify/web-api-ts-sdk/dist/mjs/responsevalidation/DefaultResponseValidator.js:9:23)
    at SpotifyApi.makeRequest (webpack-internal:///(action-browser)/./node_modules/@spotify/web-api-ts-sdk/dist/mjs/SpotifyApi.js:105:52)

Do you know any solution for this?

@ingadi
Copy link

ingadi commented May 20, 2024

@jcbraz Did you ever manage to find a solution for getting the refreshed token to the sdk?

@holnburger
Copy link

holnburger commented Nov 17, 2024

I had the same problem as @jcbraz, I modified your solution a bit and haven't experienced any problems so far (@ingadi):

Changed the refreshAccesToken function a bit:

export async function refreshAccessToken(token: JWT) {
  try {
    const basicAuth = Buffer.from(
      `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`
    ).toString("base64");

    const response = await fetch("https://accounts.spotify.com/api/token", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${basicAuth}`,
      },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: token.refresh_token as string,
      }).toString(),
      cache: "no-store",
    });

    const refreshedTokens = await response.json();

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

    const now = Math.floor(Date.now() / 1000);
    const expires_at = now + refreshedTokens.expires_in;

    return {
      ...token,
      access_token: refreshedTokens.access_token,
      token_type: refreshedTokens.token_type ?? "Bearer",
      expires_at,
      expires_in: refreshedTokens.expires_in,
      refresh_token: refreshedTokens.refresh_token ?? token.refresh_token,
      scope: refreshedTokens.scope ?? token.scope,
    };
  } catch (error) {
    console.error("Error refreshing access token:", error);
    return {
      ...token,
      error: "RefreshAccessTokenError",
    };
  }
}

There was a problem with the expiration check logic that should be fixed by this.

@ingadi
Copy link

ingadi commented Nov 17, 2024

@holnburger Nice. Somewhat related but did you ever experience an issue where once the token expired and was refreshed you had to reload the page for the new token to get picked up?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants