DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Signature-based Auth + Next.js? How to integrate Zalter under 10 minutes
Manea Adrian
Manea Adrian

Posted on

Signature-based Auth + Next.js? How to integrate Zalter under 10 minutes

Zalter Identity is an identity and authentication provider service based on signatures rather than tokens. It provides a very secure authentication system which protects your users even against Active Man In the Middle Attacks as well as protection for all the user requests ensuring that all of these requests do come from the intended user.

Zalter is currently in closed-beta, but we usually accept any type of organisations and projects! If you have any questions regarding integrations or bug reports please join the discussion on our Discord server

Zalter Diagram

GitHub Repo

Clone the sample app here

Introduction

This is a demonstration on how to add the user authentication and do basic processing of signatures in your backend. Please keep in mind that this is made only for demonstration purpose as it does not go into detail with the best security practices in order to increase the security past the level provided by a simple OAuth or token based authentication. It should be understood that it still is at least as secure as OAuth2 or other authentication systems but can be better than that.

Before you start

To get the most of this guide, you'll need:

Setup application

Create a new Next.js application.

npx create-next-app demo
Enter fullscreen mode Exit fullscreen mode

Install the SDKs

npm install --save @zalter/identity @zalter/identity-js
Enter fullscreen mode Exit fullscreen mode

Client side

Initialize the auth library

// lib/auth.js

import { Auth } from '@zalter/identity-js';

export const auth = new Auth({
  projectId: '<your-project-id>'
});
Enter fullscreen mode Exit fullscreen mode

Create the sign-in page

This page has two forms, one for the email address and one for the code that will be received to complete the authentication.

// pages/sign-in.js

import { useState } from 'react';
import Router from 'next/router';
import { auth } from '../lib/auth';

