How to implement remote following for your ActivityPub project

5 January 2022

Recently I contributed an addition to the Bookwyrm social reading software to enable "remote following". Bookwyrm uses the ActivityPub protocol for decentralised online social interaction. The most well-known ActivityPub software is Mastodon, but there are many other implementations, including Pixelfed (for sharing photos), Funkwhale (music), write.as (long form articles) and Pleroma (general). This blog post is primarily for a very niche audience: software developers who want to implement "remote following" for their own decentralised social software. Whilst the technique I discuss here is not restricted to (nor even part of) the ActivityPub specification, it is likely that you would be doing this for an ActivityPub implementation. I received some very helpful pointers from people via my Mastodon account when I was looking in to how to do this, but I could not find anything that explained the whole process, and some of the relevant documents appear to have disappeared from the web, so I figured it may be helpful to write up the process for other people who want to do the same thing.

What do we mean by "remote following?"

It's probably helpful to define our terms up front. ActivityPub is a protocol enabling social interaction between "actors" on multiple (theoretically infinite) platforms. So for example, a Mastodon account hosted at aus.social can follow a Mastodon bot at botsin.space. However because there are multiple ways to implement ActivityPub, it is not merely limited to different servers running the same software interacting: we can also interact with actors who use different implementations. So our aus.social user can also follow someone on pixelfed.social and view their photos from the Mastodon sofware, and get status updates from a bookwyrm.social user directly to their Mastodon feed.

By "remote following" I primarily mean following an account hosted on one implementation from an account using a different implementation, however the technique also works for servers using the same implementation and can improve the "workflow" for this if the remote user does not yet have any presence on your home server. From a user point of view, remote following looks like this:

  1. View another user's profile page on their chosen platform
  2. Click a button to "remote follow" the user from another platform
  3. Enter your username for your platform and click a button to follow
  4. Be redirected to your home server, with a prompt to log in if necessary
  5. Confirm the request to follow the user

In some respects this is a similar workflow to OpenID - we need to identify the remote service, authenticate into it and confirm our choice.

In the following examples we are going to use two ActivityPub actors:

  • @molly@example.social, a user on a new ActivityPub platform
  • @hugh@remote.social. a user on a Mastodon server

For a full implementation of remote-following, both users need to be able to "remote follow" the other user via the five steps listed above. The first thing we likely want to do is to allow our users to share their content outside of the walls of our own implementation: to put the social in "social media". So we should start by helping users of other ActivityPub software to follow our own users.

Steps 1 and 2 - Identifying the user to follow

The first step is fairly straightforward and even a minimal ActivityPub implementation is likely to already have it: a user profile page. Different implementations use different URL schemes. For our imaginary new platform we will use https://example.social/user/{local_username}. So @molly@example.social's profile page would be https://example.social/user/molly.

Step 2 is also pretty straightforward, you just need to add a button somewhere on the page to allow people to remote follow. You may choose to only make this visible to users who are not logged in, or to everyone, it's up to you. But of course you need this button to do something. Exactly how you implement this—whether with a pop-up window like Mastodon and Bookwyrm use, or just directing the user to a new page in the existing window—is your choice, but essentially this button should really behave like an anchor element and open a new page with a form where the requesting user can enter their own (remote) username. For our example we will use a page at the url https://example.social/remote_follow.

Molly profile page

You must pass information to this page about the user to follow. How this is done is up to you: for Bookwyrm I simply used a query parameter, but you could use an HTTP header, as Mastodon appears to do.

Step 3 - Identifying the remote follower

So far our remote user Hugh has browsed to https://example.social/user/molly, clicked on a button to follow Molly, and been directed to another page at https://example.social/remote_follow asking him to enter the account from which he wants to follow @molly@example.social.

Remote follow page

So ...now what? Hugh enters his username, and clicks the Follow button. But Hugh doesn't have an account with example.social—he's not even using the same software. In ActivityPub terms what we need here is for remote.social to send a FOLLOW request to example.social, asking it to add @hugh@remote.social to @molly@example.social's followers collection. But we only control example.social, and we're not authorised to do anything on behalf of @hugh@remote.social. We need to send Hugh to a page on remote.social from which he can send a "remote" follow request: but how do we know where to send him?

Webfinger to the rescue

The webfinger protocol can be used "to discover information about people or other entities on the Internet using standard HTTP methods". Sounds perfect! Webfinger in turn follows the "Well known" URI structure. Don't worry, you don't actually need to read either of these IETF documents, all you need to know is that it has become standard practice in ActivityPub implementations to provide information about users with this URL structure:

/.well-known/webfinger/?resource=acct:USERNAME@DOMAIN.TLD

With webfinger, as long as you know the username and the domain for a remote user, you can discover everything else you need to enable them to follow one of your local users. In our example, remote.social is a Mastodon server, so if we make an HTTP GET request to https://remote.social/.well-known/webfinger/?resource=acct:hugh@remote.social (note that we drop the initial @) we will get a JSON reply like this:

{
  "subject": "acct:hugh@remote.social",
  "aliases": [
    "https://remote.social/@hugh",
    "https://remote.social/users/hugh"
  ],
  "links": [
    {
      "rel": "http://webfinger.net/rel/profile-page",
      "type": "text/html",
      "href": "https://remote.social/@hugh"
    },
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://remote.social/users/hugh"
    },
    {
      "rel": "http://ostatus.org/schema/1.0/subscribe",
      "template": "https://remote.social/authorize_interaction?uri={uri}"
    }
  ]
}

