Node.js

End-to-end example showing how Magic works with the Node.js Express framework.

Overview

Welcome to our end-to-end example to demonstrate how Magic works with your own Node.js server using the popular Express framework! If you haven't gone through the Get Started guide, we strongly recommend going through it before starting this tutorial.

In this example, we'll be implementing an additional feature in the Hello World example - letting only authorized users buy apples ๐ŸŽ and see how many they own.

Tutorial

0. Set Up Tutorial Template

Use our Node Tutorial Template to follow along with this tutorial.

The full stack Node.js server is set up using the vanilla Express template on CodeSandBox, using NeDB as an ultra lightweight database (you can switch it to other databases like MongoDB too) and the express-session NPM package to manage sessions. We'll make use of the Magic Admin SDK for this example. You can learn how to install it via npm or yarn in the Node.js reference.

1. Setup Environment Variables

From the CodeSandBox's Node Tutorial Template, click "Fork" to create your own instance to play with.

Once you've forked the template, click on the "Server Rack" icon in the left menu, and then update your "Secret Keys".

To grab these two values, you need to sign up or log in to the Magic Dashboard to view your API keys. Once you are logged in you, you canke add MAGIC_SECRET_KEY as Name, and your actual secret API key, which looks something like sk_test_********* as Value.

Once that's done, you can add MAGIC_pubPUBLISHABLE_KEY as Name, and your publishable API key, which looks something like pk_test_********* as Value.

Once the keys are copy and pasted in the Secret Keys section, click Restart Server to load these environment variables in the CodeSandBox container.

Note that the MAGIC_SECRET_KEY environment variable is used to initialize the Magic Admin SDK.

/* 1. Setup Magic Admin SDK */
const { Magic } = require("@magic-sdk/admin");
const magic = new Magic(process.env.MAGIC_SECRET_KEY);

Your very own full-stack Magic Apple Store application is now running! ๐ŸŽ

The next steps explain each of major sections of the code base.

2๏ธ. Implement Auth Strategy

Magic leverages the popular and battle-tested Passport authentication middleware package for Express to provide our passwordless authentication service. This way it will be very seamless and easy to integrate for developers who are already familiar with Passport! (Don't worry if you are not, we'll go through comprehensive examples in this tutorial)

Magic's authentication is based on the Decentralized ID (DID) token standard, and the strategy implemented by passport-magic already takes care of the heavy-lifting by verifying the token and returning the authenticated user object. In this strategy, we'd like to define what to do if this user is a new user versus a returning user.

Note that in the DID standard we use issuer quite a lot, which looks something like did:ethr:0xE0cef4417a772512E6C95cEf366403839b0D6D6D, it represents a unique blockchain public address which can often be used as an identifier field. The issuer can be used to fetch the authenticated user's metadata, which includes their associated email address.

/* In routes/user.js */
/* 2. Implement Auth Strategy */
const passport = require("passport");
const MagicStrategy = require("passport-magic").Strategy;
const strategy = new MagicStrategy(async function(user, done) {
const userMetadata = await magic.users.getMetadataByIssuer(user.issuer);
const existingUser = await users.findOne({ issuer: user.issuer });
if (!existingUser) {
/* Create new user if doesn't exist */
return signup(user, userMetadata, done);
} else {
/* Login user if otherwise */
return login(user, done);
}
});
passport.use(strategy);

3๏ธ. Implement Auth Behaviors

Implement User Signup

The user signup behavior is pretty straightforward in this case, where a new user would be inserted into the NeDB database.

/* In routes/user.js */
/* 3. Implement Auth Behaviors */
/* Implement User Signup */
const signup = async (user, userMetadata, done) => {
let newUser = {
issuer: user.issuer,
email: userMetadata.email,
lastLoginAt: user.claim.iat
};
await users.insert(newUser);
return done(null, newUser);
};

Implement User Login

Since authentication is token-based, the user login behavior needs to implement a timestamp check to protect against replay attacks - here's a simple reference implementation, but you can make adjustments depending on your security preference.

