Nexus + Prisma resolver auth in Next.js

Nexus + Prisma resolver auth in Next.js

# Introduction

After spending an hour on trying to figure out how to protect specific GraphQL query objects in Nexus from unintended access (e.g. querying other users than you) I finally found a simple, yet effective solution. I would like to share it with others and for myself in case I face this problem again.

I was building a fullstack application with Next.js, Prisma, GraphQL and Nexus. When I needed to implement query objects for user data I noticed that I could easily query other users data using user(where: { id : "x" }) and I didn’t want to leak user data this way. So, I started googling and only found parts of the solution or irrelevant code samples.

This is what I’ve read through:

After some hacking I came up with the solution combined from all those threads and now I would like to share it.

# Dependencies and configurations

  1. Install all required dependencies:
pnpm i next react react-dom graphql prisma nexus nexus-plugin-prisma nexus-shield apollo-server-micro next-auth && pnpm i -D typescript @types/node @types/react @types/next-auth
  1. Add a few npm scripts for Prisma client and types generation:
{
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "NODE_ENV=production next start",
    "client": "prisma generate",
    "migrate": "prisma migrate dev --preview-feature",
    "generate:nexus": "ts-node --transpile-only -P nexus.tsconfig.json pages/api/graphql.ts",
    "generate:prisma": "prisma generate",
    "generate": "npm run generate:prisma && npm run generate:nexus",
    "postinstall": "npm run generate"
  }
}
  1. Create TypeScript configurations for our Next.js app and Nexus

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2019",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "preserveSymlinks": true,
    "baseUrl": ".",
    "paths": {
      "*": ["./*"]
    },
    "plugins": [
      {
        "name": "nexus/typescript-language-service"
      }
    ],
    "module": "ESNext"
  },
  "include": ["**/*.ts", "**/*.tsx", ".", "types.d.ts"],
  "exclude": ["node_modules"]
}

nexus.tsconfig.json

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext"],
    "esModuleInterop": true,
    "module": "CommonJS"
  }
}

You can also optionally add a GraphQL configuration file (graphql.config.js) for better editor support:

module.exports = {
  schema: 'generated/schema.graphql',
}

# Database and Prisma

You can read how to prepare the database for Prisma and setup Prisma itself in this tutorial.

Take in consideration that you already have Prisma installed via prisma instead of @prisma/cli (which is deprecated now).

After creating the schema, run the command to generate the Prisma client:

pnpm generate:prisma

Then reload your editor so the Prisma client will be typed.

After that, create a prisma client instance that will be shared between libraries:

// lib/prisma.ts
import { PrismaClient } from '@prisma/client'

export const prisma = new PrismaClient()

As for now, Prisma is ready. Let’s switch to other libraries next.

# Tweaking next-auth

The next library we will set up is going to be next-auth. The config looks like this:

// lib/auth.ts
import Providers from 'next-auth/providers'
import Adapters from 'next-auth/adapters'
import { prisma } from './prisma'

const options = {
  adapter: Adapters.Prisma.Adapter({ prisma }),
  site: process.env.SITE || 'http://localhost:3000',
  callbacks: {
    // Assign user ID to current session object
    session: async (sess, u) => {
      sess.id = u.id

      return Promise.resolve(sess)
    },
  },
  // Configure one or more authentication providers
  providers: [
    Providers.GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
  ],
}

export default options

The config is very similar to the one that tutorials describe with one significant change. We register a custom callback that assigns the current user ID to user session object. This way we can identify what ID our user has, compare it on server side and give only the user owned data.

# GraphQL

Due to unstability of Prisma it’s components gets either deprecated or revived from time to time. The article was written at 22/02/21, maybe in the future the way to set things up will change.

# Apollo server

The easiest part is setting up a GraphQL endpoint. It’s the same as any other Next.js API function:

import { ApolloServer } from 'apollo-server-micro'
import { schema } from '../../graphql/schema'
import { createContext } from '../../graphql/context'

const apolloServer = new ApolloServer({
  context: createContext,
  schema,
  tracing: process.env.NODE_ENV === 'development',
})

export const config = { api: { bodyParser: false } }

export default apolloServer.createHandler({ path: '/api/graphql' })

# Schema generator

The entrypoint of a GraphQL server is a schema. Thanks to Nexus we can generate schema instead of hand-writing it. Additionally we will generate typings for better editor support:

// graphql/schema.ts
import { makeSchema } from 'nexus'
import { nexusPrisma } from 'nexus-plugin-prisma'
import path from 'path'
import { Mutation, Post, Query, User } from './resolvers'
import { nexusShield, allow } from 'nexus-shield'
import { ForbiddenError } from 'apollo-server-micro'

export const schema = makeSchema({
  types: [Query, User, Post, Mutation],
  contextType: {
    module: path.join(process.cwd(), 'graphql', 'context.ts'),
    export: 'Context',
  },
  plugins: [
    nexusPrisma({
      experimentalCRUD: true,
      outputs: {
        typegen: path.join(
          process.cwd(),
          'generated',
          'typegen-nexus-plugin-prisma.d.ts',
        ),
      },
    }),
    nexusShield({
      defaultError: new ForbiddenError('Not allowed'),
      defaultRule: allow,
    }),
  ],
  outputs: {
    typegen: path.join(process.cwd(), 'generated', 'nexus-typegen.ts'),
    schema: path.join(process.cwd(), 'generated', 'schema.graphql'),
  },
})

As you see here, we use a nexus-shield plugin. With the help of this plugin we can set custom rules for specific resolvers.

# Context

Context is required for adding custom logic for query objects resolution. In our case, we put the Prisma client and session object into context.

// graphql/context.ts
import type { PrismaClient } from '@prisma/client'
import { IncomingMessage } from 'http'
import { getSession, Session } from 'next-auth/client'
import { prisma } from '../lib/prisma'

export interface Context {
  prisma: PrismaClient
  session: Session
}

export const createContext = async ({
  req,
}: {
  req: IncomingMessage
}): Promise<Context> => {
  const session = await getSession({ req })

  return { prisma, session }
}

# Resolvers

Finally, we reached the place where we handle data resolution for our GraphQL server.

import { mutationType, objectType, queryType } from 'nexus'
import { ruleType } from 'nexus-shield'

export const Query = queryType({
  definition(t) {
    t.crud.user({
      shield: ruleType({
        resolve: (_, args, ctx) => ctx.session.id === args.where.id,
      }),
    })
  },
})

export const User = objectType({
  name: 'User',
  definition(t) {
    t.model.id()
    t.model.name()
    t.model.image()
  },
})

This is where we check for current user ID and compare it to the ID that we got from the session. This kind of protection will not allow users with different IDs to request other users.

# Fetch on the client

We implemented the server logic for making users request only their own data. Now we need to fetch data from the GraphQL endpoint using our session ID.

// pages/index.tsx
import React from 'react'
import { gql, useQuery } from '@apollo/client'
import { useSession } from 'next-auth/client'

const Page = () => {
  const [sess] = useSession()
  const { data } = useQuery(
    gql`
      query userInfo($id: Int!) {
        user(where: { id: $id }) {
          id
          name
        }
      }
    `,
    { variables: { id: sess?.id } },
  )

  return <>{JSON.stringify(data)}</>
}

export default Page

We obtain session ID that we passed before and put it to query variables. Then on backend the User resolver compares those IDs and if they match, sends the requested data. You can test it on other IDs and you will get “Not Allowed” error.

# Conclusion

Despite the ease of auth protection, there’s still no good articles covering this topic. There is a lot of “quick-start” ones but none of them describes the auth flow. I hope my article saved you time that you would have to spend on the same research I did.