fedipod-server 0.11.0 → 0.13.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.
Files changed (152) hide show
  1. package/README.md +22 -6
  2. package/dist/claims.d.ts +8 -0
  3. package/dist/claims.js +10 -0
  4. package/dist/handler.d.ts +13 -0
  5. package/dist/handler.js +56 -11
  6. package/dist/handler.jsonld +8 -0
  7. package/dist/store-pod.js +18 -4
  8. package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
  9. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  10. package/lib/client/masto/accounts.mjs +264 -0
  11. package/lib/client/masto/body.mjs +69 -0
  12. package/lib/client/masto/index.mjs +183 -0
  13. package/lib/client/masto/instance.mjs +104 -0
  14. package/lib/client/masto/media.mjs +133 -0
  15. package/lib/client/masto/oauth.mjs +599 -0
  16. package/lib/client/masto/render.mjs +459 -0
  17. package/lib/client/masto/statuses.mjs +331 -0
  18. package/lib/client/masto/timelines.mjs +316 -0
  19. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  20. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  21. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  22. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  23. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  24. package/lib/{import.mjs → connections/import.mjs} +1 -1
  25. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  26. package/lib/connections/vault.mjs +114 -0
  27. package/lib/core/as2.mjs +170 -0
  28. package/lib/core/contexts/activitystreams.json +379 -0
  29. package/lib/core/contexts/did-v1.json +57 -0
  30. package/lib/core/contexts/fep-5711.json +36 -0
  31. package/lib/core/contexts/gotosocial.json +86 -0
  32. package/lib/core/contexts/identity-v1.json +152 -0
  33. package/lib/core/contexts/index.mjs +45 -0
  34. package/lib/core/contexts/join-lemmy.json +33 -0
  35. package/lib/core/contexts/joinmastodon.json +28 -0
  36. package/lib/core/contexts/map.json +16 -0
  37. package/lib/core/contexts/miscellany.json +19 -0
  38. package/lib/core/contexts/schemaorg.json +8845 -0
  39. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  40. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  41. package/lib/core/contexts/security-multikey-v1.json +35 -0
  42. package/lib/core/contexts/security-v1.json +74 -0
  43. package/lib/core/contexts/webfinger.json +10 -0
  44. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  45. package/lib/core/graphview.mjs +269 -0
  46. package/lib/core/intake/activities.mjs +437 -0
  47. package/lib/core/intake/activity.mjs +240 -0
  48. package/lib/core/intake/channel.mjs +144 -0
  49. package/lib/core/intake/group.mjs +222 -0
  50. package/lib/core/intake/index.mjs +629 -0
  51. package/lib/core/intake/notes.mjs +288 -0
  52. package/lib/core/intake/verify.mjs +142 -0
  53. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  54. package/lib/core/publisher/collections.mjs +229 -0
  55. package/lib/core/publisher/index.mjs +421 -0
  56. package/lib/core/publisher/notes.mjs +188 -0
  57. package/lib/core/publisher/questions.mjs +233 -0
  58. package/lib/core/publisher/restore.mjs +199 -0
  59. package/lib/core/shapes/activitystreams.ttl +129 -0
  60. package/lib/core/shapes/index.mjs +107 -0
  61. package/lib/core/shapes/shapes-text.mjs +13 -0
  62. package/lib/{social.mjs → core/social.mjs} +2 -2
  63. package/lib/{store.mjs → core/store.mjs} +4 -0
  64. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  65. package/lib/device/admin/index.mjs +13 -0
  66. package/lib/device/admin/origins.mjs +35 -0
  67. package/lib/device/admin/routes/connections.mjs +144 -0
  68. package/lib/device/admin/routes/gateway.mjs +199 -0
  69. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  70. package/lib/device/admin/routes/owner.mjs +322 -0
  71. package/lib/device/admin/routes/setup.mjs +393 -0
  72. package/lib/device/admin/routes/social.mjs +188 -0
  73. package/lib/device/admin/server.mjs +95 -0
  74. package/lib/device/admin/static.mjs +244 -0
  75. package/lib/device/admin/surface.mjs +274 -0
  76. package/lib/device/cli/commands/account.mjs +586 -0
  77. package/lib/device/cli/commands/run.mjs +278 -0
  78. package/lib/device/cli/commands/service.mjs +221 -0
  79. package/lib/device/cli/commands/setup.mjs +410 -0
  80. package/lib/device/cli/commands/state.mjs +559 -0
  81. package/lib/device/cli/context.mjs +288 -0
  82. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  83. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  84. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  85. package/lib/{update.mjs → device/update.mjs} +1 -1
  86. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  87. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  88. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  89. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  90. package/lib/server/embed.mjs +405 -0
  91. package/lib/{links.mjs → shared/links.mjs} +1 -1
  92. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  93. package/package.json +1 -1
  94. package/run-agent.mjs +33 -25
  95. package/web/admin/actors.js +145 -0
  96. package/web/admin/common.js +23 -0
  97. package/web/admin/connections.js +112 -0
  98. package/web/admin/gateway.js +111 -0
  99. package/web/admin/group.js +258 -0
  100. package/web/admin/index.html +7 -1
  101. package/web/admin/record.js +378 -0
  102. package/web/admin/setup/index.html +1 -0
  103. package/web/admin/setup/setup.js +2 -13
  104. package/web/admin/upkeep.js +170 -0
  105. package/web/app/README.md +6 -6
  106. package/web/app/admin-facade.mjs +3 -3
  107. package/web/app/agent.mjs +14 -16
  108. package/web/app/atproto-browser.mjs +1 -1
  109. package/web/app/boot.mjs +2 -3
  110. package/web/app/deliver-relay.mjs +1 -1
  111. package/web/app/dist/boot.js +22 -3
  112. package/web/app/dist/boot.js.map +2 -2
  113. package/web/app/dist/sw.js +21913 -5446
  114. package/web/app/dist/sw.js.map +4 -4
  115. package/web/app/fediacct-browser.mjs +1 -1
  116. package/web/app/keys-browser.mjs +27 -4
  117. package/web/app/shims/shapes-text.mjs +8 -0
  118. package/web/app/signup.mjs +2 -3
  119. package/web/app/site/admin/actors.js +145 -0
  120. package/web/app/site/admin/common.js +23 -0
  121. package/web/app/site/admin/connections.js +112 -0
  122. package/web/app/site/admin/gateway.js +111 -0
  123. package/web/app/site/admin/group.js +258 -0
  124. package/web/app/site/admin/index.html +7 -1
  125. package/web/app/site/admin/record.js +378 -0
  126. package/web/app/site/admin/setup/index.html +1 -0
  127. package/web/app/site/admin/setup/setup.js +2 -13
  128. package/web/app/site/admin/upkeep.js +170 -0
  129. package/web/app/site/boot.js +22 -3
  130. package/web/app/site/sw.js +21913 -5446
  131. package/web/app/sw-src.mjs +17 -2
  132. package/lib/admin.mjs +0 -1913
  133. package/lib/embed.mjs +0 -220
  134. package/lib/intake.mjs +0 -1981
  135. package/lib/mastoapi.mjs +0 -2284
  136. package/lib/publisher.mjs +0 -1192
  137. package/web/admin/admin.js +0 -1181
  138. package/web/app/site/admin/admin.js +0 -1181
  139. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  140. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  141. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  142. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  143. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  144. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  145. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  146. /package/lib/{account.mjs → device/account.mjs} +0 -0
  147. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  148. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  149. /package/lib/{home.mjs → device/home.mjs} +0 -0
  150. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  151. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  152. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