/* In routes/user.js */
/* Implement User Login */
const login = async (user, done) => {
/* Replay attack protection (https://go.magic.link/replay-attack) */
if (user.claim.iat <= user.lastLoginAt) {
return done(null, false, {
message: `Replay attack detected for user ${user.issuer}}.`
});
}
await users.update(
{ issuer: user.issuer },
{ $set: { lastLoginAt: user.claim.iat } }
);
return done(null, user);
};
/* Attach middleware to login endpoint */
router.post("/login", passport.authenticate("magic"));

Here's an example of how to call the Login Endpoint from the client-side:

/* In views/index.ejs */
...
const didToken = await magic.auth.loginWithMagicLink({ email });
await fetch(`${serverUrl}user/login`, {
headers: new Headers({
Authorization: "Bearer " + didToken
}),
withCredentials: true,
credentials: "same-origin",
method: "POST"
});
...

4๏ธ. Implement Session Behavior

An awesome feature from the Passport middleware is the possibility to populate the req.user object with an actual database record, so that the data can be conveniently used in your endpoints. See the next section for more examples.

/* In routes/user.js */
/* 4. Implement Session Behavior */
/* Defines what data are stored in the user session */
passport.serializeUser((user, done) => {
done(null, user.issuer);
});
/* Populates user data in the req.user object */
passport.deserializeUser(async (id, done) => {
try {
const user = await users.findOne({ issuer: id });
done(null, user);
} catch (err) {
done(err, null);
}
});

5๏ธ. Implement User Endpoints

Implement Get Data Endpoint

This endpoint is responsible for grabbing the currently authenticated user's data, including the number of apples ๐ŸŽ to be displayed in the front-end! Note how this endpoint uses req.isAuthenticated() to check if the current user is authenticated, and req.user can be passed to the front-end without fetching from the database again!

/* In routes/user.js */
/* 5. Implement User Endpoints */
/* Implement Get Data Endpoint */
router.get("/", async (req, res) => {
if (req.isAuthenticated()) {
return res
.status(200)
.json(req.user)
.end();
} else {
return res.status(401).end(`User is not logged in.`);
}
});

Here's how you would call the Get Data Endpoint from the client-side:

/* In views/index.ejs */
...
let res = await fetch(`${serverUrl}/user/`);
if (res.status == 200) {
let userData = await res.json();
let appleCount = userData.appleCount;
...
}
...

Implement Buy Apple Endpoint

Follows similar pattern as the Get Data Endpoint, instead authenticated user can now update and increment their apple count!

/* In routes/user.js */
/* Implement Buy Apple Endpoint */
router.post("/buy-apple", async (req, res) => {
if (req.isAuthenticated()) {
await users.update(
{ issuer: req.user.issuer },
{ $inc: { appleCount: 1 } }
);
return res.status(200).end();
} else {
return res.status(401).end(`User is not logged in.`);
}
});

Here's how you would call the Buy Apple Endpoint from the client-side:

/* In views/index.ejs */
const handleBuyApple = async () => {
await fetch(`${serverUrl}/user/buy-apple`, { method: "POST" });
render();
};

Implement Logout Endpoint

To log out the user, you can use the Magic Admin SDK to logout the current user based on the user ID, remember to also call req.logout() to clear the Express user session as well!

/* In routes/user.js */
/* Implement Logout Endpoint */
router.post("/logout", async (req, res) => {
if (req.isAuthenticated()) {
await magic.users.logoutByIssuer(req.user.issuer);
req.logout();
return res.status(200).end();
} else {
return res.status(401).end(`User is not logged in.`);
}
});

With this, you no longer have to call magic.user.logout() on the client-side.

/* In views/index.ejs */
const handleLogout = async () => {
// await magic.user.logout(); NO LONGER NEEDED!
await fetch(`${serverUrl}/user/logout`, { method: "POST" });
render();
};

๐ŸŽ‰ Congratulations! Now that you've completed the tutorial, you should have a working version of the ๐ŸŽ Apple Store app!

๐Ÿ‘‰ What's Next

Use Magic with existing tools

Customize your Magic flow ๐ŸŽจ

You can customize the login experience using your own UI instead of Magic's default one and/or customize the magic link email with your brand. Learn how to customize.