Guides
Guide

How to Implement Auth on Hasura with Magic

2021-01-12

Resources

See the live demo https://magic-hasura.vercel.app/login

The full codebase can be found here.

Introduction

Magic is a passwordless authentication sdk that lets you plug and play different auth methods into your app. Magic supports passwordless email login via magic links, social login , and WebAuthn (a protocol that lets users authenticate with a hardware device such as a YubiKey or fingerprint on supported devices).

Hasura is a popular tool that gives you a GraphQL API on a Postgres database and comes with built-in authorization. Hasura also provides a dashboard where you can manage your data, making it easy for anyone to use. Together, Magic and Hasura are a powerful combo to easily setup your app, complete with authentication, a GraphQL server and a SQL database.

Part one of this tutorial will cover setup and integration of Magic with Hasura, and authentication. Part two will expand on part one with CRUD functionality in the form of a to-do list.

App Flow

Once the user authenticates with Magic, the server validates the DID token returned by Magic, issues a JWT back to the client, which is then used to access the Hasura GraphQL API.

File structure

├── README.md
├── components
│   ├── email-form.js
│   ├── header.js
│   ├── layout.js
│   ├── loading.js
│   ├── social-logins.js
│   └── todo
│       ├── add-todo-form.js
│       ├── todo-item.js
│       ├── todolist-actions.js
│       └── todolist.js
├── lib
│   ├── cookies.js
│   ├── hooks.js
│   ├── magic.js
│   └── magicAdmin.js
├── package.json
├── pages
│   ├── _app.js (to apply the optional magic global css)
│   ├── _document.js (to inject magic css variables)
│   ├── api
│   │   ├── login.js
│   │   ├── logout.js
│   │   └── user.js
│   ├── callback.js
│   ├── index.js
│   ├── login.js
│   └── profile.js
├── public (images)
├── .env.local
└── yarn.lock

Quick Start Instructions

Follow the tutorial below for the instructions on how to fill out .env.local.

$ git clone https://github.com/magiclabs/example-hasura.git
$ cd example-hasura
$ mv .env.local.example .env.local
$ yarn install
$ yarn dev

.env.local File

NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_123...
MAGIC_SECRET_KEY=sk_123...
JWT_SECRET=your-32+-character-secret
NEXT_PUBLIC_HASURA_GRAPHQL_URL=your-graphql-api-server
SESSION_LENGTH_IN_DAYS=7
  • Note one: if you just want authentication, and not the to-do list, delete the /components/todo folder and the reference to <TodoList> in index.js.

  • Note two: the tutorial was built using Magic UI components. If you swap them out for your own custom CSS, you can also delete the _app.js and _document.js files, and @magiclabs/ui, framer-motion and rxjs from your package.json dependencies.

Part One - Authentication & Authorization

Magic Setup

Your Magic setup will depend on what login options you want. For just magic link login, minimal setup is required. For social logins, follow our documentation for configuration instructions.

Once you have social logins configured (if applicable), grab your API keys from the Magic dashboard. In .env.local enter your Test Publishable Key such as NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk-test1234567890 and your Test Secret Key such as MAGIC_SECRET_KEY=sk-test_1234567890.

For added security, in Magic's dashboard settings, you can specify the URLs that are allowed to use your live API keys. Test API keys are always allowed to be used on localhost, however it will block your Live API keys from working anywhere except the URLs specifically added to your allow list.

Hasura Setup

If you don’t have a Hasura account, you can create one at https://cloud.hasura.io/signup.

Once you sign in, click “Try a free database with Heroku”.

Sign up for or sign into your Heroku account. Then you’ll be redirected back to Hasura. Create your project.

In your Hasura Project dashboard, create an Admin Secret, which is the key to access your GraphQL API in admin mode.

Remaining in the Hasura dashboard, in the "Env Vars" tab you can now set up your JWT secret. Hasura has detailed documentation for all of the different (mostly optional) fields but for this you are going to enter the type, which specifies the token signing algorithm, and the key, which is a secret value Hasura will use to verify the token, being sent from the client in the Authorization header. The key will also be used to sign and verify the JWT by your backend.

