the-road-to-graphql / fullstack-apollo-express-postgresql-boilerplate

💥 A sophisticated GraphQL with Apollo, Express and PostgreSQL boilerplate project.
https://roadtoreact.com
MIT License
1.2k stars 265 forks source link

How to add refresh tokens to this? #62

Open jakec-dev opened 5 years ago

jakec-dev commented 5 years ago

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?

jakec-dev commented 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...

jakec-dev commented 5 years ago

I've got it working. I'll close the issue but here's the solution for anyone else trying to do this:

  1. Change the getMe function (starts line 24 in /src/index.js) to:
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
    }
  }
}
  1. Change the context (starts line 55 in /src/index.js) to:
context: async ({ req, res, connection }) => {
    if (connection) {
      return {
        models,
      }
    }
    if (req) {
      const me = await getMe(req, res)
      return {
        models,
        me,
        secret: process.env.SECRET,
      }
    }
  },
jakec-dev commented 5 years ago

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.

rwieruch commented 5 years ago

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 🚀 👍

jakec-dev commented 5 years ago

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:

  1. Client sends request to server with expired access token
  2. Server responds with an authentication error in the getMe method
  3. Client receives error response and attempts to refresh the access token and try again
  4. Server responds with an authentication error again before it refreshes the access token due to the current access token not being verified in getMe
  5. Loop steps 3 and 4 until server crashes or is force closed

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.

rwieruch commented 5 years ago

Thank you letting us know about your changes @StupidSexyJake I think this will be helpful for others!

nextglabs commented 5 years ago

@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

rwieruch commented 5 years ago

Related: https://github.com/the-road-to-graphql/fullstack-apollo-express-mongodb-boilerplate/issues/8