Authentication is figuring out who the user is. Authorization is figuring out what the user is allowed to do.
For this article I'll be focusing on the latter.
Authenticate
Before we can focus on authorization, we do need to be authenticated. So without diving in too deep, this is how we'll let our resolvers (and other parts of our app, a you will see) know who we are.
export const handler = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
const user = await getUser(req.header.authorization)
return { user }
},
})
Basic Authorization
Now that we know if the user is authenticated or not, the most basic thing we can do is allow or deny them to do anything.
export const handler = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
const user = await getUser(req.header.authorization)
if (!user) throw new AuthorizationError('You are not logged-in!');
return { user }
},
})
The context function always runs before any requested query. IF there's no user found that matches the authorization
header, the entire request is blocked!
Group Authorization
The next thing we want to do is allow the user access to parts of the schema if they are allowed. Let's say we only allow the doctors to access patient info. It would now be very tempting to go into the resolver for the patients
query and do this:
patients: (root, args, context) => {
// We could also throw an error again. That's up to you!
if (context.user.role !== 'doctor') return [];
return ['Carl', 'Hank'];
}
However, this could quickly become a problem when there's multiple to ways to query for patient data. If our full schema looked like this:
type Patient {
name: String
}
type Hospital {
name: String
patients: [Patient!]!
}
type Query {
patients: [Patient!]!
hospitals: [Hospital!]!
}
Now we need to duplicate the doctor
check inside the hospitals
resolver!
Models
A better way to do this is by splitting your authorization logic into a separate layer, separated from your resolver logic. This is described in more detail in Thinking in Graphs
We can for instance create a model for the Patient
type that looks like this:
const Patient = {
getAll() {...}
getById(id) {...}
getByHospitalId(hospitalId) {...}
}
Now our Patient
model is the single source of truth for business logic concerning our Patient type! We just need a way to tell our models about our authenticated user.
// models/Patient.js
export const createPatientModel = ({ user }) => ({
getAll() {...}
getById(id) {...}
getByHospitalId(hospitalId) {...}
})
// graphl handler
export const handler = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
const user = await getUser(req.header.authorization)
if (!user) throw new AuthorizationError('You are not logged-in!');
const Patient = createPatientModel({ user })
return { user, models: { Patient } }
},
})
There we go! We pass the Patient model and any other models on the context as well, so they're accessible in our resolvers:
// Query.patients resolver
patients: (root, args, context) => {
// The doctor authorization is handled in the Patient model
return context.models.Patient.getAll()
}
Ownership Authorization
Let's say we want even more fine-grained authorization for our doctors. Doctors should only be able to view information about their own patients. We can now go into our Patient model and change the required permissions, as opposed to editing all the different resolvers. Here's an example of filtering a doctor's patients if you're using Prisma 2
:
export const createPatientModel = ({ user }) => ({
getAll() {
return db.patient.findMany({ where: { doctor: { id: user.id } } })
},
getByHospitalId(hospitalId) {
return db.patient.findMany({
where: { doctor: { id: user.id }, hospital: { id: hospitalId } }
})
}
})
I'll leave it up to you to generalize the ownership part of the where
clause!
Conclusion
There's many different ways to handle authorization in a GraphQL server. This is my favourite one. Separating your business/authorization logic from your resolver logic makes your code more readable and maintainable!