-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
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
Conversation
This pull request is being automatically deployed with Vercel (learn more). 🔍 Inspect: https://vercel.com/nextauthjs/next-auth/3KXgkNkQ5pcBxFdwovwrMqRs6ezv |
Thanks! I'll check out the refresh token issue you are having. The way you did it should be unnecessary. |
There was a problem hiding this 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!
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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; | |
}, | |
}, | |
}); |
Updated with simple initialization and client error handling. Let me know if there's anything else! |
There was a problem hiding this 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>
This tutorial is optimistic now! |
There was a problem hiding this 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! 🎉
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. |
@Feradik please open an issue or ask questions under discussions |
🎉 This PR is included in version 3.5.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
🎉 This PR is included in version 4.1.0-next.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
🎉 This PR is included in version 4.0.0-next.5 🎉 The release is available on: Your semantic-release bot 📦🚀 |
* 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>
What:
Add tutorial for refresh token rotation.
Why:
Closes #1079
How:
Docs
Checklist:
I encountered strange behavior when returning
refreshToken
from thejwt()
callback function.refreshToken
is not persisted, but any other key is (lines 47 & 48), which is why I also returnedrefreshToken2
. Somehow,refreshToken2
is persisted. Would appreciate review! Here's a repo that recreates this.