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.
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:
- Follow Project Setup
Setup application
Create a new Next.js application.
npx create-next-app demo
Install the SDKs
npm install --save @zalter/identity @zalter/identity-js
Client side
Initialize the auth library
// lib/auth.js
import { Auth } from '@zalter/identity-js';
export const auth = new Auth({
projectId: '<your-project-id>'
});
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>
);
}
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.
}
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>
);
}
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();
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);
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);
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);
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
}
});
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();
}
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);
});
});
}
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!'
});
}
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)