charmed.blog

How to Add Login with Mastodon using Node.js

July 21, 2019

tl;dr

There are tradeoffs to using an instance of a federated network like Mastodon to log in to your application, but Mastodon’s OAuth2 support makes it possible! If you want to try it out without following the full tutorial, you can create the Mastodon application, fork the Express demo, update your environmental variables, and run npm start.

Introduction

Mastodon is a free, open source, federated microblogging platform made up of thousands of communities and more than 2.2 million users. Each community, or “instance”, runs independently but is connected with the whole network. A user can see a local feed of people on their instance, but they can also see a federated feed and interact with people in different instances just like people with different email providers can.

Mastodon supports the industry-standard OAuth 2.0 protocol for authorization, making it possible for developers to add a “Login with [a Mastodon instance]” button as a single sign-on option for users.

This tutorial builds on the official documentation to walk through implemementing social login with Mastodon on a Node.js stack using a generated Express server and a Pug view engine.

Before you dive in…

Unlike Facebook, Twitter, or other centralized social networking services, Mastodon is a large network of independently-operated servers. The OAuth protocol requires a peering agreement between each third-party application (your app) and authorizing server (a Mastodon instance) in the form of application-specific keys. There is no way to automate setting up peering agreements with every Mastodon instance.

In other words, setting up social login with mastodon.social (the instance run by lead Mastodon developers) will not enable users of tabletop.social (an instance for boardgamers and other hobbyists) to log in to your site. If you want them to, you will have to register your application on both servers.

In addition, the accounts information available via the REST API does not include users’ email addresses. That makes it tricky to use alongside unique identifiers from other platforms.

Do these quirks make social sign-on with Mastodon impractical? Not necessarily. It’s perfect for applications directed toward users of a particular instance. For example:

  • Educational institutions or other organizations that run Mastodon servers might want users to be able to easily log in to organization-specific apps.
  • Themed apps might want to make it easy for users of similarly-themed instances to log in. For example, a website intended to help artists find clients might have a login button for mastodon.art users.
  • You might want to make it easy for members of a friends-and-family instance to log in to your private photo blog. Cake!

The community is considering additional ways to improve auth with Mastodon. I found some particularly helpful context in this GitHub issue.

Prerequisites

For this tutorial, I’ll assume you have a working knowledge of Node.js and JavaScript. You should be comfortable working on the command line application for your operating system. I’ll also assume that you have Node.js and a Node.js package manager installed on your device.

Version information

I wrote this tutorial using the following software versions:

  • Mastodon: 2.9.2
  • Node.js: v12.6.0
  • npm: 6.10.1
  • Yeoman: 3.1.0
  • Generator Express ES6: 0.6.1

Version information for packages saved to the project with npm can be found here. You might be able to get away with using a different setup; give it a shot!

Create the Mastodon Application

Let’s get started. Your first step will be to sign in to the Mastodon instance that should authorize your users. If you don’t have a Mastodon account, you can find an instance that’s right for you at instances.social. This tutorial will use the instance I run, charmed.social, for the demo.

Once you’ve logged in, click on Preferences (the cog icon above the search bar).

preferences

Next, click on Development in the side bar and then the New Application button.

development

Now, it’s time to fill in the information for the application. I’ll be using reasonable values for development, but you can change them to suit your needs.


Application Name

This is how your OAuth application will be registered on the instance server. I’m using Mastodon OAuth Demo.

Application website

I’m using the localhost IP address on port 3000: http://127.0.0.1:3000.

Redirect URI

You can specify multiple valid redirect URIs by putting each on a different line.

The default redirect URI is urn:ietf:wg:oauth:2.0:oob. You should keep this here, as it will come in handy to let us know we’ve set everything up properly. If this callback address is specified in your local application, you will see a message on the Mastodon server when you log in rather than being redirected back to your app.

I’m going to add http://127.0.0.1:3000/callback for use later on in development. The /callback route will be built into our application.

Scopes

It’s best to avoid requesting more scopes than you need for your app’s functionality. Since we only want a user profile, I’m going to uncheck every top-level scope and check only read:accounts.


If you’ve followed along so far, your application should look something like this:

application values

