Using Hasura with NextAuth

Roger Stringer

Roger Stringer / August 19, 2021

9 min read

I was looking into a quick Nextjs-based auth solution that talked to Hasura directly. NextAuth can talk to Postgres and has several adapters that can be used to talk to Fauna, Firebase, etc but the only solution that involved Hasura was to use Postgres.

Sure that can work, and works well, but can have issues such as database credentials constantly changing in the case of hosts like Heroku.

So I looked at available adapters and decided to make my own that just talk to Hasura directly via Fetch Requests.

1

App setup

To start, we'll use the NextAuth example:

$ git clone https://github.com/nextauthjs/next-auth-example.git
$ cd next-auth-example
$ yarn
$ cp .env.local.example .env.local

Ok, got that part straightened away? Good, let's continue.

2

Database setup

Let's go to our Hasura console and add our database schema:

CREATE TABLE public."accounts" (
    id text NOT NULL,
    "userId" text NOT NULL,
    type text NOT NULL,
    provider text NOT NULL,
    "providerAccountId" text NOT NULL,
    refresh_token text,
    access_token text,
    expires_at integer,
    token_type text,
    scope text,
    id_token text,
    session_state text
);
CREATE TABLE public."sessions" (
    id text NOT NULL,
    "sessionToken" text NOT NULL,
    "userId" text NOT NULL,
    expires timestamp without time zone
);
CREATE TABLE public."users" (
    id text NOT NULL,
    name text,
    email text,
    "emailVerified" timestamp(3) without time zone,
    image text
);
CREATE TABLE public."verificationTokens" (
    identifier text NOT NULL,
    token text NOT NULL,
    expires timestamp(3) without time zone NOT NULL
);

ALTER TABLE ONLY public."accounts" ADD CONSTRAINT "accounts_pkey" PRIMARY KEY (id);
ALTER TABLE ONLY public."sessions" ADD CONSTRAINT "sessions_pkey" PRIMARY KEY (id);
ALTER TABLE ONLY public."users" ADD CONSTRAINT "users_pkey" PRIMARY KEY (id);
ALTER TABLE ONLY public."accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES public."users"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
ALTER TABLE ONLY public."sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES public."users"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;

Then you'll need to add the following permissions for the users table only:

Users Permissions
3

Actual App setup

Now we're back to our app, and we need to add the following packages:

yarn add graphql graphql-request node-fetch jsonwebtoken crypto

We're going to need to add some environment variables as well:

AUTH_PRIVATE_KEY='{"type":"RS256", "key": "RS256-KEY-THAT-IS-ALSO-SAVED-TO-YOUR-HASURA"}'
HASURA_URL=https://YOUR-HASURA-URL/v1/graphql
HASURA_ADMIN_SECRET=YOURHASURA-ADMIN-SECRET

The AUTH_PRIVATE_KEY is the key you also have saved to your HASURA instance as HASURA_GRAPHQL_JWT_SECRET, if you haven't made one, then you can create one:

ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key
# Don't add passphrase
openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub
cat jwtRS256.key

Take what is in jwtRS256.key, replace all actual new lines with \n and replace RS256-KEY-THAT-IS-ALSO-SAVED-TO-YOUR-HASURA with it, then add the environment variable as HASURA_GRAPHQL_JWT_SECRET to your hasura instance and as AUTH_PRIVATE_KEY for your app.

All right, let's get to finishing setting up NextAuth. Create a file called hasuraAdapter.ts, this can be placed in a folder such as lib:

lib/hasuraAdapter.ts
import { GraphQLClient } from 'graphql-request'
import jwt from 'jsonwebtoken';
import { randomBytes } from "crypto"

export const hasuraFetch = async ({ query, variables, token=null, admin=false }) => {
  if( process.env.HASURA_URL && process.env.HASURA_ADMIN_SECRET ) {
    let headers = {};
    if( admin ){
      headers['X-Hasura-Admin-Secret'] = process.env.HASURA_ADMIN_SECRET;
    } else if( token ) {
      headers['Authorization'] = `Bearer ${token}`;
    }
    const result = await fetch(process.env.HASURA_URL, {
      method: 'POST',
      headers,
      body: JSON.stringify({ query, variables }),
    }).then((res) => res.json());

    if (!result || !result.data) {
      console.error(result);
      return [];
    }

    return result.data;
  }
  return {};
};

