Using Hasura with NextAuth
Roger Stringer / August 20, 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.
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.
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:
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
:
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;
Modify NextAuth
Ok, open pages/api/auth/[...nextauth].tsx
and let's add our adapter:
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.
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:
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:
<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.