Intro

In part 1 of this series, I talked about how to set up a NextJS app with Apollo Server and Prisma JS. Now I want to get into how to use prisma to create a user and then log that user in. This was the most difficult thing for me to learn and I'm a strong believer in the idea of:

Do the hard thing first.

So in this article I want to go over:

  • Creating a prisma client
  • Creating mutations to create a user and log that user in. Including
    • JWT authentication
    • Server set http-only cookies
  • Fetching user data with a token

Let's get started!

Prisma

In the first post, prisma helped create the tables and columns for our database. Specifically, the users table and its associated schema. Now however, I want to get into the prisma client. When someone makes a request to the API, it will be passed into an appropriate query or mutation. Then, prisma will handle the request either getting or modifying data in the database. Finally the result will be returned. Let's consider an example.

Suppose you have a query to get all users.

query GET_ALL_USERS {
  users {
    id
    name
    email
  }
}

In the apollo server, there will be a resolver like this:

const resolvers = {
  Query: {
    users: async (parent, args, context) => {
      return await prisma.users.findMany()
    }
  }
}

Any time the users query is performed, it will pass through the resolver and return results from the database. Notice that we don't have to write our own SQL query to do this. Prisma handles that for us.

However, there's a problem.

How do you get prisma into the resolver?

I chose to add it to the server context and then pass that into resolvers as needed. So I created a prisma client that I could reuse and import into the server.

// /prisma/prisma.js

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default prisma

Then I imported that to use in the server

// /pages/api/graphql.js

// other imports
import prisma from '../../prisma/prisma'

const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req, res }) => {
    return {
      prisma
    }
  }
}) 

This way, I can access prisma as a property of the context in any resolver. Now, I can re-write the users query like this:

const resolvers = {
  Query: {
    users: async (parent, args, { prisma }) => {
      return await prisma.users.findMany()
    }
  }
}

Why go to this trouble? Because I found that creating the prisma client and then using at the scope of the graphql API file was inefficient. Doing it this way, I can reference the client one time and pass it to as many resolvers as needed (all of them) within the context of the server itself.

More Server Setup

At this point, I want to start managing the mutations to create a user and log that user in. This means that I need to import some more packages and create a new utility function to help create JWT tokens. I'm also going to start with the create user mutation for all the obvious reasons.

The createUser mutation needs to do a couple things:

  • take in the data necessary to create a user as defined in the type definitions
  • hash the user's password
  • delete the original password as a precaution
  • return the created user as an object

Recall that in the type definitions, I created an input type and mutation like this:

// /graphql/typeDefs.js

type Mutation {
  createUser(userData: UserInput!): User!
}

input UserInput {
  email: String
  name: String
  userName: String
  password: String
}

This mutation means that when the createUser mutation is run, it needs a variable called userData of the type UserInput. It will then return a User.

Looking at the mutation below, you can see that this userData is expected in the second argument of the resolver.

// /pages/api/graphql.js

import { ApolloServer } from 'apollo-server-micro'
import Cors from 'micro-cors'
import bcrypt from 'bcrypt'
import Cookies from 'cookies'
import { verify } from 'jsonwebtoken'
import { v4 as uuidv4 } from 'uuid'
import dotenv from 'dotenv'

import typeDefs from '../../graphql/typeDefs'
import prisma from '../../prisma/prisma'
// import tokenGenerator from '../../lib/tokenGenerator'

const resolvers = {
  Mutation: {
    createUser: async (_, { userData }, { prisma }) => {

    }
  }

}

// add to the server

Now, filling in the blanks is relatively straightforward.

// /pages/api/graphql.js

import { ApolloServer } from 'apollo-server-micro'
import Cors from 'micro-cors'
import bcrypt from 'bcrypt'
import Cookies from 'cookies'
import { verify } from 'jsonwebtoken'
import { v4 as uuidv4 } from 'uuid'
import dotenv from 'dotenv'

import typeDefs from '../../graphql/typeDefs'
import prisma from '../../prisma/prisma'
// import tokenGenerator from '../../lib/tokenGenerator'

const resolvers = {
 Mutation: {
   createUser: async (_, { userData }, { prisma }) => {

     // immediately hash the password the user has provided
     const hashedPassword = bcrypt.hashSync(userData.password, 10)

     // delete the original password
     delete userData.password

     // create a new user with prisma
     // give the user a uuid
     // save the hashed password
     // return the result of prisma's operation
     return await prisma.user.create({
       data: {
         uuid: uuidv4(),
         email: userData.email,
         name: userData.name,
         password: hashedPassword,
         userName: userData.userName
       }
     })
   }
 }

}

