Skip to content

Commit 5590bd7

Browse files
webfinger plugin (#164): /.well-known/webfinger discovery
RFC 7033 JRD from pods scanned under config.podsRoot: subject, WebID aliases, and links (profile-page, self->AP actor with application/activity+json, OIDC issuer). The front door for the mastodon/ and bluesky/ shims (@user@host -> actor) and Solid-OIDC RPs. acct: and WebID-URL resource forms; unknown->404; open CORS. Finding: webfinger and nip05 are the two most-wanted .well-known docs a JSS deployment serves, and BOTH are plugin-served only by the identical undocumented coincidence (core blanket-exempts /.well-known/*) — the strongest case yet for a first-class api.reservePath(). Also: #164's 'each protocol registers its own links' needs a link registry (api.webfinger.addLink) — contention is currently silent. 5/5.
1 parent 1a83a44 commit 5590bd7

3 files changed

Lines changed: 477 additions & 0 deletions

File tree

webfinger/README.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# webfinger — WebFinger (RFC 7033) discovery plugin
2+
3+
Out-of-tree take on the WebFinger half of JSS
4+
[#164](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/164):
5+
serve [WebFinger](https://www.rfc-editor.org/rfc/rfc7033)
6+
(`GET /.well-known/webfinger?resource=acct:<user>@<host>`) for every pod, as
7+
an **always-on** endpoint independent of `--activitypub`. WebFinger is the
8+
resolver the fediverse and Solid-OIDC both lean on: it turns an `acct:`
9+
handle (or a WebID URL) into a pod's actor URL, profile page, and IdP
10+
issuer. Like nip05/, the port's real subject is the path —
11+
`/.well-known/webfinger` is a fixed absolute location, and a plugin owns
12+
exactly one mount prefix.
13+
14+
```js
15+
plugins: [{ module: 'webfinger/plugin.js', prefix: '/webfinger',
16+
config: {
17+
podsRoot: './data', // pod dirs to scan (finding 3)
18+
baseUrl: 'https://pod.example', // origin for JRD URLs (required)
19+
actorPathTemplate: '/<user>/actor', // optional; '' / null omits `self`
20+
} }]
21+
```
22+
23+
## The JRD it returns
24+
25+
`GET /.well-known/webfinger?resource=acct:alice@pod.example`
26+
27+
```json
28+
{
29+
"subject": "acct:alice@pod.example",
30+
"aliases": [
31+
"https://pod.example/alice/profile/card#me",
32+
"https://pod.example/alice/profile/card"
33+
],
34+
"links": [
35+
{ "rel": "http://webfinger.net/rel/profile-page",
36+
"type": "text/html",
37+
"href": "https://pod.example/alice/profile/card" },
38+
{ "rel": "self",
39+
"type": "application/activity+json",
40+
"href": "https://pod.example/alice/actor" },
41+
{ "rel": "http://openid.net/specs/connect/1.0/issuer",
42+
"href": "https://pod.example" }
43+
]
44+
}
45+
```
46+
47+
- **subject** — echoes the queried `resource` verbatim (RFC 7033 §4.4).
48+
- **aliases** — the pod's WebID (`…/profile/card#me`) and its profile page
49+
(`…/profile/card`).
50+
- **`rel="…/rel/profile-page"`** — the pod's human-readable profile.
51+
- **`rel="self"` type `application/activity+json`** — the pod's ActivityPub
52+
actor (`<baseUrl><actorPathTemplate>`). This is the link Mastodon/AP
53+
clients follow. Omitted if `actorPathTemplate` is set to `''`/`null` (a
54+
deployment with no AP actors — the issue's "IF applicable").
55+
- **`rel="…/connect/1.0/issuer"`** — the IdP issuer (`baseUrl`), so
56+
Solid-OIDC relying parties and remoteStorage clients discover the pod's
57+
issuer off the *same* document (#164's point: one endpoint, many
58+
protocols, each contributing its own link relation).
59+
60+
Resolution:
61+
62+
- **`acct:<user>@<host>`** — the pod is resolved from the local part
63+
`<user>` by scanning `config.podsRoot` for a pod dir with a WebID card
64+
(`<user>/profile/card.jsonld`), exactly as nip05/ scans. The `<host>` is
65+
not matched against `baseUrl` — path-mode pods share one host and the
66+
handle's host is advisory.
67+
- **`resource=<webid-url>`** — the path locates the pod
68+
(`…/<user>/profile/card``<user>`; `…/profile/card` → single-user).
69+
- **single-user layout** — a card at the podsRoot itself resolves to the
70+
one pod at `/`.
71+
- Pods are scanned **fresh per request**, so pods provisioned after boot
72+
resolve immediately. Unknown users → **404** (RFC 7033 §4.5); a missing
73+
`resource`**400** (§4.2). `Access-Control-Allow-Origin: *` on every
74+
response (fediverse clients are browsers/bots with no credentials, §5).
75+
- Served at **both** `/.well-known/webfinger` (attempted; see finding 1)
76+
and `<prefix>/webfinger` (always).
77+
78+
## Fediverse & Solid integration
79+
80+
This plugin is the missing front door for the mastodon/ and bluesky/ shims.
81+
A Mastodon client resolving `@alice@pod.example` first fetches
82+
`/.well-known/webfinger?resource=acct:alice@pod.example`, follows the
83+
`self` link to `…/alice/actor`, and only then talks the API the mastodon/
84+
shim serves. Without WebFinger the handle is unresolvable; with it, the pod
85+
becomes a first-class fediverse actor address. The same document's issuer
86+
link is what a Solid-OIDC RP or a remoteStorage app (#164's other callers)
87+
reads to bootstrap auth — which is precisely why #164 wants WebFinger
88+
lifted out of `--activitypub` into an always-on, multi-protocol plugin.
89+
90+
## Findings
91+
92+
1. **A plugin can serve a well-known path — but only by accident (shared
93+
with nip05).** `/.well-known/webfinger` lies outside any prefix a plugin
94+
can mount on, yet `api.fastify.get('/.well-known/webfinger', h)` works —
95+
registered at the absolute path, served, and served *unauthenticated*
96+
(the test proves an anonymous 200 against a server whose default is not
97+
public-read). Every link in that chain is the *same* accident nip05/
98+
documents, not the plugin contract:
99+
- the loader hands plugins the real scoped Fastify instance and does not
100+
confine routes to `prefix`;
101+
- the route only wins because core's GET there is the LDP wildcard
102+
`GET /*`, which an exact path outranks — had core also claimed an exact
103+
`GET /.well-known/webfinger`, this registration would throw
104+
`FST_ERR_DUPLICATED_ROUTE` at boot, so it is wrapped in try/catch and
105+
degrades to prefix-only;
106+
- it is WAC-exempt only because core's auth preHandler blanket-skips
107+
`/.well-known/*`; the loader's own exemption covers `prefix` alone.
108+
109+
**This is the load-bearing observation for #164.** WebFinger and nip05
110+
are the *two most-wanted* `.well-known` documents a JSS deployment
111+
serves (fediverse/OIDC discovery, and Nostr identity) — and **both are
112+
plugin-served purely by luck**, resting on the identical undocumented
113+
routing coincidence. Two independent, high-value endpoints hitting the
114+
same wall is the strongest case yet for a first-class
115+
`api.reservePath('/.well-known/webfinger')` (or `wellKnown: [...]` /
116+
`paths: [...]` in the plugin entry): the loader would reserve the route,
117+
fail informatively on conflicts with core or other plugins, and extend
118+
the auth exemption deliberately instead of by coincidence. #164's own
119+
framing — "AP, RS, and future protocols register their own links" —
120+
presumes a *contended*, reservable endpoint; today that contention is
121+
silent and undetectable (see finding 2).
122+
123+
2. **Multi-protocol link contention is undetectable.** #164 wants AP, RS,
124+
and OIDC to each contribute link relations to one WebFinger document. On
125+
the current api there is no registry: this plugin owns the whole route
126+
and hardcodes the three relations. A second plugin wanting to add its own
127+
`rel` would have to register the *same* absolute path and lose to
128+
`FST_ERR_DUPLICATED_ROUTE` — there is no `api.webfinger.addLink(rel, …)`
129+
seam. The same reserved-path/registry seam from finding 1 is what would
130+
let the endpoint actually be the shared, extensible surface the issue
131+
describes.
132+
133+
3. **`config.podsRoot` / `baseUrl` repetition (as in nip05, notifications,
134+
mastodon, webdav).** A plugin cannot learn the data root or the server's
135+
own origin, so the operator repeats both in config. Here `baseUrl` is a
136+
hard requirement — every URL in a JRD (WebID, profile page, actor,
137+
issuer) is absolute against it — so unlike nip05 the plugin *hard-fails*
138+
boot without it. `podsRoot` is the same repetition nip05 documents, with
139+
the same failure mode: point it at the wrong directory and every lookup
140+
confidently 404s while the rest of the server works.
141+
`api.storage.serverRoot` and `api.serverInfo` (both read-only) remain the
142+
candidate seams.

webfinger/plugin.js

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// WebFinger (RFC 7033, https://www.rfc-editor.org/rfc/rfc7033) as a #206
2+
// loader plugin — the WebFinger half of JSS issue #164.
3+
//
4+
// plugins: [{ module: 'webfinger/plugin.js', prefix: '/webfinger',
5+
// config: { podsRoot: './data',
6+
// baseUrl: 'https://pod.example',
7+
// actorPathTemplate: '/<user>/actor' } }]
8+
//
9+
// Serves `GET /.well-known/webfinger?resource=acct:<user>@<host>` → a JRD
10+
// (JSON Resource Descriptor, RFC 7033 §4.4):
11+
//
12+
// { "subject": "acct:alice@pod.example",
13+
// "aliases": [ "<webid>", "<profile-page>" ],
14+
// "links": [
15+
// { "rel": "http://webfinger.net/rel/profile-page", "href": "<profile>" },
16+
// { "rel": "self", "type": "application/activity+json", "href": "<actor>" },
17+
// { "rel": "http://openid.net/specs/connect/1.0/issuer", "href": "<idp>" }
18+
// ] }
19+
//
20+
// WebFinger is the acct: → resource resolver the whole fediverse (Mastodon,
21+
// Pleroma, any ActivityPub client) uses to turn a handle like
22+
// `@alice@pod.example` into that pod's actor URL — it is exactly what the
23+
// mastodon/ and bluesky/ shims need pointed at their pods. It is ALSO how
24+
// Solid-OIDC relying parties and remoteStorage clients discover a pod's
25+
// issuer (the `http://openid.net/specs/connect/1.0/issuer` link) — hence
26+
// #164 wants it extracted from `--activitypub` into an always-on plugin
27+
// that many protocols hang links off.
28+
//
29+
// The pod is resolved from the acct: local part by scanning config.podsRoot
30+
// for a matching pod dir (the same scan nip05/ does), or in single-user
31+
// layout the one pod at the root. Unknown users get a 404 (RFC 7033 §4.5).
32+
//
33+
// ------------------------------------------------------------- the path
34+
//
35+
// Like nip05/, the interesting part is the path. `/.well-known/webfinger`
36+
// is a fixed absolute path, and a plugin is mounted under ONE prefix. It
37+
// works today by the identical chain of luck nip05 documents: the loader
38+
// hands plugins the real (scoped) Fastify instance and does not confine
39+
// routes to the prefix, so an exact-path GET registers fine and beats
40+
// core's LDP `GET /*` wildcard on route specificity; and it is served
41+
// unauthenticated only because core's auth preHandler blanket-exempts
42+
// `/.well-known/*` as the spec-mandated public namespace — NOT because the
43+
// plugin api granted anything. All load-bearing accidents of core's current
44+
// routing (see README "Findings"), so the absolute registration is a
45+
// guarded attempt and the same document is also served at the
46+
// contract-safe `<prefix>/webfinger`.
47+
48+
import fs from 'node:fs/promises';
49+
import path from 'node:path';
50+
51+
/** acct: local part / pod dir grammar (also blocks path traversal). */
52+
const LOCAL_PART = /^[a-zA-Z0-9._-]+$/;
53+
54+
/** Collapse accidental `//` (from an empty <user> substitution) to `/`. */
55+
const collapseSlashes = (p) => p.replace(/\/{2,}/g, '/');
56+
57+
/** Does `<dir>/profile/card.jsonld` exist? (a pod always has its WebID card) */
58+
async function hasCard(dir) {
59+
try {
60+
await fs.access(path.join(dir, 'profile', 'card.jsonld'));
61+
return true;
62+
} catch {
63+
return false;
64+
}
65+
}
66+
67+
/**
68+
* Parse the `resource` query into a pod local part.
69+
* acct:alice@host → { user: 'alice' }
70+
* acct:host → { user: 'host' } (no @ — bare acct)
71+
* https://host/alice/... → { user: 'alice' } (path-mode WebID URL)
72+
* https://host/profile/... → { user: null } (single-user WebID URL)
73+
* Returns null when the resource is syntactically unusable.
74+
*/
75+
export function parseResource(resource) {
76+
if (typeof resource !== 'string' || resource === '') return null;
77+
if (resource.startsWith('acct:')) {
78+
const acct = resource.slice('acct:'.length);
79+
const at = acct.lastIndexOf('@');
80+
const user = at >= 0 ? acct.slice(0, at) : acct;
81+
return user ? { user } : null;
82+
}
83+
// Otherwise treat it as a URI whose path locates the pod (the WebID form).
84+
let u;
85+
try {
86+
u = new URL(resource);
87+
} catch {
88+
return null;
89+
}
90+
const segs = u.pathname.split('/').filter(Boolean);
91+
// Single-user layout: WebID lives at `<origin>/profile/card`.
92+
if (segs.length === 0 || segs[0] === 'profile') return { user: null };
93+
return { user: segs[0] };
94+
}
95+
96+
export async function activate(api) {
97+
const prefix = api.prefix || '/webfinger';
98+
const podsRoot = api.config.podsRoot ?? null;
99+
const baseUrl = (api.config.baseUrl || '').replace(/\/$/, '');
100+
// `null`/`''` disables the `self` (ActivityPub actor) link — "IF
101+
// applicable" from the issue: a deployment without AP actors omits it.
102+
const actorPathTemplate = 'actorPathTemplate' in api.config
103+
? api.config.actorPathTemplate
104+
: '/<user>/actor';
105+
106+
if (!baseUrl) {
107+
// A JRD is nothing but absolute URLs of this server's own origin, and
108+
// the plugin api exposes no server origin (same finding as mastodon/,
109+
// notifications/, webdav/). Without it there is nothing truthful to
110+
// serve, so unlike nip05 (whose document carries no self-origin URLs)
111+
// this is a hard boot failure.
112+
throw new Error(
113+
'webfinger plugin requires config.baseUrl — the plugin api exposes no '
114+
+ 'server origin, and every URL in a JRD (WebID, profile page, actor, '
115+
+ 'issuer) is absolute against it (same finding as mastodon/notifications).',
116+
);
117+
}
118+
if (!podsRoot) {
119+
// Without a data root the plugin cannot resolve any user, so every
120+
// query 404s. Still activate (a purely additive discovery endpoint
121+
// shouldn't fail the whole boot), but say so loudly — see README
122+
// finding on podsRoot repetition.
123+
api.log.warn('webfinger: no config.podsRoot — every resource will 404 '
124+
+ '(the plugin api cannot learn the data root itself)');
125+
}
126+
127+
/**
128+
* Confirm the pod named by `user` exists (has a WebID card), scanning
129+
* fresh per request so pods provisioned after boot resolve immediately.
130+
* `user === null` means single-user layout (card at the podsRoot itself).
131+
*/
132+
async function podExists(user) {
133+
if (!podsRoot) return false;
134+
if (user === null) return hasCard(podsRoot);
135+
if (!LOCAL_PART.test(user)) return false; // illegal / traversal attempt
136+
if (user.startsWith('.')) return false; // .idp, .plugins, .well-known…
137+
return hasCard(path.join(podsRoot, user));
138+
}
139+
140+
/** Build the JRD for a resolved pod. `subject` echoes the query verbatim. */
141+
function buildJrd(user, subject) {
142+
const podPath = user ? `/${user}/` : '/';
143+
const webid = `${baseUrl}${podPath}profile/card#me`;
144+
const profilePage = `${baseUrl}${podPath}profile/card`;
145+
const links = [
146+
{
147+
rel: 'http://webfinger.net/rel/profile-page',
148+
type: 'text/html',
149+
href: profilePage,
150+
},
151+
];
152+
if (actorPathTemplate) {
153+
const actorPath = collapseSlashes(actorPathTemplate.replaceAll('<user>', user ?? ''));
154+
links.push({
155+
rel: 'self',
156+
type: 'application/activity+json',
157+
href: `${baseUrl}${actorPath}`,
158+
});
159+
}
160+
// The IdP issuer link makes Solid-OIDC / remoteStorage discovery work
161+
// off the same document (the #164 point: WebFinger serves many protocols).
162+
links.push({
163+
rel: 'http://openid.net/specs/connect/1.0/issuer',
164+
href: baseUrl,
165+
});
166+
return { subject, aliases: [webid, profilePage], links };
167+
}
168+
169+
async function handler(request, reply) {
170+
// Fediverse clients (browsers, bots) carry no credentials: CORS must be
171+
// wide open, which RFC 7033 §5 calls out explicitly.
172+
reply.header('access-control-allow-origin', '*');
173+
const resource = request.query?.resource;
174+
if (typeof resource !== 'string' || resource === '') {
175+
// RFC 7033 §4.2: `resource` is REQUIRED.
176+
return reply.code(400)
177+
.header('content-type', 'application/json; charset=utf-8')
178+
.send({ error: 'the "resource" query parameter is required' });
179+
}
180+
const parsed = parseResource(resource);
181+
if (!parsed || !(await podExists(parsed.user))) {
182+
// RFC 7033 §4.5: no such resource → 404.
183+
return reply.code(404)
184+
.header('content-type', 'application/json; charset=utf-8')
185+
.send({ error: 'no such resource' });
186+
}
187+
return reply
188+
.header('content-type', 'application/jrd+json; charset=utf-8')
189+
.send(buildJrd(parsed.user, resource));
190+
}
191+
192+
// Contract-safe mount: under the plugin's own prefix (WAC-exempt via the
193+
// loader's appPaths push — the only exemption the plugin api grants).
194+
api.fastify.get(`${prefix}/webfinger`, handler);
195+
196+
// Attempt the real WebFinger location. Works today for the same reasons
197+
// nip05/ documents (loader doesn't confine routes to the prefix; core's
198+
// GET there is only the LDP wildcard; core blanket-exempts /.well-known/*
199+
// from auth) — but nothing in the plugin CONTRACT promises any of it, so
200+
// treat a conflict (e.g. core someday claiming the GET route) as degraded,
201+
// not fatal: the prefix mount still serves.
202+
let wellKnown = false;
203+
try {
204+
api.fastify.get('/.well-known/webfinger', handler);
205+
wellKnown = true;
206+
} catch (err) {
207+
api.log.warn(`webfinger: could not claim /.well-known/webfinger (${err.message}); `
208+
+ `serving under ${prefix}/webfinger only`);
209+
}
210+
211+
api.log.info(`webfinger: serving ${prefix}/webfinger`
212+
+ (wellKnown ? ' and /.well-known/webfinger' : '')
213+
+ (podsRoot ? ` from ${podsRoot}` : ' (no podsRoot: every resource 404s)'));
214+
}

0 commit comments

Comments
 (0)