Benjamin Milde

Serve the webfinger protocol with phoenix

With Elon Musk taking over Twitter many, especially tech savvy, people have been looking at Mastodon as an alternative – and so did I. Mastodon is a federated platform, where you join as a user of a certain instance, like for example hachyderm.io, the instance I joined a few days ago.

So people can now find me under the account name @lostkobrakai@hachyderm.io.

So far so good, but it kinda rubed me the wrong way to be @lostkobrakai of some random Mastodon instance. What if it goes away, will people still find my profile? In the end these instance often depend on volunteers, which are in my opinion not to blame if they would like to move on or stop doing what they do today. However I’d really like to have my profile be linked to my own domain, even though I don’t run my own instance (yet?).

Luckily that’s actually rather simple to do as explained to me when I asked on the ElixirForum.

Webfinger

The answer is webfinger – a http based protocol to “to discover information about people or other entities on the Internet”1.

The protocol works by having a known path on a website:

GET /.well-known/webfinger

That path can be queried with a ?resource=… query string. Websites supporting webfinger can return any suitable information they know about the resource in the JSON Resource Description (JRD) format, a standardized schema for data encoded as JSON. More details on it can be found in the RFC.

Given I recently moved my website to be powered by phoenix I did implement this as a simple controller.

I knew I wanted to have ETag support, so I pulled in {:etag_plug, "~> 1.0"} first and put that on the controller. ETags are a mechanism to skip sending data when the client already has the data cached locally, which I hadn’t really touched much, so this was a good excuse to see how it goes.

defmodule MyAppWeb.WebfingerController do
  use MyAppWeb, :controller

  plug ETag.Plug
end

Next the RFC for webfinger expects servers to return a 400, if the request misses the required ?resource=… query parameter. I handled this one in a plug as well, though a custom one this time.

defmodule MyAppWeb.WebfingerController do
  use MyAppWeb, :controller

  []
  plug :resource_required

  defp resource_required(conn, _) do
    if conn.query_params["resource"] do
      conn
    else
      conn
      |> send_resp(:bad_request, "")
      |> halt()
    end
  end
end

Clients are also allowed to add filters using the ?rel=… query parameter, but I didn’t implement supporting that feature. The RFC makes this optional, as the server may just ignore those parameters and still work to the specification.

So the last thing to do is figuring out if our server knows about the requested resource. This I handled in the controller action:

defmodule MyAppWeb.WebfingerController do
  use MyAppWeb, :controller

  []

  @aliases ["acct:lostkobrakai@kobrakai.de", "acct:lostkobrakai@hachyderm.io"]

  def finger(conn, %{"resource" => resource}) do
    case resource do
      r when r in @aliases ->
        data = %{
          subject: "acct:lostkobrakai@kobrakai.de",
          aliases: [
            "acct:lostkobrakai@hachyderm.io",
            "https://hachyderm.io/@lostkobrakai",
            "https://hachyderm.io/users/lostkobrakai"
          ],
          links: [
            %{
              rel: "http://webfinger.net/rel/profile-page",
              type: "text/html",
              href: "https://hachyderm.io/@lostkobrakai"
            },
            %{
              rel: "self",
              type: "application/activity+json",
              href: "https://hachyderm.io/users/lostkobrakai"
            },
            %{
              rel: "self",
              href: "https://kobrakai.de"
            },
            %{
              rel: "http://ostatus.org/schema/1.0/subscribe",
              template: "https://hachyderm.io/authorize_interaction?uri={uri}"
            }
          ]
        }

        response = Phoenix.json_library().encode_to_iodata!(data)

        conn
        |> put_resp_content_type("application/jrd+json")
        |> send_resp(200, response)

      _ ->
        send_resp(conn, :not_found, "")
    end
  end
end

As you can see I just hardcoded the values, but this could easily become more flexible when needed. One thing of note however is the returned content type of application/jrd+json.

With phoenix we can do proper content type negotiation, so in the router I didn’t just add the router under the :browser pipeline, but I created a custom pipeline:

pipeline :webfinger do
  plug :accepts, ["jrd", "json"]
end

scope "/", MyAppWeb do
  pipe_through :webfinger

  get "/.well-known/webfinger", WebfingerController, :finger
end

The "jrd" format isn’t known by the :mime application, so I’ve added the necessary config to extend it as well. Make sure to recompile it using mix deps.compile mime --force. Otherwise you might run into compile time errors around mismatched compiled and runtime configuration.

config :mime, :types, %{
  "application/jrd+json" => ["jrd"]
}

With that everything is in place for me to be discovered under my own domain as @lostkobrakai@kobrakai.de:

https://kobrakai.de/.well-known/webfinger?resource=acct:lostkobrakai@kobrakai.de

This should allow people to find me without them needing to know which Mastodon instance actually hosts my account at the time.