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.
- package/README.md +22 -6
- package/dist/claims.d.ts +8 -0
- package/dist/claims.js +10 -0
- package/dist/handler.d.ts +13 -0
- package/dist/handler.js +56 -11
- package/dist/handler.jsonld +8 -0
- package/dist/store-pod.js +18 -4
- package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +170 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/graphview.mjs +269 -0
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +142 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +199 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/server/embed.mjs +405 -0
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +33 -25
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +14 -16
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/boot.mjs +2 -3
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +21913 -5446
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/signup.mjs +2 -3
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +21913 -5446
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/embed.mjs +0 -220
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|