fedipod 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin.mjs +11 -1
- package/lib/c2s.mjs +104 -4
- package/lib/mastoapi.mjs +197 -11
- package/lib/publisher.mjs +12 -1
- package/lib/storage.mjs +8 -2
- package/lib/wire.mjs +18 -3
- package/package.json +1 -1
- package/run-agent.mjs +4 -0
package/lib/admin.mjs
CHANGED
|
@@ -485,9 +485,19 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
485
485
|
// C2S (ActivityPub §6) carries its own authentication — a Solid-OIDC
|
|
486
486
|
// DPoP proof or the facade's bearer — so the dk-token gate does not
|
|
487
487
|
// stand in front of it. The Host/Origin firewall above still does.
|
|
488
|
-
if (p === '/ap/outbox' || p === '/ap/actor') {
|
|
488
|
+
if (p === '/ap/outbox' || p === '/ap/actor' || p === '/ap/inbox') {
|
|
489
489
|
if (await c2s.handle(req, res, p, url)) return;
|
|
490
490
|
}
|
|
491
|
+
// Where a client looks first to find out how to sign in (RFC 8414), and
|
|
492
|
+
// in front of the door for the same reason C2S is: a client that has to
|
|
493
|
+
// be handed a secret before it can ask how to sign in cannot set itself
|
|
494
|
+
// up at all. It names endpoints and nothing else, the endpoints it names
|
|
495
|
+
// refuse without a password anyway, and the host and origin firewall
|
|
496
|
+
// above still decides who gets this far.
|
|
497
|
+
if (p === '/.well-known/oauth-authorization-server') {
|
|
498
|
+
const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
499
|
+
return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
|
|
500
|
+
}
|
|
491
501
|
if (atDoor && gate(req, res)) return;
|
|
492
502
|
if (p === '/api/v1/streaming/health') {
|
|
493
503
|
res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
|
package/lib/c2s.mjs
CHANGED
|
@@ -5,14 +5,26 @@
|
|
|
5
5
|
// SAME helper the facade and admin surfaces use — this module re-implements
|
|
6
6
|
// no persistence and no delivery, so one write path stays one.
|
|
7
7
|
//
|
|
8
|
-
// GETs are redirects: the pod's documents are the
|
|
9
|
-
// and a second renderer here would only drift from them.
|
|
8
|
+
// GETs on the actor and outbox are redirects: the pod's documents are the
|
|
9
|
+
// canonical ones, and a second renderer here would only drift from them.
|
|
10
|
+
//
|
|
11
|
+
// The inbox is the exception, and has to be. Deliveries land in a container on
|
|
12
|
+
// the pod which the drain empties as it handles each item, so reading that
|
|
13
|
+
// container tells the owner only what has not been dealt with yet. What was
|
|
14
|
+
// actually received is whole only in the archive, so §5.2's "the owner can
|
|
15
|
+
// read their own inbox" is served from there, by this agent, to the owner
|
|
16
|
+
// alone.
|
|
10
17
|
|
|
11
18
|
import * as social from './social.mjs';
|
|
12
19
|
import * as wire from './wire.mjs';
|
|
13
20
|
|
|
14
21
|
const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
|
|
15
22
|
|
|
23
|
+
// How many archived items one page of the inbox will read. A page is one
|
|
24
|
+
// month, and a month with more than this is served short rather than costing
|
|
25
|
+
// the pod an unbounded read; the log says when that happened.
|
|
26
|
+
const MAX_INBOX_PAGE = 500;
|
|
27
|
+
|
|
16
28
|
// §6 names activities; anything else with a type is an object to wrap.
|
|
17
29
|
const ACTIVITY_TYPES = new Set([
|
|
18
30
|
'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo',
|
|
@@ -60,14 +72,102 @@ export class C2S {
|
|
|
60
72
|
return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
|
|
61
73
|
}
|
|
62
74
|
|
|
75
|
+
/** The months the archive holds, newest first. One container listing. */
|
|
76
|
+
async archiveMonths() {
|
|
77
|
+
const archive = this.agent.intake?.archive;
|
|
78
|
+
if (!archive) return [];
|
|
79
|
+
const { names } = await archive.list('');
|
|
80
|
+
return (names || [])
|
|
81
|
+
.map((n) => n.replace(/\/$/u, ''))
|
|
82
|
+
.filter((n) => /^\d{4}-\d{2}$/u.test(n))
|
|
83
|
+
.sort()
|
|
84
|
+
.reverse();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The owner's own inbox, §5.2. Paged by month because that is how the
|
|
89
|
+
* archive is stored, so a page costs one listing and a read per item and no
|
|
90
|
+
* page is dearer for another month being large.
|
|
91
|
+
*/
|
|
92
|
+
async sendInbox(res, url) {
|
|
93
|
+
const id = `${this.urls.base}ap/inbox`;
|
|
94
|
+
const page = url?.searchParams?.get('page') || null;
|
|
95
|
+
let months;
|
|
96
|
+
try {
|
|
97
|
+
months = await this.archiveMonths();
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!page) {
|
|
103
|
+
if (!months.length && this.store.getConfig()?.archiveInbox === false) {
|
|
104
|
+
this.log('inbox read: nothing to show — this identity does not keep what it receives');
|
|
105
|
+
}
|
|
106
|
+
return this.send(res, 200, {
|
|
107
|
+
'@context': wire.AS_CTX, id, type: 'OrderedCollection',
|
|
108
|
+
...(months.length ? { first: `${id}?page=${months[0]}` } : { orderedItems: [] }),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!/^\d{4}-\d{2}$/u.test(page)) {
|
|
112
|
+
return this.send(res, 400, { error: 'page names a month, written 2026-09' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const archive = this.agent.intake?.archive;
|
|
116
|
+
let names = [];
|
|
117
|
+
try {
|
|
118
|
+
// The trailing slash matters: without it this names a document, not the
|
|
119
|
+
// container, and a pod answers about the wrong thing.
|
|
120
|
+
({ names } = await archive.list(`${page}/`));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
|
|
123
|
+
}
|
|
124
|
+
const files = (names || []).filter((n) => n.endsWith('.json')).sort();
|
|
125
|
+
if (files.length > MAX_INBOX_PAGE) {
|
|
126
|
+
this.log(`inbox read: ${page} holds ${files.length} items; serving the first ${MAX_INBOX_PAGE}`);
|
|
127
|
+
}
|
|
128
|
+
const kept = [];
|
|
129
|
+
for (const file of files.slice(0, MAX_INBOX_PAGE)) {
|
|
130
|
+
// Read as written: these records are JSON-LD, and the default read asks
|
|
131
|
+
// turtle-first, which a server is free to answer with turtle.
|
|
132
|
+
const read = await archive.read(`${page}/${file}`, { accept: '*/*' });
|
|
133
|
+
if (!read?.ok || !read.body) continue;
|
|
134
|
+
try {
|
|
135
|
+
const record = JSON.parse(read.body);
|
|
136
|
+
// The record wraps the bytes as they arrived; the activity is those
|
|
137
|
+
// bytes, not a retelling of them.
|
|
138
|
+
kept.push({ at: record.receivedAt || '', activity: JSON.parse(record.raw) });
|
|
139
|
+
} catch { /* a record that will not parse is not one that can be served */ }
|
|
140
|
+
}
|
|
141
|
+
kept.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
142
|
+
const older = months.filter((m) => m < page)[0] || null;
|
|
143
|
+
return this.send(res, 200, {
|
|
144
|
+
'@context': wire.AS_CTX,
|
|
145
|
+
id: `${id}?page=${page}`,
|
|
146
|
+
type: 'OrderedCollectionPage',
|
|
147
|
+
partOf: id,
|
|
148
|
+
...(older ? { next: `${id}?page=${older}` } : {}),
|
|
149
|
+
orderedItems: kept.map((k) => k.activity),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
63
153
|
async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
|
|
64
|
-
if (pathname !== '/ap/outbox' && pathname !== '/ap/actor') return false;
|
|
154
|
+
if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
|
|
65
155
|
if (req.method === 'OPTIONS') {
|
|
66
|
-
res.writeHead(204, { allow: 'GET, POST, OPTIONS' });
|
|
156
|
+
res.writeHead(204, { allow: pathname === '/ap/inbox' ? 'GET, OPTIONS' : 'GET, POST, OPTIONS' });
|
|
157
|
+
res.end(); return true;
|
|
67
158
|
}
|
|
68
159
|
if (!this.agent.configured() || !this.urls) {
|
|
69
160
|
return this.send(res, 409, { error: 'agent not configured' });
|
|
70
161
|
}
|
|
162
|
+
if (pathname === '/ap/inbox') {
|
|
163
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
164
|
+
return this.send(res, 405, { error: "deliveries go to this actor's inbox on the pod, which the "
|
|
165
|
+
+ 'actor document names; this address is the owner reading their own' });
|
|
166
|
+
}
|
|
167
|
+
const reader = await this.auth(req, pathname);
|
|
168
|
+
if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
|
|
169
|
+
return this.sendInbox(res, url);
|
|
170
|
+
}
|
|
71
171
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
72
172
|
// The pod's copy is the document; send the reader there.
|
|
73
173
|
const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
|
package/lib/mastoapi.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { sanitizeHtml, followsNeedApproval, publicHandle } from './wire.mjs';
|
|
|
16
16
|
import { authorOf } from './intake.mjs';
|
|
17
17
|
import { profileUrl, postUrl } from './bskyfeed.mjs';
|
|
18
18
|
import { Push } from './webpush.mjs';
|
|
19
|
+
import { safeFetch, readCapped } from './safefetch.mjs';
|
|
19
20
|
|
|
20
21
|
// What an attachment is allowed to BE. Anything else is stored as bytes, which
|
|
21
22
|
// a browser downloads rather than runs.
|
|
@@ -62,6 +63,8 @@ const AUTHZ_WINDOW_MS = 60_000;
|
|
|
62
63
|
const AUTHZ_MAX_ATTEMPTS = 5;
|
|
63
64
|
const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
|
|
64
65
|
const MAX_APPS = 200; // registered third-party clients, capped
|
|
66
|
+
const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
|
|
67
|
+
const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
|
|
65
68
|
|
|
66
69
|
export class MastoApi {
|
|
67
70
|
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false }) {
|
|
@@ -127,7 +130,91 @@ export class MastoApi {
|
|
|
127
130
|
// said, and only that client, presenting its secret, can exchange it for a
|
|
128
131
|
// bearer. A redirect back to this agent's own origin keeps the local flow.
|
|
129
132
|
apps() { return this.store.read('oauth-apps.json', []); }
|
|
133
|
+
/**
|
|
134
|
+
* What a client needs to know before it can sign in, at the address RFC 8414
|
|
135
|
+
* puts it. The actor carries the same two endpoints; a client that looks
|
|
136
|
+
* here first finds everything rather than the minimum.
|
|
137
|
+
*
|
|
138
|
+
* `none` among the authentication methods is what says a client keeping no
|
|
139
|
+
* secret is welcome, which is the whole of what a browser app needs to hear.
|
|
140
|
+
*/
|
|
141
|
+
authorizationServerMetadata(origin) {
|
|
142
|
+
const at = (p) => `${origin.replace(/\/$/u, '')}${p}`;
|
|
143
|
+
return {
|
|
144
|
+
issuer: origin.replace(/\/$/u, ''),
|
|
145
|
+
authorization_endpoint: at('/oauth/authorize'),
|
|
146
|
+
token_endpoint: at('/oauth/token'),
|
|
147
|
+
revocation_endpoint: at('/oauth/revoke'),
|
|
148
|
+
registration_endpoint: at('/api/v1/apps'),
|
|
149
|
+
response_types_supported: [ 'code' ],
|
|
150
|
+
grant_types_supported: [ 'authorization_code' ],
|
|
151
|
+
code_challenge_methods_supported: [ 'S256', 'plain' ],
|
|
152
|
+
token_endpoint_auth_methods_supported: [ 'client_secret_post', 'none' ],
|
|
153
|
+
scopes_supported: [ 'read', 'write', 'follow', 'push' ],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
130
157
|
findApp(clientId) { return clientId ? this.apps().find(a => a.clientId === clientId) || null : null; }
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A client that publishes its own metadata document is named by that
|
|
161
|
+
* document's URL and registers nothing here: the document says who it is
|
|
162
|
+
* and where it may be sent back to. Such a client keeps no secret, so it
|
|
163
|
+
* always proves itself with a challenge instead.
|
|
164
|
+
*
|
|
165
|
+
* The fetch is the guarded one — a client id is a URL a stranger chose, and
|
|
166
|
+
* an unguarded fetch of it would ask this machine to reach wherever they
|
|
167
|
+
* pointed.
|
|
168
|
+
*/
|
|
169
|
+
async resolveClientDocument(clientId) {
|
|
170
|
+
if (!/^https:\/\//iu.test(String(clientId || ''))) return null; // cleartext is refused
|
|
171
|
+
this.clientDocs = this.clientDocs || new Map();
|
|
172
|
+
const seen = this.clientDocs.get(clientId);
|
|
173
|
+
if (seen && Date.now() - seen.at < CLIENT_DOC_TTL_MS) return seen.client;
|
|
174
|
+
let doc;
|
|
175
|
+
try {
|
|
176
|
+
const res = await safeFetch(clientId, { headers: { accept: 'application/json' } });
|
|
177
|
+
if (res.status >= 400) { this.log(`client document ${clientId} → ${res.status}`); return null; }
|
|
178
|
+
doc = JSON.parse(await readCapped(res, CLIENT_DOC_MAX));
|
|
179
|
+
} catch (e) {
|
|
180
|
+
this.log(`client document ${clientId} could not be read: ${e.message}`);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
// It must claim to be itself: a document naming some other id would let
|
|
184
|
+
// one client borrow another's name.
|
|
185
|
+
if (doc?.client_id !== clientId) {
|
|
186
|
+
this.log(`client document ${clientId} names ${doc?.client_id ?? 'nothing'} — refused`);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const redirectUris = [].concat(doc.redirect_uris || []).filter((u) => typeof u === 'string');
|
|
190
|
+
if (!redirectUris.length) { this.log(`client document ${clientId} names no redirect — refused`); return null; }
|
|
191
|
+
const client = {
|
|
192
|
+
clientId, redirectUris,
|
|
193
|
+
name: String(doc.client_name || clientId).slice(0, 200),
|
|
194
|
+
scopes: 'read write follow',
|
|
195
|
+
};
|
|
196
|
+
this.clientDocs.set(clientId, { at: Date.now(), client });
|
|
197
|
+
return client;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether a redirect the client asked for is one it published.
|
|
202
|
+
*
|
|
203
|
+
* A native client listens on whatever port the machine gave it, so it can
|
|
204
|
+
* only publish the loopback address without one (RFC 8252). The port is
|
|
205
|
+
* therefore not part of the match there, and nowhere else.
|
|
206
|
+
*/
|
|
207
|
+
static redirectMatches(published, asked) {
|
|
208
|
+
if (published === asked) return true;
|
|
209
|
+
try {
|
|
210
|
+
const a = new URL(published);
|
|
211
|
+
const b = new URL(asked);
|
|
212
|
+
const loopback = (h) => h === '127.0.0.1' || h === '[::1]' || h === 'localhost';
|
|
213
|
+
if (!loopback(a.hostname) || a.hostname !== b.hostname) return false;
|
|
214
|
+
return a.protocol === b.protocol
|
|
215
|
+
&& a.pathname.replace(/\/$/u, '') === b.pathname.replace(/\/$/u, '');
|
|
216
|
+
} catch { return false; }
|
|
217
|
+
}
|
|
131
218
|
registerApp({ name, website, redirectUris, scopes }) {
|
|
132
219
|
const app = {
|
|
133
220
|
clientId: crypto.randomBytes(16).toString('hex'),
|
|
@@ -143,13 +230,39 @@ export class MastoApi {
|
|
|
143
230
|
// A short-lived, single-use authorization code for a registered client, kept
|
|
144
231
|
// apart from masto-tokens.json so the code is NOT a bearer until it is
|
|
145
232
|
// exchanged with the client secret.
|
|
146
|
-
mintCode({ clientId, redirectUri, scope }) {
|
|
233
|
+
mintCode({ clientId, redirectUri, scope, challenge = null, challengeMethod = null }) {
|
|
147
234
|
const code = crypto.randomBytes(24).toString('hex');
|
|
148
235
|
const now = Date.now();
|
|
149
236
|
const kept = this.store.read('oauth-codes.json', []).filter(c => now - c.createdAt < CODE_TTL_MS);
|
|
150
|
-
this.store.write('oauth-codes.json', [...kept, {
|
|
237
|
+
this.store.write('oauth-codes.json', [ ...kept, {
|
|
238
|
+
code, clientId, redirectUri, scope, createdAt: now,
|
|
239
|
+
// What the client promised to prove when it comes back for the token.
|
|
240
|
+
// A client that cannot keep a secret — anything running in a browser —
|
|
241
|
+
// has this instead, and it is the only thing standing between a stolen
|
|
242
|
+
// code and a token.
|
|
243
|
+
...(challenge ? { challenge, challengeMethod: challengeMethod || 'plain' } : {}),
|
|
244
|
+
} ].slice(-50));
|
|
151
245
|
return code;
|
|
152
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Whether this verifier is the one the challenge was made from (RFC 7636).
|
|
249
|
+
* Length is checked because a short verifier is guessable, which is the
|
|
250
|
+
* whole thing this is here to prevent.
|
|
251
|
+
*/
|
|
252
|
+
static provesCode(rec, verifier) {
|
|
253
|
+
const v = String(verifier || '');
|
|
254
|
+
if (v.length < 43 || v.length > 128) return false;
|
|
255
|
+
if ((rec.challengeMethod || 'plain') === 'S256') {
|
|
256
|
+
const made = crypto.createHash('sha256').update(v).digest('base64url');
|
|
257
|
+
const given = Buffer.from(made);
|
|
258
|
+
const known = Buffer.from(String(rec.challenge));
|
|
259
|
+
return given.length === known.length && crypto.timingSafeEqual(given, known);
|
|
260
|
+
}
|
|
261
|
+
const given = Buffer.from(v);
|
|
262
|
+
const known = Buffer.from(String(rec.challenge));
|
|
263
|
+
return given.length === known.length && crypto.timingSafeEqual(given, known);
|
|
264
|
+
}
|
|
265
|
+
|
|
153
266
|
consumeCode(code) {
|
|
154
267
|
const now = Date.now();
|
|
155
268
|
const all = this.store.read('oauth-codes.json', []);
|
|
@@ -620,19 +733,35 @@ export class MastoApi {
|
|
|
620
733
|
if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
|
|
621
734
|
const redirect = params.get('redirect_uri') || '';
|
|
622
735
|
const app = this.findApp(params.get('client_id') || '');
|
|
736
|
+
// A client that published its own metadata document needs no
|
|
737
|
+
// registration here: the document is its name and says where it may be
|
|
738
|
+
// sent back to.
|
|
739
|
+
const doc = app ? null : await this.resolveClientDocument(params.get('client_id') || '');
|
|
623
740
|
// A REGISTERED client is always the third-party flow — its code is
|
|
624
741
|
// bound to it and exchanged with its secret — even when its redirect
|
|
625
742
|
// points back at this very agent (a web client served from our own
|
|
626
743
|
// origin registers itself exactly like a phone app does). The local
|
|
627
744
|
// code-is-the-token flow is only for the built-in client, which never
|
|
628
745
|
// registers.
|
|
629
|
-
const external = !!app;
|
|
630
|
-
const client = { name: app?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
631
|
-
if (
|
|
746
|
+
const external = !!app || !!doc;
|
|
747
|
+
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
748
|
+
if (app) {
|
|
632
749
|
if (!app.redirectUris.includes(redirect)) {
|
|
633
750
|
this.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
|
|
634
751
|
return send(400, { error: 'redirect_uri was not registered by this client' });
|
|
635
752
|
}
|
|
753
|
+
} else if (doc) {
|
|
754
|
+
if (!doc.redirectUris.some((u) => MastoApi.redirectMatches(u, redirect))) {
|
|
755
|
+
this.log(`authorize refused: redirect_uri "${redirect}" is not one ${doc.clientId} published`);
|
|
756
|
+
return send(400, { error: 'redirect_uri is not one this client published' });
|
|
757
|
+
}
|
|
758
|
+
// It keeps no secret, so the challenge is the only thing that will
|
|
759
|
+
// stand between its code and a token. Refuse now rather than mint a
|
|
760
|
+
// code nothing can prove.
|
|
761
|
+
if (!params.get('code_challenge')) {
|
|
762
|
+
this.log(`authorize refused: ${doc.clientId} keeps no secret and offered no challenge`);
|
|
763
|
+
return send(400, { error: 'a client identified by its own document must send a code_challenge' });
|
|
764
|
+
}
|
|
636
765
|
} else if (!this.redirectAllowed(redirect)) {
|
|
637
766
|
this.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
|
|
638
767
|
return send(400, { error: 'redirect_uri must be an address of this agent' });
|
|
@@ -640,7 +769,11 @@ export class MastoApi {
|
|
|
640
769
|
if (req.method === 'POST') {
|
|
641
770
|
if (this.rateLimited()) {
|
|
642
771
|
this.log('authorize rate limited');
|
|
643
|
-
|
|
772
|
+
// 429, not 401: a client that reads this as a wrong password will
|
|
773
|
+
// ask the person to type it again, which is the one thing that
|
|
774
|
+
// cannot help. Retry-After says how long the wait actually is.
|
|
775
|
+
return sendLoginForm(res, params, 'too many attempts — wait a minute', client,
|
|
776
|
+
429, { 'retry-after': String(Math.ceil(AUTHZ_WINDOW_MS / 1000)) });
|
|
644
777
|
}
|
|
645
778
|
if (!pw || !checkPassword(pw, body.password || '')) {
|
|
646
779
|
return sendLoginForm(res, params, 'wrong password — try again', client);
|
|
@@ -670,7 +803,9 @@ export class MastoApi {
|
|
|
670
803
|
}
|
|
671
804
|
// External clients get a bound code; the local flow keeps code==token.
|
|
672
805
|
const code = external
|
|
673
|
-
? this.mintCode({ clientId: app.clientId, redirectUri: redirect, scope: client.scope
|
|
806
|
+
? this.mintCode({ clientId: (app || doc).clientId, redirectUri: redirect, scope: client.scope,
|
|
807
|
+
challenge: params.get('code_challenge') || null,
|
|
808
|
+
challengeMethod: params.get('code_challenge_method') || null })
|
|
674
809
|
: this.mintToken();
|
|
675
810
|
if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
|
|
676
811
|
const target = new URL(redirect);
|
|
@@ -685,6 +820,44 @@ export class MastoApi {
|
|
|
685
820
|
// A registered third-party client exchanges its bound code, proving its
|
|
686
821
|
// secret, for a real bearer — the code alone is not a token.
|
|
687
822
|
const app = this.findApp(body.client_id || '');
|
|
823
|
+
// A client named by its own document keeps no secret at all, so the
|
|
824
|
+
// challenge is the whole of its proof. The code carries the document's
|
|
825
|
+
// URL as the client it was bound to.
|
|
826
|
+
if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ''))) {
|
|
827
|
+
const rec = this.consumeCode(body.code || '');
|
|
828
|
+
if (!rec || rec.clientId !== body.client_id
|
|
829
|
+
|| (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
830
|
+
this.log('token refused: code is not a live authorization for that client document');
|
|
831
|
+
return send(400, { error: 'invalid_grant' });
|
|
832
|
+
}
|
|
833
|
+
if (!rec.challenge || !MastoApi.provesCode(rec, body.code_verifier)) {
|
|
834
|
+
this.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
835
|
+
return send(400, { error: 'invalid_grant' });
|
|
836
|
+
}
|
|
837
|
+
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
838
|
+
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
839
|
+
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
840
|
+
}
|
|
841
|
+
// A client that runs in a browser cannot keep a secret, so it proves it
|
|
842
|
+
// is the same caller that asked instead: it sends the verifier for the
|
|
843
|
+
// challenge it presented at authorize (RFC 7636). Sending a verifier is
|
|
844
|
+
// what says which of the two flows this is.
|
|
845
|
+
if (app && body.code_verifier) {
|
|
846
|
+
const rec = this.consumeCode(body.code || '');
|
|
847
|
+
if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
848
|
+
this.log('token refused: code is not a live authorization for this client');
|
|
849
|
+
return send(400, { error: 'invalid_grant' });
|
|
850
|
+
}
|
|
851
|
+
// A code minted without a challenge cannot be redeemed with one: that
|
|
852
|
+
// would let anyone holding a stolen code invent the proof for it.
|
|
853
|
+
if (!rec.challenge || !MastoApi.provesCode(rec, body.code_verifier)) {
|
|
854
|
+
this.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
855
|
+
return send(400, { error: 'invalid_grant' });
|
|
856
|
+
}
|
|
857
|
+
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
858
|
+
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
859
|
+
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
860
|
+
}
|
|
688
861
|
if (app && body.client_secret) {
|
|
689
862
|
const given = Buffer.from(String(body.client_secret));
|
|
690
863
|
const known = Buffer.from(app.clientSecret);
|
|
@@ -695,8 +868,18 @@ export class MastoApi {
|
|
|
695
868
|
this.log('token refused: code is not a live authorization for this client');
|
|
696
869
|
return send(400, { error: 'invalid_grant' });
|
|
697
870
|
}
|
|
871
|
+
// A challenge, once made, is not optional: without this a client could
|
|
872
|
+
// present one and then skip past it with the secret alone.
|
|
873
|
+
if (rec.challenge && !MastoApi.provesCode(rec, body.code_verifier)) {
|
|
874
|
+
this.log('token refused: this code was made with a challenge and the verifier does not answer it');
|
|
875
|
+
return send(400, { error: 'invalid_grant' });
|
|
876
|
+
}
|
|
698
877
|
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
699
|
-
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000)
|
|
878
|
+
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
879
|
+
// Which actor the token acts for. A Mastodon client ignores it; an
|
|
880
|
+
// ActivityPub API client needs it, and asking for it separately
|
|
881
|
+
// would mean a second round trip before it knows who it is.
|
|
882
|
+
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
700
883
|
}
|
|
701
884
|
// Local flow: the code IS the token, minted by /oauth/authorize after the
|
|
702
885
|
// password gate. Minting one here for an unrecognised code handed a
|
|
@@ -707,7 +890,9 @@ export class MastoApi {
|
|
|
707
890
|
this.log('token refused: code is not a live authorization');
|
|
708
891
|
return send(400, { error: 'invalid_grant' });
|
|
709
892
|
}
|
|
710
|
-
return send(200, { access_token: body.code, token_type: 'Bearer',
|
|
893
|
+
return send(200, { access_token: body.code, token_type: 'Bearer',
|
|
894
|
+
scope: body.scope || 'read write follow push', created_at: Math.floor(Date.now() / 1000),
|
|
895
|
+
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
711
896
|
}
|
|
712
897
|
if (pathname === '/oauth/revoke' && req.method === 'POST') {
|
|
713
898
|
// It used to answer 200 and keep the token, so logging out of a client
|
|
@@ -1554,7 +1739,7 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
|
|
|
1554
1739
|
const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
|
|
1555
1740
|
.map(s => s.trim()).filter(Boolean);
|
|
1556
1741
|
|
|
1557
|
-
function sendLoginForm(res, params, error = '', client = null) {
|
|
1742
|
+
function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}) {
|
|
1558
1743
|
const hidden = [...params.entries()].filter(([k]) => k !== 'password')
|
|
1559
1744
|
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
|
|
1560
1745
|
// Name what is asking, so the owner approves a client they can see rather
|
|
@@ -1569,7 +1754,8 @@ function sendLoginForm(res, params, error = '', client = null) {
|
|
|
1569
1754
|
+ `${where ? `, sending the authorization to <code>${escapeHtml(where)}</code>` : ''}.</p>`
|
|
1570
1755
|
+ `<p>Scope: <code>${escapeHtml(client.scope || 'read')}</code>. Enter the agent password to allow it.</p>`;
|
|
1571
1756
|
}
|
|
1572
|
-
res.writeHead(error ? 401 : 200,
|
|
1757
|
+
res.writeHead(status || (error ? 401 : 200),
|
|
1758
|
+
{ 'content-type': 'text/html; charset=utf-8', ...headers });
|
|
1573
1759
|
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1574
1760
|
<title>FediPod — authorize</title>
|
|
1575
1761
|
<style>:root{color-scheme:light dark;font-size:125%;--heading:#1a4f8a}
|
package/lib/publisher.mjs
CHANGED
|
@@ -23,7 +23,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
|
23
23
|
|
|
24
24
|
export class Publisher {
|
|
25
25
|
constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
26
|
-
probeFetch = null, resolveMention = null, privateOnPod = true,
|
|
26
|
+
probeFetch = null, resolveMention = null, privateOnPod = true, clientOrigin = null,
|
|
27
27
|
}) {
|
|
28
28
|
this.config = config;
|
|
29
29
|
this.remote = remote;
|
|
@@ -32,6 +32,10 @@ export class Publisher {
|
|
|
32
32
|
this.deliverer = deliverer;
|
|
33
33
|
this.publicKeyPem = publicKeyPem;
|
|
34
34
|
this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
|
|
35
|
+
// Where this identity's client surface answers, when that is an address a
|
|
36
|
+
// stranger can reach. Null on a laptop, where the surface is on loopback
|
|
37
|
+
// and advertising it to the world would name somewhere nobody can go.
|
|
38
|
+
this.clientOrigin = clientOrigin;
|
|
35
39
|
// A fronted identity (config.gateway.frontActor) advertises its ids on a
|
|
36
40
|
// shared domain; the map tells RemotePod where each writes on the pod.
|
|
37
41
|
const publicBase = config.gateway?.frontActor
|
|
@@ -88,6 +92,13 @@ export class Publisher {
|
|
|
88
92
|
pendingFollowing: priv ? urls.pendingFollowing : null,
|
|
89
93
|
blocked: priv ? urls.blocked : null,
|
|
90
94
|
inbox: gwActive ? gw.url : null,
|
|
95
|
+
// The agent's own outbox endpoint, where it is reachable: a client
|
|
96
|
+
// following the actor must arrive somewhere that will take a write.
|
|
97
|
+
outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
|
|
98
|
+
// How a client-to-server client finds the way in with nothing configured
|
|
99
|
+
// by hand. Advertised only where the surface is publicly reachable.
|
|
100
|
+
oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
|
|
101
|
+
oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
|
|
91
102
|
});
|
|
92
103
|
const surface = crypto.createHash('sha256').update(JSON.stringify({
|
|
93
104
|
actor: actorDoc, handle: this.config.handle, host,
|
package/lib/storage.mjs
CHANGED
|
@@ -61,9 +61,15 @@ export class HttpStorage {
|
|
|
61
61
|
return { notModified: false, names, etag: res.headers.get('etag') };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// `accept` is for callers reading something that is RDF but is wanted as it
|
|
65
|
+
// was written: asking turtle-first for a JSON-LD document gets turtle back,
|
|
66
|
+
// because the server is entitled to convert between two RDF syntaxes.
|
|
67
|
+
async read(p, { etag, accept } = {}) {
|
|
65
68
|
const res = await this.fetchImpl(this._url(p), {
|
|
66
|
-
headers: {
|
|
69
|
+
headers: {
|
|
70
|
+
accept: accept || 'text/turtle, application/json;q=0.9, */*;q=0.8',
|
|
71
|
+
...(etag ? { 'if-none-match': etag } : {}),
|
|
72
|
+
},
|
|
67
73
|
});
|
|
68
74
|
if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag };
|
|
69
75
|
if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
|
package/lib/wire.mjs
CHANGED
|
@@ -108,7 +108,7 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
|
|
|
108
108
|
export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
|
|
109
109
|
approveJoins = false, summary = null, icon = null, image = null, fields = [],
|
|
110
110
|
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
|
|
111
|
-
blocked = null, inbox = null }) {
|
|
111
|
+
blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null }) {
|
|
112
112
|
// manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
|
|
113
113
|
// inline exactly as Mastodon declares it — and only when we actually use it.
|
|
114
114
|
// It is what makes a client show "Request to follow" rather than "Follow" and
|
|
@@ -188,8 +188,23 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
|
|
|
188
188
|
inbox: inbox || urls.inbox,
|
|
189
189
|
// Every Mastodon actor publishes one. With a single actor per pod ours is
|
|
190
190
|
// just the inbox, but its absence is the non-standard thing.
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
//
|
|
192
|
+
// The two OAuth entries are how a client-to-server client finds its way in
|
|
193
|
+
// without being told anything by hand (ActivityPub 4.1). They appear only
|
|
194
|
+
// where this identity's client surface answers on an address a stranger
|
|
195
|
+
// can reach, which is why the caller supplies them rather than this
|
|
196
|
+
// building them from the pod URL.
|
|
197
|
+
endpoints: {
|
|
198
|
+
sharedInbox: inbox || urls.inbox,
|
|
199
|
+
...(oauthAuthorize ? { oauthAuthorizationEndpoint: oauthAuthorize } : {}),
|
|
200
|
+
...(oauthToken ? { oauthTokenEndpoint: oauthToken } : {}),
|
|
201
|
+
},
|
|
202
|
+
// Where a client sends what this actor writes, which the protocol says is
|
|
203
|
+
// this same address. The pod holds the collection and answers reads of it;
|
|
204
|
+
// a client-to-server write has to reach the agent, and the pod cannot take
|
|
205
|
+
// one. So where the agent is reachable it is named here, and a read of it
|
|
206
|
+
// is sent straight on to the pod's own document.
|
|
207
|
+
outbox: outbox || urls.outbox,
|
|
193
208
|
// The pinned posts, as the collection other servers read when they show
|
|
194
209
|
// this profile. Mastodon's term, declared the way Mastodon declares it.
|
|
195
210
|
featured: urls.featured,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/run-agent.mjs
CHANGED
|
@@ -338,6 +338,10 @@ export class Agent {
|
|
|
338
338
|
// Whether the fediverse tree is on the pod at all, so the ACL check does
|
|
339
339
|
// not probe for something the default layout keeps on local disk.
|
|
340
340
|
privateOnPod: !cred.privateRoot,
|
|
341
|
+
// Inside a pod server the client surface answers on the pod's own
|
|
342
|
+
// origin, so it can be advertised. Standalone it is on loopback, and
|
|
343
|
+
// naming it in a world-readable actor would send clients nowhere.
|
|
344
|
+
clientOrigin: this.embedded ? this.urls.base : null,
|
|
341
345
|
});
|
|
342
346
|
// Intake is constructed even for viewers — its signed fetchAP powers
|
|
343
347
|
// search/deref; start() (draining) is active-only.
|