{
  "type": "HS256",
  "key": "your-32+-character-jwt-secret"
}

Back in your IDE, in .env.local you can enter the key in the JWT_SECRET variable in the form of JWT_SECRET=your-32+-character-jwt-secret, as well as your GraphQL API such as NEXT_PUBLIC_HASURA_GRAPHQL_URL=your-hasura-app-url.

Once that’s complete, click “Launch Console” to enter Hasura's UI for your project, where you’ll manage data, permissions and can run GraphQL queries.

In the Data tab, click “Create Table”, and fill it out accordingly. For this tutorial, we'll use issuer, publicAddress and email, all of which you can get from Magic. issuer is the primary key. When you’re all set, click “Add Table”.

Next you're going to add user roles, controlling what a user can and cannot do. For this app, add a custom check, which ensures that only the issuer, specified in the X-Hasura-User-Id API header, is able to perform CRUD operations on their own data. With this in place, one user cannot send a query to delete another user’s information in the database. Additionally, if a user sends a query to get all users, the only data returned will be that person.

Set these custom permissions for insert, select, update and delete, or however is best for your app. Click "Toggle All" for Column Insert Permissions so a user can be created in the database when they sign up.

Setting user roles with custom permissions is one way to protect your Hasura API. Here are some others.

  • Allow-listing queries so only queries you predefined are able to be run by your API.
  • API limits so that users cannot overload your API with requests.
  • CORS domain settings so that your GraphQL API can only be accessed by requests from your domain.

Now that your .env.local file is filled out and Hasura has been configured properly, you can start building the application.

Implementing Magic Auth

In login.js, handle magic.auth.loginWithMagicLink() which is what triggers the magic link to be emailed to the user. It takes an object with two parameters, email and an optional redirectURI. Magic allows you to configure the email link to open up a new tab, bringing the user back to your application. With the redirect in place, a user will get logged in on both the original and new tab. Once the user clicks the email link, send the DID token to your server endpoint at /api/login.

async function handleLoginWithEmail(email) {
  try {
    setDisabled(true); // disable login button to prevent multiple emails from being triggered

    // Trigger Magic link to be sent to user
    let didToken = await magic.auth.loginWithMagicLink({
      email,
      redirectURI: new URL('/callback', window.location.origin).href, // optional redirect back to your app after magic link is clicked
    });

    // Validate didToken with server
    const res = await fetch('/api/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer ' + didToken,
      },
    });

    res.status === 200 && Router.push('/');
  } catch (error) {
    setDisabled(false); // re-enable login button - user may have requested to edit their email
    console.log(error);
  }
}

The social login implementation is similar. magic.oauth.loginWithRedirect() takes an object with a provider, and a redirectURI for where to redirect back to once the user is authenticated with Magic. After the user authenticates with the social provider, and then with Magic, they are redirected back to /callback where you complete the authentication with your server.

async function handleLoginWithSocial(provider) {
  await magic.oauth.loginWithRedirect({
    provider, // 'google', 'apple', etc
    redirectURI: `${process.env.NEXT_PUBLIC_SERVER_URL}/callback`,
  });
}

Magic will redirect the user to /callback with several query parameters. You can check if the query parameters include a provider, and if so, finish the social login, otherwise, you know it’s a user completing the email login.

// The redirect contains a `provider` query param if the user is logging in with a social provider
useEffect(() => {
  router.query.provider ? finishSocialLogin() : finishEmailRedirectLogin();
}, [router.query]);

// `getRedirectResult()` returns an object with user data from Magic and the social provider
const finishSocialLogin = async () => {
  magic.oauth.getRedirectResult().then(result => authenticateWithServer(result.magic.idToken));
};

// `loginWithCredential()` returns a didToken for the user logging in
const finishEmailRedirectLogin = () => {
  if (router.query.magic_credential)
    magic.auth.loginWithCredential().then(didToken => authenticateWithServer(didToken));
};

Authenticating user (server-side)

In /api/login, verify the DID token, then create a JWT that will allow the user to access Hasura from client-side calls to the API. If this issuer doesn’t exist in the users table, create a new user, otherwise, return the JWT inside a cookie.

