Open jakec-dev opened 5 years ago
So I've got most of it sorted, it's just this section that's giving me grief:
const getMe = async (req) => {
const token = req.headers['x-token']
if (token) {
try {
return jwt.verify(token, process.env.SECRET)
} catch (e) {
const refreshToken = req.headers['x-token-refresh']
const newTokens = await refreshTokens(
token,
refreshToken,
models,
process.env.SECRET
)
if (newTokens.token && newTokens.refreshToken) {
// Tell the client to update tokens here... But how?
}
// What to return?
return newTokens.user
}
}
}
If the token is expired then I have a refreshTokens function that provides a new auth token and refresh token (after checking that the refresh token is valid). The issue is, how do I push these new tokens to the client? I know it needs to go in the commented section but I have no idea what to put here...
I've got it working. I'll close the issue but here's the solution for anyone else trying to do this:
const getMe = async (req, res) => {
const token = req.headers['x-token']
if (token) {
try {
return jwt.verify(token, process.env.SECRET)
} catch (e) {
const refreshToken = req.headers['x-token-refresh']
const newTokens = await refreshTokens(
token,
refreshToken,
models,
process.env.SECRET
)
if (newTokens.token && newTokens.refreshToken) {
res.set('Access-Control-Expose-Headers', 'x-token, x-refresh-token')
res.set('x-token', newTokens.token)
res.set('x-refresh-token', newTokens.refreshToken)
}
return newTokens.user
}
}
}
context: async ({ req, res, connection }) => {
if (connection) {
return {
models,
}
}
if (req) {
const me = await getMe(req, res)
return {
models,
me,
secret: process.env.SECRET,
}
}
},
Actually ignore my last comment. res.set isn't working. It just keeps using the same refresh token in the client to create a new auth token, but it doesn't send the new tokens back to the client.
Hello @StupidSexyJake No newbie question at all! I have put your Issue into our Slack Community. Hopefully there are people who have implemented this who can help out. If you got any further with this issue, would love to see your implementation. I guess lots of other people would find it helpful 🚀 👍
I managed to get it working, although I'm using Next.js and cookies to store the data rather than local storage (since you can't access local storage on the server side). Refreshing tokens is handled on the client side though so you could still use local storage if that's your preference.
Here's some of the changes I made:
Server Side
index.js : Rather than return an Authentication Error in the getMe() method, if verification fails it simply returns null as 'me' in context. Otherwise a looping issue occurs when attempting to refresh the access token, as the flow becomes:
Code example:
// Set 'me' in context
const getMe = async (req) => {
// Get access token from headers
const token = req.headers['x-token']
// Return null if no access token provided
if (!token) {
return null
}
// Attempt to verify access token
try {
// Return 'me' if token verified
return jwt.verify(token, process.env.SECRET)
}
// Return null if access token not verified
catch (e) {
return null
}
}
schema/users.js : Add a refreshAccessToken mutation and add refreshToken to Token type
extend type Mutation {
....
refreshAccessToken(refreshToken: String!): Token!
}
type Token {
token: String!
refreshToken: String!
}
resolvers/users.js: Throw the authentication error in the 'me' query so it only throws the error when the client requests 'me', create the refreshAccessToken resolver and method, and update the createToken function to include a refreshToken.
I've also included a 'Remember me for 30 days' options in my sign-in form that extends the expiry of the refresh token for 30 days. Otherwise the refresh token expires after 1 hour of inactivity.
// Define token expiry times
const accessTokenExpiry = '10m'
const defaultRefreshTokenExpiry = '60m'
const rememberMeRefreshTokenExpiry = '30d'
// Create access and refresh tokens
const createTokens = async (user, secret, remember) => {
// Define default refresh token expiry
let refreshTokenExpiry = defaultRefreshTokenExpiry
// Extend refresh token expiry if user selects 'remember me' during login
if (remember) {
refreshTokenExpiry = rememberMeRefreshTokenExpiry
}
// Define user values
const { id, email, username, role } = user
// Create access token
const createAccessToken = jwt.sign(
{
id, email, username, role
},
secret,
{
expiresIn: accessTokenExpiry,
}
)
// Create refresh token
const createRefreshToken = jwt.sign(
{
id, remember
},
secret,
{
expiresIn: refreshTokenExpiry,
}
)
return Promise.all([createAccessToken, createRefreshToken])
}
// Refresh access token
export const refreshAccessToken = async (refreshToken, models, secret) => {
let userId = -1
let newRefreshToken = refreshToken
let rememberMe
// Attempt to verify refresh token
try {
const { id, remember } = await jwt.verify(refreshToken, secret)
// Get return values from verification
userId = id
rememberMe = remember
} catch (err) {
// Return nothing if verification fails :: TO DO: Throw error
throw new ForbiddenError(
'Please sign in again to continue'
)
}
// Find user'me' by id
const user = await models.User.findByPk(userId)
const { id, email, username, role } = user
// Create new access token
const newAccessToken = jwt.sign(
{
id, email, username, role
},
secret,
{
expiresIn: accessTokenExpiry,
}
)
// Create new refresh token to extend idle timeout if user is not to be remembered
if (!rememberMe) {
newRefreshToken = jwt.sign(
{
id,
remember: rememberMe
},
secret,
{
expiresIn: defaultRefreshTokenExpiry,
}
)
}
// Return tokens and user
return { newAccessToken, newRefreshToken, user }
}
Query: {
....
me: async (
parent,
args,
{ models, me }
) => {
// Throw authentication error if not authenticated
if (!me) {
throw new AuthenticationError(
'Your session expired. Sign in again.',
)
}
// Otherwise, return 'me'
return await models.User.findByPk(me.id)
},
},
Mutation: {
....
refreshAccessToken: async (
parent,
{ refreshToken },
{ models, secret },
) => {
// Get new access token
const { newAccessToken, newRefreshToken } = await refreshAccessToken(refreshToken, models, secret)
// Return new tokens
return { token: newAccessToken, refreshToken: newRefreshToken }
},
}
Minor changes will also need to be made to resolvers/authorization.js however I haven't had a chance to do it yet. I'll update this post when I get around to it.
Client Side
I'm using nookies to set and destroy cookies as it works well with Next.js. Also since I'm using Next.js my code is a little bit different.
api/init-apollo.js: change the error link so that it refreshes tokens if the access token fails and a refresh token is available:
// Create Apollo Client
const client = new ApolloClient({
connectToDevTools: process.browser,
ssrMode: !process.browser, // Disables forceFetch on the server (so queries are only run once)
link: ApolloLink.from([
authLink,
onError(({ graphQLErrors, networkError, operation, forward }) => {
// If network error, output message to console for debugging
if (networkError) console.error(`[Network error]: ${networkError}`)
// If graphQL error...
if (graphQLErrors) {
// If error is due to unathenticated user request and a refresh token is available...
const { extensions } = graphQLErrors[0]
const refreshToken = getTokens()['x-token-refresh']
if (extensions.code === 'UNAUTHENTICATED' && refreshToken) {
// Create a new Observerable
return new Observable(async observer => {
// Refresh the access token
refreshAccessToken(refreshToken, client)
// On successful refresh...
.then((newTokens) => {
// Handle cookies
if (!newTokens.token) {
// Delete cookies if no new access token provided
destroyCookie(ctx, 'x-token')
destroyCookie(ctx, 'x-token-refresh')
}
else {
// Update cookies if new access token available
setCookie(ctx, 'x-token', newTokens.token, { maxAge: 30 * 60 })
setCookie(ctx, 'x-token-refresh', newTokens.refreshToken, { maxAge: 30 * 24 * 60 * 60 })
}
// Bind observable subscribers
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer)
}
// Retry last failed request
forward(operation).subscribe(subscriber)
})
// On refresh failure...
.catch(error => {
observer.error(error)
})
})
}
}
}),
terminatingLink,
]),
cache: new InMemoryCache().restore(initialState || {})
})
pages/login.js : Save the tokens returned from the sign-in query to cookies
// Handle form submit
const onSubmit = (event) => {
// Prevent default form behaviour
event.preventDefault()
// Get login values from form
const form = event.target
const formData = new window.FormData(form)
const login = formData.get('login')
const password = formData.get('password')
const remember = isRememberMeChecked
// Attempt to sign in
client.query({
query: USER_SIGN_IN,
variables: {
login,
password,
remember
}
})
// On successful sign-in
.then(({ data }) => {
// Save tokens to cookies
setCookie(ctx, 'x-token', data.signIn.token, { maxAge: 30 * 60 })
setCookie(ctx, 'x-token-refresh', data.signIn.refreshToken, { maxAge: 30 * 24 * 60 * 60 })
// Reset user login state
dispatch(resetState('user'))
// Force a reload of all the current queries
client.cache.reset()
.then(() => {
// Redirect user to homepage
redirect({}, '/')
})
})
.catch(error => {
console.error('error logging in:')
console.error(error)
})
}
And that's just about it. You might need to tweak a few things but that should be enough to get you going. If you have any questions just ask.
Thank you letting us know about your changes @StupidSexyJake I think this will be helpful for others!
@StupidSexyJake Thanks for sharing your code!
I think it's worth to mention to other developers that the signIn
& signUp
need to be adjusted accordingly.
The createToken
is currently returning an array of tokens
return Promise.all([createAccessToken, createRefreshToken])
I have changed that to be:
const createTokens = async (user, secret, remember) => {
...
const tokens = await Promise.all([
createAccessToken,
createRefreshToken,
]);
return {
token: tokens[0],
refreshToken: tokens[1],
};
}
The signIn function will now look like this:
signIn: async (
parent,
{ login, password, remember },
{ models, secret }
) => {
const user = await models.User.findByLogin(login);
if (!user) {
throw new UserInputError(
'No user found with this login credentials.'
);
}
const isValid = await user.validatePassword(password);
if (!isValid) {
throw new AuthenticationError('Invalid password.');
}
const tokens = await createTokens(user, secret, remember);
return {
token: tokens.token,
refreshToken: tokens.refreshToken,
user,
};
},
@StupidSexyJake, please share with us what you other changes you have made to the resolvers/authorization.js
file
Hi, sorry for the newbie question but how do I incorporate refresh tokens with this?
When following the boilerplate, any signed-in users will need to login again every 30 minutes due to the token expiring. From what I've read (I'm very new to backend stuff), the best way to handle this is by using refresh tokens. I've tried following a few guides online but they use a different stack so it's hard to figure out how to incorporate it into this boilerplate.
Any tips?