@vantageos/mcp-boilerplate 0.2.0 → 0.2.2
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +63 -0
- package/README.fr.md +23 -2
- package/README.md +21 -2
- package/dist/envKnobs.d.ts +14 -0
- package/dist/envKnobs.js +54 -0
- package/dist/http.js +4 -20
- package/dist/httpApp.js +17 -0
- package/dist/locales/en.json +3 -0
- package/dist/locales/fr.json +3 -0
- package/dist/nodeAdapter.d.ts +20 -0
- package/dist/nodeAdapter.js +63 -0
- package/dist/oauth/authorizationServer.d.ts +15 -1
- package/dist/oauth/authorizationServer.js +48 -7
- package/dist/oauth/clerk.d.ts +5 -1
- package/dist/oauth/clerk.js +29 -11
- package/dist/oauth/clientRegistry.d.ts +13 -1
- package/dist/oauth/clientRegistry.js +76 -7
- package/dist/oauth/codeStore.d.ts +19 -1
- package/dist/oauth/codeStore.js +28 -3
- package/dist/oauth/picker.d.ts +6 -1
- package/dist/oauth/picker.js +8 -2
- package/dist/oauth/settings.d.ts +7 -0
- package/dist/oauth/settings.js +21 -3
- package/dist/ui/uiResource.d.ts +3 -3
- package/dist/ui/uiResource.js +22 -7
- package/dist/ui-bundle/widget.html +9 -49
- package/docs/oauth-stores.md +12 -1
- package/examples/oauth.env.example +10 -0
- package/package.json +6 -6
- package/plugin.json +1 -1
- package/scripts/generate-signing-key.mjs +14 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vantage-mcp-boilerplate",
|
|
3
3
|
"displayName": "VantageOS MCP Boilerplate",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.2",
|
|
5
5
|
"description": "Clonable MCP server template: SDK v2 on protocol 2026-07-28 (stdio + stateless Streamable HTTP), its own OAuth 2.1 authorization server (Clerk person + organisation), MCP Apps UI, FR/EN i18n and a declarative eval harness. Scaffold, extend, publish.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ElPi Corp / Laurent Perello"
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2] - 2026-10-10
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- Consent page shows the ORIGIN the authorization code is sent to and the requested scope, and
|
|
15
|
+
flags a Dynamic-Client-Registration client as unverified (FR + EN).
|
|
16
|
+
- `/register` rate limit keys on the socket address, or on the `X-Forwarded-For` entry the
|
|
17
|
+
N-th trusted proxy appended (`MCP_TRUSTED_PROXY_COUNT`, default 0); a client-supplied first
|
|
18
|
+
entry no longer bypasses it.
|
|
19
|
+
- In-memory client registry expires registrations (30 days) and evicts the oldest at the cap
|
|
20
|
+
instead of refusing forever; in-memory code store drops expired codes and prunes its used set.
|
|
21
|
+
- `NODE_ENV=production` refuses the in-memory stores unless both durable adapters are injected
|
|
22
|
+
or `MCP_ALLOW_IN_MEMORY_STORES=1` is set (explicit, logged opt-in).
|
|
23
|
+
- The Node entry caps the request body (`MCP_MAX_BODY_BYTES`, default 1 MiB): `413` before any
|
|
24
|
+
handler runs, counted while the body streams in.
|
|
25
|
+
- `/mcp` enforces the `mcp:full` scope on the own-authorization-server tier (`403
|
|
26
|
+
insufficient_scope`).
|
|
27
|
+
- Clerk Backend calls time out (5 s); organisation memberships are paginated past 100 and fail
|
|
28
|
+
loudly rather than truncate past 5 000.
|
|
29
|
+
- `http:` issuer, sign-in and callback URLs are refused outside loopback.
|
|
30
|
+
- A consent denial answers the OAuth error redirect (`error=access_denied`, `state`, `iss`)
|
|
31
|
+
instead of a `403` JSON body; the redirect target is read only from a state the flow verified.
|
|
32
|
+
- `/register` sends no CORS headers unless the origin is listed in `MCP_REGISTER_CORS_ORIGINS`.
|
|
33
|
+
- SSRF guard also blocks the IPv4 ranges embedded in IPv4-translated (`::ffff:0:0/96`) and
|
|
34
|
+
6to4 (`2002::/16`) addresses, and refuses Teredo (`2001::/32`).
|
|
35
|
+
|
|
36
|
+
- A SET but invalid `MCP_MAX_BODY_BYTES`, `MCP_TRUSTED_PROXY_COUNT`, `MCP_REGISTER_CORS_ORIGINS`
|
|
37
|
+
or `MCP_ALLOW_IN_MEMORY_STORES` refuses to start, naming the variable and what was expected
|
|
38
|
+
(the value is never echoed); unset or empty keeps the default. No silent fallback.
|
|
39
|
+
- SSRF guard reads the IPv4 embedded in ISATAP addresses (interface id `0000:5efe` /
|
|
40
|
+
`0200:5efe`, any prefix) and refuses it when that IPv4 is in a blocked range.
|
|
41
|
+
|
|
42
|
+
- `@vantageos/cloud-identity` ^0.17.0: the consent page reads `OrgPickerModel.redirectOrigin` and a
|
|
43
|
+
denial is `resumeAuthorize({ denied: true })` (a verified redirect-to-client, `iss` appended in
|
|
44
|
+
one place). The local authorize-state parser (`src/oauth/stateBlob.ts`) is removed.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- `createInMemoryClientRegistry` takes an options object (`maxClients`, `ttlSeconds`, `now`)
|
|
49
|
+
instead of a bare number; `createInMemoryCodeStore` takes `{ now, maxEntries }` and exposes
|
|
50
|
+
`stats()`; `renderOrgPicker` takes a third `ConsentContext` argument (`redirectUri`,
|
|
51
|
+
`unverified`).
|
|
52
|
+
- `tests/toolRecordComplete.test.ts` asserts the four annotations are declared booleans, not
|
|
53
|
+
fixed values (a fork's write tool is rightly `readOnlyHint: false`).
|
|
54
|
+
|
|
55
|
+
### Dependencies
|
|
56
|
+
|
|
57
|
+
- `hono` 4.13.13, `vitest` 5, `vite` 8. `vite-plugin-singlefile` is removed (its
|
|
58
|
+
`micromatch` -> `braces` chain has an advisory with no patched release); `vite.config.ts`
|
|
59
|
+
inlines the bundle itself. `npm audit`: 0 critical, 0 high.
|
|
60
|
+
|
|
61
|
+
## [0.2.1] - 2026-10-10
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- The MCP Apps UI bundle is resolved relative to the module, not to `process.cwd()`: the
|
|
66
|
+
published package failed `resources/read ui://mcp-boilerplate/items-widget.html` whenever it
|
|
67
|
+
was run from any directory other than its own root.
|
|
68
|
+
- A missing UI bundle now answers an MCP error with no filesystem path in its message (the
|
|
69
|
+
detail is logged on stderr only); it used to leak the absolute `ENOENT` path to the client.
|
|
70
|
+
- `scripts/generate-signing-key.mjs`, which the README tells users to run, is now in the
|
|
71
|
+
published `files`.
|
|
72
|
+
|
|
10
73
|
## [0.2.0] - 2026-10-10
|
|
11
74
|
|
|
12
75
|
SDK v2 / protocol 2026-07-28 migration, own authorization server, MCP Apps + ChatGPT
|
package/README.fr.md
CHANGED
|
@@ -122,6 +122,27 @@ interfaces avec adaptateurs en mémoire pour les tests et le développement
|
|
|
122
122
|
mono-processus. Utilisez des adaptateurs durables en production — contrat dans
|
|
123
123
|
[`docs/oauth-stores.md`](docs/oauth-stores.md).
|
|
124
124
|
|
|
125
|
+
**Garde de production** : avec `NODE_ENV=production`, le serveur refuse de démarrer
|
|
126
|
+
sur les stores en mémoire (codes et clients enregistrés perdus au redémarrage, non
|
|
127
|
+
partagés entre instances) sauf si les deux adaptateurs durables sont injectés ou si
|
|
128
|
+
`MCP_ALLOW_IN_MEMORY_STORES=1` est défini — un choix explicite et journalisé pour un
|
|
129
|
+
déploiement mono-instance. Derrière un reverse proxy, réglez `MCP_TRUSTED_PROXY_COUNT`
|
|
130
|
+
sur le nombre de proxys de confiance (défaut `0`) : la limite de débit de `/register`
|
|
131
|
+
se fonde alors sur l'entrée `X-Forwarded-For` ajoutée par le proxy, jamais sur la
|
|
132
|
+
première, fournie par le client ; avec `0`, elle se fonde sur l'adresse du socket et
|
|
133
|
+
ignore l'en-tête.
|
|
134
|
+
|
|
135
|
+
**Limites de requête et CORS** : Une variable d'exploitation définie mais invalide (par exemple `MCP_MAX_BODY_BYTES=abc`) arrête le serveur au démarrage avec le nom de la variable et la forme attendue, jamais un défaut silencieux ; non définie ou vide, le défaut s'applique. l'entrée Node lit au plus `MCP_MAX_BODY_BYTES`
|
|
136
|
+
(défaut 1 Mio) du corps d'une requête et répond `413` avant tout handler.
|
|
137
|
+
`/register` n'envoie aucun en-tête CORS sauf si l'origine du navigateur figure dans
|
|
138
|
+
`MCP_REGISTER_CORS_ORIGINS` (séparées par des virgules). `/mcp` exige le scope
|
|
139
|
+
`mcp:full` sur le palier serveur d'autorisation propre (`403 insufficient_scope`
|
|
140
|
+
sinon). La page de consentement affiche l'origine vers laquelle le code est envoyé et
|
|
141
|
+
le scope demandé, signale comme non vérifié un client issu de l'enregistrement
|
|
142
|
+
dynamique, et un refus redirige vers le client avec `error=access_denied` (plus
|
|
143
|
+
`state` et `iss`). Les URL `http:` d'émetteur, de connexion et de rappel sont refusées
|
|
144
|
+
hors boucle locale.
|
|
145
|
+
|
|
125
146
|
**Non inclus** : jetons de rafraîchissement (les jetons d'accès durent une heure),
|
|
126
147
|
clés d'API par organisation pour les clients hors ChatGPT (ce code n'a jamais eu ce
|
|
127
148
|
chemin ; c'est un manque, pas une régression), URI de redirection à joker, et liste
|
|
@@ -217,11 +238,11 @@ de `npm publish`.
|
|
|
217
238
|
qui ne négocient jamais l'extension.
|
|
218
239
|
|
|
219
240
|
Le widget (`src/ui/`) réutilise une famille `MosaicCard` vendorisée et est empaqueté
|
|
220
|
-
en un seul fichier HTML autonome (`vite
|
|
241
|
+
en un seul fichier HTML autonome (un plugin d'inlining local dans `vite.config.ts`,
|
|
221
242
|
`dist/ui-bundle/widget.html`) par `npm run build:ui`, sur un chemin qui n'entre
|
|
222
243
|
jamais en collision avec `dist/ui/` de tsc.
|
|
223
244
|
|
|
224
|
-
**Couche d'identité** : `@vantageos/cloud-identity` (^0.
|
|
245
|
+
**Couche d'identité** : `@vantageos/cloud-identity` (^0.17.0) est le seul paquet
|
|
225
246
|
d'identité. Son point d'entrée resource-server (`verifyMcpAccessToken`,
|
|
226
247
|
`buildProtectedResourceMetadata`) vérifie le jeton Bearer sur `/mcp` ; `jose` ne sert
|
|
227
248
|
plus qu'à signer les jetons d'accès du serveur d'autorisation. Un jeu de clés
|
package/README.md
CHANGED
|
@@ -118,6 +118,25 @@ log (names, never values). There is no auto-approve path.
|
|
|
118
118
|
with in-memory adapters for tests and single-process development. Use durable
|
|
119
119
|
adapters in production — contract in [`docs/oauth-stores.md`](docs/oauth-stores.md).
|
|
120
120
|
|
|
121
|
+
**Production guard**: with `NODE_ENV=production` the server refuses to start on
|
|
122
|
+
the in-memory stores (codes and registered clients are lost on restart and not
|
|
123
|
+
shared between instances) unless both durable adapters are injected or
|
|
124
|
+
`MCP_ALLOW_IN_MEMORY_STORES=1` is set — an explicit, logged opt-in for a
|
|
125
|
+
single-instance deployment. Behind a reverse proxy set `MCP_TRUSTED_PROXY_COUNT`
|
|
126
|
+
to the number of trusted proxies (default `0`): the `/register` rate limit then
|
|
127
|
+
keys on the proxy-appended `X-Forwarded-For` entry, never on the client-supplied
|
|
128
|
+
first one; with `0` it keys on the socket address and ignores the header.
|
|
129
|
+
|
|
130
|
+
**Request limits and CORS**: An operator variable that is set but invalid (for example `MCP_MAX_BODY_BYTES=abc`) stops the server at startup with the variable's name and the expected form, never a silent default; unset or empty keeps the default. the Node entry reads at most `MCP_MAX_BODY_BYTES`
|
|
131
|
+
(default 1 MiB) of a request body and answers `413` before any handler runs.
|
|
132
|
+
`/register` sends no CORS headers unless the browser origin is listed in
|
|
133
|
+
`MCP_REGISTER_CORS_ORIGINS` (comma-separated). `/mcp` requires the `mcp:full`
|
|
134
|
+
scope on the own-authorization-server tier (`403 insufficient_scope` otherwise).
|
|
135
|
+
The consent page shows the origin the code is sent to and the requested scope,
|
|
136
|
+
flags a Dynamic-Client-Registration client as unverified, and a denial redirects
|
|
137
|
+
to the client with `error=access_denied` (plus `state` and `iss`). `http:` issuer,
|
|
138
|
+
sign-in and callback URLs are refused outside loopback.
|
|
139
|
+
|
|
121
140
|
**Not included**: refresh tokens (access tokens last one hour), per-organisation
|
|
122
141
|
API keys for non-ChatGPT clients (this code never had that path; it is a gap, not
|
|
123
142
|
a regression), wildcard redirect URIs, and a token revocation list.
|
|
@@ -211,11 +230,11 @@ Publication is proven at the registry's `dist-tag`, never by the exit code of
|
|
|
211
230
|
negotiate the extension.
|
|
212
231
|
|
|
213
232
|
The widget (`src/ui/`) reuses a vendored `MosaicCard` family and is bundled to
|
|
214
|
-
one self-contained HTML file (`vite
|
|
233
|
+
one self-contained HTML file (an in-repo inlining plugin in `vite.config.ts`,
|
|
215
234
|
`dist/ui-bundle/widget.html`) by `npm run build:ui`, on a path that never
|
|
216
235
|
collides with tsc's `dist/ui/`.
|
|
217
236
|
|
|
218
|
-
**Identity layer**: `@vantageos/cloud-identity` (^0.
|
|
237
|
+
**Identity layer**: `@vantageos/cloud-identity` (^0.17.0) is the only identity
|
|
219
238
|
package. Its resource-server entry point (`verifyMcpAccessToken`,
|
|
220
239
|
`buildProtectedResourceMetadata`) verifies the Bearer token on `/mcp`; `jose` is
|
|
221
240
|
kept only to mint the authorization server's access tokens. An unreachable key set
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator knobs read from the environment. UNSET (or empty) means the default; a value that is
|
|
3
|
+
* SET and invalid throws at startup, naming the variable and what was expected. The value is
|
|
4
|
+
* never echoed (it may be a secret pasted into the wrong variable) and never replaced by the
|
|
5
|
+
* default: a silent fallback would run the server on a setting its operator did not choose.
|
|
6
|
+
*/
|
|
7
|
+
/** The raw value, or undefined when the variable is unset or empty. */
|
|
8
|
+
export declare function rawKnob(env: NodeJS.ProcessEnv, name: string): string | undefined;
|
|
9
|
+
/** A base-10 integer within [min, max]; undefined when unset. */
|
|
10
|
+
export declare function integerKnob(env: NodeJS.ProcessEnv, name: string, min: number, max?: number): number | undefined;
|
|
11
|
+
/** Exactly "0" or "1"; undefined when unset. */
|
|
12
|
+
export declare function flagKnob(env: NodeJS.ProcessEnv, name: string): boolean | undefined;
|
|
13
|
+
/** A comma-separated list of origins (`scheme://host[:port]`, http or https, no path). */
|
|
14
|
+
export declare function originListKnob(env: NodeJS.ProcessEnv, name: string): string[];
|
package/dist/envKnobs.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator knobs read from the environment. UNSET (or empty) means the default; a value that is
|
|
3
|
+
* SET and invalid throws at startup, naming the variable and what was expected. The value is
|
|
4
|
+
* never echoed (it may be a secret pasted into the wrong variable) and never replaced by the
|
|
5
|
+
* default: a silent fallback would run the server on a setting its operator did not choose.
|
|
6
|
+
*/
|
|
7
|
+
/** The raw value, or undefined when the variable is unset or empty. */
|
|
8
|
+
export function rawKnob(env, name) {
|
|
9
|
+
const raw = env[name];
|
|
10
|
+
return raw === undefined || raw === "" ? undefined : raw;
|
|
11
|
+
}
|
|
12
|
+
/** A base-10 integer within [min, max]; undefined when unset. */
|
|
13
|
+
export function integerKnob(env, name, min, max = Number.MAX_SAFE_INTEGER) {
|
|
14
|
+
const raw = rawKnob(env, name);
|
|
15
|
+
if (raw === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
const n = /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
|
|
18
|
+
if (!Number.isSafeInteger(n) || n < min || n > max) {
|
|
19
|
+
const range = max === Number.MAX_SAFE_INTEGER ? `at least ${min}` : `from ${min} to ${max}`;
|
|
20
|
+
throw new Error(`${name} is set but invalid: expected ${min > 0 ? "a positive" : "a non-negative"} integer ${range} (value not shown)`);
|
|
21
|
+
}
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
/** Exactly "0" or "1"; undefined when unset. */
|
|
25
|
+
export function flagKnob(env, name) {
|
|
26
|
+
const raw = rawKnob(env, name);
|
|
27
|
+
if (raw === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (raw !== "0" && raw !== "1") {
|
|
30
|
+
throw new Error(`${name} is set but invalid: expected "0" or "1" (value not shown)`);
|
|
31
|
+
}
|
|
32
|
+
return raw === "1";
|
|
33
|
+
}
|
|
34
|
+
/** A comma-separated list of origins (`scheme://host[:port]`, http or https, no path). */
|
|
35
|
+
export function originListKnob(env, name) {
|
|
36
|
+
const raw = rawKnob(env, name);
|
|
37
|
+
if (raw === undefined)
|
|
38
|
+
return [];
|
|
39
|
+
const origins = raw.split(",").map((entry) => entry.trim());
|
|
40
|
+
for (const entry of origins) {
|
|
41
|
+
let ok = false;
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(entry);
|
|
44
|
+
ok = (url.protocol === "https:" || url.protocol === "http:") && url.origin === entry;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
ok = false;
|
|
48
|
+
}
|
|
49
|
+
if (!ok) {
|
|
50
|
+
throw new Error(`${name} is set but invalid: expected a comma-separated list of origins like https://app.example.com (scheme, host, optional port; no path, no "*") (value not shown)`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return origins;
|
|
54
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadAuthConfig, loadAuthMode } from "./config.js";
|
|
2
2
|
import { buildHttpApp } from "./httpApp.js";
|
|
3
|
+
import { createNodeListener, maxBodyBytesFrom } from "./nodeAdapter.js";
|
|
3
4
|
const authMode = loadAuthMode();
|
|
4
5
|
const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
|
|
5
6
|
// OAuth tier: MCP_OAUTH_ISSUER + MCP_OAUTH_AUDIENCE select an EXTERNAL issuer (resource server
|
|
@@ -13,26 +14,9 @@ const app = buildHttpApp(authMode === "oauth"
|
|
|
13
14
|
: { authMode, authorizationServer: {}, uiDomain }
|
|
14
15
|
: { authMode, uiDomain });
|
|
15
16
|
const port = Number(process.env.PORT ?? 8787);
|
|
16
|
-
// Hono's fetch handler adapted onto Node's http server
|
|
17
|
-
//
|
|
17
|
+
// Hono's fetch handler adapted onto Node's http server, with the request-body cap
|
|
18
|
+
// (MCP_MAX_BODY_BYTES, default 1 MiB) enforced while the body streams in: src/nodeAdapter.ts.
|
|
18
19
|
const { createServer } = await import("node:http");
|
|
19
|
-
createServer(
|
|
20
|
-
const url = `http://${req.headers.host ?? "localhost"}${req.url}`;
|
|
21
|
-
const chunks = [];
|
|
22
|
-
for await (const chunk of req)
|
|
23
|
-
chunks.push(chunk);
|
|
24
|
-
const body = req.method === "GET" || req.method === "HEAD" ? undefined : Buffer.concat(chunks);
|
|
25
|
-
const headers = new Headers();
|
|
26
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
27
|
-
if (typeof value === "string")
|
|
28
|
-
headers.set(key, value);
|
|
29
|
-
}
|
|
30
|
-
const request = new Request(url, { method: req.method, headers, body });
|
|
31
|
-
const response = await app.fetch(request);
|
|
32
|
-
res.statusCode = response.status;
|
|
33
|
-
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
34
|
-
const buf = Buffer.from(await response.arrayBuffer());
|
|
35
|
-
res.end(buf);
|
|
36
|
-
}).listen(port, () => {
|
|
20
|
+
createServer(createNodeListener(app, { maxBodyBytes: maxBodyBytesFrom(process.env) })).listen(port, () => {
|
|
37
21
|
console.log(`mcp-boilerplate HTTP transport listening on :${port} (authMode: ${authMode})`);
|
|
38
22
|
});
|
package/dist/httpApp.js
CHANGED
|
@@ -23,11 +23,13 @@
|
|
|
23
23
|
* trusted (mirrors stdio's trust model). The DEFAULT when `buildHttpApp()` gets no deps.
|
|
24
24
|
*/
|
|
25
25
|
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
26
|
+
import { unauthorizedChallenge } from "@vantageos/cloud-identity";
|
|
26
27
|
import { Hono } from "hono";
|
|
27
28
|
import { authenticateRequest, protectedResourceMetadataDocument } from "./auth.js";
|
|
28
29
|
import { detectLocale, t } from "./i18n.js";
|
|
29
30
|
import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
|
|
30
31
|
import { createAuthorizationServer, } from "./oauth/authorizationServer.js";
|
|
32
|
+
import { MCP_SCOPE } from "./oauth/settings.js";
|
|
31
33
|
import { resolveTenantFromJwtPayload } from "./tenancy.js";
|
|
32
34
|
const PRM_PATHS = [
|
|
33
35
|
"/.well-known/oauth-protected-resource",
|
|
@@ -82,6 +84,21 @@ export function buildHttpApp(deps = {}) {
|
|
|
82
84
|
c.header("WWW-Authenticate", result.headers["WWW-Authenticate"]);
|
|
83
85
|
return c.json({ error: t(locale, "auth.unauthorized") }, result.status);
|
|
84
86
|
}
|
|
87
|
+
// A token must carry the scope this resource is protected by. Only the own-authorization-
|
|
88
|
+
// server tier knows its scope vocabulary; an external issuer's scopes are its own.
|
|
89
|
+
if (deps.authorizationServer) {
|
|
90
|
+
const granted = String(result.session.claims.scope ?? "").split(/\s+/);
|
|
91
|
+
if (!granted.includes(MCP_SCOPE)) {
|
|
92
|
+
const challenge = unauthorizedChallenge({
|
|
93
|
+
realm: rs.auth.realm,
|
|
94
|
+
resourceMetadataUrl: rs.auth.resourceMetadataUrl,
|
|
95
|
+
error: "insufficient_scope",
|
|
96
|
+
scope: MCP_SCOPE,
|
|
97
|
+
});
|
|
98
|
+
c.header("WWW-Authenticate", challenge.headers["WWW-Authenticate"]);
|
|
99
|
+
return c.json({ error: "insufficient_scope" }, 403);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
85
102
|
// Tenant resolution (tenancy.ts) is attempted here but is NOT request-fatal:
|
|
86
103
|
// `initialize`/`tools/list` and any tenant-agnostic tool (e.g. `ping`) must keep working
|
|
87
104
|
// for a verified caller with no organization on their token. Tenant-scoped tools enforce
|
package/dist/locales/en.json
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"oauth": {
|
|
13
13
|
"pickerTitle": "Connect {client}",
|
|
14
14
|
"pickerIntro": "{client} asks to act on your behalf. Choose the organisation it may reach, then approve or deny.",
|
|
15
|
+
"pickerDestination": "After approval you are sent to:",
|
|
16
|
+
"pickerScope": "Requested access:",
|
|
17
|
+
"pickerUnverified": "Unverified client: it registered itself automatically and nobody has vouched for it. Approve only if you recognise the address above.",
|
|
15
18
|
"pickerOrganisation": "Organisation",
|
|
16
19
|
"approve": "Approve",
|
|
17
20
|
"deny": "Deny",
|
package/dist/locales/fr.json
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"oauth": {
|
|
13
13
|
"pickerTitle": "Connecter {client}",
|
|
14
14
|
"pickerIntro": "{client} demande à agir en votre nom. Choisissez l'organisation à laquelle il peut accéder, puis approuvez ou refusez.",
|
|
15
|
+
"pickerDestination": "Redirection après validation vers :",
|
|
16
|
+
"pickerScope": "Accès demandé :",
|
|
17
|
+
"pickerUnverified": "Client non vérifié : il s'est enregistré automatiquement et personne ne s'en porte garant. N'approuvez que si vous reconnaissez l'adresse ci-dessus.",
|
|
15
18
|
"pickerOrganisation": "Organisation",
|
|
16
19
|
"approve": "Approuver",
|
|
17
20
|
"deny": "Refuser",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono's fetch handler adapted onto Node's `http` server (no framework beyond hono + node:http).
|
|
3
|
+
*
|
|
4
|
+
* The request body is read here, so the cap lives here: bytes are counted as they stream in and
|
|
5
|
+
* the read stops the moment the cap is crossed — an oversized body is answered 413 before any
|
|
6
|
+
* handler runs and is never held whole in memory.
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
/** 1 MiB: far above any MCP JSON-RPC request or OAuth form this server accepts. */
|
|
10
|
+
export declare const DEFAULT_MAX_BODY_BYTES = 1048576;
|
|
11
|
+
/** `MCP_MAX_BODY_BYTES`, a positive integer. Unset = default; set and invalid throws. */
|
|
12
|
+
export declare function maxBodyBytesFrom(env: NodeJS.ProcessEnv): number;
|
|
13
|
+
export interface FetchApp {
|
|
14
|
+
fetch(request: Request, bindings?: {
|
|
15
|
+
remoteAddress?: string;
|
|
16
|
+
}): Response | Promise<Response>;
|
|
17
|
+
}
|
|
18
|
+
export declare function createNodeListener(app: FetchApp, options?: {
|
|
19
|
+
maxBodyBytes?: number;
|
|
20
|
+
}): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono's fetch handler adapted onto Node's `http` server (no framework beyond hono + node:http).
|
|
3
|
+
*
|
|
4
|
+
* The request body is read here, so the cap lives here: bytes are counted as they stream in and
|
|
5
|
+
* the read stops the moment the cap is crossed — an oversized body is answered 413 before any
|
|
6
|
+
* handler runs and is never held whole in memory.
|
|
7
|
+
*/
|
|
8
|
+
import { integerKnob } from "./envKnobs.js";
|
|
9
|
+
/** 1 MiB: far above any MCP JSON-RPC request or OAuth form this server accepts. */
|
|
10
|
+
export const DEFAULT_MAX_BODY_BYTES = 1_048_576;
|
|
11
|
+
/** `MCP_MAX_BODY_BYTES`, a positive integer. Unset = default; set and invalid throws. */
|
|
12
|
+
export function maxBodyBytesFrom(env) {
|
|
13
|
+
return integerKnob(env, "MCP_MAX_BODY_BYTES", 1) ?? DEFAULT_MAX_BODY_BYTES;
|
|
14
|
+
}
|
|
15
|
+
function tooLarge(res, maxBodyBytes) {
|
|
16
|
+
res.writeHead(413, { "content-type": "application/json", connection: "close" });
|
|
17
|
+
res.end(JSON.stringify({
|
|
18
|
+
error: "payload_too_large",
|
|
19
|
+
error_description: `request body exceeds ${maxBodyBytes} bytes`,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
export function createNodeListener(app, options = {}) {
|
|
23
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
24
|
+
return async (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
27
|
+
let body;
|
|
28
|
+
if (hasBody) {
|
|
29
|
+
const declared = Number(req.headers["content-length"]);
|
|
30
|
+
if (Number.isFinite(declared) && declared > maxBodyBytes)
|
|
31
|
+
return tooLarge(res, maxBodyBytes);
|
|
32
|
+
const chunks = [];
|
|
33
|
+
let received = 0;
|
|
34
|
+
for await (const chunk of req) {
|
|
35
|
+
const piece = chunk;
|
|
36
|
+
received += piece.byteLength;
|
|
37
|
+
if (received > maxBodyBytes)
|
|
38
|
+
return tooLarge(res, maxBodyBytes);
|
|
39
|
+
chunks.push(piece);
|
|
40
|
+
}
|
|
41
|
+
body = new Uint8Array(Buffer.concat(chunks));
|
|
42
|
+
}
|
|
43
|
+
const headers = new Headers();
|
|
44
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
45
|
+
if (typeof value === "string")
|
|
46
|
+
headers.set(key, value);
|
|
47
|
+
}
|
|
48
|
+
const url = `http://${req.headers.host ?? "localhost"}${req.url}`;
|
|
49
|
+
const request = new Request(url, { method: req.method, headers, body });
|
|
50
|
+
const response = await app.fetch(request, { remoteAddress: req.socket.remoteAddress });
|
|
51
|
+
res.statusCode = response.status;
|
|
52
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
53
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.error("[http] request failed:", err instanceof Error ? err.message : err);
|
|
57
|
+
if (!res.headersSent) {
|
|
58
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
59
|
+
}
|
|
60
|
+
res.end(JSON.stringify({ error: "server_error" }));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* No refresh tokens are issued; access tokens live one hour.
|
|
22
22
|
*/
|
|
23
23
|
import { type AuthorizationCodeStore } from "@vantageos/cloud-identity";
|
|
24
|
-
import type { Hono } from "hono";
|
|
24
|
+
import type { Context, Hono } from "hono";
|
|
25
25
|
import type { AuthConfig } from "../auth.js";
|
|
26
26
|
import { type ClerkCollaborators } from "./clerk.js";
|
|
27
27
|
import { type CimdOptions, type ClientRegistry } from "./clientRegistry.js";
|
|
@@ -52,5 +52,19 @@ export interface AuthorizationServer {
|
|
|
52
52
|
/** The resource-server (`/mcp` gate) configuration derived from the same settings. */
|
|
53
53
|
resourceServer(): Promise<ResourceServerConfig>;
|
|
54
54
|
}
|
|
55
|
+
/** Bindings the Node entry (src/http.ts) hands to `app.fetch(request, bindings)`. */
|
|
56
|
+
export interface NodeBindings {
|
|
57
|
+
/** Address of the TCP peer, from the socket. */
|
|
58
|
+
remoteAddress?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The rate-limit key of a request: who is really on the other end.
|
|
62
|
+
*
|
|
63
|
+
* `X-Forwarded-For` is client-controlled up to the point a trusted proxy appends to it. With
|
|
64
|
+
* N trusted proxies the real client is the N-th entry from the END (each proxy appended the
|
|
65
|
+
* peer it saw); anything left of it was supplied by the client and is ignored. With none
|
|
66
|
+
* configured the header is not read at all and the socket address decides.
|
|
67
|
+
*/
|
|
68
|
+
export declare function clientKeyOf(c: Context, trustedProxyCount: number): string;
|
|
55
69
|
export declare function createAuthorizationServer(deps?: AuthorizationServerDeps): AuthorizationServer;
|
|
56
70
|
export {};
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resumeAuthorize, startAuthorize, } from "@vantageos/cloud-identity";
|
|
24
24
|
import { getCookie } from "hono/cookie";
|
|
25
25
|
import { cors } from "hono/cors";
|
|
26
|
+
import { flagKnob, integerKnob, originListKnob } from "../envKnobs.js";
|
|
26
27
|
import { detectLocaleFromAcceptLanguage, t } from "../i18n.js";
|
|
27
28
|
import { createClerkBackendCollaborators } from "./clerk.js";
|
|
28
29
|
import { createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
|
|
@@ -36,6 +37,26 @@ function noStore(c) {
|
|
|
36
37
|
c.header("Cache-Control", "no-store");
|
|
37
38
|
c.header("Pragma", "no-cache");
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The rate-limit key of a request: who is really on the other end.
|
|
42
|
+
*
|
|
43
|
+
* `X-Forwarded-For` is client-controlled up to the point a trusted proxy appends to it. With
|
|
44
|
+
* N trusted proxies the real client is the N-th entry from the END (each proxy appended the
|
|
45
|
+
* peer it saw); anything left of it was supplied by the client and is ignored. With none
|
|
46
|
+
* configured the header is not read at all and the socket address decides.
|
|
47
|
+
*/
|
|
48
|
+
export function clientKeyOf(c, trustedProxyCount) {
|
|
49
|
+
const socket = c.env?.remoteAddress || "unknown";
|
|
50
|
+
if (trustedProxyCount <= 0)
|
|
51
|
+
return socket;
|
|
52
|
+
const chain = (c.req.header("x-forwarded-for") ?? "")
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((entry) => entry.trim())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
return chain.length >= trustedProxyCount
|
|
57
|
+
? (chain[chain.length - trustedProxyCount] ?? socket)
|
|
58
|
+
: socket;
|
|
59
|
+
}
|
|
39
60
|
function sessionTokenFrom(c) {
|
|
40
61
|
const header = c.req.header("authorization");
|
|
41
62
|
if (header?.toLowerCase().startsWith("bearer ")) {
|
|
@@ -47,7 +68,20 @@ function sessionTokenFrom(c) {
|
|
|
47
68
|
}
|
|
48
69
|
export function createAuthorizationServer(deps = {}) {
|
|
49
70
|
const env = deps.env ?? process.env;
|
|
71
|
+
// Operator knobs are validated here, at construction: a bad value stops the boot.
|
|
72
|
+
const trustedProxyCount = integerKnob(env, OAUTH_ENV.trustedProxyCount, 0, 10) ?? 0;
|
|
73
|
+
const registerOrigins = originListKnob(env, OAUTH_ENV.registerCorsOrigins);
|
|
74
|
+
const allowInMemory = flagKnob(env, OAUTH_ENV.allowInMemoryStores) === true;
|
|
50
75
|
const now = deps.now ?? (() => Date.now());
|
|
76
|
+
// Fail at boot, loudly, rather than lose every code and client on the first restart.
|
|
77
|
+
if (!deps.codeStore || !deps.clientRegistry) {
|
|
78
|
+
if (env.NODE_ENV === "production") {
|
|
79
|
+
if (!allowInMemory) {
|
|
80
|
+
throw new Error(`NODE_ENV=production refuses the in-memory OAuth stores (codes and registered clients are lost on restart and not shared between instances). Inject durable adapters (docs/oauth-stores.md), or set ${OAUTH_ENV.allowInMemoryStores}=1 to accept a single-instance deployment that forgets them on restart.`);
|
|
81
|
+
}
|
|
82
|
+
console.warn(`[oauth] running on in-memory stores in production (${OAUTH_ENV.allowInMemoryStores}=1): single instance only, state is lost on restart`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
51
85
|
const codeStore = deps.codeStore ?? createInMemoryCodeStore();
|
|
52
86
|
const registry = deps.clientRegistry ?? createInMemoryClientRegistry();
|
|
53
87
|
const injectedClerk = deps.clerk;
|
|
@@ -134,7 +168,10 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
134
168
|
case "org-picker": {
|
|
135
169
|
c.header("Content-Security-Policy", await pickerContentSecurityPolicy());
|
|
136
170
|
const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
137
|
-
return c.html(renderOrgPicker(outcome.model, locale
|
|
171
|
+
return c.html(renderOrgPicker(outcome.model, locale, {
|
|
172
|
+
// Only a URL-shaped (CIMD) client_id is bound to a domain its owner controls.
|
|
173
|
+
unverified: !isUrlShapedClientId(outcome.model.client.clientId),
|
|
174
|
+
}));
|
|
138
175
|
}
|
|
139
176
|
case "refused": {
|
|
140
177
|
const mapped = oauthErrorFor(outcome.refusal);
|
|
@@ -178,7 +215,9 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
178
215
|
},
|
|
179
216
|
mount(app) {
|
|
180
217
|
app.use("/.well-known/*", cors());
|
|
181
|
-
|
|
218
|
+
// /register is for native and server-side clients, which send no Origin. A browser origin
|
|
219
|
+
// may call it only when the operator lists it.
|
|
220
|
+
app.use("/register", cors({ origin: (origin) => (registerOrigins.includes(origin) ? origin : null) }));
|
|
182
221
|
app.use("/token", cors());
|
|
183
222
|
const metadata = async (c) => {
|
|
184
223
|
const result = await settingsNow();
|
|
@@ -216,7 +255,7 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
216
255
|
return c.json((await issuerFor(result.settings)).jwks);
|
|
217
256
|
});
|
|
218
257
|
app.post("/register", async (c) => {
|
|
219
|
-
const key = (c
|
|
258
|
+
const key = clientKeyOf(c, trustedProxyCount);
|
|
220
259
|
if (!registerAllowed(key)) {
|
|
221
260
|
c.header("Retry-After", "60");
|
|
222
261
|
return c.json({ error: "too_many_requests" }, 429);
|
|
@@ -308,12 +347,14 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
308
347
|
return c.json({ error: "invalid_request", error_description: "unreadable form" }, 400);
|
|
309
348
|
}
|
|
310
349
|
const field = (name) => typeof form[name] === "string" ? form[name] : undefined;
|
|
350
|
+
const { cfg, authDeps } = runtimeFor(result.settings);
|
|
351
|
+
// A denial is the package's `denied` outcome: it verifies state, expiry, client and
|
|
352
|
+
// session, then answers the redirect-to-client with error=access_denied and the
|
|
353
|
+
// client's state. `respond` appends `iss` to it like to every authorization response.
|
|
311
354
|
if (field("approved") === "false") {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
return c.json({ error: "access_denied", error_description: t(locale, "oauth.denied") }, 403);
|
|
355
|
+
const denied = await resumeAuthorize({ state: field("state") ?? "", sessionToken: sessionTokenFrom(c), denied: true }, cfg, authDeps);
|
|
356
|
+
return respond(c, denied, result.settings);
|
|
315
357
|
}
|
|
316
|
-
const { cfg, authDeps } = runtimeFor(result.settings);
|
|
317
358
|
const outcome = await resumeAuthorize({
|
|
318
359
|
state: field("state") ?? "",
|
|
319
360
|
sessionToken: sessionTokenFrom(c),
|
package/dist/oauth/clerk.d.ts
CHANGED
|
@@ -12,4 +12,8 @@ export interface ClerkCollaborators {
|
|
|
12
12
|
listMemberships(userId: string): Promise<readonly ClerkOrgMembership[]>;
|
|
13
13
|
getUser(userId: string): Promise<ClerkUserLike>;
|
|
14
14
|
}
|
|
15
|
-
export
|
|
15
|
+
export interface ClerkBackendOptions {
|
|
16
|
+
/** Per-call time budget (JWKS, memberships, user). Default 5 s. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function createClerkBackendCollaborators(secretKey: string, fetchImpl?: typeof fetch, options?: ClerkBackendOptions): ClerkCollaborators;
|
package/dist/oauth/clerk.js
CHANGED
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
const CLERK_API = "https://api.clerk.com/v1";
|
|
10
10
|
const JWKS_CACHE_MS = 10 * 60 * 1000;
|
|
11
11
|
const MEMBERSHIP_PAGE_LIMIT = 100;
|
|
12
|
-
|
|
12
|
+
/** 50 pages x 100 = 5 000 memberships: past it the lookup FAILS rather than truncate. */
|
|
13
|
+
const MEMBERSHIP_MAX_PAGES = 50;
|
|
14
|
+
const CLERK_TIMEOUT_MS = 5000;
|
|
15
|
+
export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, options = {}) {
|
|
16
|
+
const timeoutMs = options.timeoutMs ?? CLERK_TIMEOUT_MS;
|
|
13
17
|
let cachedJwks = null;
|
|
14
18
|
const api = async (path) => {
|
|
15
19
|
const res = await fetchImpl(`${CLERK_API}${path}`, {
|
|
16
20
|
headers: { Authorization: `Bearer ${secretKey}`, Accept: "application/json" },
|
|
21
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
17
22
|
});
|
|
18
23
|
if (!res.ok)
|
|
19
24
|
throw new Error(`Clerk Backend API answered ${res.status}`);
|
|
@@ -25,7 +30,9 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch) {
|
|
|
25
30
|
if (cachedJwks && cachedJwks.issuer === issuer && now - cachedJwks.at < JWKS_CACHE_MS) {
|
|
26
31
|
return cachedJwks.keys;
|
|
27
32
|
}
|
|
28
|
-
const res = await fetchImpl(`${issuer}/.well-known/jwks.json
|
|
33
|
+
const res = await fetchImpl(`${issuer}/.well-known/jwks.json`, {
|
|
34
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
35
|
+
});
|
|
29
36
|
if (!res.ok)
|
|
30
37
|
throw new Error(`Clerk JWKS answered ${res.status}`);
|
|
31
38
|
const keys = (await res.json());
|
|
@@ -33,15 +40,26 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch) {
|
|
|
33
40
|
return keys;
|
|
34
41
|
},
|
|
35
42
|
async listMemberships(userId) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const all = [];
|
|
44
|
+
for (let page = 0; page < MEMBERSHIP_MAX_PAGES; page++) {
|
|
45
|
+
const offset = page * MEMBERSHIP_PAGE_LIMIT;
|
|
46
|
+
const body = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}&offset=${offset}`);
|
|
47
|
+
all.push(...body.data);
|
|
48
|
+
const last = body.data.length < MEMBERSHIP_PAGE_LIMIT ||
|
|
49
|
+
(typeof body.total_count === "number" && all.length >= body.total_count);
|
|
50
|
+
if (last) {
|
|
51
|
+
return all.map((m) => ({
|
|
52
|
+
role: m.role,
|
|
53
|
+
organization: {
|
|
54
|
+
id: m.organization.id,
|
|
55
|
+
slug: m.organization.slug ?? null,
|
|
56
|
+
name: m.organization.name,
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// A truncated list would hide organisations the person belongs to: refuse instead.
|
|
62
|
+
throw new Error(`Clerk memberships exceed ${MEMBERSHIP_MAX_PAGES * MEMBERSHIP_PAGE_LIMIT}`);
|
|
45
63
|
},
|
|
46
64
|
async getUser(userId) {
|
|
47
65
|
const user = await api(`/users/${encodeURIComponent(userId)}`);
|