export default async function login(req, res) {
  try {
    const didToken = req.headers.authorization.substr(7);
    await magic.token.validate(didToken);
    const metadata = await magic.users.getMetadataByToken(didToken);

    let token = jwt.sign(
      {
        ...metadata,
        'https://hasura.io/jwt/claims': {
          'x-hasura-allowed-roles': ['user'],
          'x-hasura-default-role': 'user',
          'x-hasura-user-id': `${metadata.issuer}`,
        },
        exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * process.env.SESSION_LENGTH_IN_DAYS,
      },
      process.env.JWT_SECRET,
    );

    // Check if user trying to log in already exists
    let newUser = await isNewUser(metadata.issuer, token);

    // If not, create a new user in Hasura
    newUser && (await createNewUser(metadata, token));

    setTokenCookie(res, token);
    res.status(200).send({ done: true });
  } catch (error) {
    // handle error
  }
}

async function isNewUser(issuer, token) {
  let query = {
    query: `{
      users( where: {issuer: {_eq: "${issuer}"}}) {
        email
      }
    }`,
  };
  try {
    let data = await queryHasura(query, token);
    return data?.users.length ? false : true;
  } catch (error) {
    c; // handle error
  }
}

async function createNewUser({ issuer, publicAddress, email }, token) {
  let query = {
    query: `mutation {
      insert_users_one( object: { email: "${email}", issuer: "${issuer}", publicAddress: "${publicAddress}" }) {
        email
      }
    }`,
  };
  try {
    await queryHasura(query, token);
  } catch (error) {
    // handle error
  }
}

async function queryHasura(query, token) {
  try {
    let res = await fetch(process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        Authorization: 'Bearer ' + token,
      },
      body: JSON.stringify(query),
    });
    let { data } = await res.json();
    return data;
  } catch (error) {
    // handle error
  }
}

To make sessions persist, you can rely on the JWT that’s stored in a cookie and automatically sent on each request to your server. The endpoint you send it to is /api/user which verifies the token, then refreshes it on each request. If you didn’t refresh the token, you could run into the scenario where a user logs in, then is browsing your site a week later and gets logged out in the middle of their session because the cookie/token had expired.

export default async function user(req, res) {
  try {
    if (!req.cookies.token) return res.json({ user: null });
    let token = req.cookies.token;
    let user = jwt.verify(token, process.env.JWT_SECRET);
    let { issuer, publicAddress, email } = user;
    let newToken = jwt.sign(
      {
        issuer,
        publicAddress,
        email,
        exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * process.env.SESSION_LENGTH_IN_DAYS,
        'https://hasura.io/jwt/claims': {
          'x-hasura-allowed-roles': ['user'],
          'x-hasura-default-role': 'user',
          'x-hasura-user-id': `${issuer}`,
        },
      },
      process.env.JWT_SECRET,
    );
    user.token = newToken; // send JWT in response to the client, necessary for API requests to Hasrua
    setTokenCookie(res, newToken);
    res.status(200).json({ user });
  } catch (error) {
    res.status(200).json({ user: null });
  }
}

Client-side, you want to check if a user is logged in, and can create a custom hook in /lib/hooks.js that leverages Vercel’s SWR (stale while revalidate) library. This hook sends a request to the server with the JWT which verifies the authenticity of the token, and as long as you get a user object returned, you know it’s valid and to keep the user logged in.

const fetchUser = url =>
  fetch(url)
    .then(r => r.json())
    .then(data => {
      return { user: data?.user || null };
    });

export function useUser() {
  const { data, error } = useSWR('/api/user', fetchUser);
  return !data && !error ? { loading: true } : data && data?.user;
}

To complete the authentication, you need to allow users to log out. In /api/logout you clear the cookie containing the JWT and log the user out of their session with Magic if it's still valid.

  • Note: when a user logs in with Magic, they create a session between Magic and the user that's valid for 7 days by default (the session length is customizable by the developer through the Magic dashboard). This is doesn't matter in the context of this app, since we're relying on the JWT being issued by our server for session management, but it's always good to ensure the user is being logged out with Magic when logging out of your app as well.