// add to the server

Great! Now at this point, if you go to your API's route in the browser, you should see an instance of the GraphQL Playground application and try the mutation like this:

mutation CREATE_USER ($userData: UserInput!) {
  createUser (
    userData: $userData
  ) {
    id
    uuid
    name
    userName
    password
  }
}

# query variables
{
  "userData": {
    "email": "user@test.com",
    "name": "Sam",
    "userName": "sam",
    "password": "test"
  }
}

Next, let's take care of logging the user in.

Login

I have a confession. This login pattern took me a couple weeks to figure out. In general at work, I don't have to deal with this for a variety of reasons. But, I wanted to understand this authentication pattern better. So I took a crack at it. In general here's what I came away understanding.

If on a Winter's Night a User...

visits your site. They created an account a while ago and now they need to log in. They have their email address and password. Carefully they fill out the login form and click "Log In". They're taken to their user profile page.

It all seems simple, but on the server, things start to get complex. I'm going to break this into steps.

  1. When the login request is made, the mutation will receive the login credentials. The mutation will also receive the ability to set cookies from the server context.
  2. We'll want to check for a specific user using prisma's findUnique method. This will require a where filter. This requires a unique field like an email address or user name
  3. If there's no user from the prisma query, an error is thrown immediately.
  4. Otherwise, there is a user. Therefore, we check the validity of the password sent in plaintext in the credentials against the hashed password stored in the database
  5. If they don't match, an error is thrown
  6. If the user is found and the passwords match, two JWTs are issued a. One will give access and be set to a cookie with a short expiry. This one will be sent on requests from the client in the authorization header. b. Another will be a refresh token and have a long expiry. If the access token is invalid, but this one is valid, we can then re-issue a new set of tokens as cookies.
  7. The tokens are set as cookies
  8. The user is returned and access is granted.

Phew! Let's set up a token generator and then zoom into the login mutation.

The sign method from jsonwebtoken lets you create JWTs. It needs three things: data to store, a secret, and an expiry. It's important to note that you need to be very careful with what data is stored in the token. It shouldn't be anything secret or that requires security. An id or email address are fine. A password? Definitely not.

// /lib/tokenGenerator.js

import { sign } from 'jsonwebtoken'
import dotenv from 'dotenv'

dotenv.config()

const tokenGenerator = (uuid, email, expiresIn = '15m') => {
  return sign(
    { uuid, email },
    process.env.JWT_SECRET,
    { expiresIn }
  )
}

export default tokenGenerator
Mutation: {
  login: async (_, { credentials }, { prisma, cookies }) => {
    const cookies = context.cookies

    // set the `where` condition
    const where = {}

    if (credentials.user.email !== null && credentials.user.email !== '') {
      where.email = credentials.user.email
    } else {
      where.userName = credentials.user.userName
    }

    // find a user and handle errors
    const user = await prisma.user.findUnique({ where })

    if (!user) {
      throw new Error('No user with that email or username found')
    }

    // check the validity of the password and handle errors
    const isValid = await bcrypt.compare(credentials.password, user.password)

    if (!isValid) {
      throw new Error('Invalid password')
    }

    // the user exists and has a valid password, so it's time to...
    // create tokens
    const token = tokenGenerator(user.uuid, user.email, '15m')
    const refreshToken = tokenGenerator(user.uuid, user.email, '1w')

    // create expiry times for cookies that match the expiry time on the token
    const tokenDate = new Date(Date.now() + 60 * 15 * 1000)
    const refreshDate = new Date(Date.now() + 60 * 60 * 24 * 7 * 1000)

    // when the cookies are `set` they're sent back to the client as httpOnly cookies
    cookies.set('mat-token', token, {
      expires: tokenDate
    })
    cookies.set('mat-refresh-token', refreshToken, {
      expires: refreshDate
    })

    // return the user
    return user
  }
}

If the user has given valid credentials and they exist in the database, the login mutation will succeed at this point. The browser will also have received cookies with tokens. That brings us to the next part. Evalutating the tokens as part of the server context

Context

Let's say that a user wants to update their profile or access some of their data. How do we ensure they're authorized to do so? We need to intercept requests and validate them somehow before they get to the relevant query or mutation. This can be done in apollo server's context property as I'll show in a moment. From the client side application, the access token will be sent on the Authorization header using the Bearer scheme. The server will also be able to get the refresh token from the cookie that was stored when the user was logged in.