Save your values by clicking the Submit button at the bottom of the screen. If all goes well, you’ll be redirected to the Development tab with your application listed and the message “Application successfully created”.

success

Client variables

Now, click on the link to your application again, and you’ll see that private keys have been generated for your application. At the top of the screen, you should see values for:

  • Client key
  • Client secret
  • Your access token

secrets

Make a note of the top two, as you’ll need them in your environment later.

Create the Express Application

Generate an Express Template

Now to set up the Express application. To speed things up a little, we’ll use the scaffolding tool Yeoman with a package called Generator Express ES6. These tools produce a JavaScript server scaffolding similar to that of the official Express application generator but include ES6 syntax and other goodies out of the box.

To install Yeoman and Generator Express ES6 on your system, type the following in your terminal:

npm i -g yo generator-express-es6

Once installation is complete, generate your new project with:

yo express-es6

Yeoman will now guide you through setup and installation. You may choose whatever options suit you, but this tutorial will keep it simple.

  1. Specify whether you want to report usage statistics → your call
  2. Name your project → your call; mine is mastodon-oauth-demo
  3. Create a new directory → y
  4. Select a view engine → pug
  5. Select a CSS preprocessor → none
  6. Skip adding a default test → n
  7. Install dependencies with npm → Yes, with npm

In a few seconds, you’ll have a complete Express scaffolding!

Environmental Variables

Remember the client key and client secret Mastodon generated when you registered your application with the server? We’ll store those in a local file your Express application can access while keeping them confidential.

We’ll need to install another package to do this. Navigate into your project directory. In your terminal, type:

npm i dotenv

Now create a .env file at the root of your project. Inside it, put the following, filling in the blanks with the correct values for your project.

# .env

# The `Client key` from your Mastodon app
MASTODON_CLIENT_ID=

# The `Client secret` from your Mastodon app
MASTODON_CLIENT_SECRET=

# The display name of the Mastodon instance
# E.g., Mastodon Social
MASTODON_INSTANCE_NAME=

# The URI of the Mastodon instance
# E.g., https://www.mastodon.social
MASTODON_INSTANCE_URI=

# Server redirect
MASTODON_REDIRECT_URI=urn:ietf:wg:oauth:2.0:oob

# Local redirect
# MASTODON_REDIRECT_URI=http://127.0.0.1:3000/callback

# Scopes
MASTODON_SCOPES=read:accounts

The values you enter should not include quotation marks or trailing slashes. Notice that the default redirect from the server is currently enabled, while the local /callback route is commented out.

Now, update the top of bin/start to include the following:

// bin/start

#!/usr/bin/env node

/** * Load environmental variables. */require('dotenv').config();

This will make your environmental variables accessible throughout the application.

Login

Add this point, we will update the project views with a login link and project-specific display titles, and then we’ll add a /login route to our server.

Navigate to the views/ directory and open index.pug. Modify the file to include the following:

//- views/index.pug

extends layout

block content
  h1= title
  p Welcome to #{title}
  if user != null    a(href='/user') View your profile  else    a(href='/login') Log in with #{instance}

This will set up some conditional rendering: if a user object exists, a link to the user’s profile will appear. Otherwise, the user will see a login link that includes the name of the Mastodon instance they can log in with.

Now, head to the routes/ directory to make this work. In routes/index.js, update the title rendered to the title you’d like to display for your project, and add an instance key with your environment value for MASTODON_INSTANCE_NAME to the argument object. Pug will use these values to render appropriate views.

// routes/index.js

const { Router } = require('express');

const router = Router();

/* GET index page. */
router.get('/', (req, res) => {
  res.render('index', {
    title: 'Mastodon OAuth Demo',    instance: process.env.MASTODON_INSTANCE_NAME  });
});

module.exports = router;

Next, create an auth.js file in the same directory. This is where we’ll put the routes related to Mastodon login.

Add the following to that file:

// routes/auth.js

const { Router } = require('express');

const router = Router();