export default async function logout(req, res) {
  try {
    if (!req.cookies.token) return res.status(401).json({ message: 'User is not logged in' });
    let token = req.cookies.token;
    let user = jwt.verify(token, process.env.JWT_SECRET);
    removeTokenCookie(res);

    // Add the try/catch because a user's session may have already expired with Magic (expired 7 days after login)
    try {
      await magic.users.logoutByIssuer(user.issuer);
    } catch (error) {
      console.log('Users session with Magic already expired');
    }
    res.writeHead(302, { Location: '/login' });
    res.end();
  } catch (error) {
    res.status(401).json({ message: 'User is not logged in' });
  }
}

At this point, your app is complete with authentication and authorization, connecting Magic with Hasura. When a user signs up, a new row is inserted in the users table. Additionally, the developer is able to control how long users stay logged in for, just by editing the cookie’s MAX_AGE in the cookie.js file, and the exp field in the JSON web token.

Part Two - CRUD

Create a new todos table in Hasura. Each todo will have an id, the todo itself, an is_completed boolean field and a user_id. The user_id will be a foreign key that’s mapped to your users table, specifically the issuer value.

Allow a similar custom check in the Permissions tab for this table that you enabled for the users table. So a user is only able to access only their tasks.

To create a new todo, send a mutation query to your Hasura API with the JWT inside your Authorization header. Hasura also has the JWT secret, and will verify the token with that secret. If it matches, the user is authorized and the query is run.

const handleSubmit = async e => {
  e.preventDefault();
  if (!todo) return;
  await fetch(process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
      Authorization: 'Bearer ' + user?.token,
    },
    body: JSON.stringify(addTodoQuery),
  });
};

const addTodoQuery = {
  query: `mutation {
   insert_todos_one(object: {todo: "${todo}", user_id: "${user?.issuer}"}) {
     todo
   }
 }`,
};

To fetch all todos, similarly, send a query to your Hasura API with the JWT inside the Authorization header.

const getTodos = async () => {
  const getTodosQuery = {
    query: `{
        todos(where: {user_id: {_eq: "${user?.issuer}"}}, order_by: {is_completed: asc, id: asc}) {
          id
          todo
          is_completed
        }
      }`,
  };
  try {
    let res = await queryHasura(getTodosQuery);
    let { data, error } = await res.json();
    error ? console.log(error) : data.todos && setTodos(data.todos);
  } catch (error) {
    // handle error
  }
};

const queryHasura = async query => {
  try {
    let res = await fetch(process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        Authorization: 'Bearer ' + token,
      },
      body: JSON.stringify(query),
    });
    return res;
  } catch (error) {
    // handle error
  }
};

To update a todo (toggle completed), it follows the same format as creating one.

const toggleCompleted = async (id, isCompleted) => {
  const toggleCompletedQuery = {
    query: `mutation {
       update_todos(where: {id: {_eq: "${id}"}, user_id: {_eq: "${user?.issuer}"}}, _set: {is_completed: "${isCompleted}"}) {
         returning {
           id
         }
       }
     }`,
  };
  await queryHasura(toggleCompletedQuery);
};

And again, deleting (one or many) todos follows the same structure.

// delete one todo
const deleteTodo = async todoId => {
  let deleteQuery = {
    query: `mutation {
       delete_todos(where: {id: {_eq: "${todoId}"}, user_id: {_eq: "${user?.issuer}"}}) {
         affected_rows
       }
     }`,
  };
  await queryHasura(deleteQuery);
};

// delete all completed todos
const clearCompleted = async () => {
  let clearCompletedQuery = {
    query: `mutation {
       delete_todos(where: {is_completed: {_eq: true}, user_id: {_eq: "${user?.issuer}"}})
       {
         returning {
           id
         }
       }
     }`,
  };
  await queryHasura(clearCompletedQuery);
};

And that’s it! You now have a full CRUD application using Magic for auth and Hasura for your GraphQL API and database. It’s a powerful and easy combination to get any application going quickly.

Let's make some magic!