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

Add tutorial on user-land refresh token usage #1079

Closed
balazsorban44 opened this issue Jan 10, 2021 · 14 comments · Fixed by #1310
Closed

Add tutorial on user-land refresh token usage #1079

balazsorban44 opened this issue Jan 10, 2021 · 14 comments · Fixed by #1310
Labels
docs Relates to documentation good first issue Good issue to take for first time contributors

Comments

@balazsorban44
Copy link
Member

balazsorban44 commented Jan 10, 2021

Summary of proposed feature
There is some confusion about how refreshing tokens can happen or what expiration means in the context of next-auth and a given provider. This probably stems from that users simply expect the access token to be always up-to-date when used. We don't currently support automatic access token refreshing, but it CAN be implemented by the users. Until we have built-in support, it would be nice to add a tutorial on how to do it by the user.

Purpose of proposed feature

It could either be a standalone tutorial page in https://next-auth.js.org/tutorials or could be an external link to a post, preferably with code example, maybe even an accessible live demo and source code.

Describe any alternatives you've considered
Preferably this should already be a built-in feature, but until we do have this, a tutorial would be useful to link confused users to.

Additional context

Here is my approach using IdentityServer 4, but probably any OIDC compliant provider would do. The code assumes at least next-auth@3.2.0:

...
callbacks: {
  async jwt(token, profile, account) {
    // Signing in
    if (account && profile) {
      return {
        accessToken: account.access_token,
        accessTokenExpires: Date.now() + account.expires_in * 1000,
        refreshToken: account.refresh_token,
        user: profile,
      }
    }
  
    // Subsequent use of JWT, the user has been logged in before
    // access token has not expired yet
    if (Date.now() < token.accessTokenExpires) {
      return token
    }
    // access token has expired, try to update it
    return refreshAccessToken(token)
  },
  async session(session, token) {
    if (token) {
      session.user = token.user
      session.accessToken = token.accessToken
      session.error = token.error
    }
    return session
  }
}
...

/**
 * Takes a token and returns a new one with updated
 * `accessToken` and `accessTokenExpires`. If an error occurs,
 * returns the old token with an error property.
 */
async function refreshAccessToken(token) {
  try {
    const url = `https://${process.env.PROVIDER_DOMAIN}/connect/token`

    const response = await fetch(url, {
      body: new URLSearchParams({
        client_id: process.env.PROVIDER_CLIENT_ID,
        client_secret: process.env.PROVIDER_CLIENT_SECRET,
        grant_type: "refresh_token",
        refresh_token: token.refreshToken,
      }),
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      method: "POST",
    })
    const tokens = await response.json()

    if (!response.ok) {
      throw tokens
    }

    return {
      ...token,
      accessToken: tokens.access_token,
      accessTokenExpires: Date.now() + refreshToken.expires_in * 1000,
      refreshToken: tokens.refresh_token,
    }
  } catch (error) {
    return {
      ...token,
      error: "RefreshAccessTokenError", // This is used in the front-end, and if present, we can force a re-login, or similar
    }
  }
}

Up for grab, as a good first issue.

@balazsorban44 balazsorban44 added docs Relates to documentation good first issue Good issue to take for first time contributors labels Jan 10, 2021
@lawrencecchen
Copy link
Contributor

I'm happy to help with this one!

@balazsorban44
Copy link
Member Author

@lawrencecchen Please do, just open a PR, so we can follow your progress! 🙂

@lawrencecchen
Copy link
Contributor

I've created a sample using Google's OAuth 2 here.

However, I'm confused about the client's session response when using the useSession() hook (the tokens are fake):

{
  "user": {
    "provider": "google",
    "type": "oauth",
    "id": "114609347520974957949",
    "refreshToken": "1//0634DctwasdfklDSAYIARAAGAYSNwF-L9Ir2ZtBT1MZlQ935LZrKfaskljwlr4ilJw31fFqyMdlJVO2s",
    "accessToken": "ya29.a0AfH6SMAkfYzR9E-9-TTOMpcB0Mmwk57NZGpsd6LwAr3oCll1-ydTFxkbEBSRFNwa02IHlaWXjyNpDtwVCojYDLyy4ApJ9vK2lh12fM4i2IQe6L2ulKxis8wMELc-HWCTlnYmjnjsDTiVghwI8ImlLzzxfChOtmViVtE6c-MVguw",
    "accessTokenExpires": null
  },
  "iat": 1610496123,
  "exp": 1613088123,
  "accessToken": "ya29.a0AfH6SMAkfYzR9E-9-TTOMpcB0Mmwk57NZGpsd6LwAr3oCll1-ydTFxkbEBSRFNwa02IHlaWXjyNpDtwVCojYDLyy4ApJ9vK2lh12fM4i2IQe6L2ulKxis8wMELc-HWCTlnYmjnjsDTiVghwI8ImlLzzxfChOtmViVtE6c-MVguw",
  "accessTokenExpires": 1610498482446
}

