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.
- 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.
- We'll want to check for a specific user using prisma's
findUnique
method. This will require awhere
filter. This requires a unique field like an email address or user name - If there's no user from the prisma query, an error is thrown immediately.
- 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
- If they don't match, an error is thrown
- 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.
- The tokens are set as cookies
- 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:
- 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.
- Get the refresh token from the appropriate cookie. This should be a valid token, but we can't be sure.
- Get the payload of the authorization header. Again, this should be the access token, be we don't know yet.
- Verify the tokens using
jsonwebtoken
. If there's an error, returnfalse
otherwise, return the decoded token. - If both tokens fail, wipe out the cookies and return the context. Handle errors in mutations. This should return users to a login screen.
- 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
- If both tokens are valid, set the user to the decoded value of the access token from the header.
- 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!