/* GET login - redirects to OAuth endpoint with query string. */
router.get('/login', (req, res) => {
  const authEndpoint = `${process.env.MASTODON_INSTANCE_URI}/oauth/authorize`;

  // Set up parameters for the query string
  const options = {
    client_id: process.env.MASTODON_CLIENT_ID,
    redirect_uri: process.env.MASTODON_REDIRECT_URI,
    response_type: 'code',
    scope: process.env.MASTODON_SCOPES
  };

  // Generate the query string
  const queryString = Object.keys(options)
    .map(key => `${key}=${encodeURIComponent(options[key])}`)
    .join('&');

  // Redirect the user with app credentials to instance sign in
  const loginURI = `${authEndpoint}?${queryString}`;
  res.redirect(loginURI);
});

module.exports = router;

This block creates a /login route that redirects the user to Mastodon with the parameters for the application you set up.

To enable this router, we’ll need to head back to app.js in the project root.

First, require the auth router at the top of the document where the other routers are imported. Then, have the app use your router. I tend to put auth routers before the others.

// app.js

// Some generated `require` statements...

const authRouter = require('./routes/auth');const indexRouter = require('./routes/index');

// Additional setup and middleware...

app.use(authRouter);app.use('/', indexRouter);

// The rest of the file...

This is a good time to test that our application works so far.

Make sure your changes are saved, and start the server in your terminal:

npm start

You should see node start and log to your terminal. Now, in your browser, navigate to http://127.0.0.1:3000.

You should see a simple home page with your project title, a welcome message, and a link to log in using your Mastodon instance.

home page

Now for the moment of truth: try the login link. You should be directed to a Mastodon login page. Once you sign in, you should see an authorization code on the default redirect page. The code will be different every time you login.

default redirect page

If you’ve gotten this far, well done! You have set up a peering agreement between a Mastodon instance and a local Express application. You are able to connect to that instance and have received an authorization code that allows you to request access to the Mastodon REST API.

In our next step, we will implement a custom callback so the Express server will automatically use the authorization code it receives to access the Mastodon server’s REST API and get the user’s profile.

Callback

Let’s head back to the .env file. With a # symbol, comment out the MASTODON_REDIRECT_URI value we were using and uncomment the one that uses the localhost URI you registered in the Mastodon application. The next time we try to log in, instead of redirecting us to a page with the authorization code, Mastodon will redirect us to the callback address.

# .env

# Other values above...

# Server redirect
# MASTODON_REDIRECT_URI=urn:ietf:wg:oauth:2.0:oob
# Local redirect
MASTODON_REDIRECT_URI=http://127.0.0.1:3000/callback
# Other values below...

Now we need to create a callback route in routes/auth.js and implement a function to get the user profile.

At the top of routes/auth.js, require the authorize method from ./oauth/mastodon.js at the top of the file, and add a /callback route below the /login route.

// routes/auth.js

const { Router } = require('express');

const { authorize } = require('./oauth/mastodon');
const router = Router();

// Login route...

/* GET OAuth callback */router.get('/callback', (req, res) => {  authorize(req)    .then((user) => {      res.json(user);    })    .catch((err) => {      console.error(err);      res.redirect('/');    });});
module.exports = router;

This code calls an authorize function that returns a Promise when the /callback route is hit. In this implementation, the Promise will either resolve to a user object and display that object in the browser in JSON format, or it will reject with an error and redirect to the application’s home page. In a later step, we will add some additional code to incorporate the user object into our views.

Currently, we are requiring authorize from a nonexistent file. Let’s fix that. First, create a folder called oauth in routes/. If you refactor this project later to include other OAuth providers (Twitter, GitHub, etc.), it will be nicer to have the different implementations in their own folder.

Create a mastodon.js file inside oauth/. The authorize function will need to interact with Mastodon’s REST API with POST and GET requests. We can make those easier by installing an HTTP client. This tutorial will use superagent, but you use vanilla AJAX or your preferred client. (Click here to see an implementation that uses request—I wrote it to return a Promise, so it can be simply dropped in as routes/oauth/mastodon.js in place of the below.)

To install superagent, enter the following on the command line:

npm i superagent

Then, paste the below into routes/oauth/mastodon.js.

// routes/oauth/mastodon.js

const superagent = require('superagent');