Why does user.accessTokenExpires default to null? Is my example implemented correctly?

I'll PR the tutorial post in the next few days after I figure this out. Lastly, I'm not sure if I create a live demo using Google because of their limits imposed on unverified apps. Any workarounds for this?

@balazsorban44
Copy link
Member Author

balazsorban44 commented Jan 30, 2021

@lawrencecchen you can now access account.expires_in in the latest canary!

You can learn more about why accessTokenExpires is null (didn't know the reason either) here #1216

@tesharp
Copy link

tesharp commented Feb 2, 2021

I was trying to do this for Azure AD authentication but problem with this is that the cookie is getting too big when adding both access and refresh token to the jwt token.. No option to set refresh_token in another cookie, so should at least be mentioned as a limitation when not using any database backend.

@balazsorban44
Copy link
Member Author

balazsorban44 commented Feb 2, 2021

@tesharp yes, that is an unfortunate browser limitation. I did mention it in my PR #951 (comment), and had an idea splitting things up, but if I remember our chat with @iaincollins, he had some reasoning why we should not split it up.

UPDATE: We will have another internal discussion about how we could support token rotation for non-db users. I don't have a date yet, unfortunately.

UPDATE: I added a user-land implementation for token rotation for non-db users at the original description.

@knthm
Copy link

knthm commented Feb 7, 2021

@balazsorban44 thank you for the token refresh boilerplate code!

A few small corrections though:

      accessTokenExpires: Date.now() + refreshToken.expires_in * 1000,

should be

      accessTokenExpires: Date.now() + tokens.expires_in * 1000,

Also it's unnecessary to pass the client_secret in the request body for a refresh_token grant type.

@balazsorban44
Copy link
Member Author

balazsorban44 commented Feb 7, 2021

Thank you for your concerns! Let me explain:

I guess it's provider specific then (which would be sad, bigger chance for edge cases when we will implement this internally 😒), because I wrote that one specifially for our IdentityServer 4 usecase:

https://identityserver4.readthedocs.io/en/latest/topics/refresh_tokens.html#requesting-an-access-token-using-a-refresh-token

The spec says:

The client requests a new access token by authenticating with the authorization server and presenting the refresh token. The client authentication requirements are based on the client type and on the authorization server policies

Auth0 sends it as well: https://auth0.com/docs/tokens/refresh-tokens/use-refresh-tokens

So at the end, I at least dont think sending it is any harm. I should probably check more providers, but I have a hunch that most of them will work with thre secret, where some of them wouldn't. So I think it's just safer to send it anyway.

Also, refreshToken.expires_in makes sure that you calculate the expiry date from the new token you got. I guess the expires_in value won't change usually, but the new refresh token response should also include this - potentially more up-to-date - value.

@knthm
Copy link

knthm commented Feb 8, 2021

I guess it's provider specific then (which would be sad, bigger chance for edge cases when we will implement this internally unamused), because I wrote that one specifially for our IdentityServer 4 usecase

Possibly, for me personally it's not required on a default public client and standard authorization code flow in Keycloak.

The spec needs to be a bit flexible in its definition of client authentication since there are so many possible permutations and applications of the various flows. I guess it really comes down to developer preference for interacting with a particular OAuth2 IdP.

IMHO: if a user retrieves an access token by logging in via authorization code flow on a public endpoint, then it makes little sense to force them to present a client secret in addition to the refresh token; the refresh token in that context already authenticates the user. Requiring a client secret would also require more information than a client would have needed to provide if the auth code flow was used to retrieve the access and refresh token in the first place. (This would break token refresh for any purely in-browser application that authenticates this way)

Note on Auth0's refresh documentation:

client_secret Optional. Your application's Client Secret. Only required for confidential applications.


Also, refreshToken.expires_in makes sure that you calculate the expiry date from the new token you got. I guess the expires_in value won't change usually, but the new refresh token response should also include this - potentially more up-to-date - value.

In my case with a standard Keycloak setup it does not: I can't access the refreshToken in that context, here the various outputs:

Could not refresh access token!
ReferenceError: refreshToken is not defined
    at refreshAccessToken (webpack-internal:///./pages/api/auth/[...nextauth].js:116:17)
Expired token
{
  accessToken: 'XXXXX',
  accessTokenExpires: 1612767367916,
  refreshToken: 'XXXXX',
  user: {
    sub: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
    email_verified: true,
    'my_client_id': { user_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
    name: 'Bobby Tables',
    preferred_username: 'btables',
    locale: 'en',
    given_name: 'Bobby',
    family_name: 'Tables',
    email: 'bobby@example.com'
  },
  iat: 1612767634,
  exp: 1615359634,
  error: undefined
}
Newly refreshed token
{
  access_token: 'XXXXX',
  expires_in: 300,
  refresh_expires_in: 0,
  refresh_token: 'XXXXX',
  token_type: 'Bearer',
  id_token: 'XXXXX',
  'not-before-policy': 0,
  session_state: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
  scope: 'openid offline_access profile email'
}

My only option here is to use the current timestamp plus the token validity period 😕

@balazsorban44
Copy link
Member Author

balazsorban44 commented Feb 8, 2021

If you have implementation issues, please open a question in https://github.com/nextauthjs/next-auth/discussions or if you think there is a bug in next-auth, you may open a different issue, because we are getting away from the original issue here (which is about to add some guidance on adding a token rotation feature, not solving provider-specific problems. We have to leave some space in the tutorial for the small differences in provider behavior, and make sure that our upcoming built-in implementation will take care of edge-cases instead). I would be happy to continue this in a different thread! (also, #1261 could be interesting for you)

@github-actions
Copy link

🎉 This issue has been resolved in version 3.5.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@github-actions
Copy link

github-actions bot commented Mar 1, 2021

🎉 This issue has been resolved in version 4.1.0-next.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@github-actions
Copy link

🎉 This issue has been resolved in version 4.0.0-next.5 🎉

The release is available on:

Your semantic-release bot 📦🚀

@aakash19here
Copy link

Summary of proposed feature There is some confusion about how refreshing tokens can happen or what expiration means in the context of next-auth and a given provider. This probably stems from that users simply expect the access token to be always up-to-date when used. We don't currently support automatic access token refreshing, but it CAN be implemented by the users. Until we have built-in support, it would be nice to add a tutorial on how to do it by the user.

Purpose of proposed feature

It could either be a standalone tutorial page in https://next-auth.js.org/tutorials or could be an external link to a post, preferably with code example, maybe even an accessible live demo and source code.

Describe any alternatives you've considered Preferably this should already be a built-in feature, but until we do have this, a tutorial would be useful to link confused users to.

Additional context

Here is my approach using IdentityServer 4, but probably any OIDC compliant provider would do. The code assumes at least next-auth@3.2.0:

...
callbacks: {
  async jwt(token, profile, account) {
    // Signing in
    if (account && profile) {
      return {
        accessToken: account.access_token,
        accessTokenExpires: Date.now() + account.expires_in * 1000,
        refreshToken: account.refresh_token,
        user: profile,
      }
    }
  
    // Subsequent use of JWT, the user has been logged in before
    // access token has not expired yet
    if (Date.now() < token.accessTokenExpires) {
      return token
    }
    // access token has expired, try to update it
    return refreshAccessToken(token)
  },
  async session(session, token) {
    if (token) {
      session.user = token.user
      session.accessToken = token.accessToken
      session.error = token.error
    }
    return session
  }
}
...

/**
 * Takes a token and returns a new one with updated
 * `accessToken` and `accessTokenExpires`. If an error occurs,
 * returns the old token with an error property.
 */
async function refreshAccessToken(token) {
  try {
    const url = `https://${process.env.PROVIDER_DOMAIN}/connect/token`

    const response = await fetch(url, {
      body: new URLSearchParams({
        client_id: process.env.PROVIDER_CLIENT_ID,
        client_secret: process.env.PROVIDER_CLIENT_SECRET,
        grant_type: "refresh_token",
        refresh_token: token.refreshToken,
      }),
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      method: "POST",
    })
    const tokens = await response.json()

    if (!response.ok) {
      throw tokens
    }

    return {
      ...token,
      accessToken: tokens.access_token,
      accessTokenExpires: Date.now() + refreshToken.expires_in * 1000,
      refreshToken: tokens.refresh_token,
    }
  } catch (error) {
    return {
      ...token,
      error: "RefreshAccessTokenError", // This is used in the front-end, and if present, we can force a re-login, or similar
    }
  }
}

Up for grab, as a good first issue.

This doesn't work and the docs haven't been changed yet regarding this.
The main issue is while checking the expiration of the token. The token consists of a stale value of expires in and it causes the triggering of refresh token for every request. Sure it updates the session object temporarily but when the user calls the getServerSession it will call the refreshAccessToken function each time which is not good as it should only be triggered after the expiration of access token.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs Relates to documentation good first issue Good issue to take for first time contributors
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants