Use case of Mastodon OAuth vs IndieAuth for comments in Hakyll sites

2026-04-19
, , , , , , ,

The Plan That Did Not Work

The first attempt at a comment system for this blog used Hastodon, a Haskell client library for the Mastodon API. The idea was straightforward: readers with Mastodon accounts authenticate, post a comment, and the server posts it as a toot reply on their behalf. The blog has a Mastodon presence at mathstodon.xyz/@xameer, so the integration felt natural.

It was not natural. Here is why.

What Hastodon Actually Does

Hastodon is a Mastodon API client library, last updated in 2018 (0.6.0 on Hackage). It wraps the Mastodon REST API and provides functions for posting statuses, reading timelines, following accounts, and streaming. Its authentication model relies on the OAuth 2.0 password grant flow:

maybeClient <- mkHastodonClient clientId clientSecret email password "mastodon.social"

You give it credentials, it gives you a bearer token via POST /oauth/token with grant_type=password. This is the Resource Owner Password Credentials (ROPC) grant, and it is the core of why the approach failed.

The Password Grant Problem

The OAuth 2.0 password grant requires the client application to receive the user’s plaintext username and password, then forward them to the authorization server. This is the opposite of what OAuth was designed for. It defeats the entire point of delegated authorization: the user trusts the client application with their credentials rather than the identity provider.

Mastodon’s own documentation now reads:

Mastodon has historically supported the Password Grant flow, however, usage is not recommended by the OAuth 2 Specification authors due to security issues, and has subsequently been removed from future versions of Mastodon.

The password grant was removed from Mastodon 4.x. mkHastodonClient in Hastodon calls this removed endpoint. On any modern Mastodon instance, it returns a 401 or 400 immediately. The library cannot be used for user authentication on current Mastodon without modifying the authentication module entirely.

Hastodon 0.6.0 depends on http-client, aeson, and conduit pinned to versions from 2018. Getting it to build with a modern GHC requires overriding multiple dependency bounds. The library is effectively unmaintained — 34 stars, no releases, 7 open issues, zero pull requests.

What the Code Looked Like

The attempt produced three modules: ApiClient.hs, WarpHandler.hs, and a TLS diagnostic. ApiClient hand-rolled the OAuth token request using http-client directly rather than Hastodon’s mkHastodonClient, because Hastodon’s internals were not composable with Warp’s request model. This is telling: if you are rewriting the authentication layer of a library to use it, the library is not helping you.

WarpHandler implemented a multi-step HTML form flow: login form → POST credentials → check login → 2FA form → POST OTP → toot form → POST toot. The app function pattern-matched on (requestMethod req, pathInfo req):

("POST", []) -> do
    mCreds <- handleLoginPost req
    case mCreds of
        Just (uid, pass) -> do
            success <- mastoCheckLogin manager uid pass
            if success
                then respond $ responseLBS status200 [] twoFAFormHtml
                else respond $ responseLBS status200 [] loginFailHtml
        Nothing ->
            respond $ responseLBS status200 [] loginFormHtml

The mastoCheckLogin function sent a POST to /oauth/token with grant_type=password. On mathstodon.xyz (Mastodon 4.x), this returned 401 on every call. The 2FA form was never reached. The toot form was never reached.

Development hours spent: approximately 12–15 hours across three sessions. The blocker was not discovered until the Mastodon documentation was read carefully. The library’s README does not mention the password grant deprecation.

Could It Have Been Made to Work?

Not without significant changes, and not cleanly for a blog commenting use case even if the auth had worked.

Auth problem: Modern Mastodon requires the authorization code flow. The user is redirected to the Mastodon instance login page, authenticates there, and is redirected back with a code. The client never sees the password. Implementing this in Warp requires a proper OAuth callback endpoint, session state between the redirect and the callback, and PKCE support for Mastodon 4.3+.

Use case problem: Even if auth worked, the comment flow would require the commenter to have a Mastodon account, be comfortable with OAuth redirects, and accept that their comment appears as a toot reply on a specific Mastodon account. This ties the comment system to Mastodon’s availability and the specific instance. It also means comments are stored as toots, not as structured data local to the blog.

Integration problem: Warp + Hastodon + session management + PKCE + toot-as-comment mapping is a significant amount of code for a blog comment form. The initAppState / IORef AppState pattern in WarpHandler.hs was already growing to handle per-session instance selection — a problem that does not exist in IndieAuth because the commenter’s identity URL is instance-agnostic.