export default function SignIn() {
  const [email, setEmail] = useState('');
  const [emailSent, setEmailSent] = useState(false);
  const [code, setCode] = useState('');
  const [error, setError] = useState('');

  const onEmailSubmit = async (event) => {
    event.preventDefault();

    try {
      await auth.signInWithCode('start', {
        email
      });
      setEmailSent(true);
    } catch (err) {
      console.error(err);
      setError(err.message || 'Something went wrong');
    }
  };

  const onCodeSubmit = async (event) => {
    event.preventDefault();

    try {
      await auth.signInWithCode('finalize', {
        code
      });

      // Now you can redirect the user to a private page
      Router.push('/dashboard').catch(console.error);
    } catch (err) {
      console.error(err);
      setError(err.message || 'Something went wrong');
      // Zalter Identity service allows only one try per code.
      // The user has to request another code.
      // This allows Zalter to prevent man-in-the-middle attacks.
      setCode('');
      setEmailSent(false);
    }
  };

  return (
    <div>
      {!emailSent ? (
        <form onSubmit={onEmailSubmit}>
          <h3>Enter your email</h3>
          <div>
            <input
              name="email"
              onChange={(event) => setEmail(event.target.value)}
              placeholder="Email address"
              type="email"
              value={email}
            />
          </div>
          {error && (
            <div>
              {error}
            </div>
          )}
          <button type="submit">
            Continue
          </button>
        </form>
      ) : (
        <form onSubmit={onCodeSubmit}>
          <h3>Enter your code</h3>
          <div>
            <input
              name="code"
              onChange={(event) => setCode(event.target.value)}
              placeholder="Code"
              type="text"
              value={code}
            />
          </div>
          <button type="submit">
            Continue
          </button>
        </form>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Routing

You can use the auth.isAuthenticated() function to check whether the user has been authenticated in case of a page refresh and in order to route the user to one page or another.

if (await auth.isAuthenticated()) {
  // show the user back-office pages
} else {
  // show the login page.
}
Enter fullscreen mode Exit fullscreen mode

Sign out

// pages/dashboard.js

import Router from 'next/router';
import { auth } from '../lib/auth';

export default function Dashboard() {
  const onSignOut = async () => {
    try {
      await auth.signOut();

      // Redirect user to a public page
      Router.push('/').catch(console.error);
    } catch (err) {
      console.error(err);
    }
  };

  return (
     <div>
      <h1>Dashboard</h1>
      <button onClick={onSignOut}>
        Sign out
      </button>
     </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Retrieve current authenticated user

To sign a message, you can get the current authenticated user which exposes the necessary utility functions.

const user = await auth.getCurrentUser();
Enter fullscreen mode Exit fullscreen mode

Sign / authorize requests

Now that the user has been successfully authenticated, you can sign all the requests that you need the user to be authenticated in order for them to perform.

/*
 * Signing helper function that accepts a requestInit, similar to fetch.
 * @param {RequestInit} request
 * @return {Promise<RequestInit>}
 */
async function signRequest(request) {
  const { method, headers, body } = request;

  // Load current user
  const user = await auth.getCurrentUser();

  // Get signing key ID
  const keyId = user.subSigKeyId;

  // Convert the body from String to Uint8Array
  const dataToSign = new TextEncoder().encode(body);

  // Sign the data using the user credentials
  const sig = await user.signMessage(dataToSign);

  // Convert the sig from Uint8Array to Base64
  // You might need an external package to handle Base64 encode/decode
  // https://www.npmjs.com/package/buffer
  const encodedSig = Buffer.from(sig).toString('base64');

  return {
    method,
    headers: {
      ...headers,
      'x-signature': `${keyId};${encodedSig}`
    },
    body
  };
}

// Lets say that we want to make a POST request to an API

const request = await signRequest({
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    name: 'John',
    email: 'john@example.com'
  })
});

const response = await fetch('/api/orders', request);
Enter fullscreen mode Exit fullscreen mode

This example only signs the body, but it can be adapted to sign other data, such as current timestamp, expire date, request headers, etc.

Server side

Initialize the identity client

In order for the SDK to be usable on the server side it needs to be initialized using the service account credentials.

// lib/identity.js

import { IdentityClient } from '@zalter/identity';

const config = {
  projectId: '<your-project-id>',
  credentials: '<your-credentials>'
};

export const identityClient = new IdentityClient(config);
Enter fullscreen mode Exit fullscreen mode

Retrieve the user public key

Once the client has been initiated you can easily retrieve the public key for the user making any requests to your server, and verify their signature once you have their key.

Please note that the public key may be invalidated for a lot of reasons, and you should try not to cache it for long as you'd put your server in situations where it would process a key that is no longer valid (say for example the user logged out from all sessions).

const keyId = ''; // retrieve key ID from 'x-signature' header

const keyRecord = await identityClient.getPublicKey(keyId);
Enter fullscreen mode Exit fullscreen mode

Create body parser middleware

Currently Next.js does not expose the raw data of the request. It automatically handles the request and parses the body based on the request content-type header. So we need to handle it ourselves.

// lib/middlewares/body-parser-middleware.js

import bodyParser from 'body-parser';

export const bodyParserMiddleware = bodyParser.json({
  verify: (req, res, buf) => {
    req.rawBody = buf
  }
});
Enter fullscreen mode Exit fullscreen mode

Create authorization middleware

This middleware retrieves the value of the x-signature header sent from the client side.
Extracts the keyId to get the public key from Zalter Identity service, and the sig to verify
the request.

// lib/middlewares/auth-middleware.js

import { Verifier } from '@zalter/identity';
import { identityClient } from './identity';

export async function authMiddleware(req, res, next) {
  const signatureHeader = req.headers['x-signature'];

  if (!signatureHeader) {
    console.log('Missing signature header');
    res.status(401).json({ message: 'Not authorized' });
    return;
  }

  // Get the key ID and sig
  const [keyId, sig] = signatureHeader.split(';');

  if (!keyId || !sig) {
    console.log('Invalid signature header format');
    res.status(401).json({ message: 'Not authorized' });
    return;
  }

  // Decode sig to get the signature bytes
  const rawSig = new Uint8Array(Buffer.from(sig, 'base64'));

  // Fetch the user public key from Zalter Identity service
  let keyRecord;

  try {
    keyRecord = await identityClient.getPublicKey(keyId);
  } catch (err) {
    console.error(err);

    if (err.statusCode === 404) {
      console.log('Public key not found');
      res.status(401).json({ message: 'Not authorized' });
      return;
    }

    res.status(500).json({ message: 'Internal Server Error' });
    return;
  }

  // Get the raw body of the message
  // Remember to add your own bodyParser since Next.js server does not expose the raw body
  const { rawBody } = req;

  // Construct data to verify (must be the same value as the data signed on the browser side)
  const dataToVerify = rawBody ? new Uint8Array(rawBody) : new Uint8Array(0);

  // Verify the signature
  const isValid = Verifier.verify(
    keyRecord.key,
    keyRecord.alg,
    rawSig,
    dataToVerify
  );

  if (!isValid) {
    console.log('Invalid signature');
    res.status(401).json({ message: 'Not authorized' });
    return;
  }

  // Persist user ID for other use
  // We can use any storage strategy. Here we simulate the "res.locals" from Express.
  res.locals = res.locals || {};
  res.locals.userId = keyRecord.subId;

  // Continue to the next middleware / handler
  next();
}
Enter fullscreen mode Exit fullscreen mode

Create middleware helper

This helper allows us to run middlewares in any API handler.

// lib/run-middleware.js

export function runMiddleware(req, res, fn) {
  return new Promise((resolve, reject) => {
    fn(req, res, (result) => {
      if (result instanceof Error) {
        return reject(result);
      }

      return resolve(result);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Create an API route

Now that we have the auth middleware we can protect any route. Until Next.js offers a solution, we need to disable the default body-parser and use our own implementation.

// pages/api/orders.js

import { authMiddleware } from '../../lib/middlewares/auth-middleware';
import { bodyParserMiddleware } from '../../lib/middlewares/body-parser-middleware';
import { runMiddleware } from '../../lib/run-middleware';

// Disable default body parser middleware
export const config = {
  api: {
    bodyParser: false
  }
};

export default async function(req, res) {
  await runMiddleware(req, res, bodyParserMiddleware);
  await runMiddleware(req, res, authMiddleware);

  // To retrieve the user ID set in auth middleware you can use
  // res.locals.userId

  // Now you can retrieve the user data from your database.

  res.status(200).json({
    message: 'It works!'
  });
}
Enter fullscreen mode Exit fullscreen mode

Final notes

Hopefully this article makes the integration a little less scary and may even get you excited about our new approach to user authentication. We’re constantly updating our API and products as a result of feedback from developers - we want to hear from you. Reach out on Discord or by email.

Top comments (0)

Head to your account's Settings to...

🌚 Enable dark mode
πŸ”  Change your default font
πŸ“š Adjust your experience level to see more relevant content