README.md

atmofed #

Log into a Fediverse server with your ATmosphere account, and check from either side that an AT Protocol DID and an ActivityPub actor belong together.

The specification is SPEC.md. It has the details and the reasoning; this file is about the code.

The DID document names the actor in its alsoKnownAs; the actor names the DID in its alsoKnownAs (or, on software that won't let you edit that, in a profile field). Both together make a link anyone can verify. A Fediverse server that is an atproto OAuth client can set both up when someone logs in.

The package has three parts:

  • a library: identifier parsing, hardened fetching, resolution on both sides, bidirectional verification, and editing the DID document's alsoKnownAs;
  • atmofed verify, a CLI over the verifier;
  • a demo Fediverse server (demo/) whose only way in is an ATmosphere account, speaking enough ActivityPub (WebFinger, actor documents) to be looked up and verified from elsewhere.

Status: draft.

Install #

npm install atmofed

Node 24 or later; plain ESM, no build step.

Verifying #

import { verify } from 'atmofed';

const result = await verify('robin.berjon.com');          // or a DID, at:// URI,
                                                           // @user@host, or actor URL
result.verified;        // true if at least one link checks out both ways
result.links;           // one entry per candidate on the other side
result.links[0];
// {
//   verified: true,
//   did: 'did:plc:…', handle: 'robin.berjon.com', pds: 'https://…',
//   actor: 'https://fedi.example/users/robin.berjon.com', acct: 'robin.berjon.com@fedi.example',
//   atproto:     { pointers: [{ via: 'alsoKnownAs', actor: 'https://fedi.example/…' }], all: […] },
//   activitypub: { pointers: [{ via: 'alsoKnownAs', kind: 'did', value: 'did:plc:…', did: 'did:plc:…' }], all: […] },
// }

pointers are the claims that matched; all is everything that side said, matched or not, which is what to show someone whose link isn't verifying. A candidate that broke along the way comes back with verified: false and an error rather than throwing; only a failure to resolve the input itself throws.

For more control, make a verifier:

import { createVerifier, createFetch, createResolver } from 'atmofed';

const fetch = createFetch();                 // SSRF-protected, size- and time-bounded
const verifier = createVerifier({
  fetch,
  resolver: createResolver({ fetch, plcUrl: 'https://plc.directory' }),
});
await verifier.verify('@robin@mastodon.social');
await verifier.verifyPair({ did: 'did:plc:…', actor: 'https://mastodon.social/users/robin' });

Some instances (mastodon.social among them) only hand out actor documents to signed requests. A verifier that is itself a Fediverse server can sign as one of its actors:

import { createSignedFetch } from 'atmofed';

const apFetch = createSignedFetch({ keyId: 'https://fedi.example/actor#main-key', privateKeyPem, fetch });
const verifier = createVerifier({ fetch, apFetch });   // apFetch is used for WebFinger and actors only

The key has to be fetchable at keyId for the other side to check the signature, so this does nothing for you on a laptop; the demo does it in production. verifyRequest is the matching check for incoming requests.

The pieces are exported on their own too: parseIdentifier, webfinger, fetchActor, actorPointers, didPointers, currentAlsoKnownAs, namesActor, signRequest.

Naming the actor in the DID document #

For did:plc, the PDS changes the DID document on the account's behalf, but only with a token it emails to the account holder. With an authenticated Agent from @atproto/api (an OAuth session with the identity:* scope):

import { requestToken, link, unlink } from 'atmofed';

await requestToken({ agent });                            // the PDS emails a code
await link({ agent, actor: 'https://fedi.example/users/robin', token });
// → { changed: true, alsoKnownAs: ['at://robin.berjon.com', 'https://fedi.example/users/robin'] }
await unlink({ agent, actor: 'https://fedi.example/users/robin', token });   // a fresh token

link appends the actor and keeps every other entry; if the document already names the actor it returns changed: false and needs no token, so it's safe to call whenever. A did:web document is a file its owner edits, so both throw for one, with a message saying what to add.

The actor must also name the DID back (alsoKnownAs: ["at://did:plc:…"], or a profile field) for the link to verify.

Logging people in #

createLogin sets a server up as an atproto OAuth client and returns the handlers and functions a web app needs. With Express:

import express from 'express';
import { createLogin } from 'atmofed/oauth';

const login = await createLogin({
  publicUrl: 'https://fedi.example',
  clientName: 'Fedi Example',
  keys: [process.env.CLIENT_KEY_JWK],        // ES256 private JWK(s); see below
  stateStore, sessionStore,                  // get/set/del stores; memoryStore() by default
});

const app = express();
app.get('/oauth/client-metadata.json', login.handlers.metadata);
app.get('/oauth/jwks.json', login.handlers.jwks);

app.post('/login', async (req, res) => {
  const state = crypto.randomUUID();         // tie the callback to this browser
  res.cookie('oauth_state', state, { httpOnly: true, sameSite: 'lax', secure: true });
  res.redirect(await login.authorize(req.body.identifier, { state }));
});

app.get('/oauth/callback', async (req, res) => {
  const { did, handle, agent, state } = await login.callback(req.query);
  if (state !== req.cookies.oauth_state) return res.sendStatus(400);
  const account = await findOrCreateAccount(did, handle);        // yours
  // … start your own session for `account`, then, if
  // !(await login.namesActor({ did, actor: account.actorUrl })),
  // offer to add it: login.requestToken({ did }), ask for the emailed code,
  // login.link({ did, actor: account.actorUrl, token }).
});

login.callback returns once the library has checked that the token's sub is a DID served by the issuer that minted it; handle is the DID document's handle only if it resolves back to the DID, otherwise null. Later on, login.restore(did) gives an Agent for the saved session, and login.requestToken / login.link / login.unlink take either an agent or a did. login.signOut(did) revokes the session.

The default scope is atproto identity:*. That is a lot more than adding one URL to alsoKnownAs needs (it covers keys and the PDS endpoint too), but atproto has nothing narrower; the emailed token is what stops a server doing anything the person didn't ask for. If you only want logins, pass scope: 'atproto' and let people edit their DID document some other way.

Keys: a confidential client authenticates with ES256 keys. Make one with

node -e "import('@atproto/oauth-client-node').then(async m => console.log(JSON.stringify((await m.JoseKey.generate(['ES256'], 'k1')).privateJwk)))"

and keep it secret. Without keys, createLogin generates an ephemeral one and warns: sessions then die with the process.

For development, an http://127.0.0.1:<port> publicUrl makes a loopback client, which PDSes accept without any hosted metadata. Stores for node:sqlite are included (sqliteStore(db, table)).

CLI #

npx atmofed verify robin.berjon.com
npx atmofed verify @robin@mastodon.social
npx atmofed verify did:plc:… --json

Exit code 0 when a link verifies, 1 when none does, 2 on error.

The demo server #

cp .env.example .env         # optional
npm run dev                  # http://127.0.0.1:3000, data in ./data

Open it, type your handle, authorise on your PDS. You come back as @<your handle>@127.0.0.1:3000 with an actor at /users/<your handle> whose alsoKnownAs names your DID. The /me page runs the verifier on the pair and, if your DID document doesn't name the actor yet, offers to add it (your PDS emails you a code to paste in). /verify runs the verifier on anything; /api/verify?id=… returns JSON.

In production set PUBLIC_URL to an https origin and ATMOFED_CLIENT_KEY (or ATMOFED_CLIENT_KEY_FILE) to the private JWK. PORT, DATA_DIR, SITE_NAME, PLC_URL, and OAUTH_SCOPE are the other knobs (demo/server.js, configFromEnv).

What the demo does on login (demo/server.js): find or create a local account for the DID, naming it after the handle; generate an RSA key so the actor is well-formed for other servers; start a cookie session. Adding the actor to the DID document is a separate step from /me, as is removing it. The server also has an instance actor at /actor whose key signs the verifier's ActivityPub fetches. Nothing is federated: inboxes accept and drop, the collections are empty.

Tests #

npm test

Everything runs offline against fakes.

Licence #

MIT.