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

docs(tutorials): refresh token rotation #1310

Merged
merged 6 commits into from
Feb 21, 2021

Conversation

lawrencecchen
Copy link
Contributor

What:
Add tutorial for refresh token rotation.

Why:
Closes #1079

How:
Docs

Checklist:

  • Documentation
  • Tests N/A
  • Ready to be merged

I encountered strange behavior when returning refreshToken from the jwt() callback function. refreshToken is not persisted, but any other key is (lines 47 & 48), which is why I also returned refreshToken2. Somehow, refreshToken2 is persisted. Would appreciate review! Here's a repo that recreates this.

@vercel
Copy link

vercel bot commented Feb 13, 2021

This pull request is being automatically deployed with Vercel (learn more).
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: https://vercel.com/nextauthjs/next-auth/3KXgkNkQ5pcBxFdwovwrMqRs6ezv
✅ Preview: https://next-auth-git-fork-lawrencecchen-main-nextauthjs.vercel.app

@vercel vercel bot temporarily deployed to Preview February 13, 2021 09:49 Inactive
@github-actions github-actions bot added the docs Relates to documentation label Feb 13, 2021
@balazsorban44 balazsorban44 self-assigned this Feb 13, 2021
@balazsorban44
Copy link
Member

Thanks! I'll check out the refresh token issue you are having. The way you did it should be unnecessary.

Copy link
Member

@balazsorban44 balazsorban44 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So some small comments.

I would prefer if we used the simple initialization form NextAuth(options), instead of (req, res) => NextAuth(req, res, options).

I see you have used Google in your example. From what I can gather from their docs at https://developers.google.com/identity/protocols/oauth2/web-server#exchange-authorization-code, the Google refresh_token is special, because you won't get a new one unless the user authorizes themselves again. Because of this, the refresh_token must be kept between the refreshAccessToken calls, meaning we have to fall back to the previous one if we did not get a new one (added this in the suggestion code.) Doing so will hopefully make sure you won't need a second refresh token.

Might be also worth mentioning that error: "RefreshAccessTokenError" is passed all the way to the client, so it can be used to do some error handling. What I did at work in a custom hook in a pseudo-code-ish way was something like useSession()[0].error === "RefreshAccessTokenError" && forceLoginUser()

Please update your example repo to mirror the suggestions as well.

Otherwise, it looks like a worthy addition to the docs, until we have a built-in solution. Thanks!