export const hasuraRequest = async ({ query = "", variables = {}, token=null, admin=false }) => {
  try {
    if( process.env.HASURA_URL && process.env.HASURA_ADMIN_SECRET ) {
      let headers = {};
      if( admin ){
        headers['X-Hasura-Admin-Secret'] = process.env.HASURA_ADMIN_SECRET;
      } else if( token ) {
        headers['Authorization'] = `Bearer ${token}`;
      }
      const graphQLClient = new GraphQLClient(process.env.HASURA_URL, {
        headers
      });
      return graphQLClient.request(query, variables);
    }
    return {};
  } catch (error) {

  }
};

export const hasuraClaims = async (id, email) => {
  const jwtSecret = JSON.parse(process.env.AUTH_PRIVATE_KEY || "");
  const token = jwt.sign({
    userId: String(id),
    'https://hasura.io/jwt/claims': {
      'x-hasura-user-id': String(id),
      "x-hasura-role": "user",
      'x-hasura-default-role': 'user',
      'x-hasura-allowed-roles': ['user']
    },
    iat: Date.now() / 1000,
    exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
    sub: id,
  }, jwtSecret.key, {
     algorithm: jwtSecret.type
  })
  return token;
}

export const hasuraDecodeToken = async (token) => {
  const jwtSecret = JSON.parse(process.env.AUTH_PRIVATE_KEY || "");
  const decodedToken = jwt.verify(token, jwtSecret.key, {
    algorithms: jwtSecret.type,
  });
  return decodedToken
}

/** @return { import("next-auth/adapters").Adapter } */
export const HasuraAdapter = (config={}, options = {}) => {
  return {
    displayName: "HASURA",
    async createUser(user) {
      const data = await hasuraRequest({
        query: `
          mutation createUser($user: users_insert_input!) {
            insert_users_one(object: $user) {
              id
              email
            }
          }
        `,
        variables: { 
          user: {
            id: randomBytes(32).toString("hex"),
            emailVerified: user.emailVerified?.toISOString() ?? null,
            ...user,
          }
        },
        admin: true,
      });
      return data?.insert_users_one || null      
    },
    async getUser(id) {
      const data = await hasuraRequest({
        query: `
          query getUser($id: Int!){
            users(where: {id: {_eq: $id}}) {
              id
              email
            }
          }
        `,
        variables: { 
          id
        },
        admin: true,
      });
      return data?.users[0] || null
    },
    async getUserByEmail(email) {
      const data = await hasuraRequest({
        query: `
          query getUser($email: String!){
            users(where: {email: {_eq: $email}}) {
              id
              email
            }
          }
        `,
        variables: { 
          email, 
        },
        admin: true,
      });
      return data?.users[0] || null
    },
    async getUserByAccount({ providerAccountId, provider }) {
      const data = await hasuraRequest({
        query: `
          query getUserByAccount($provider: String!, $providerAccountId: String!){
            users(where: {
                _and: {
                  accounts: {
                      provider: {_eq: $provider}, 
                      providerAccountId: {_eq: $providerAccountId}
                    }
                }
              }){
              id
              email
              accounts {
                provider
                providerAccountId
              }
            }
          }
        `,
        variables: { 
          provider,
          providerAccountId
        },
        admin: true,
      });
      return data?.users[0] || null
    },
    async updateUser(user) {
      return
    },
    async deleteUser(userId) {
      return
    },
    async linkAccount(account) {
      const data = await hasuraRequest({
        query: `
          mutation linkAccount($account: accounts_insert_input!) {
            insert_accounts_one(object: $account) {
              id
              userId
              provider
              providerAccountId
            }
          }
        `,
        variables: { 
          account: {
            id: randomBytes(32).toString("hex"),
            ...account,
          }
        },
        admin: true,
      });
      return data?.insert_accounts_one || null
    },
    async unlinkAccount({ providerAccountId, provider }) {
      return
    },
    async createSession({ sessionToken, userId, expires }) {
      const sessionMaxAge = 30 * 24 * 60 * 60

      const data = await hasuraRequest({
        query: `
          mutation createSession($session: sessions_insert_input!) {
            insert_sessions_one(object: $session) {
              id
              userId
              expires
              sessionToken
            }
          }
        `,
        variables: { 
          session: {
            id: randomBytes(32).toString("hex"),
            expires: new Date(Date.now() + sessionMaxAge),
            userId,
            sessionToken,
          }
        },
        admin: true,
      });
      return data?.insert_sessions_one || null
    },
    async getSessionAndUser(sessionToken) {
      const data = await hasuraRequest({
        query: `
          query getSessionAndUser(!sessionToken: String!){
            session(where: {
              sessionToken: {_eq: $sessionToken}
            }){
              sessionToken
              expires
              User {
                id
                emai;
              }
            }
          }
        `,
        variables: { 
          sessionToken,
        },
      });
      return data?.sessions[0] || null
    },
    async updateSession({ sessionToken }) {
      return
    },
    async deleteSession(sessionToken) {
      return
    },
    async createVerificationToken({ identifier, expires, token }) {
      return
    },
    async useVerificationToken({ identifier, token }) {
      return
    },
  }
}
export default HasuraAdapter;
4