Here are the steps I took to manage this:

  1. Check for the authorization. If the header is not undefined, a user is trying to do something (we don't care what) that requires tokens.
  2. Get the refresh token from the appropriate cookie. This should be a valid token, but we can't be sure.
  3. Get the payload of the authorization header. Again, this should be the access token, be we don't know yet.
  4. Verify the tokens using jsonwebtoken. If there's an error, return false otherwise, return the decoded token.
  5. If both tokens fail, wipe out the cookies and return the context. Handle errors in mutations. This should return users to a login screen.
  6. If the access token fails, but the refresh token is still valid a. Create new tokens as if the user has logged in b. Send the tokens back to the browser c. Set the verified user in the context based on the refresh token
  7. If both tokens are valid, set the user to the decoded value of the access token from the header.
  8. In all cases, return the context.

Let's zoom into the context function to see how this works.

const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req, res }) => {

    // prepare a non-authorized context
    const cookies = new Cookies(req, res, { 
      keys: [ process.env.COOKIE_KEY ]
    })

    const context = {
      prisma,
      response: res,
      cookies
    }

    // begin the authorization pattern
    if (req.headers.authorization !== undefined) {
      // get the access token from the header and refresh token from the cookie.
      const refreshToken = cookies.get('mat-refresh-token')

      const authHeader = req.headers.authorization
      const payload = authHeader.replace('Bearer', '').trim()

      // verify the two tokens then handle the following cases
      const verified = verify(payload, "secret!!", (err, decoded) => {
        return err ? false : decoded
      })

      const verifiedRefreshToken = verify(refreshToken, 'secret!!', (err, decoded) => {
        return err ? false : decoded
      })

      /**
       * If both tokens fail, return the context immediately
       * Let the resolvers handle any errors
       * Redirect back to login
       */
      if (!verified && !verifiedRefreshToken) {
        // setting cookies without values should delete them from the browser
        cookies.set('ma-app-token')
        cookies.set('ma-app-refresh-token')
        return context
      } 
      /**
       * If the token fails, but the refresh token is still good
       * - create new tokens to send back as cookies
       * - then use the verified refresh token to add the user to the context
       */
      else if (!verified && verifiedRefreshToken) {

        const token = tokenGenerator(verifiedRefreshToken.id, verifiedRefreshToken.email, '5m')
        const refreshToken = tokenGenerator(verifiedRefreshToken.id, verifiedRefreshToken.email, '1w')

        const tokenDate = new Date(Date.now() + 60 * 5 * 1000)
        const refreshDate = new Date(Date.now() + 60 * 60 * 24 * 7 * 1000)

        // then create and send new tokens
        cookies.set('mat-token', token, {
          expires: tokenDate
        })
        cookies.set('mat-refresh-token', refreshToken, {
          expires: refreshDate
        })

        const user = verifiedRefreshToken

        context.verifiedUser = { user }
      } 
      /*
      * Both tokens are valid. Verify the user and move on to the appropriate query or mutation.
      */
      else if (verified && verifiedRefreshToken) {
        const user = verified
        context.verifiedUser = { user }
      }
    }

    return context
  }
})

Let's look at an example of how this works. With the me query.

Look at Me!

A user should be able to access their own data after they login. I'm going to demonstrate the me query. Wherever the type definitions are defined, we can add a query like this:

type Query {
  # other queries
  me: User
}

This query isn't going to accept any arguments. In order to get the information, the user will have to have valid tokens which allow them to access it.

Now, we can go back to the resolvers and add it to the Query property. Note that the only thing passed to the method below is the authorized user. This is the same user object that was validated and returned in the context. If for whatever case there's no valid user, we immediately throw an error that can be caught be the client. Otherwise, we can find the user with prisma's findUnique method.

const resolvers = {
  Query: {
    me: async (_, __, { verifiedUser }) => {
      const { user } = verifiedUser

      if (!user) {
        throw new Error('Not authenticated')
      }

      return await prisma.user.findUnique({
        where: {
          uuid: user.uuid
        }
      })
    }
  }
}

If at this point a user is returned, the visitor will have access to whatever is in that object in the browser.

Conclusion

I'll be honest with you. This is the first time I've tried to implement this login strategy. I hope that I've done it fairly well and that I've explained it adequately. If I learn something new (or find out this is a terrible way to do it) I'll do my best to write a follow up post.

In the next post in the series, I intend to discuss how to implement relay style cursor based pagination.

I hope you've enjoyed this!