A workaround that could have worked: Skip mkHastodonClient entirely. Register an OAuth app once, hardcode the client credentials, redirect the user to /oauth/authorize, receive the code on /callback, exchange it for a bearer token, and use the Hastodon API functions that take a token directly via HastodonClient instance token. This bypasses the broken authentication layer. The Hastodon API surface for posting a status is fine — postStatus client "text" works. The cost is implementing the entire authorization code flow manually, at which point Hastodon provides only postStatus and timeline reads, which are both straightforward http-client calls.

The Approach That Worked: IndieAuth

IndieAuth is a decentralized identity protocol built on OAuth 2.0. The commenter provides a URL — their personal website, any domain with a <link rel="authorization_endpoint"> in its HTML. The server discovers the authorization endpoint from that URL, redirects the commenter there, and receives a verified identity claim on callback.

The key property: the server never handles a password. The commenter authenticates at their own identity provider. The blog comment system receives only a verified me URL — proof that the commenter controls the domain they claimed.

How it works

This blog has a commenting service built on a single-binary Rust microservice - rs-comment-api. It runs as a NixOS systemd service on node1, a QEMU VM inside my ThinkPad, and is exposed to the public internet via Tailscale Funnel. No VPS, no managed cloud, no accounts required to comment.

Anyone with a domain that supports IndieAuth can leave a comment. The service verifies identity by redirecting the commenter to their own authorization endpoint, then stores the verified comment in Turso libSQL and pushes a JSON file back to the blog repository via git2. The next CI pipeline rebuilds the static site with the comment included.

POST /comment receives a JSON body with the post slug, the commenter’s website URL, the comment text, and a honeypot field. The service scrapes the commenter’s website for a <link rel="authorization_endpoint"> tag, generates a random state token, stores the pending auth state in Turso, and returns a redirect URL for the browser to navigate to.

GET /auth/callback is the IndieAuth redirect target - publicly reachable via Tailscale Funnel. It receives the authorization code and state, verifies the code against the commenter’s endpoint, fetches their display name, inserts the comment into Turso, and pushes comments/{slug}.json to the blog repository. The git push triggers a GitLab CI pipeline which rebuilds the site.

GET /health returns {"status":"ok"} and is used for uptime checks.

Proof of Concept

Unless my VM is down, this curl command against localhost should return a redirect URL:

curl -v -X POST http://127.0.0.1:3000/comment \
  -H 'Content-Type: application/json' \
  -d '{
    "slug": "2026-03-18-rust-python-interops",
    "author_url": "https://xameer.gitlab.io",
    "body": "test comment",
    "trap": ""
  }'

The trap field is a honeypot - any non-empty value causes the server to silently discard the request.

The response on success is a JSON object with a redirect key pointing to the commenter’s IndieAuth provider:

{"redirect":"https://indieauth.com/auth?response_type=code&..."}

After the commenter authenticates at their provider and is redirected back, the comment appears in the next site build.

Deployment

The service is deployed as a NixOS systemd unit:

systemd.services.comment-api = {
  wantedBy = [ "multi-user.target" ];
  after = [ "network.target" "agenix.service" ];
  environment = {
    PORT = "3000";
    RUST_LOG = "comment_api=info,warp=warn";
  };
  serviceConfig = {
    ExecStart = "${comment-api-pkg}/bin/comment-api";
    EnvironmentFile = config.age.secrets."comment-api-env".path;
    Restart = "on-failure";
    RestartSec = 5;
    User = "comment-api";
    StateDirectory = "comment-api";
  };
};

The environment file (managed by agenix) contains TURSO_URL, TURSO_AUTH_TOKEN, BLOG_REPO, and BASE_URL.

Tailscale Funnel exposes port 3000 publicly:

tailscale funnel --bg --https=443 3000

The public URL is https://node1-2.tail1a8ca.ts.net. The IndieAuth /auth/callback endpoint must be publicly reachable - this is why Tailscale Funnel is used rather than a Cloudflare Zero Trust tunnel with access controls, which would block the callback redirect.

Tailscale Funnel availability depends on the ThinkPad being on and connected. There is no queue - if the host is offline when someone submits a comment, the request fails silently. For always-on availability the service would need to move to a VPS or a Raspberry Pi. The Turso database is independent of host uptime.

Data Flow

browser POST /comment → discover IndieAuth endpoint (scraper) → save pending state (Turso) → return redirect URL browser follows redirect → commenter’s IdP → commenter authenticates → IdP redirects to /auth/callback GET /auth/callback → verify code (commenter’s endpoint) → fetch display name → insert comment (Turso) → git push comments/{slug}.json (git2) → CI rebuild → new static site

Webmentions

Leave a comment

Comments are verified via IndieAuth. You will be redirected to authenticate before your comment is published.