We are interested in the last two links. The link with a rel of self tells us the canonical user URI. This can differ between implementations, so Webfinger tells us the exact URI we need for this particular implementation.

The last link has a rel of http://ostatus.org/schema/1.0/subscribe. You might be thinking this looks like a linked data source, and that if you follow the link it will provide a specification for subscribe in the ostatus schema. I mean, that would be a reasonable assumption. However, this is simply a reference indicating that this link has the meaning of a subscribe template link as defined in the ostatus schema. This is useful, because at the time of writing, the link will simply send you to some kind of advertising page for online slot machines. Don't worry though, you can find the relevant bit of the ostatus specification on github pages—at least for now.

In any case, the webfinger response tells us what we need to do, which is redirect Hugh to the template url, replacing the {uri} section with the URI of our local user Molly. The template URI must always have a reference to {uri} which refers to the local user's URI, and this must be URL encoded.

Confirming the follow request on the remote server (steps 4 and 5)

Your head may be spinning a little at this point, so let's recap. Remote user Hugh wants to follow local user Molly. Hugh clicks on the Remote Follow button on Molly's profile. We present Hugh with a form to tell us his remote account name and he enters @hugh@remote.social and submits the form. We send a GET request from our server to https://remote.social/.well-known/webfinger/?resource=acct:hugh@remote.social, and in return we get sent some JSON conforming to the Webfinger protocol. If it uses ostatus subscribe, we can pull out the template value from the link where rel is http://ostatus.org/schema/1.0/subscribe.

We are now ready to respond to Hugh's button pressing!

First we URL-encode local user Molly's canonical user URI, then use this in place of the {uri} provided in the template value:

https://remote.social/authorize_interaction?uri=https%3A%2F%2Fexample.social%2Fuser%2Fmolly

Now we redirect Hugh to this URL. At this point, there is nothing more for us to do. The remote.social server will ensure Hugh is actually logged in, ask him to confirm the request, and then send an ActivityPub FOLLOW request to our server, and if we have set it up correctly, it will respond just the same as any other ActivityPub compliant follow request.

Hugh following Molly from Mastodon

What about the other way around?

So far so good, but what if Molly wants to follow Hugh using the same logic? At the moment, there is no way for this to happen. Molly will go to Hugh's profile page on Mastodon, click "Follow", fill in her username in the pop-up window, and ...get an error. This is because remote.social doesn't know how to find our ostatus subscribe template. There's a good reason for that: we don't have one yet.

To put the final pieces into place we need to do two things: create the template page, and add a reference to it at a .well-known/webfinger URI.

Setting up your remote follow template

To allow our local users to follow remote users, we need a template page that can display basic information about the user they want to follow, and require them to take some kind of action to confirm they want to remote follow. Typically this means we display the avatar and username of the remote user, and ask our local user to press a button to confirm.

Our page logic should also check that the user is actually logged in! You have probably already set up some templating logic for this across your application: it should be used here too so that our local user is prompted to log in but then returned to the remote follow page.

So in this example we need a route like this:

https://example.social/ostatus_subscribe?acct={uri}

The exact path and query param isn't important: Bookwyrm uses ostatus_subscribe and acct to make clear the connection with ostatus and webfinger, Mastodon uses authorize_interaction and uri, which makes just as much sense. As we discussed above, the {uri} will be the URL-encoded canonical URI for the remote user. What I didn't make clear in the earlier sections is that if you add .json to the end of this user URI and GET it, the request should send back all the ActivityPub actor information you need for this user. This is helpful because we can use that to get the username, user icon (avatar), and anything else you want to display to your local user.

Now when we get a request to this path, it's a matter of grabbing the information we want from their canonical URI, presenting a confirmation screen to our user, and then sending a normal follow request to the remote server once our user (in this case, Molly) confirms.

Molly following Hugh

Telling other servers where to find your template

As I just noted, the path to your template is arbitrary and you can use whatever path you like. However, you need to tell other servers where to find it. As we saw above, we can do this using webfinger. You need to implement a webfinger route as follows:

https://example.social/.well-known/webfinger/?resource=acct:{uri}

Requests to this route should check for local users identified by {username} (e.g. molly@example.social) and return a JSON response with the user's webfinger data. A fairly minimal but compliant webfinger response would look like this:

{
  "subject": "acct:molly@example.social",
  "links": [
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://example.social/users/molly"
    },
    {
      "rel": "http://ostatus.org/schema/1.0/subscribe",
      "template": "https://example.social/ostatus_subscribe?acct={uri}"
    }
  ]
}

Now other ActivityPub services like Mastodon, Bookwyrm and Pleroma can find your remote subscribe template, and your users will be able to take advantage of the remote following buttons on user profiles on those services. 🎉

Errors

During these communications between servers, there are all sorts of errors you can get, and it's helpful to your users if you deal with them separately and display different messages for each one, so that they know why their request failed. Here is a non-exhaustive list of reasons the request might fail:

  • the remote user doesn't exist
  • one of the users has blocked the other one
  • the remote user is already following the local user
  • the remote server has not implemented webfinger
  • the remote server has implemented webfinger but does not define an ostatus subscribe template
  • the remote server fails to respond for some reason
  • the remote server experiences an error
  • the local server experiences an error

Conclusion

Hopefully this explanation will be helpful to others who want to implement remote following for software using the ActivityPub protocol. As you can see, this feature is really nothing to do with ActivityPub, instead using the webfinger protocol, and a remnant of the ostatus protocol, which is not well documented or easy to find. If you spot any errors or have any suggestions for improvement or additions to this post, get in touch via @hugh@ausglam.space.