Modify NextAuth

Ok, open pages/api/auth/[...nextauth].tsx and let's add our adapter:

pages/api/auth/[...nextauth].tsx
import NextAuth from "next-auth"
import GoogleProvider from "next-auth/providers/google"
import GithubProvider from "next-auth/providers/github"

import { HasuraAdapter } from "../../lib/hasuraAdapter"

// For more information on each option (and a full list of options) go to
// https://next-auth.js.org/configuration/options
export default NextAuth({
  // https://next-auth.js.org/configuration/providers/oauth
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_ID,
      clientSecret: process.env.GOOGLE_SECRET,
    }),
  ],
  adapter: HasuraAdapter(),
  theme: {
    colorScheme: "light",
  },
  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30 days
    updateAge: 24 * 60 * 60, // 24 hours
  },
  jwt: {
    maxAge: 60 * 60 * 24 * 30,
  },
  callbacks: {
    async jwt({ token }) {
      token.userRole = "admin"
      return token
    },
  },
})

If you looked at the original file then all we did was include the hasuraAdapter file and replace the database reference with the adapter.

We also want to make sure the session and jwt options are set as jwt strategy with a maxAge, this is because we want to use JWT tokens for authentication.

4

Testing Claims

Once you've set up the nextauth code, we also want to see the hasura claims working.

In the pages/api/examples folder, create a file called hasura.ts:

pages/api/examples/hasura.ts
import { hasuraRequest, hasuraClaims } from "lib/hasuraAdapter"

import { getToken } from "next-auth/jwt"

import { getSession } from "next-auth/react"

// This is an example of how to read a JSON Web Token from an API route
import type { NextApiRequest, NextApiResponse } from "next"

const secret = process.env.NEXTAUTH_SECRET

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const session = await getSession({ req })
  const token = await getToken({ req, secret })
  if (session && token) {
    const htoken = await hasuraClaims(token.sub, session?.user?.email);
    const data = await hasuraRequest({
        token: htoken,
        query: `
            query getUser($id: String!){
                users(where: {id: {_eq: $id}}) {
                    id
                    email
                }
            }
       `,
        variables: { 
            id: token.sub
        },
    });
    res.send({ me: data?.users[0] })
  } else {
    res.send({ error: 'You must be sign in to view the protected content on this page.' })
  }
}

This file will use your logged in info and create an hasura claim which then returns your saved user id and email.

Then in the pages/api-example.tsx file, after the last <details> section, we'll add our own:

pages/api-example.js
<details>
    <summary>Hasura call</summary>
    <h2>Hasura call</h2>
    <p>/api/examples/h</p>
    <iframe src="/api/examples/hasura"/>
</details>

Now you'll see your user id and email display, same as the way the other examples work.

This can be used anywhere else in the app to make calls to the user based on, you could use my earlier React Admin post and add auth to it using this method for example.

NextAuth is a pretty handy utility and adding Hasura to it as an adapter, just makes it that much more handy.

Do you like my content?

Sponsor Me On Github