const authorize = (req) => {
  // The authorization code returned from Mastodon on a successful login
  const { code } = req.query;
  console.log('(1) AUTHORIZATION CODE:', code);

  // Token endpoint
  const tokenURI = `${process.env.MASTODON_INSTANCE_URI}/oauth/token`;

  // Profile endpoint
  const profileURI = `${process.env.MASTODON_INSTANCE_URI}/api/v1/accounts/verify_credentials`;

  // Parameters to send for a token
  const params = {
    client_id: process.env.MASTODON_CLIENT_ID,
    client_secret: process.env.MASTODON_CLIENT_SECRET,
    code,
    grant_type: 'authorization_code',
    redirect_uri: process.env.MASTODON_REDIRECT_URI,
    scopes: 'read:accounts'
  };

  // Post the `params` as form data to the `tokenURI` endpoint
  // to retrieve an access token.
  // `superagent` returns a Promise by default.
  return superagent
    .post(tokenURI)
    .type('form')
    .send(params)
    .then((tokenResponse) => {
      // Access the token in the response body
      const token = tokenResponse.body.access_token;
      console.log('(2) ACCESS TOKEN:', token);

      // Use the token to GET the user's profile
      return superagent
        .get(profileURI)
        .set('Authorization', `Bearer ${token}`)
        .then((profileResponse) => {
          // The response body contains the profile information needed
          // for the app. Log `p` to see all the data included.
          const p = profileResponse.body;

          // Normalize the profile.
          const profile = {
            created_at: p.created_at,
            picture: p.avatar,
            nickname: p.display_name,
            user_id: p.id,
            username: p.username
          };
          console.log('(3) USER PROFILE:', profile);

          return profile;
        })
        .catch(err => err);
    });
};

module.exports = { authorize };

The authorize function does the following:

  1. Given the request object from the /callback route, it accesses the authorization code received from the Mastodon server.
  2. It posts that code with other required application parameters to the token endpoint.
  3. With the token it gets back, it makes another request to get the profile information for the logged-in user from the profile endpoint.

This is another good time to test our progress. Restart the server and try to log in. This time, you should be redirected back to your local Express application instead of the Mastodon page from before, and your profile information should be displayed in JSON format. (My browser formats JSON responses.)

JSON profile

In addition, the authorize function should log each step to the terminal application where the server is running. Next, we will make this user data persistent and display it with our Pug view engine.

Sessions

The HTTP protocol is stateless, which means that the information we receive from the Mastodon server won’t automatically persist through a browser refresh, be saved across routes, or be visible to the view engine without a local store. Let’s set up session middleware to provide one.

Enter the following in your terminal to install the express-session middleware:

npm i express-session

Now, update app.js by requiring express-session at the top and initializing it just before the routers.

// app.js

const cookieParser = require('cookie-parser');
const express = require('express');
const session = require('express-session');const httpErrors = require('http-errors');
const logger = require('morgan');
const path = require('path');

// More code...

app.use(express.static(path.join(__dirname, 'public')));

