message-cp 0.0.0-stage → 0.1.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/dist/oauth.js ADDED
@@ -0,0 +1,384 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { AccountError, addToken, recordClient } from "./accounts.js";
3
+ import { ToolError, registerWithIdentity, validateName } from "./agents.js";
4
+ // MCP's authorization flow for `serve --public-url`: a client registers itself (RFC 7591) or names the URL of its Client
5
+ // ID Metadata Document as its `client_id`, which the authorization endpoint fetches, a person approves it in the
6
+ // browser with a full or person token of their account, and the client exchanges the code it is sent back with for an
7
+ // agent token bound to the approved agent (OAuth 2.1 authorization code with PKCE). Authorization requests and codes are
8
+ // stored only as hashes, as is the client's `state`; nothing here logs anything.
9
+ export class OAuthError extends Error {
10
+ error;
11
+ constructor(error, message) {
12
+ super(message);
13
+ this.error = error;
14
+ }
15
+ }
16
+ /**
17
+ * Client registrations kept at once, besides those with an authorization request or code not yet used or expired;
18
+ * registering past this forgets the oldest of the rest, which has to register again.
19
+ */
20
+ export const MAX_CLIENTS = 1000;
21
+ /** Authorization requests and codes not yet used or expired, kept at once; past this a new request is refused. */
22
+ export const MAX_AUTHORIZATIONS = 1000;
23
+ /** How long a person has to approve a request once its page is shown. */
24
+ export const AUTHORIZATION_TTL_MS = 10 * 60 * 1000;
25
+ /** How long a client has to exchange its code. */
26
+ export const CODE_TTL_MS = 2 * 60 * 1000;
27
+ const MAX_REDIRECT_URIS = 10;
28
+ const MAX_URI_LENGTH = 2000;
29
+ const MAX_CLIENT_NAME_LENGTH = 100;
30
+ const MAX_STATE_LENGTH = 1000;
31
+ const CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
32
+ const VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
33
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
34
+ /** Whether `url`'s host is this machine: 127.x.x.x, [::1] or the name localhost. */
35
+ export const isLoopback = (url) => /^127(\.\d{1,3}){3}$/.test(url.hostname) || url.hostname === "[::1]" || url.hostname === "localhost";
36
+ /**
37
+ * Checks a redirect URI a client registers, parsed with `new URL()`: https, or http on a loopback host, with no username,
38
+ * password, query or fragment. Unlike `publicUrl` in connect.ts, `localhost` is allowed: current clients register
39
+ * `http://localhost:<port>/callback`, the MCP spec allows a redirect URI that is "either `localhost` or use HTTPS", and
40
+ * what the server sends there is a code, useless without the client's PKCE verifier, never a token.
41
+ */
42
+ export function checkRedirectUri(raw) {
43
+ if (typeof raw !== "string" || raw.length > MAX_URI_LENGTH)
44
+ throw new OAuthError("invalid_redirect_uri", "Each redirect URI must be a URL.");
45
+ let url;
46
+ try {
47
+ url = new URL(raw);
48
+ }
49
+ catch {
50
+ throw new OAuthError("invalid_redirect_uri", "Each redirect URI must be a URL.");
51
+ }
52
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url))) {
53
+ throw new OAuthError("invalid_redirect_uri", "A redirect URI must be https, or http on localhost, 127.x.x.x or [::1].");
54
+ }
55
+ // `href` keeps an empty `?` or `#`, which `search` and `hash` report as "".
56
+ if (url.username || url.password || url.href.includes("?") || url.href.includes("#")) {
57
+ throw new OAuthError("invalid_redirect_uri", "A redirect URI must not carry a username, password, query or fragment.");
58
+ }
59
+ return raw;
60
+ }
61
+ /**
62
+ * `raw` parsed, when it is an http URI on a loopback host written exactly as `new URL()` writes it (so no other letter
63
+ * case, default port or encoding), with no username, password, query or fragment; otherwise undefined.
64
+ */
65
+ function loopbackUri(raw) {
66
+ if (raw.length > MAX_URI_LENGTH)
67
+ return undefined;
68
+ let url;
69
+ try {
70
+ url = new URL(raw);
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ if (url.href !== raw || url.protocol !== "http:" || !isLoopback(url))
76
+ return undefined;
77
+ return url.username || url.password || raw.includes("?") || raw.includes("#") ? undefined : url;
78
+ }
79
+ /**
80
+ * Whether `requested` is one of a client's `registered` redirect URIs: the same string, or, for an http loopback URI,
81
+ * the same but for the port, which OAuth 2.1 (section 2.3.1) and RFC 8252 (section 7.3) let a native client choose when
82
+ * it asks. Codex registers `http://127.0.0.1/callback/<id>` and asks with a port of its choosing. Scheme, host and path
83
+ * are compared parsed, and both URIs must pass `loopbackUri`, so `localhost` never matches `127.0.0.1`.
84
+ */
85
+ export function matchesRedirectUri(registered, requested) {
86
+ if (registered.includes(requested))
87
+ return true;
88
+ const asked = loopbackUri(requested);
89
+ if (!asked)
90
+ return false;
91
+ return registered.some((raw) => {
92
+ const url = loopbackUri(raw);
93
+ return url !== undefined && url.hostname === asked.hostname && url.pathname === asked.pathname;
94
+ });
95
+ }
96
+ /** Whether `clientId` names a Client ID Metadata Document by its URL, rather than a registration: it starts with https://. */
97
+ export const isMetadataUrl = (clientId) => clientId.startsWith("https://");
98
+ /**
99
+ * Checks a `client_id` that `isMetadataUrl` takes for a metadata document's URL before anything is fetched, parsed with
100
+ * `new URL()`: https, a path other than /, no username, password, query or fragment, at most MAX_URI_LENGTH characters,
101
+ * and its own canonical form, so no dot segments and nothing `new URL()` would rewrite. The address it names is checked
102
+ * when it is fetched (client-metadata.ts). Throws `OAuthError` with fixed text.
103
+ */
104
+ export function checkMetadataUrl(raw) {
105
+ const bad = () => new OAuthError("invalid_client", "This client's address is not one this server fetches a description from.");
106
+ if (raw.length > MAX_URI_LENGTH)
107
+ throw bad();
108
+ let url;
109
+ try {
110
+ url = new URL(raw);
111
+ }
112
+ catch {
113
+ throw bad();
114
+ }
115
+ // `href` keeps an empty `?` or `#`, which `search` and `hash` report as "".
116
+ if (url.protocol !== "https:" || url.pathname === "/" || url.username || url.password || url.href.includes("?") || url.href.includes("#"))
117
+ throw bad();
118
+ if (url.href !== raw)
119
+ throw bad();
120
+ return raw;
121
+ }
122
+ /**
123
+ * The client a fetched Client ID Metadata Document describes, for the metadata URL `url`: a JSON object whose `client_id`
124
+ * is `url` exactly, with 1-MAX_REDIRECT_URIS `redirect_uris`, each passing `checkRedirectUri`, an optional `client_name`
125
+ * of at most MAX_CLIENT_NAME_LENGTH characters, `token_endpoint_auth_method` absent or "none", and no `client_secret`.
126
+ * Other fields are ignored. Throws `OAuthError` with fixed text that never quotes the document.
127
+ */
128
+ export function clientFromMetadata(url, doc) {
129
+ const bad = (why) => new OAuthError("invalid_client_metadata", `This client's description ${why}`);
130
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc))
131
+ throw bad("is not a JSON object.");
132
+ const input = doc;
133
+ if (input.client_id !== url)
134
+ throw bad("names another client_id than its own address.");
135
+ const uris = input.redirect_uris;
136
+ if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS) {
137
+ throw bad(`must list 1-${MAX_REDIRECT_URIS} redirect URIs.`);
138
+ }
139
+ let redirectUris;
140
+ try {
141
+ redirectUris = uris.map(checkRedirectUri);
142
+ }
143
+ catch {
144
+ throw bad("lists a redirect URI this server does not accept.");
145
+ }
146
+ const name = input.client_name;
147
+ if (name !== undefined && (typeof name !== "string" || name.length > MAX_CLIENT_NAME_LENGTH)) {
148
+ throw bad(`has a client_name that is not a string of at most ${MAX_CLIENT_NAME_LENGTH} characters.`);
149
+ }
150
+ if ((input.token_endpoint_auth_method !== undefined && input.token_endpoint_auth_method !== "none") || "client_secret" in input) {
151
+ throw bad("is not for a public client.");
152
+ }
153
+ return { clientId: url, clientName: name ?? null, redirectUris };
154
+ }
155
+ /**
156
+ * Forgets the oldest client rows past MAX_CLIENTS that have no authorization request or code still live, with their
157
+ * expired ones. Run inside the transaction that added a row.
158
+ */
159
+ function pruneClients(db) {
160
+ db.prepare(`DELETE FROM mcp_clients WHERE id IN (
161
+ SELECT id FROM mcp_clients
162
+ WHERE NOT EXISTS (SELECT 1 FROM mcp_authorizations WHERE client_id = mcp_clients.id AND expires_at > ?)
163
+ ORDER BY id DESC LIMIT -1 OFFSET ?)`).run(Date.now(), MAX_CLIENTS);
164
+ }
165
+ /**
166
+ * Registers a public client from an RFC 7591 request body: `client_name` (optional) and `redirect_uris`, each checked by
167
+ * `checkRedirectUri` as the client sent it. A bad request stores nothing. Past `MAX_CLIENTS` the oldest registrations
168
+ * with no authorization request or code still live are forgotten, with their expired ones, so registering never ends
169
+ * another client's flow; tokens already issued are not affected. Those kept with a live one number at most
170
+ * `MAX_AUTHORIZATIONS`.
171
+ */
172
+ export const registerClient = (db, body) => db.transaction(() => {
173
+ const input = (typeof body === "object" && body !== null && !Array.isArray(body) ? body : {});
174
+ const name = input.client_name;
175
+ if (name !== undefined && (typeof name !== "string" || name.length > MAX_CLIENT_NAME_LENGTH)) {
176
+ throw new OAuthError("invalid_client_metadata", `client_name must be a string of at most ${MAX_CLIENT_NAME_LENGTH} characters.`);
177
+ }
178
+ const uris = input.redirect_uris;
179
+ if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS) {
180
+ throw new OAuthError("invalid_redirect_uri", `redirect_uris must list 1-${MAX_REDIRECT_URIS} URIs.`);
181
+ }
182
+ const redirectUris = uris.map(checkRedirectUri);
183
+ const clientId = randomBytes(16).toString("hex");
184
+ db.prepare("INSERT INTO mcp_clients (client_id, client_name, redirect_uris, created_at) VALUES (?, ?, ?, ?)").run(clientId, name ?? null, JSON.stringify(redirectUris), Date.now());
185
+ pruneClients(db);
186
+ return { clientId, clientName: name ?? null, redirectUris };
187
+ }).immediate();
188
+ /** The registered client `clientId`, or undefined for none. */
189
+ export function findClient(db, clientId) {
190
+ const row = db.prepare("SELECT id, client_id, client_name, redirect_uris FROM mcp_clients WHERE client_id = ?").get(clientId);
191
+ return row && { id: row.id, clientId: row.client_id, clientName: row.client_name, redirectUris: JSON.parse(row.redirect_uris) };
192
+ }
193
+ /** `redirectUri` with the query `params` (and `state`, when given) added. `redirectUri` passed `matchesRedirectUri`. */
194
+ export function redirectTo(redirectUri, params, state) {
195
+ const url = new URL(redirectUri);
196
+ for (const [k, v] of Object.entries(params))
197
+ url.searchParams.set(k, v);
198
+ if (state !== null)
199
+ url.searchParams.set("state", state);
200
+ return url.href;
201
+ }
202
+ /**
203
+ * Stores the client a metadata document describes, keyed by its URL: the existing row's name and redirect URIs replaced,
204
+ * keeping its id so requests and codes already made for it stay, or a new row, after which the oldest past MAX_CLIENTS
205
+ * are forgotten as for a registration. Shared by every account, as a registration is.
206
+ */
207
+ function storeMetadataClient(db, client) {
208
+ const known = findClient(db, client.clientId);
209
+ if (known) {
210
+ db.prepare("UPDATE mcp_clients SET client_name = ?, redirect_uris = ? WHERE id = ?").run(client.clientName, JSON.stringify(client.redirectUris), known.id);
211
+ return { ...client, id: known.id };
212
+ }
213
+ const id = db
214
+ .prepare("INSERT INTO mcp_clients (client_id, client_name, redirect_uris, created_at) VALUES (?, ?, ?, ?)")
215
+ .run(client.clientId, client.clientName, JSON.stringify(client.redirectUris), Date.now()).lastInsertRowid;
216
+ pruneClients(db);
217
+ return { ...client, id };
218
+ }
219
+ /**
220
+ * Checks an authorization request's query and, if it may go ahead, stores it hashed with an expiry. `resource` is the
221
+ * server's `/mcp` address, the only resource it accepts. A parameter given twice counts as bad. `fetched` is the client
222
+ * a metadata document described, for a `client_id` that is its URL (see `clientFromMetadata`); it is stored in the same
223
+ * transaction, in place of looking up a registration, even when the request then redirects back with an error.
224
+ */
225
+ export const startAuthorization = (db, query, resource, fetched) => db.transaction(() => {
226
+ const one = (key) => {
227
+ const values = query.getAll(key);
228
+ return values.length === 0 ? null : values.length === 1 ? values[0] : undefined;
229
+ };
230
+ const clientId = one("client_id");
231
+ let client;
232
+ if (clientId && fetched)
233
+ client = fetched.clientId === clientId ? storeMetadataClient(db, fetched) : undefined;
234
+ else if (clientId)
235
+ client = findClient(db, clientId);
236
+ if (!client)
237
+ return { page: "error", message: "This client is not registered with this server. Start connecting again from the client." };
238
+ const redirectUri = one("redirect_uri");
239
+ if (!redirectUri || !matchesRedirectUri(client.redirectUris, redirectUri)) {
240
+ return { page: "error", message: "The address this client asked to be sent back to is not one it registered." };
241
+ }
242
+ const state = one("state");
243
+ const echoed = typeof state === "string" && state.length <= MAX_STATE_LENGTH ? state : null;
244
+ const invalid = () => ({ page: "redirect", location: redirectTo(redirectUri, { error: "invalid_request" }, echoed) });
245
+ if (state === undefined || (state !== null && state.length > MAX_STATE_LENGTH))
246
+ return invalid();
247
+ const challenge = one("code_challenge");
248
+ const asked = one("resource");
249
+ if (one("response_type") !== "code" ||
250
+ !challenge ||
251
+ !CHALLENGE.test(challenge) ||
252
+ one("code_challenge_method") !== "S256" ||
253
+ asked === undefined ||
254
+ (asked !== null && asked !== resource)) {
255
+ return invalid();
256
+ }
257
+ const now = Date.now();
258
+ db.prepare("DELETE FROM mcp_authorizations WHERE expires_at <= ?").run(now);
259
+ const pending = db.prepare("SELECT count(*) FROM mcp_authorizations").pluck().get();
260
+ if (pending >= MAX_AUTHORIZATIONS) {
261
+ return { page: "redirect", location: redirectTo(redirectUri, { error: "temporarily_unavailable" }, echoed) };
262
+ }
263
+ const request = randomBytes(32).toString("hex");
264
+ db.prepare(`INSERT INTO mcp_authorizations (request_hash, client_id, redirect_uri, code_challenge, resource, state_hash, expires_at)
265
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(sha256(request), client.id, redirectUri, challenge, asked, state === null ? null : sha256(state), now + AUTHORIZATION_TTL_MS);
266
+ return { page: "approve", authorization: { request, client, redirectUri, state } };
267
+ }).immediate();
268
+ /**
269
+ * Approves the stored request `request` for the account `account` (the approving token's), as its agent `name`: a live
270
+ * agent of that name in the account is bound to as it is, with its key and sessions left alone; a new name makes that
271
+ * agent, as `connectAgent` does. In one transaction it checks the request is unexpired and unused and that `state` is
272
+ * the one it was made with, and stores a code bound to the account, agent, client, redirect URI, challenge and resource.
273
+ * Returns where to send the person: the redirect URI with the code and state. With `fresh` (a name the approval page
274
+ * suggested and the person left as it was), a live agent of that name is refused rather than bound to, so a suggested
275
+ * name never connects the client as an existing agent. With `name` null, the client registers its own agents: the code is
276
+ * bound to the account and no agent, and marked so `exchangeCode` issues a token that registers them. A refusal throws
277
+ * `OAuthError` or `ToolError` and stores nothing.
278
+ */
279
+ export const approveAuthorization = (db, account, request, state, name, fresh = false) => db.transaction(() => {
280
+ const now = Date.now();
281
+ const row = db
282
+ .prepare("SELECT id, redirect_uri, state_hash FROM mcp_authorizations WHERE request_hash = ? AND code_hash IS NULL AND expires_at > ?")
283
+ .get(sha256(request), now);
284
+ if (!row || row.state_hash !== (state === null ? null : sha256(state))) {
285
+ throw new OAuthError("invalid_request", "This approval has expired or was already used. Start connecting again from the client.");
286
+ }
287
+ let agent = null;
288
+ if (name !== null) {
289
+ validateName(name);
290
+ const live = db
291
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
292
+ .get(account, name);
293
+ if (live?.person === 1)
294
+ throw new ToolError(`The name '${live.name}' belongs to a person, not an agent.`);
295
+ if (live && fresh)
296
+ throw new ToolError(`An agent named '${live.name}' now exists. Choose another name, or type its name to connect the client as it.`);
297
+ agent = live ? live.id : registerWithIdentity(db, account, name).identity.id;
298
+ }
299
+ const code = randomBytes(32).toString("hex");
300
+ db.prepare("UPDATE mcp_authorizations SET code_hash = ?, account_id = ?, agent_id = ?, registers = ?, expires_at = ? WHERE id = ?").run(sha256(code), account, agent, name === null ? 1 : 0, now + CODE_TTL_MS, row.id);
301
+ return redirectTo(row.redirect_uri, { code }, state);
302
+ }).immediate();
303
+ /**
304
+ * Exchanges a code for an agent token bound to the approved agent, made with `addToken` in the approving account, or,
305
+ * for an approval of a client that registers its own agents, an agent token bound to no agent that registers them. In one
306
+ * transaction it finds the code and deletes it, so it is used up by its first exchange, successful or not; then it
307
+ * checks it is unexpired, and that the client, redirect URI and resource are those of its request and the verifier
308
+ * matches the challenge (S256). Any mismatch, or an agent removed meanwhile, throws `OAuthError` `invalid_grant` and
309
+ * issues nothing; the code stays used up.
310
+ */
311
+ export function exchangeCode(db, exchange) {
312
+ const result = db
313
+ .transaction(() => {
314
+ const row = db
315
+ .prepare(`SELECT mcp_authorizations.id, mcp_clients.client_id, mcp_clients.client_name, redirect_uri, code_challenge, resource, account_id, agent_id, registers, expires_at,
316
+ (SELECT name FROM accounts WHERE accounts.id = mcp_authorizations.account_id) AS account
317
+ FROM mcp_authorizations JOIN mcp_clients ON mcp_clients.id = mcp_authorizations.client_id
318
+ WHERE code_hash = ?`)
319
+ .get(sha256(exchange.code));
320
+ if (!row)
321
+ return { refused: true };
322
+ db.prepare("DELETE FROM mcp_authorizations WHERE id = ?").run(row.id);
323
+ const challenge = createHash("sha256").update(exchange.verifier).digest("base64url");
324
+ if (row.expires_at <= Date.now() ||
325
+ row.client_id !== exchange.clientId ||
326
+ row.redirect_uri !== exchange.redirectUri ||
327
+ row.resource !== exchange.resource ||
328
+ !VERIFIER.test(exchange.verifier) ||
329
+ challenge !== row.code_challenge) {
330
+ return { refused: true };
331
+ }
332
+ try {
333
+ // A code approved before `registers` existed, or by a server from before it, has 0 there and an agent, which
334
+ // `addToken` checks; one with neither is refused.
335
+ if (row.registers !== 1 && row.agent_id === null)
336
+ return { refused: true };
337
+ const issued = row.registers === 1 ? addToken(db, row.account, false, true, undefined, undefined, true) : addToken(db, row.account, false, true, row.agent_id);
338
+ // The client row may be forgotten later (pruneClients), so what the account's approvals show is kept on the token.
339
+ recordClient(db, issued.id, { name: row.client_name, host: isMetadataUrl(row.client_id) ? new URL(row.client_id).host : null });
340
+ return { token: issued.token };
341
+ }
342
+ catch (err) {
343
+ // The agent was removed since the approval: its savepoint is rolled back, the code stays used up.
344
+ if (err instanceof AccountError)
345
+ return { refused: true };
346
+ throw err;
347
+ }
348
+ })
349
+ .immediate();
350
+ if ("refused" in result)
351
+ throw new OAuthError("invalid_grant", "The code is invalid, expired or already used, or does not match this request.");
352
+ return result.token;
353
+ }
354
+ /** Suggestions tried for one approval before the page offers none. */
355
+ const MAX_SUGGESTIONS = 100;
356
+ /** The longest base `agentNameBase` gives, leaving room for a "-100" suffix within a name's 32 characters. */
357
+ export const MAX_NAME_BASE = 24;
358
+ /**
359
+ * The base of the agent name the approval page offers for a client named `clientName`: the name in lower case with each
360
+ * run of other characters made one "-", at most MAX_NAME_BASE characters, or "client" for none left (or "all").
361
+ */
362
+ export function agentNameBase(clientName) {
363
+ const slug = (clientName ?? "")
364
+ .toLowerCase()
365
+ .replace(/[^a-z0-9_-]+/g, "-")
366
+ .replace(/^-+|-+$/g, "")
367
+ .slice(0, MAX_NAME_BASE)
368
+ .replace(/-+$/, "");
369
+ return slug === "" || slug === "all" ? "client" : slug;
370
+ }
371
+ /**
372
+ * A name made from `base` that no live agent or person of `account` has: `base`, else `base`-2, `base`-3 and so on;
373
+ * undefined if the first MAX_SUGGESTIONS are all taken. Read-only; `approveAuthorization` with `fresh` refuses it if it
374
+ * is taken by the time the person approves.
375
+ */
376
+ export function freeAgentName(db, account, base) {
377
+ const taken = db.prepare("SELECT 1 FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL").pluck();
378
+ for (let n = 1; n <= MAX_SUGGESTIONS; n++) {
379
+ const name = n === 1 ? base : `${base}-${n}`;
380
+ if (!taken.get(account, name))
381
+ return name;
382
+ }
383
+ return undefined;
384
+ }