Use custom directives to protect your graphql apis

Authentication is required for apps that not only have public api endpoints but also authenticated ones that are accessible to users with certain level of access. In REST world, we will normally attach authentication bit (token verification) to the middleware before requests hitting the actual route.
Take express for instance, we can use passport to verify the token that is set on request header. If JWT token contains expected scope, we will let the request through to route handler to process or otherwise rejects it with proper errors.
But, can we follow this pattern on our Graphql server?
Well, the answer is — it really depends.
In the following section, I will show you how you can implement authentication on your graphql server using middleware pattern.
Please note, I will only provide a high level overview and for the complete working example, please refer to https://github.com/jessedvrs/graphql-passport-example.
Auth your apis via express middleware pattern
I will use passport to give us a helping hand in authentications. Firstly, let’s code the authentication strategy:
Next, add auth middleware:
Finally, register middlewares:
const app = express();
app.use(authMiddleware);
app.use('/api/graphql', graphqlMiddleware);
Voila! Now you are able to retrieve user object that is available in your resolver:
export default {
cakes: {
type: new GraphQLList(CakeType),
resolve: (_, args, context) => {
if (!context.user) {
throw new Error('Only users can eat cakes.');
}
return [{flavour: 'red-velvet'}, {flavour: 'mocca'}];
},
},
};
It gets the job done for sure but it lacks flexibility and has downsides:
- All apis must be authenticated against callers even though you have public apis since auth is applied to middleware
- Scope check cannot be done once — you need to do it in route handlers where certain level of access is required
- Could be tricky to implement granular scope check on specific fields in type
With custom directives, you can easily resolve those issues mentioned above.
Keep reading on!
Note: I assume you already have a basic understanding of how directives work in graphql. If that sounds unfamiliar to you, make sure you take time to read the post below to wrap your head around this concept.
The custom directives way
In this section, I will build a simple products supply server to illustrate my way of authentication implementation via custom directives.
Essentially, I will break the implementations into two parts:
- Schema
- Directive and Query/Mutation resolvers
Let’s start!
Add schema
First thing first, let’s define our two custom directives:
directive @isAuthenticated on QUERY | FIELD
directive @hasScope(scope: [String]) on QUERY | FIELD
Two things need to be pointed out:
QUERY | FIELD
indicates that this directive can be applied to both field and query level.- In
hasScope
directive, it takesscope
as a parameter which is an array of scopes in string representation.
Now, we have required directives defined. Great!
Next thing is about how and where we can use them. In this demo app, we have three query operations and one mutation operation.
- allProductBySupplier
- product
- addProduct
- suppliers
We will make allProductBySupplier
, product
and addProduct
authenticated while leave suppliers
public.
type Query {
allProductsBySupplier: [Product] @isAuthenticated
product: Product @isAuthenticated
suppliers: [Supplier]
}
type Mutation {
addProduct(input: ProductInput!): Product @hasScope(scope: [“add:product”])
}
As you can see, @isAuthenticated
is applied to the non-public queries on query level.
For mutation, we require users who want to add product must have add:product
scope. Similarly, we will also restrict who can read rating from product to only users who have read:rating
scope.
…
type Product {
id: ID!
supplierId: ID!
sku: String
qty: Int
price: Int
parrot: String
rating: Int @hasScope(scope: [“read:rating”])
}
…
Here is the complete schema:
directive @isAuthenticated on QUERY | FIELD
directive @hasScope(scope: [String]) on QUERY | FIELDtype Product {
id: ID!
supplierId: ID!
sku: String
qty: Int
price: Int
parrot: String
rating: Int @hasScope(scope: ["read:rating"])
}type Supplier {
id: ID!
name: String!
}input ProductInput {
supplierId: ID!
sku: String!
qty: Int!
price: Int!
parrot: String!
rating: Int!
}type Query {
allProductsBySupplier: [Product] @isAuthenticated
product: Product @isAuthenticated
suppliers: [Supplier]
}type Mutation {
addProduct(input: ProductInput!): Product @hasScope(scope: ["add:product"])
}
Schema is ready now! let’s walk into next square — build query resolvers!
Add Resolvers
We will begin with custom directive resolvers — isAuthenticated
and hasScope
.
The idea here is we will want to read the Jwt token from authorization
header set by the client and decode it to verify.
If it is not compromised, we then set the decoded value (user in our case) to context which will be available in all resolvers — i.e query resolvers.
Once we have the user set on context, next thing is to trigger next
which is our query resolver to resolve each field.
In the case of invalid token, we should simply reject the requests by throwing errors.
isAuthenticated
is implemented as follow:
isAuthenticated: (next, source, args, context) => {
const token = context.headers.authorization;
if (!token) {
throw new AuthorizationError({
message: "You must supply a JWT for authorization!"
});
}
try {
const decoded = jwt.verify(
token.replace("Bearer ", ""),
process.env.JWT_SECRET
);
context.user = decoded;
return next();
} catch (err) {
throw new AuthorizationError({
message: "You are not authorized."
});
}
};
hasScope
behaves similarly to isAuthenticated
except that it does not need to set the user on context:
hasScope: (next, source, args, context) => {
const token = context.headers.authorization;
const expectedScopes = args.scope;
if (!token) {
throw new AuthorizationError({
message: 'You must supply a JWT for authorization!'
});
}
try {
const decoded = jwt.verify(
token.replace('Bearer ', ''),
process.env.JWT_SECRET
);
const scopes = decoded.scope.split(' ');
if (expectedScopes.some(scope => scopes.indexOf(scope) !== -1)) {
return next();
}
} catch (err) {
return Promise.reject(
new AuthorizationError({
message: `You are not authorized. Expected scopes: ${expectedScopes.join(
', '
)}`
})
);
}
}
Query and mutation resolvers are not fancy and full code is given below:
let PRODUCTS = require('./products');
let SUPPLIERS = require('./suppliers');const allProductsBySupplier = (obj, args, ctx) => PRODUCTS.filter(p => p.supplierId === Number(ctx.user.sub));const addProduct = (obj, args, ctx) => {
const id = PRODUCTS.length + 1;
PRODUCTS.push({
...args.input,
id
});
return PRODUCTS.slice(-1)[0];
};const product = (obj, args, ctx) => PRODUCTS.find(p => p.id === args.productId);const suppliers = (obj, args, ctx) => SUPPLIERS;module.exports = {allProductsBySupplier, product, suppliers, addProduct};
Glue all together
Up to this point, we have everything in place to protect our apis. The last bit is wire all these up into graphql server.
In the newer version of graphql-tools
, makeExecutableSchema
accepts one more parameter called directiveResolvers
that allows us to attach custom directive resolvers to schema.
…
const resolvers = {
Query: {
allProductsBySupplier,
product,
suppliers
},
Mutation: {
addProduct
}
};const schema = makeExecutableSchema({
typeDefs,
resolvers,
directiveResolvers
});
…
Demo
Demo time! We will use node-fetch
to make our requests to the app server.
Copy token from section Supplier 1 JWT with ‘read:rating’ scope and paste it into Authorization
header in client.js
.
Start the server by running npm run dev
and once server is up and running, go to terminal and run node client.js
.
You should be able to see a list of products with rating filled up if token is valid.
Take your time and play with it to find more fun!
Last note
Before I let you go, there is one important thing I would like to point out — flow of resolvers execution
Recall how we use isAuthenticated
:
type Query {
allProductsBySupplier: [Product] @isAuthenticated
}
It is applied to query level and will run first before allProductsBySupplier
in the above example.
This is why we need to call next
upon successful token verification to execute next resolver.
hasScope
on the other hand is attached to the field:
type Product {
…
rating: Int @hasScope(scope: [“read:rating”])
…
}
Therefore, it will be run later than query resolvers in which case rating
will be returned from the query resolver to hasScope
where it will be either set to null or retained depending on the required scope of that user.
Conclusion
As you can see, have your apis protected through custom directive is more flexible than express middlware approach. Apart from this, custom directives also allow you to do more cool stuff such as capitalize the field value before it reaches query resolvers.
If you are interested in seeing potential power of custom directive, check Custom directives
Credits
Special thanks to Ryan Chenkie who gave the talk on how to do auth in graphql.