Comment on lines 32 to 116
const options = {
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorizationUrl: GOOGLE_AUTHORIZATION_URL,
}),
],
callbacks: {
async jwt(token, user, account) {
// Initial sign in
if (account && user) {
return {
accessToken: account.accessToken,
accessTokenExpires: Date.now() + account.expires_in * 1000,
refreshToken2: account.refresh_token,
refreshToken: account.refresh_token,
user,
};
}

// Return previous token if the 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 token with updated
* `accessToken` and `accessTokenExpires`. If an error occurs,
* returns the old token and an error property
*/
async function refreshAccessToken(token) {
try {
const url =
"https://oauth2.googleapis.com/token?" +
new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: token.refreshToken2,
});

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

const refreshedTokens = await response.json();

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

return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token,
};
} catch (error) {
console.log(error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
}
export default (req, res) => NextAuth(req, res, options);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const options = {
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorizationUrl: GOOGLE_AUTHORIZATION_URL,
}),
],
callbacks: {
async jwt(token, user, account) {
// Initial sign in
if (account && user) {
return {
accessToken: account.accessToken,
accessTokenExpires: Date.now() + account.expires_in * 1000,
refreshToken2: account.refresh_token,
refreshToken: account.refresh_token,
user,
};
}
// Return previous token if the 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 token with updated
* `accessToken` and `accessTokenExpires`. If an error occurs,
* returns the old token and an error property
*/
async function refreshAccessToken(token) {
try {
const url =
"https://oauth2.googleapis.com/token?" +
new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: token.refreshToken2,
});
const response = await fetch(url, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
method: "POST",
});
const refreshedTokens = await response.json();
if (!response.ok) {
throw refreshedTokens;
}
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token,
};
} catch (error) {
console.log(error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
}
export default (req, res) => NextAuth(req, res, options);
/**
* 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(token) {
try {
const url =
"https://oauth2.googleapis.com/token?" +
new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: token.refreshToken,
});
const response = await fetch(url, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
method: "POST",
});
const refreshedTokens = await response.json();
if (!response.ok) {
throw refreshedTokens;
}
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Fall back to old refresh token
};
} catch (error) {
console.log(error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
}
export default NextAuth({
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorizationUrl: GOOGLE_AUTHORIZATION_URL,
}),
],
callbacks: {
async jwt(token, user, account) {
// Initial sign in
if (account && user) {
return {
accessToken: account.accessToken,
accessTokenExpires: Date.now() + account.expires_in * 1000,
refreshToken: account.refresh_token,
user,
};
}
// Return previous token if the 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;
},
},
});

@vercel vercel bot temporarily deployed to Preview February 21, 2021 03:07 Inactive
@vercel vercel bot temporarily deployed to Preview February 21, 2021 03:19 Inactive
@lawrencecchen
Copy link
Contributor Author

Updated with simple initialization and client error handling. Let me know if there's anything else!

Copy link
Member

@balazsorban44 balazsorban44 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! So made a tiny change where we signal that this is something we would like to support out-of-the-box one day. Apart from that, I see a yarn.lock file slipped in, but we use package-lock.json, could you please remove yarn.lock from this PR? Maybe add it to .gitignore, so others won't commit it by accident either.

Co-authored-by: Balázs Orbán <info@balazsorban.com>
@vercel vercel bot temporarily deployed to Preview February 21, 2021 20:03 Inactive
@vercel vercel bot temporarily deployed to Preview February 21, 2021 20:06 Inactive
@lawrencecchen
Copy link
Contributor Author

This tutorial is optimistic now!

Copy link
Member

@balazsorban44 balazsorban44 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Looks awesome! 🎉

@balazsorban44 balazsorban44 merged commit 6b5a215 into nextauthjs:main Feb 21, 2021
@Feradik
Copy link

Feradik commented Feb 22, 2021

Hi guys! Does anybody know how to implement "silent" refresh token flow (custom provider)? I've checked the tutorial about token rotation. So, let's imagine, I have a protected page with form. Pre conditions: the form can be sent to the server with only accessToken (Authorization Bearer), the token expires in 3600 (1 hour), user is logged in and we have a valid session.
The user filled out the form and not submit it and didn't close the page and didn't change the tab.
In 1 hour 20 minutes user returns to the computer and tries to send the form at that time the token is already expired.
I have an api endpoint for sending the form (like /api/send-form).
In api handler I use the helper from next-auth - getSession(), I can use it on server side for getting the active session. This helper trigers the jwt callback from next-auth options - inside the callback I can check the token and refresh if it's needed.
But the access token from getSession() is from "previous session" and token is not valid...
How can I update it "under the hood" and always keep the valid token in cookies (session)? thank you a lot in advance

@balazsorban44
Copy link
Member

@Feradik please open an issue or ask questions under discussions

@github-actions
Copy link

🎉 This PR is included 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 PR is included in version 4.1.0-next.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@github-actions
Copy link

🎉 This PR is included in version 4.0.0-next.5 🎉

The release is available on:

Your semantic-release bot 📦🚀

mnphpexpert added a commit to mnphpexpert/next-auth that referenced this pull request Sep 2, 2024
* docs(tutorials): refresh token rotation

* use simple initialization

* be optimistic

Co-authored-by: Balázs Orbán <info@balazsorban.com>

* add yarn.lock to .gitignore

Co-authored-by: Balázs Orbán <info@balazsorban.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs Relates to documentation
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add tutorial on user-land refresh token usage
4 participants