This blog details usage of cookies on a Postgraphile-based application. We will be using Postgraphile with Express for processing the cookies, but any similar library can be used.
Cookies can be a very safe method for storage on the client side. They can be set as:
- HTTP only: cannot be accessed through client-side JavaScript, saving it from any third party client-side scripts or web extensions.
- Secure: The web browser ensures that the cookies are set only on a secure channel.
- Signed: We can sign the content to make sure it isn't changed on the client side.
- Same Site: Make sure that the cookie is sent only if the site matches your domain/subdomain (details)
Prerequisites
- Postgraphile - Generates an instant GraphQL API from a Postgres database
- Express - Minimalistic backend framework for NodeJS
Setup
We will start off with a base Express setup generated with express-generator.
1const createError = require("http-errors"); 2const express = require("express"); 3const path = require("path"); 4const cookieParser = require("cookie-parser"); 5const logger = require("morgan"); 6 7const app = express(); 8 9require("dotenv").config(); 10 11app.use(logger("dev")); 12app.use(express.json()); 13app.use(express.urlencoded({ extended: false })); 14app.use(express.static(path.join(__dirname, "public"))); 15 16// Use secret key to sign the cookies on creation and parsing 17app.use(cookieParser(process.env.SECRET_KEY)); 18 19// Catch 404 and forward to error handler 20app.use(function (req, res, next) { 21 next(createError(404)); 22}); 23 24// Error handler 25app.use(function (err, req, res) { 26 // Set locals, only providing error in development 27 res.locals.message = err.message; 28 res.locals.error = req.app.get("env") === "development" ? err : {}; 29 30 // Render the error page 31 res.status(err.status || 500); 32 res.render("error"); 33}); 34 35module.exports = app;
From Postgraphile's usage library page for adding Postgraphile to an express app:
1app.use( 2 postgraphile( 3 process.env.DATABASE_URL || "postgres://user:pass@host:5432/dbname", 4 "public", 5 { 6 watchPg: true, 7 graphiql: true, 8 enhanceGraphiql: true, 9 } 10 ) 11);
Now for the table setup. We need a private user_accounts table and a method named authenticate_user that will return a JWT token of the form:
1{ 2 token: 'jwt_token_here', 3 username: '', 4 ...anyOtherDetails 5}
We will not be detailing table creation or authentication as there are many ways to go about it. But if you need help, Postgraphile security is the page to rely on.
Adding the Plugin library
To attach a cookie to the request, we will use the @graphile/operation-hooks library which is open-sourced on Github.
1npm install @graphile/operation-hooks 2# OR 3yarn add @graphile/operation-hooks
To add the library to the app:
1const { postgraphile, makePluginHook } = require("postgraphile"); 2 3const pluginHook = makePluginHook([ 4 require("@graphile/operation-hooks").default, 5 // Any more PostGraphile server plugins here 6]); 7 8app.use( 9 postgraphile( 10 process.env.DATABASE_URL || "postgres://user:pass@host:5432/dbname", 11 "public", 12 { 13 watchPg: true, 14 graphiql: true, 15 enhanceGraphiql: true, 16 pluginHook, 17 appendPlugins: [ 18 // You will be adding the hooks here 19 ], 20 } 21 ) 22);
Adding the Plugin
The plugin allows for two different types of hooks:
Since accessing cookies is a JavaScript operation, we will be concentrating on the second type.
To hook the plugin into the build system, we can use the addOperationHook method.
1module.exports = function OperationHookPlugin(builder) { 2 builder.hook("init", (_, build) => { 3 // Register our operation hook (passing it the build object): 4 // setAuthCookie is a function we will define later. 5 build.addOperationHook(useAuthCredentials(build)); 6 7 // Graphile Engine hooks must always return their input or a derivative of 8 // it. 9 return _; 10 }); 11};
If this is contained in a file named set-auth-cookie.js, then the plugin can be added to the append plugins array as follows:
1{ 2 appendPlugins: [ 3 require('./set-auth-cookie.js'), 4 ], 5}
Designing the hook
The function to be executed receives two arguments: build process and the current fieldContext.
The fieldContext consists of fields that can be used to narrow down the mutation or query that we want to target; e.g. if the hook is to run only on mutations, we can use the fieldContext.isRootMutation field.
1const useAuthCredentials = build => fieldContext => { 2 const { isRootMutation } = fieldContext; 3 if (!isRootMutation) { 4 // No hook added here 5 return null; 6 } 7};
To direct the system on usage of the plugin, we have to return an object with before, after or error fields. Here is how these keywords can be used:
(comments are from the example repository)
1return { 2 // An optional list of callbacks to call before the operation 3 before: [ 4 // You may register more than one callback if you wish. They will be mixed in with the callbacks registered from other plugins and called in the order specified by their priority value. 5 { 6 // Priority is a number between 0 and 1000. If you're not sure where to put it, then 500 is a great starting point. 7 priority: 500, 8 // This function (which can be asynchronous) will be called before the operation. It will be passed a value that it must return verbatim. The only other valid return is `null` in which case an error will be thrown. 9 callback: logAttempt, 10 }, 11 ], 12 13 // As `before`, except the callback is called after the operation and will be passed the result of the operation; you may return a derivative of the result. 14 after: [], 15 16 // As `before`; except the callback is called if an error occurs; it will be passed the error and must return either the error or a derivative of it. 17 error: [], 18};
Since we want our action to happen after we get result from the mutation, we will add it to the after array.
1const useAuthCredentials = build => fieldContext => { 2 const { isRootMutation, pgFieldIntrospection } = fieldContext; 3 if (!isRootMutation) { 4 // No hook added here 5 return null; 6 } 7 8 if ( 9 !pgFieldIntrospection || 10 // Name of the mutation is authenticateUser 11 pgFieldIntrospection.name !== "authenticateUser" 12 ) { 13 // narrowing the scope down to the mutation we want 14 return null; 15 } 16 17 return { 18 before: [], 19 after: [ 20 { 21 priority: 1000, 22 callback: (result, args, context) => { 23 // The result is here, so we can access accessToken and username. 24 console.log(result); 25 }, 26 }, 27 ], 28 error: [], 29 }; 30};
Since the functionality is inside the plugin hook, we do not have the express result to set the cookie 😞.
But we do have an escape hatch with the third argument: context. Postgraphile allows us to pass functions or values into the context variable from the postgraphile instance.
1app.use( 2 postgraphile(process.env.DATABASE_URL, "public", { 3 async additionalGraphQLContextFromRequest(req, res) { 4 return { 5 // Function to set the cookie passed into the context object 6 setAuthCookie: function (authCreds) { 7 res.cookie("app_creds", authCreds, { 8 signed: true, 9 httpOnly: true, 10 secure: true, 11 // Check if you want to include SameSite cookies here, depending on your hosting. 12 }); 13 }, 14 }; 15 }, 16 }) 17);
We can now set the cookie inside the plugin hook.
1{ 2 priority: 1000, 3 callback: (result, args, context) => { 4 // This function is passed from additionalGraphQLContextFromRequest as detailed in the snippet above 5 context.setAuthCookie(result); 6 } 7}
Reading from the Cookie 🍪
We have already added the cookieParser with SECRET_KEY, so express will parse the cookies for us.
But we probably want them to be accessible inside SQL functions for Postgraphile. That is how we can determine if the user is signed in or what their permissions are. To do that, Postgraphile provides a pgSettings object.
1app.use( 2 postgraphile(process.env.DATABASE_URL, "public", { 3 pgSettings: async req => ({ 4 user: req.signedCookies["app_creds"], 5 }), 6 }) 7);
Inside an SQL function, the variables passed from settings can be accessed like this:
1current_setting('user')
That's all 🎉. We can store any details in cookies, retrieve them on the Express end and use them inside Postgres functions for authentication or authorization.
Check out operation-hooks plugin for more details.