@@ -0,0 +1,599 @@
1
+ // oauth.mjs — how a client gets in: registered apps and client documents,
2
+ // authorization codes and PKCE, the bearer tokens and their scopes, the
3
+ // password gate, and the three /oauth endpoints plus app registration.
4
+
5
+ import crypto from 'node:crypto';
6
+ import { isCrossSiteNavigation } from '../../shared/guard.mjs';
7
+ import { safeFetch, readCapped } from '../../shared/safefetch.mjs';
8
+ import { readBody } from './body.mjs';
9
+
10
+ const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // tokens age out after 90 days
11
+ const AUTHZ_WINDOW_MS = 60_000;
12
+ const AUTHZ_MAX_ATTEMPTS = 5;
13
+ const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
14
+ const MAX_APPS = 200; // registered third-party clients, capped
15
+ const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
16
+ const CLIENT_DOC_MAX_CACHED = 200; // ids remembered at once; oldest out
17
+ const CLIENT_DOC_WINDOW_MS = 60_000;
18
+ const CLIENT_DOC_MAX_FETCHES = 20; // outbound lookups per window
19
+ const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
20
+
21
+ // scrypt check for the optional UI password ({ saltHex, hashHex } record —
22
+ // see hashPassword, used by the passwd CLI).
23
+ export function hashPassword(password) {
24
+ const salt = crypto.randomBytes(16);
25
+ const hash = crypto.scryptSync(String(password), salt, 32);
26
+ return { saltHex: salt.toString('hex'), hashHex: hash.toString('hex') };
27
+ }
28
+ export function checkPassword(rec, password) {
29
+ try {
30
+ const hash = crypto.scryptSync(String(password), Buffer.from(rec.saltHex, 'hex'), 32);
31
+ return crypto.timingSafeEqual(hash, Buffer.from(rec.hashHex, 'hex'));
32
+ } catch { return false; }
33
+ }
34
+
35
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
36
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
37
+
38
+ // The authorize login page: every original OAuth param rides along as a
39
+ // hidden field so the POST can complete the flow.
40
+ // redirect_uris arrive as an array or a whitespace-separated string (Mastodon
41
+ // accepts both); normalise to a trimmed, non-empty list.
42
+ const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
43
+ .map(s => s.trim()).filter(Boolean);
44
+
45
+ function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}) {
46
+ const hidden = [...params.entries()].filter(([k]) => k !== 'password')
47
+ .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
48
+ // Name what is asking, so the owner approves a client they can see rather
49
+ // than an opaque one. The destination the code will be sent to is the fact
50
+ // that matters for a cross-site client.
51
+ let asking = '<p>Enter the agent password to authorize this client.</p>';
52
+ if (client && (client.name || client.redirect)) {
53
+ let where = '';
54
+ try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
55
+ const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
56
+ asking = `<p><strong>${who}</strong> is asking to access your account`
57
+ + `${where ? `, sending the authorization to <code>${escapeHtml(where)}</code>` : ''}.</p>`
58
+ + `<p>Scope: <code>${escapeHtml(client.scope || 'read')}</code>. Enter the agent password to allow it.</p>`;
59
+ }
60
+ res.writeHead(status || (error ? 401 : 200),
61
+ { 'content-type': 'text/html; charset=utf-8', ...headers });
62
+ res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
63
+ <title>FediPod — authorize</title>
64
+ <style>:root{color-scheme:light dark;font-size:125%;--heading:#1a4f8a}
65
+ body{font:1rem system-ui,sans-serif;max-width:22rem;margin:15vh auto;padding:0 1rem}
66
+ h1{color:var(--heading)}
67
+ code{word-break:break-all}
68
+ @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8}}
69
+ input,button{font:inherit;width:100%;padding:.5rem;margin:.3rem 0;box-sizing:border-box}
70
+ .err{color:#b00020}
71
+ @media (prefers-color-scheme:dark){.err{color:#ff8a8a}}</style></head><body>
72
+ <main>
73
+ <h1>FediPod</h1>
74
+ ${asking}
75
+ ${error ? `<p class="err" id="login-err" role="alert">${escapeHtml(error)}</p>` : ''}
76
+ <form method="POST" action="/oauth/authorize">
77
+ ${hidden}
78
+ <label for="password">Agent password</label>
79
+ <input type="password" id="password" name="password" autofocus autocomplete="current-password"
80
+ ${error ? 'aria-invalid="true" aria-describedby="login-err"' : ''}>
81
+ <button type="submit">Authorize</button>
82
+ </form>
83
+ </main></body></html>`);
84
+ return true;
85
+ }
86
+
87
+ // What one request needs. Mastodon's four coarse scopes; a client that was
88
+ // granted a granular `write:statuses` satisfies `write` here, which is the
89
+ // direction that cannot let anything through that `write` would not.
90
+ //
91
+ // Reads are `read`, writes are `write`, and the two Mastodon carves out are
92
+ // kept: relationship changes accept the legacy `follow`, and push
93
+ // subscriptions want `push`. Nothing here grants across: `write` does NOT
94
+ // imply `read`, exactly as on Mastodon, so a write-only client cannot read
95
+ // the owner's direct messages.
96
+ export function scopeFor(method, pathname) {
97
+ if (/^\/api\/v\d\/push\//u.test(pathname)) return 'push';
98
+ const relationship = /^\/api\/v1\/(accounts\/[a-f0-9]+\/(follow|unfollow|block|unblock|mute|unmute|remove_from_followers)|follow_requests\/)/u;
99
+ if (method !== 'GET' && method !== 'HEAD') {
100
+ return relationship.test(pathname) ? 'follow' : 'write';
101
+ }
102
+ return 'read';
103
+ }
104
+
105
+ // Whether a token's granted scopes satisfy `need`.
106
+ //
107
+ // A record with NO scope is a token minted before scopes were kept — it is
108
+ // full authority, because that is what it was granted, and quietly demoting
109
+ // live 90-day tokens would sign people out of working clients for a bug that
110
+ // was ours. New tokens all carry one.
111
+ export function scopeAllows(granted, need) {
112
+ if (granted == null) return true; // pre-scope token
113
+ const have = String(granted).split(/[\s,+]+/u).filter(Boolean);
114
+ if (!have.length) return true;
115
+ if (need === 'follow') {
116
+ // Mastodon's `follow` is the legacy spelling; `write` covers it too.
117
+ return have.some((g) => g === 'follow' || g === 'write' || g.startsWith('write:'));
118
+ }
119
+ return have.some((g) => g === need || g.startsWith(`${need}:`));
120
+ }
121
+
122
+ /**
123
+ * Whether a redirect the client asked for is one it published.
124
+ *
125
+ * A native client listens on whatever port the machine gave it, so it can
126
+ * only publish the loopback address without one (RFC 8252). The port is
127
+ * therefore not part of the match there, and nowhere else.
128
+ */
129
+ export function redirectMatches(published, asked) {
130
+ if (published === asked) return true;
131
+ try {
132
+ const a = new URL(published);
133
+ const b = new URL(asked);
134
+ const loopback = (h) => h === '127.0.0.1' || h === '[::1]' || h === 'localhost';
135
+ if (!loopback(a.hostname) || a.hostname !== b.hostname) return false;
136
+ return a.protocol === b.protocol
137
+ && a.pathname.replace(/\/$/u, '') === b.pathname.replace(/\/$/u, '');
138
+ } catch { return false; }
139
+ }
140
+
141
+ /**
142
+ * Whether this verifier is the one the challenge was made from (RFC 7636).
143
+ * Length is checked because a short verifier is guessable, which is the
144
+ * whole thing this is here to prevent.
145
+ */
146
+ export function provesCode(rec, verifier) {
147
+ const v = String(verifier || '');
148
+ if (v.length < 43 || v.length > 128) return false;
149
+ if ((rec.challengeMethod || 'plain') === 'S256') {
150
+ const made = crypto.createHash('sha256').update(v).digest('base64url');
151
+ const given = Buffer.from(made);
152
+ const known = Buffer.from(String(rec.challenge));
153
+ return given.length === known.length && crypto.timingSafeEqual(given, known);
154
+ }
155
+ const given = Buffer.from(v);
156
+ const known = Buffer.from(String(rec.challenge));
157
+ return given.length === known.length && crypto.timingSafeEqual(given, known);
158
+ }
159
+
160
+ // ---- tokens ----
161
+ // Tokens are records {token, createdAt} and expire; legacy bare strings
162
+ // are read as undated and treated as expired-on-sight only if older
163
+ // formats can't be dated (they get an epoch of now on first migration).
164
+ export function tokenRecords(api) {
165
+ const raw = api.store.read('masto-tokens.json', []);
166
+ return raw.map(r => (typeof r === 'string' ? { token: r, createdAt: Date.now() } : r));
167
+ }
168
+
169
+ export function tokens(api) {
170
+ const now = Date.now();
171
+ return api.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS).map(r => r.token);
172
+ }
173
+
174
+ // `scope` is what the owner actually granted at /oauth/authorize. It used to
175
+ // be discarded: every token was full authority, so a client that asked for
176
+ // `read` could post, delete, and edit the profile. Recorded now, and enforced
177
+ // at the one gate every client route passes (see scopeFor / authed).
178
+ export function mintToken(api, scope = null) {
179
+ const t = crypto.randomBytes(24).toString('hex');
180
+ const now = Date.now();
181
+ const kept = api.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS);
182
+ api.store.write('masto-tokens.json',
183
+ [...kept, { token: t, createdAt: now, ...(scope ? { scope } : {}) }].slice(-20));
184
+ return t;
185
+ }
186
+
187
+ // Registered OAuth apps. A third-party (browser) client registers here, and
188
+ // the authorization code it later receives is bound to the client_id and the
189
+ // redirect_uri it registered — so the code reaches only where that client
190
+ // said, and only that client, presenting its secret, can exchange it for a
191
+ // bearer. A redirect back to this agent's own origin keeps the local flow.
192
+ export function apps(api) { return api.store.read('oauth-apps.json', []); }
193
+
194
+ /**
195
+ * What a client needs to know before it can sign in, at the address RFC 8414
196
+ * puts it. The actor carries the same two endpoints; a client that looks
197
+ * here first finds everything rather than the minimum.
198
+ *
199
+ * `none` among the authentication methods is what says a client keeping no
200
+ * secret is welcome, which is the whole of what a browser app needs to hear.
201
+ */
202
+ export function authorizationServerMetadata(api, origin) {
203
+ const at = (p) => `${origin.replace(/\/$/u, '')}${p}`;
204
+ return {
205
+ issuer: origin.replace(/\/$/u, ''),
206
+ authorization_endpoint: at('/oauth/authorize'),
207
+ token_endpoint: at('/oauth/token'),
208
+ revocation_endpoint: at('/oauth/revoke'),
209
+ registration_endpoint: at('/api/v1/apps'),
210
+ response_types_supported: [ 'code' ],
211
+ grant_types_supported: [ 'authorization_code' ],
212
+ code_challenge_methods_supported: [ 'S256', 'plain' ],
213
+ token_endpoint_auth_methods_supported: [ 'client_secret_post', 'none' ],
214
+ scopes_supported: [ 'read', 'write', 'follow', 'push' ],
215
+ };
216
+ }
217
+
218
+ export function findApp(api, clientId) { return clientId ? api.apps().find(a => a.clientId === clientId) || null : null; }
219
+
220
+ /**
221
+ * A client that publishes its own metadata document is named by that
222
+ * document's URL and registers nothing here: the document says who it is
223
+ * and where it may be sent back to. Such a client keeps no secret, so it
224
+ * always proves itself with a challenge instead.
225
+ *
226
+ * The fetch is the guarded one — a client id is a URL a stranger chose, and
227
+ * an unguarded fetch of it would ask this machine to reach wherever they
228
+ * pointed.
229
+ */
230
+ export async function resolveClientDocument(api, clientId) {
231
+ if (!/^https:\/\//iu.test(String(clientId || ''))) return null; // cleartext is refused
232
+ api.clientDocs = api.clientDocs || new Map();
233
+ const seen = api.clientDocs.get(clientId);
234
+ // A cached FAILURE counts. Only successes were remembered, so a client id
235
+ // that 404s (or is not a client document at all) was re-fetched on every
236
+ // single call — and this runs on an unauthenticated GET, so a page could
237
+ // fire these in a loop and have the owner's machine hammer an address of
238
+ // the attacker's choosing, from the owner's IP, indefinitely.
239
+ if (seen && Date.now() - seen.at < CLIENT_DOC_TTL_MS) return seen.client;
240
+ // And a budget, because caching alone still lets a fresh id per request
241
+ // through. `safeFetch` keeps every one of these to a public address, so
242
+ // this is not internal SSRF — it is amplification, and a cap is what
243
+ // amplification needs.
244
+ if (!api._clientDocFetches || Date.now() - api._clientDocWindow > CLIENT_DOC_WINDOW_MS) {
245
+ api._clientDocWindow = Date.now();
246
+ api._clientDocFetches = 0;
247
+ }
248
+ if (api._clientDocFetches >= CLIENT_DOC_MAX_FETCHES) {
249
+ api.log(`client document ${clientId} not fetched: too many lookups this minute`);
250
+ return null;
251
+ }
252
+ api._clientDocFetches += 1;
253
+ // The Map has a TTL but had no ceiling, so it grew for as long as an
254
+ // attacker cared to serve distinct documents. Oldest out when it is full;
255
+ // Map iterates in insertion order, so the first key is the oldest.
256
+ if (api.clientDocs.size >= CLIENT_DOC_MAX_CACHED) {
257
+ api.clientDocs.delete(api.clientDocs.keys().next().value);
258
+ }
259
+ const remember = (client) => { api.clientDocs.set(clientId, { at: Date.now(), client }); return client; };
260
+ let doc;
261
+ try {
262
+ const res = await safeFetch(clientId, { headers: { accept: 'application/json' } });
263
+ if (res.status >= 400) { api.log(`client document ${clientId} → ${res.status}`); return remember(null); }
264
+ doc = JSON.parse(await readCapped(res, CLIENT_DOC_MAX));
265
+ } catch (e) {
266
+ api.log(`client document ${clientId} could not be read: ${e.message}`);
267
+ return remember(null);
268
+ }
269
+ // It must claim to be itself: a document naming some other id would let
270
+ // one client borrow another's name.
271
+ if (doc?.client_id !== clientId) {
272
+ api.log(`client document ${clientId} names ${doc?.client_id ?? 'nothing'} — refused`);
273
+ return remember(null);
274
+ }
275
+ const redirectUris = [].concat(doc.redirect_uris || []).filter((u) => typeof u === 'string');
276
+ if (!redirectUris.length) { api.log(`client document ${clientId} names no redirect — refused`); return remember(null); }
277
+ const client = {
278
+ clientId, redirectUris,
279
+ name: String(doc.client_name || clientId).slice(0, 200),
280
+ scopes: 'read write follow',
281
+ };
282
+ return remember(client);
283
+ }
284
+
285
+ export function registerApp(api, { name, website, redirectUris, scopes }) {
286
+ const app = {
287
+ clientId: crypto.randomBytes(16).toString('hex'),
288
+ clientSecret: crypto.randomBytes(32).toString('base64url'),
289
+ name: String(name || 'client').slice(0, 200),
290
+ website: String(website || '').slice(0, 500),
291
+ redirectUris, scopes: String(scopes || 'read'), createdAt: Date.now(),
292
+ };
293
+ api.store.write('oauth-apps.json', [...api.apps(), app].slice(-MAX_APPS));
294
+ return app;
295
+ }
296
+
297
+ // A short-lived, single-use authorization code for a registered client, kept
298
+ // apart from masto-tokens.json so the code is NOT a bearer until it is
299
+ // exchanged with the client secret.
300
+ export function mintCode(api, { clientId, redirectUri, scope, challenge = null, challengeMethod = null }) {
301
+ const code = crypto.randomBytes(24).toString('hex');
302
+ const now = Date.now();
303
+ const kept = api.store.read('oauth-codes.json', []).filter(c => now - c.createdAt < CODE_TTL_MS);
304
+ api.store.write('oauth-codes.json', [ ...kept, {
305
+ code, clientId, redirectUri, scope, createdAt: now,
306
+ // What the client promised to prove when it comes back for the token.
307
+ // A client that cannot keep a secret — anything running in a browser —
308
+ // has this instead, and it is the only thing standing between a stolen
309
+ // code and a token.
310
+ ...(challenge ? { challenge, challengeMethod: challengeMethod || 'plain' } : {}),
311
+ } ].slice(-50));
312
+ return code;
313
+ }
314
+
315
+ export function consumeCode(api, code) {
316
+ const now = Date.now();
317
+ const all = api.store.read('oauth-codes.json', []);
318
+ const rec = all.find(c => c.code === code && now - c.createdAt < CODE_TTL_MS);
319
+ if (rec) api.store.write('oauth-codes.json', all.filter(c => c.code !== code)); // single-use
320
+ return rec || null;
321
+ }
322
+
323
+ // The live record for the bearer on this request, or null.
324
+ export function tokenOf(api, req) {
325
+ const m = /^Bearer (.+)$/.exec(req.headers.authorization || '');
326
+ if (!m) return null;
327
+ const now = Date.now();
328
+ return api.tokenRecords().find(
329
+ (r) => r.token === m[1] && now - (r.createdAt || 0) < TOKEN_TTL_MS) || null;
330
+ }
331
+
332
+ export function authed(api, req) { return !!api.tokenOf(req); }
333
+
334
+ // A redirect_uri must name an authority this agent answers on — otherwise
335
+ // a visited page could navigate to /oauth/authorize and have the freshly
336
+ // minted code delivered to itself.
337
+ export function redirectAllowed(api, redirect) {
338
+ if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return true;
339
+ if (!api.allowed) return true; // no policy configured (tests)
340
+ try {
341
+ const u = new URL(redirect);
342
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
343
+ return api.allowed.has(u.host.toLowerCase());
344
+ } catch { return false; }
345
+ }
346
+
347
+ export function rateLimited(api) {
348
+ const now = Date.now();
349
+ api.authzAttempts = api.authzAttempts.filter(t => now - t < AUTHZ_WINDOW_MS);
350
+ if (api.authzAttempts.length >= AUTHZ_MAX_ATTEMPTS) return true;
351
+ api.authzAttempts.push(now);
352
+ return false;
353
+ }
354
+
355
+ export async function handle(api, ctx) {
356
+ const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
357
+
358
+ // --- oauth ---
359
+ // With no UI password configured this is "theater": whoever can reach the
360
+ // loopback surface IS the trusted user, and authorize redirects at once.
361
+ // With a password set (required before any non-loopback exposure), the
362
+ // authorize step becomes a real login form.
363
+ if (pathname === '/api/v1/apps' && req.method === 'POST') {
364
+ const body = await readBody(req);
365
+ const redirectUris = parseRedirects(body.redirect_uris);
366
+ const app = api.registerApp({ name: body.client_name, website: body.website,
367
+ redirectUris, scopes: body.scopes });
368
+ return send(200, {
369
+ id: app.clientId, name: app.name, website: app.website,
370
+ client_id: app.clientId, client_secret: app.clientSecret,
371
+ redirect_uri: redirectUris.join(' ') || 'urn:ietf:wg:oauth:2.0:oob',
372
+ ...(api.webPush ? { vapid_key: api.push.publicKey() } : {}),
373
+ });
374
+ }
375
+ if (pathname === '/oauth/authorize' && (req.method === 'GET' || req.method === 'POST')) {
376
+ const pw = api.store.getConfig()?.uiPassword; // { saltHex, hashHex } scrypt record
377
+ let params = url.searchParams;
378
+ let body = null;
379
+ if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
380
+ const redirect = params.get('redirect_uri') || '';
381
+ const app = api.findApp(params.get('client_id') || '');
382
+ // A client that published its own metadata document needs no
383
+ // registration here: the document is its name and says where it may be
384
+ // sent back to.
385
+ const doc = app ? null : await api.resolveClientDocument(params.get('client_id') || '');
386
+ // A REGISTERED client is always the third-party flow — its code is
387
+ // bound to it and exchanged with its secret — even when its redirect
388
+ // points back at this very agent (a web client served from our own
389
+ // origin registers itself exactly like a phone app does). The local
390
+ // code-is-the-token flow is only for the built-in client, which never
391
+ // registers.
392
+ const external = !!app || !!doc;
393
+
394
+ // S36. Two doors were open at once, and together they handed a bearer for
395
+ // this account to any page the owner happened to be reading.
396
+ //
397
+ // /api/v1/apps takes any redirect_uri from anyone — as it must, that is
398
+ // how a fediverse client registers — and the instant-authorize path below
399
+ // mints without a password whenever the request "is this machine". A page
400
+ // in another tab is this machine. So: register a client whose redirect is
401
+ // your own server, navigate the owner's browser to /oauth/authorize, and
402
+ // the code arrives at your address. The agent's own CA is in the browser's
403
+ // trust store, so https://localhost:<port> loads without a murmur.
404
+ //
405
+ // Two locks, either of which alone would do, both cheap:
406
+ //
407
+ // 1. A cross-site NAVIGATION may not reach the mint. A client signing in
408
+ // navigates from its own page and is same-site; a page on somebody
409
+ // else's site is not. (Absent Sec-Fetch-Site — curl, an old browser —
410
+ // is not cross-site and still passes, as everywhere else in this
411
+ // project; the second lock is what covers that case.)
412
+ if (isCrossSiteNavigation(req)) {
413
+ api.log(`authorize refused: cross-site navigation to the mint from ${req.headers.referer || 'nowhere'}`);
414
+ return send(403, { error: 'a cross-site navigation may not authorize a client' });
415
+ }
416
+ // 2. With NO password set there is nothing to authorize a third party
417
+ // WITH. The password-less path is honest only for a client on an
418
+ // address this agent itself answers on — where "whoever reaches the
419
+ // port is the owner" is a statement about the machine rather than
420
+ // about a web page. Anything pointing elsewhere has to be approved by
421
+ // somebody who knows the password.
422
+ if (external && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
423
+ api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
424
+ return send(403, {
425
+ error: 'this client asks to be sent somewhere other than this agent, and no password is '
426
+ + 'set to approve that with. Run `fedipod passwd` and try again.',
427
+ });
428
+ }
429
+ const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
430
+ if (app) {
431
+ if (!app.redirectUris.includes(redirect)) {
432
+ api.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
433
+ return send(400, { error: 'redirect_uri was not registered by this client' });
434
+ }
435
+ } else if (doc) {
436
+ if (!doc.redirectUris.some((u) => redirectMatches(u, redirect))) {
437
+ api.log(`authorize refused: redirect_uri "${redirect}" is not one ${doc.clientId} published`);
438
+ return send(400, { error: 'redirect_uri is not one this client published' });
439
+ }
440
+ // It keeps no secret, so the challenge is the only thing that will
441
+ // stand between its code and a token. Refuse now rather than mint a
442
+ // code nothing can prove.
443
+ if (!params.get('code_challenge')) {
444
+ api.log(`authorize refused: ${doc.clientId} keeps no secret and offered no challenge`);
445
+ return send(400, { error: 'a client identified by its own document must send a code_challenge' });
446
+ }
447
+ } else if (!api.redirectAllowed(redirect)) {
448
+ api.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
449
+ return send(400, { error: 'redirect_uri must be an address of this agent' });
450
+ }
451
+ if (req.method === 'POST') {
452
+ if (api.rateLimited()) {
453
+ api.log('authorize rate limited');
454
+ // 429, not 401: a client that reads this as a wrong password will
455
+ // ask the person to type it again, which is the one thing that
456
+ // cannot help. Retry-After says how long the wait actually is.
457
+ return sendLoginForm(res, params, 'too many attempts — wait a minute', client,
458
+ 429, { 'retry-after': String(Math.ceil(AUTHZ_WINDOW_MS / 1000)) });
459
+ }
460
+ if (!pw || !checkPassword(pw, body.password || '')) {
461
+ return sendLoginForm(res, params, 'wrong password — try again', client);
462
+ }
463
+ } else if (pw) {
464
+ // The login/approve screen names what is asking before the owner types
465
+ // the password — a cross-site client cannot forge past that.
466
+ return sendLoginForm(res, params, '', client);
467
+ } else if (api.allowed && !api.allowed.isLocalRequest(req)) {
468
+ // No password set, and this request did not come from this machine.
469
+ //
470
+ // The instant-authorize path is honest theatre on loopback: whoever can
471
+ // reach it IS the trusted user. It stops being theatre the moment an
472
+ // operator takes the documented AP_ALLOWED_HOSTS route and puts the
473
+ // agent on a tailnet name or behind a reverse proxy, because anyone who
474
+ // reaches that name is then handed a 90-day bearer for the whole facade
475
+ // — update_credentials included, which is precisely the authority the
476
+ // isLocal check on /setup and /config exists to withhold. /oauth is
477
+ // dispatched before that check ever runs, so it needs its own.
478
+ api.log(`authorize refused: no UI password, and "${req.headers.host}" is not this machine`);
479
+ return send(403, {
480
+ error: 'this agent answers on an address outside this machine and has no password set — '
481
+ + (api.embedded
482
+ ? 'POST {"password":"…"} to the owner door\'s /config with its door secret before logging in'
483
+ : 'run `fedipod passwd` before logging in over that address'),
484
+ });
485
+ }
486
+ // External clients get a bound code; the local flow keeps code==token.
487
+ const code = external
488
+ ? api.mintCode({ clientId: (app || doc).clientId, redirectUri: redirect, scope: client.scope,
489
+ challenge: params.get('code_challenge') || null,
490
+ challengeMethod: params.get('code_challenge_method') || null })
491
+ : api.mintToken(client.scope);
492
+ if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
493
+ const target = new URL(redirect);
494
+ target.searchParams.set('code', code);
495
+ if (params.get('state')) target.searchParams.set('state', params.get('state'));
496
+ res.writeHead(302, { location: target.href });
497
+ res.end();
498
+ return true;
499
+ }
500
+ if (pathname === '/oauth/token' && req.method === 'POST') {
501
+ const body = await readBody(req);
502
+ // A registered third-party client exchanges its bound code, proving its
503
+ // secret, for a real bearer — the code alone is not a token.
504
+ const app = api.findApp(body.client_id || '');
505
+ // A client named by its own document keeps no secret at all, so the
506
+ // challenge is the whole of its proof. The code carries the document's
507
+ // URL as the client it was bound to.
508
+ if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ''))) {
509
+ const rec = api.consumeCode(body.code || '');
510
+ if (!rec || rec.clientId !== body.client_id
511
+ || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
512
+ api.log('token refused: code is not a live authorization for that client document');
513
+ return send(400, { error: 'invalid_grant' });
514
+ }
515
+ if (!rec.challenge || !provesCode(rec, body.code_verifier)) {
516
+ api.log('token refused: the verifier does not answer the challenge this code was made with');
517
+ return send(400, { error: 'invalid_grant' });
518
+ }
519
+ return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
520
+ scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
521
+ ...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
522
+ }
523
+ // A client that runs in a browser cannot keep a secret, so it proves it
524
+ // is the same caller that asked instead: it sends the verifier for the
525
+ // challenge it presented at authorize (RFC 7636). Sending a verifier is
526
+ // what says which of the two flows this is.
527
+ if (app && body.code_verifier) {
528
+ const rec = api.consumeCode(body.code || '');
529
+ if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
530
+ api.log('token refused: code is not a live authorization for this client');
531
+ return send(400, { error: 'invalid_grant' });
532
+ }
533
+ // A code minted without a challenge cannot be redeemed with one: that
534
+ // would let anyone holding a stolen code invent the proof for it.
535
+ if (!rec.challenge || !provesCode(rec, body.code_verifier)) {
536
+ api.log('token refused: the verifier does not answer the challenge this code was made with');
537
+ return send(400, { error: 'invalid_grant' });
538
+ }
539
+ return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
540
+ scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
541
+ ...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
542
+ }
543
+ if (app && body.client_secret) {
544
+ const given = Buffer.from(String(body.client_secret));
545
+ const known = Buffer.from(app.clientSecret);
546
+ const okSecret = given.length === known.length && crypto.timingSafeEqual(given, known);
547
+ if (!okSecret) { api.log('token refused: client secret mismatch'); return send(401, { error: 'invalid_client' }); }
548
+ const rec = api.consumeCode(body.code || '');
549
+ if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
550
+ api.log('token refused: code is not a live authorization for this client');
551
+ return send(400, { error: 'invalid_grant' });
552
+ }
553
+ // A challenge, once made, is not optional: without this a client could
554
+ // present one and then skip past it with the secret alone.
555
+ if (rec.challenge && !provesCode(rec, body.code_verifier)) {
556
+ api.log('token refused: this code was made with a challenge and the verifier does not answer it');
557
+ return send(400, { error: 'invalid_grant' });
558
+ }
559
+ return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
560
+ scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
561
+ // Which actor the token acts for. A Mastodon client ignores it; an
562
+ // ActivityPub API client needs it, and asking for it separately
563
+ // would mean a second round trip before it knows who it is.
564
+ ...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
565
+ }
566
+ // Local flow: the code IS the token, minted by /oauth/authorize after the
567
+ // password gate. Minting one here for an unrecognised code handed a
568
+ // bearer to anyone who could reach the port — and a non-browser client
569
+ // sends no Origin, so the firewall never saw it. That defeated `passwd`
570
+ // on any agent deliberately exposed through AP_ALLOWED_HOSTS.
571
+ if (!body.code || !api.tokens().includes(body.code)) {
572
+ api.log('token refused: code is not a live authorization');
573
+ return send(400, { error: 'invalid_grant' });
574
+ }
575
+ // The scope this token was actually minted with, not whatever the client
576
+ // asks to be told — reporting one and enforcing another is how a client
577
+ // ends up surprised by a 403 it was promised it would not get.
578
+ const granted = api.tokenRecords().find((r) => r.token === body.code)?.scope
579
+ || 'read write follow push';
580
+ return send(200, { access_token: body.code, token_type: 'Bearer',
581
+ scope: granted, created_at: Math.floor(Date.now() / 1000),
582
+ ...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
583
+ }
584
+ if (pathname === '/oauth/revoke' && req.method === 'POST') {
585
+ // It used to answer 200 and keep the token, so logging out of a client
586
+ // left a working 90-day bearer behind. Mastodon's endpoint takes `token`;
587
+ // an unknown one is still a 200, which is what the spec asks for.
588
+ const body = await readBody(req).catch(() => ({}));
589
+ const gone = body?.token;
590
+ if (gone) {
591
+ const kept = api.tokenRecords().filter(r => r.token !== gone);
592
+ api.store.write('masto-tokens.json', kept);
593
+ api.log('client token revoked');
594
+ }
595
+ return send(200, {});
596
+ }
597
+
598
+ return false;
599
+ }