Coded Geekery

BlogAboutContact
BlogAboutContact

Using Hasura with NextAuth

Roger Stringer

Roger Stringer / August 20, 2021

11 min read––– views

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
    Hold up!

    Let's pause here... You can look at the NextAuth Docs to see how to add providers such as Google, so we're only going to focus on the Hasura adapter

    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 accounts ( id SERIAL, compound_id VARCHAR(255), user_id INTEGER NOT NULL, provider_type VARCHAR(255) NOT NULL, provider_id VARCHAR(255) NOT NULL, provider_account_id VARCHAR(255) NOT NULL, refresh_token TEXT, access_token TEXT, access_token_expires TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); CREATE TABLE sessions ( id SERIAL, user_id INTEGER NOT NULL, expires TIMESTAMPTZ NOT NULL, session_token VARCHAR(255) NOT NULL, access_token VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); CREATE TABLE users ( id SERIAL, name VARCHAR(255), email VARCHAR(255), email_verified TIMESTAMPTZ, image TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); CREATE TABLE verification_requests ( id SERIAL, identifier VARCHAR(255) NOT NULL, token VARCHAR(255) NOT NULL, expires TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); CREATE UNIQUE INDEX compound_id ON accounts(compound_id); CREATE INDEX provider_account_id ON accounts(provider_account_id); CREATE INDEX provider_id ON accounts(provider_id); CREATE INDEX user_id ON accounts(user_id); CREATE UNIQUE INDEX session_token ON sessions(session_token); CREATE UNIQUE INDEX access_token ON sessions(access_token); CREATE UNIQUE INDEX email ON users(email); CREATE UNIQUE INDEX token ON verification_requests(token);

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

    Users Permissions
  • Actual App setup

  • Now we're back to our app, and we need to add graphql-request:

    yarn add node-fetch bcryptjs 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.js, this can be placed in a folder such as lib:

    lib/hasuraAdapter.js
    const fetch = require('node-fetch'); const bcrypt = require('bcryptjs') const jwt = require('jsonwebtoken') import { createHash, randomBytes } from "crypto" import { Profile, Session, User } from "next-auth" import { Adapter } from "next-auth/adapters" export const hasuraRequest = async ({ query, variables }) => { const result = await fetch(process.env.HASURA_URL, { method: 'POST', headers: { 'X-Hasura-Admin-Secret': process.env.HASURA_ADMIN_SECRET, }, body: JSON.stringify({ query, variables }), }).then((res) => res.json()); if (!result || !result.data) { console.error(result); return []; } return result.data; }; export const hasuraAuthRequest = async ({ token, query, variables }) => { const result = await fetch(process.env.HASURA_URL, { method: 'POST', headers: { Authorization: `Bearer ${token}`, }, body: JSON.stringify({ query, variables }), }).then((res) => res.json()); if (!result || !result.data) { console.error(result); return []; } return result.data; }; export const hasuraClaims = async (id, email) => { const jwtSecret = JSON.parse(process.env.AUTH_PRIVATE_KEY); const token = jwt.sign({ userId: id, 'https://hasura.io/jwt/claims': { 'x-hasura-user-id': 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 } export const HasuraAdapter = (config={}, options = {}) => { return { async getAdapter ({ session, secret, ...appOptions }) { const sessionMaxAge = session.maxAge * 1000 // default is 30 days const sessionUpdateAge = session.updateAge * 1000 // default is 1 day const jwtSecret = JSON.parse(process.env.AUTH_PRIVATE_KEY); const hashToken = (token) => createHash("sha256").update(`${token}${secret}`).digest("hex") return { displayName: "HASURA", async createUser (profile) { const data = await hasuraRequest({ query: ` mutation createUser($user: users_insert_input!) { insert_users_one(object: $user) { id email } } `, variables: { user: { name: profile.name, email: profile.email, image: profile.image, email_verified: profile.emailVerified?.toISOString() ?? null, } }, }); 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 }, }); 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, }, }); return data?.users[0] || null }, async getUserByProviderAccountId ( providerId, providerAccountId ) { const data = await hasuraRequest({ query: ` query getUser($providerId: String!, $providerAccountId: String!){ users(where: { _and: { accounts: { provider_id: {_eq: $providerId}, provider_account_id: {_eq: $providerAccountId} } } }){ id email accounts { provider_id provider_account_id } } } `, variables: { providerId, providerAccountId }, }); return data?.users[0] || null }, async updateUser (user) { return null }, async deleteUser (userId) { return null }, async linkAccount ( userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires ) { const data = await hasuraRequest({ query: ` mutation linkAccount($account: accounts_insert_input!) { insert_accounts_one(object: $account) { id user_id provider_id provider_type provider_account_id refresh_token access_token access_token_expires } } `, variables: { account: { user_id: userId, provider_id: providerId, provider_type: providerType, provider_account_id: providerAccountId, refresh_token: refreshToken, access_token: accessToken, access_token_expires: accessTokenExpires, } }, }); return data?.insert_accounts_one || null }, async unlinkAccount ( userId, providerId, providerAccountId ) { return null }, async createSession (user) { const data = await hasuraRequest({ query: ` mutation createSession($session: sessions_insert_input!) { insert_sessions_one(object: $session) { id user_id expires session_token access_token } } `, variables: { session: { user_id: userId, expires: new Date(Date.now() + sessionMaxAge), session_token: randomBytes(32).toString("hex"), access_token: randomBytes(32).toString("hex"), } }, }); return data?.insert_sessions_one || null }, async getSession (sessionToken) { return null }, async updateSession ( session, force ) { return null }, async deleteSession (sessionToken) { return null }, async createVerificationRequest ( identifier, url, token, secret, provider ) { return null }, async getVerificationRequest ( identifier, token, secret, provider ) { return null }, async deleteVerificationRequest ( identifier, token, secret, provider ) { return null } } } } }
  • Modify NextAuth

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

    pages/api/auth/[...nextauth].js
    import { HasuraAdapter } from "../../lib/hasuraAdapter" import NextAuth from "next-auth" import Providers from "next-auth/providers" // 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 providers: [ Providers.Email({ server: process.env.EMAIL_SERVER, from: process.env.EMAIL_FROM, }), // Temporarily removing the Apple provider from the demo site as the // callback URL for it needs updating due to Vercel changing domains /* Providers.Apple({ clientId: process.env.APPLE_ID, clientSecret: { appleId: process.env.APPLE_ID, teamId: process.env.APPLE_TEAM_ID, privateKey: process.env.APPLE_PRIVATE_KEY, keyId: process.env.APPLE_KEY_ID, }, }), */ Providers.Facebook({ clientId: process.env.FACEBOOK_ID, clientSecret: process.env.FACEBOOK_SECRET, }), Providers.GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, // https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps scope: "read:user" }), Providers.Google({ clientId: process.env.GOOGLE_ID, clientSecret: process.env.GOOGLE_SECRET, }), Providers.Twitter({ clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET, }), Providers.Auth0({ clientId: process.env.AUTH0_ID, clientSecret: process.env.AUTH0_SECRET, domain: process.env.AUTH0_DOMAIN, }), ], adapter: HasuraAdapter(), // The secret should be set to a reasonably long random string. // It is used to sign cookies and to sign and encrypt JSON Web Tokens, unless // a separate secret is defined explicitly for encrypting the JWT. secret: process.env.SECRET, session: { // Use JSON Web Tokens for session instead of database sessions. // This option can be used with or without a database for users/accounts. // Note: `jwt` is automatically set to `true` if no database is specified. jwt: true, // Seconds - How long until an idle session expires and is no longer valid. // maxAge: 30 * 24 * 60 * 60, // 30 days // Seconds - Throttle how frequently to write to database to extend a session. // Use it to limit write operations. Set to 0 to always update the database. // Note: This option is ignored if using JSON Web Tokens // updateAge: 24 * 60 * 60, // 24 hours }, // JSON Web tokens are only used for sessions if the `jwt: true` session // option is set - or by default if no database is specified. // https://next-auth.js.org/configuration/options#jwt jwt: { // A secret to use for key generation (you should set this explicitly) // secret: 'INp8IvdIyeMcoGAgFGoA61DdBglwwSqnXJZkgz8PSnw', // Set to true to use encryption (default: false) // encryption: true, // You can define your own encode/decode functions for signing and encryption // if you want to override the default behaviour. // encode: async ({ secret, token, maxAge }) => {}, // decode: async ({ secret, token, maxAge }) => {}, }, // You can define custom pages to override the built-in ones. These will be regular Next.js pages // so ensure that they are placed outside of the '/api' folder, e.g. signIn: '/auth/mycustom-signin' // The routes shown here are the default URLs that will be used when a custom // pages is not specified for that route. // https://next-auth.js.org/configuration/pages pages: { // signIn: '/auth/signin', // Displays signin buttons // signOut: '/auth/signout', // Displays form with sign out button // error: '/auth/error', // Error code passed in query string as ?error= // verifyRequest: '/auth/verify-request', // Used for check email page // newUser: null // If set, new users will be directed here on first sign in }, // Callbacks are asynchronous functions you can use to control what happens // when an action is performed. // https://next-auth.js.org/configuration/callbacks callbacks: { // async signIn(user, account, profile) { return true }, // async redirect(url, baseUrl) { return baseUrl }, // async session(session, user) { return session }, // async jwt(token, user, account, profile, isNewUser) { return token } }, // Events are useful for logging // https://next-auth.js.org/configuration/events events: {}, // You can set the theme to 'light', 'dark' or use 'auto' to default to the // whatever prefers-color-scheme is set to in the browser. Default is 'auto' theme: 'light', // Enable debug messages in the console if you are having problems debug: false, })

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

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

    pages/api/examples/hasura.js
    import { hasuraAuthRequest, hasuraClaims } from "../..//lib/hasuraAdapter" import jwt from 'next-auth/jwt' import { getSession } from 'next-auth/client' const secret = process.env.SECRET export default async (req, res) => { const session = await getSession({ req }) const token = await jwt.getToken({ req, secret }) if (session && token) { const htoken = await hasuraClaims(session.id, session.user.email); const data = await hasuraAuthRequest({ token: htoken, query: ` query getUser($id: Int!){ 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.js 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.

    Tagged In:#code

    Coded Geekery

    © 2021 Roger Stringer. All rights reserved.

    TwitterGitHubInstagram

    Stuff

    HomeAbout MeContact Me

    Not Playing

    Spotify