const sess = {  secret: 'nutella-wonton', // Choose your own secret!  cookie: {    secure: app.get('env') === 'production'  },  resave: false,  saveUninitialized: false};app.use(session(sess));
app.use(authRouter);
app.use('/', indexRouter);

// The rest of the file...

Now, we can store information across routes in a session object appended to our requests. You can read more about configuring Express sessions here.

User Profile

Now that we’re able to retain user information, let’s create a Pug view to display the user profile!

In views/, create a new file named user.pug. Inside, we’ll extend the default layout and render a display based on whether the server has received a user object.

//- views/user.pug

extends layout

block content
  h1= title
  if user != null
    h2 Hi there, #{user.nickname}!
    h3 Your profile
    pre=JSON.stringify(user, null, 2)
    a(href='/logout') Log out
  else
    p Hi there, anonymous!
    a(href='/login') Log in with #{instance}

If the user is logged in, they will see their nickname (display name) from Mastodon, a JSON output of their user profile, and a logout button we’ll work on later. If they navigate to /user without being logged in, they will be presented with a generic greeting with a login button.

Now, let’s update our routes to render this page.

First, we should update the /callback route to append the Mastodon user object to the session object on the request and redirect us to the /user page.

// routes/auth.js

// Setup and login code...

/* GET OAuth callback */
router.get('/callback', (req, res) => {
  authorize(req)
    .then((user) => {
      req.session.user = user;      res.redirect('/user');    })
    .catch((err) => {
      console.error(err);
      res.redirect('/');
    });
});

module.exports = router;

Now that we have user information to work with, create a new file in the routes/ folder named user.js. The /user route should look like this:

// routes/user.js

const { Router } = require('express');

const router = Router();

/* GET user profile. */
router.get('/user', (req, res) => {
  res.render('user', {
    instance: process.env.MASTODON_INSTANCE_NAME,
    title: 'User Profile',
    user: req.session.user
  });
});

module.exports = router;

The handler will now render a user view and pass our Mastodon instance name, application title, and user object to the view layer!

Let’s enable this route in app.js!

// app.js

// Some generated `require` statements...

const authRouter = require('./routes/auth');
const indexRouter = require('./routes/index');
const userRouter = require('./routes/user');
// Additional setup and middleware...

app.use(authRouter);
app.use('/', indexRouter);
app.use(userRouter);
// The rest of the file...

Time for another test! Make sure to restart your server and return to the home page at http://127.0.0.1:3000. Click on the login link, sign into your Mastodon account, and you should be redirected to your User Profile page that displays a JSON object of your Mastodon profile information.

user profile

Your Express application is now able to connect to the application you created on the Mastodon server and use local credentials to get an authorization code. It uses the code to request authorization to access the REST API. When it receives a Bearer token, it requests your user profile. The user profile is parsed and stored in a local session, allowing it to be displayed in the application’s views.

We’re almost finished. The last step is implementing a /logout route.

Logout

Let’s make a couple of changes to our setup to ensure a user’s session is terminated both on the Mastodon instance and in our local application on logout. First, in routes/auth.js, require a revoke method from the same file as authorize. Then, add one more route at the bottom, /logout.

// routes/auth.js

const { Router } = require('express');

const { authorize, revoke } = require('./oauth/mastodon');
// More code, including login and callback routes...

/* GET logout - destroys local session and revokes API token. */router.get('/logout', (req, res) => {  revoke(req)    .then((response) => {      req.session.destroy((err) => {        if (err) {          console.log(err);        }        res.redirect('/');      });    })    .catch(console.error);});
module.exports = router;

This handler calls a revoke function we’ll soon implement in routes/oauth/mastodon.js and expects it to return a Promise. If revoke is successful (invalidating the access token from the Mastodon instance), it will call the session.destroy() method to remove the user data from the local session.

Now to write the revoke function. As above, I’m implementing this function with the superagent HTTP client, but the request version can be found here.

// routes/oauth/mastodon.js

// Require `superagent`...

// Define `authorize` function...

const revoke = (req) => {  const params = {    client_id: process.env.MASTODON_CLIENT_ID,    client_secret: process.env.MASTODON_CLIENT_SECRET  };  const logoutURI = `${process.env.MASTODON_INSTANCE_URI}/oauth/revoke`;  return superagent    .post(logoutURI)    .type('form')    .send(params)    .then(response => response)    .catch(err => err);};
module.exports = { authorize, revoke };

That’s it! Let’s test the login/logout functionality to be sure everything’s in order.

Restart the server, make sure everything’s in order on the home page, and click the login link. You should be presented with your user profile.

Don’t click the logout button yet! Instead, manually type in the address for your home page again, then manually re-enter http://127.0.0.1:3000/user. Your profile should still be there!

This time, click logout at the bottom. You’ll be redirected to the home page. If you manually visit http://127.0.0.1:3000/user, the server should consider you anonymous because your user session was destroyed.

Summary

And there we have it! You now have an Express server with a Pug view engine. It will allow you to view a home page, log in with Mastodon social sign-on, see information from your Mastodon profile on a user profile page, and log out.

Although the Mastodon profiles lack unique identifiers that can be used across Mastodon instances or other services (e.g., no email), on top of other challenges OAuth with Mastodon faces, it also might be perfect for your use case.


CharmedSatyr

Written by CharmedSatyr, who lives in Seattle. He is available for hire. You should follow him on Mastodon!