@vantageos/mcp-boilerplate 0.2.2 → 0.2.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vantage-mcp-boilerplate",
3
3
  "displayName": "VantageOS MCP Boilerplate",
4
- "version": "0.2.2",
4
+ "version": "0.2.3",
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,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.3] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - `/authorize/callback` resolves the person's Clerk session with Clerk's own handshake
15
+ (`@vantageos/cloud-identity` `^0.19.0`, `resolveClerkSessionFromRequest`, built on
16
+ `@clerk/backend` `authenticateRequest()`). A return from Clerk's hosted sign-in with no session
17
+ on this host used to answer `403 session-required`; it now redirects through the handshake and
18
+ continues. An explicit Bearer token and injected Clerk collaborators keep their path. The
19
+ publishable key is `CLERK_PUBLISHABLE_KEY` or derived from `CLERK_DOMAIN`.
20
+ - README section "Sign-in with ChatGPT and Claude" (EN + FR): the production instance on the
21
+ server's own registrable domain, the three URLs including `AUTHORIZE_SIGN_IN_URL`, why no
22
+ redirect URL is registered, development-instance limits, how the session arrives, what is and
23
+ is not yet measured.
24
+ - The handshake redirect carries `Cache-Control: no-store`, and a forged or stale
25
+ `__clerk_handshake` continues signed out instead of answering 500.
26
+ - Boot warning (EN + FR) when the Clerk instance is a development one (`sk_test_` key or an
27
+ `accounts.dev` domain) and `PUBLIC_BASE_URL` is not localhost: it names the Home URL
28
+ requirement. A warning, never a refusal.
29
+
30
+ ### Fixed
31
+
32
+ - The Node adapter emitted only the last `Set-Cookie` of a response. A response with N cookies
33
+ now yields N (Clerk's handshake return sets five).
34
+ - The deny route (`POST /authorize/org` with `approved=false`) forwards `consentToken`;
35
+ cloud-identity 0.18 and later verify it on a denial too.
36
+
37
+ - CIMD: a Client ID Metadata Document that declares `token_endpoint_auth_method` other than
38
+ `none` (for example ChatGPT's `private_key_jwt`) is now accepted when its
39
+ `token_endpoint_auth_methods_supported` includes `none`. A document with neither is still
40
+ refused. All other checks (client_id equality, https, SSRF guard, size cap, redirect_uris)
41
+ are unchanged. `/authorize` no longer answers `unknown-client` for ChatGPT.
42
+ - `/token` verifies a `private_key_jwt` client assertion instead of ignoring it: keys from the
43
+ CIMD document's `jwks_uri` (same SSRF-guarded fetch), RS256 only, `iss` = `sub` = `client_id`,
44
+ `aud` = the token endpoint or the issuer, `exp` required (30 s skew), `jti` required and
45
+ accepted once (bounded in-memory set, fail closed when full). Any failure answers 401
46
+ `invalid_client`. A request without an assertion keeps the public-client path (`client_id` +
47
+ PKCE). AS metadata advertises `["none","private_key_jwt"]` and RS256.
48
+
10
49
  ## [0.2.2] - 2026-10-10
11
50
 
12
51
  ### Security
package/README.fr.md CHANGED
@@ -148,6 +148,53 @@ clés d'API par organisation pour les clients hors ChatGPT (ce code n'a jamais e
148
148
  chemin ; c'est un manque, pas une régression), URI de redirection à joker, et liste
149
149
  de révocation des jetons.
150
150
 
151
+ ### Connexion avec ChatGPT et Claude
152
+
153
+ ChatGPT et Claude envoient la personne sur le `/authorize` de ce serveur, qui l'envoie vers la
154
+ page de connexion hébergée par Clerk puis la ramène sur `/authorize/callback`. Pour que cela
155
+ fonctionne sur un serveur que vous hébergez, dans cet ordre :
156
+
157
+ 1. **Utilisez une instance Clerk de PRODUCTION sur le domaine enregistrable du serveur.** Un
158
+ serveur sur `mcp.example.com` prend le domaine Clerk `example.com` : Clerk sert alors
159
+ `clerk.example.com` et `accounts.example.com`. Tout autre domaine relève d'une configuration
160
+ satellite, non mesurée. Ajoutez les CNAME DNS listés par le tableau de bord Clerk. Créez votre propre client OAuth Google
161
+ avec l'URI de redirection autorisée `https://clerk.<domaine>/v1/oauth_callback`, renseignez
162
+ son identifiant et son secret dans le tableau de bord Clerk, et publiez l'écran de
163
+ consentement Google avec une page d'accueil d'application et une politique de
164
+ confidentialité (un écran non publié n'admet que les utilisateurs de test).
165
+ 2. **Réglez les trois URL.** `PUBLIC_BASE_URL` = `https://<serveur>` ; `AUTHORIZE_CALLBACK_URL`
166
+ = `https://<serveur>/authorize/callback` ; `AUTHORIZE_SIGN_IN_URL` = la page de connexion
167
+ hébergée de l'instance : `https://accounts.<domaine>/sign-in` en production,
168
+ `https://<instance>.accounts.dev/sign-in` en développement (l'URL de l'Account Portal,
169
+ affichée dans le tableau de bord Clerk sous **Account Portal**). Ce parcours n'a aucune
170
+ « URL de redirection autorisée » à remplir dans le tableau de bord : la page hébergée ne
171
+ renvoie le navigateur que vers un hôte du domaine de l'instance (production, selon la
172
+ documentation Clerk, pas encore mesuré) ou vers `localhost` / la Home URL (développement,
173
+ mesuré). C'est pourquoi l'étape 1 met le serveur et l'instance sur un même domaine.
174
+ *Mesuré* : la liste `redirect_urls` de l'API Backend n'est pas consultée par les pages
175
+ hébergées.
176
+ 3. **Une instance de développement sert uniquement en local ou en test.** Sa page de connexion
177
+ hébergée ne redirige que vers `localhost` ou vers l'URL d'accueil (Home URL) de l'instance.
178
+ Pour un test distant, réglez l'origine de développement / Home URL de l'instance de
179
+ développement sur l'origine du serveur dans le tableau de bord Clerk, faites le test, puis
180
+ rétablissez-la.
181
+ 4. **Comment la session arrive au serveur.** Après la connexion, le navigateur revient sur
182
+ `/authorize/callback` sans session sur votre hôte. Le serveur la résout avec la poignée de
183
+ main (handshake) propre à Clerk (`@vantageos/cloud-identity` 0.19.0, fondé sur
184
+ `authenticateRequest()` de `@clerk/backend`) : il redirige le navigateur par le point
185
+ d'entrée handshake de Clerk, et Clerk pose `__session` sur votre hôte. Il faut
186
+ `CLERK_SECRET_KEY` et la clé publiable : `CLERK_PUBLISHABLE_KEY`, ou, à défaut, dérivée de
187
+ `CLERK_DOMAIN` sous la forme `pk_test_` / `pk_live_` (selon la clé secrète) +
188
+ base64(`<hôte de l'API Frontend>$`). *Mesuré* : une instance de développement avec un serveur
189
+ distant fonctionne dès que sa Home URL pointe sur le serveur. *Pas encore mesuré* : une
190
+ instance de production sur un domaine enregistrable différent de celui du serveur.
191
+ 5. **Avertissement au démarrage.** Quand l'instance configurée est de développement (clé
192
+ `sk_test_`, ou `CLERK_DOMAIN` sous `accounts.dev`) et que `PUBLIC_BASE_URL` n'est pas
193
+ localhost, le serveur écrit un avertissement au démarrage, en anglais et en français, qui
194
+ nomme l'exigence de Home URL. C'est un avertissement, jamais un refus : il signifie « une
195
+ connexion distante ne marchera que si la Home URL pointe sur ce serveur, ou si vous passez à
196
+ une instance de production ». Il ne contient ni clé ni secret.
197
+
151
198
  ### Multi-tenance
152
199
 
153
200
  `list_items` filtre les lignes par leur `orgId` stocké face à l'organisation
@@ -242,7 +289,7 @@ en un seul fichier HTML autonome (un plugin d'inlining local dans `vite.config.t
242
289
  `dist/ui-bundle/widget.html`) par `npm run build:ui`, sur un chemin qui n'entre
243
290
  jamais en collision avec `dist/ui/` de tsc.
244
291
 
245
- **Couche d'identité** : `@vantageos/cloud-identity` (^0.17.0) est le seul paquet
292
+ **Couche d'identité** : `@vantageos/cloud-identity` (^0.19.0) est le seul paquet
246
293
  d'identité. Son point d'entrée resource-server (`verifyMcpAccessToken`,
247
294
  `buildProtectedResourceMetadata`) vérifie le jeton Bearer sur `/mcp` ; `jose` ne sert
248
295
  plus qu'à signer les jetons d'accès du serveur d'autorisation. Un jeu de clés
package/README.md CHANGED
@@ -141,6 +141,49 @@ sign-in and callback URLs are refused outside loopback.
141
141
  API keys for non-ChatGPT clients (this code never had that path; it is a gap, not
142
142
  a regression), wildcard redirect URIs, and a token revocation list.
143
143
 
144
+ ### Sign-in with ChatGPT and Claude
145
+
146
+ ChatGPT and Claude send the person to this server's `/authorize`, which sends them to
147
+ Clerk's hosted sign-in and takes them back to `/authorize/callback`. To make that work on a
148
+ server you host, in this order:
149
+
150
+ 1. **Use a Clerk PRODUCTION instance on the server's own registrable domain.** A server at
151
+ `mcp.example.com` takes the Clerk domain `example.com`, so Clerk serves `clerk.example.com`
152
+ and `accounts.example.com`. Any other domain is a satellite setup and is not measured. Add
153
+ the DNS CNAMEs the Clerk dashboard lists. Create your own Google OAuth client with the authorized redirect URI
154
+ `https://clerk.<domain>/v1/oauth_callback`, put its id and secret in the Clerk dashboard, and
155
+ publish the Google consent screen with an application home page and a privacy policy (an
156
+ unpublished consent screen only lets test users in).
157
+ 2. **Set the three URLs.** `PUBLIC_BASE_URL` = `https://<server>`; `AUTHORIZE_CALLBACK_URL` =
158
+ `https://<server>/authorize/callback`; `AUTHORIZE_SIGN_IN_URL` = the instance's hosted
159
+ sign-in page: `https://accounts.<domain>/sign-in` on production,
160
+ `https://<instance>.accounts.dev/sign-in` on development (the Account Portal URL, shown in
161
+ the Clerk dashboard under **Account Portal**). There is no "allowed redirect URL" to fill in
162
+ the dashboard for this flow: the hosted page sends the browser back only to a host under
163
+ the instance's own domain (production, per Clerk's documentation, not yet measured) or to
164
+ `localhost` / the Home URL (development, measured). This is why step 1 puts the server and
165
+ the instance on one domain. *Measured*: the Backend API `redirect_urls` list is not
166
+ consulted by the hosted pages.
167
+ 3. **A development instance is for local or test use only.** Its hosted sign-in page redirects
168
+ only to `localhost` or to the instance Home URL. For a remote test, set the development
169
+ instance's development origin / Home URL to the server origin in the Clerk dashboard, run
170
+ the test, and revert it afterwards.
171
+ 4. **How the session reaches the server.** After sign-in the browser comes back to
172
+ `/authorize/callback` without a session on your host. The server resolves it with Clerk's
173
+ own handshake (`@vantageos/cloud-identity` 0.19.0, built on `@clerk/backend`
174
+ `authenticateRequest()`): it redirects the browser through Clerk's handshake endpoint and
175
+ Clerk sets `__session` on your host. It needs `CLERK_SECRET_KEY` and the publishable key:
176
+ `CLERK_PUBLISHABLE_KEY`, or, when unset, derived from `CLERK_DOMAIN` as
177
+ `pk_test_` / `pk_live_` (following the secret key) + base64(`<frontend API host>$`).
178
+ *Measured*: a development instance with a remote server works once its Home URL points at
179
+ the server. *Not yet measured*: a production instance on a registrable domain different
180
+ from the server's.
181
+ 5. **Boot warning.** When the configured instance is a development one (an `sk_test_` key, or
182
+ a `CLERK_DOMAIN` under `accounts.dev`) and `PUBLIC_BASE_URL` is not localhost, the server
183
+ logs one warning at boot, in English and French, naming the Home URL requirement. It is a
184
+ warning, never a refusal: it means "a remote sign-in will only work if the Home URL points
185
+ at this server, or if you move to a production instance". It carries no key or secret.
186
+
144
187
  ### Multi-tenancy
145
188
 
146
189
  `list_items` filters rows by their stored `orgId` against the caller's verified
@@ -234,7 +277,7 @@ one self-contained HTML file (an in-repo inlining plugin in `vite.config.ts`,
234
277
  `dist/ui-bundle/widget.html`) by `npm run build:ui`, on a path that never
235
278
  collides with tsc's `dist/ui/`.
236
279
 
237
- **Identity layer**: `@vantageos/cloud-identity` (^0.17.0) is the only identity
280
+ **Identity layer**: `@vantageos/cloud-identity` (^0.19.0) is the only identity
238
281
  package. Its resource-server entry point (`verifyMcpAccessToken`,
239
282
  `buildProtectedResourceMetadata`) verifies the Bearer token on `/mcp`; `jose` is
240
283
  kept only to mint the authorization server's access tokens. An unreachable key set
@@ -19,7 +19,8 @@
19
19
  "approve": "Approve",
20
20
  "deny": "Deny",
21
21
  "denied": "The request was denied; you can close this window.",
22
- "unavailable": "Authorization is not configured on this server."
22
+ "unavailable": "Authorization is not configured on this server.",
23
+ "devInstanceRemote": "Development Clerk instance on a remote server: its hosted sign-in page redirects only to localhost or to the instance Home URL. Set the development instance's Home URL (development origin) to this server's origin for a remote test and revert it afterwards, or use a Clerk production instance (see README, \"Sign-in with ChatGPT and Claude\")."
23
24
  }
24
25
  }
25
26
  }
@@ -19,7 +19,8 @@
19
19
  "approve": "Approuver",
20
20
  "deny": "Refuser",
21
21
  "denied": "La demande a été refusée ; vous pouvez fermer cette fenêtre.",
22
- "unavailable": "L'autorisation n'est pas configurée sur ce serveur."
22
+ "unavailable": "L'autorisation n'est pas configurée sur ce serveur.",
23
+ "devInstanceRemote": "Instance Clerk de développement sur un serveur distant : sa page de connexion hébergée ne redirige que vers localhost ou vers l'URL d'accueil (Home URL) de l'instance. Pour un test distant, réglez l'URL d'accueil (origine de développement) de l'instance sur l'origine de ce serveur, puis rétablissez-la ; sinon utilisez une instance Clerk de production (voir README, « Connexion avec ChatGPT et Claude »)."
23
24
  }
24
25
  }
25
26
  }
@@ -49,7 +49,15 @@ export function createNodeListener(app, options = {}) {
49
49
  const request = new Request(url, { method: req.method, headers, body });
50
50
  const response = await app.fetch(request, { remoteAddress: req.socket.remoteAddress });
51
51
  res.statusCode = response.status;
52
- response.headers.forEach((value, key) => res.setHeader(key, value));
52
+ // `forEach` hands back Set-Cookie folded into one value, and setHeader overwrites: several
53
+ // cookies (Clerk's handshake sets five) would collapse to the last. Emit them as an array.
54
+ const cookies = response.headers.getSetCookie();
55
+ response.headers.forEach((value, key) => {
56
+ if (key.toLowerCase() !== "set-cookie")
57
+ res.setHeader(key, value);
58
+ });
59
+ if (cookies.length > 0)
60
+ res.setHeader("Set-Cookie", cookies);
53
61
  res.end(Buffer.from(await response.arrayBuffer()));
54
62
  }
55
63
  catch (err) {
@@ -66,5 +66,11 @@ export interface NodeBindings {
66
66
  * configured the header is not read at all and the socket address decides.
67
67
  */
68
68
  export declare function clientKeyOf(c: Context, trustedProxyCount: number): string;
69
+ /**
70
+ * The instance publishable key: `CLERK_PUBLISHABLE_KEY` when set, else derived from the Frontend
71
+ * API host in `CLERK_DOMAIN` as `pk_<test|live>_` + base64(host + "$"), the format Clerk documents
72
+ * (the prefix follows the secret key's `sk_live_` / `sk_test_`).
73
+ */
74
+ export declare function publishableKeyFor(env: Record<string, string | undefined>, clerkIssuer: string): string;
69
75
  export declare function createAuthorizationServer(deps?: AuthorizationServerDeps): AuthorizationServer;
70
76
  export {};
@@ -20,16 +20,17 @@
20
20
  * FAIL-CLOSED: any missing configuration value answers 503 `temporarily_unavailable`.
21
21
  * No refresh tokens are issued; access tokens live one hour.
22
22
  */
23
- import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resumeAuthorize, startAuthorize, } from "@vantageos/cloud-identity";
23
+ import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resumeAuthorize, startAuthorize, resolveClerkSessionFromRequest, } from "@vantageos/cloud-identity";
24
24
  import { getCookie } from "hono/cookie";
25
25
  import { cors } from "hono/cors";
26
26
  import { flagKnob, integerKnob, originListKnob } from "../envKnobs.js";
27
27
  import { detectLocaleFromAcceptLanguage, t } from "../i18n.js";
28
28
  import { createClerkBackendCollaborators } from "./clerk.js";
29
+ import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier } from "./clientAssertion.js";
29
30
  import { createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
30
31
  import { createInMemoryCodeStore } from "./codeStore.js";
31
32
  import { pickerContentSecurityPolicy, renderOrgPicker } from "./picker.js";
32
- import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, readAuthorizeSettings, } from "./settings.js";
33
+ import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, isDevClerkInstanceOnRemoteServer, readAuthorizeSettings, } from "./settings.js";
33
34
  import { ACCESS_TOKEN_TTL_SECONDS, createTokenIssuer } from "./tokenIssuer.js";
34
35
  const REGISTER_LIMIT_PER_MINUTE = 20;
35
36
  const MAX_REDIRECT_URIS = 10;
@@ -57,6 +58,19 @@ export function clientKeyOf(c, trustedProxyCount) {
57
58
  ? (chain[chain.length - trustedProxyCount] ?? socket)
58
59
  : socket;
59
60
  }
61
+ /**
62
+ * The instance publishable key: `CLERK_PUBLISHABLE_KEY` when set, else derived from the Frontend
63
+ * API host in `CLERK_DOMAIN` as `pk_<test|live>_` + base64(host + "$"), the format Clerk documents
64
+ * (the prefix follows the secret key's `sk_live_` / `sk_test_`).
65
+ */
66
+ export function publishableKeyFor(env, clerkIssuer) {
67
+ const explicit = env.CLERK_PUBLISHABLE_KEY;
68
+ if (explicit)
69
+ return explicit;
70
+ const live = (env[OAUTH_ENV.clerkSecretKey] ?? "").startsWith("sk_live_");
71
+ const host = new URL(clerkIssuer).host;
72
+ return `pk_${live ? "live" : "test"}_${Buffer.from(`${host}$`).toString("base64")}`;
73
+ }
60
74
  function sessionTokenFrom(c) {
61
75
  const header = c.req.header("authorization");
62
76
  if (header?.toLowerCase().startsWith("bearer ")) {
@@ -82,6 +96,11 @@ export function createAuthorizationServer(deps = {}) {
82
96
  console.warn(`[oauth] running on in-memory stores in production (${OAUTH_ENV.allowInMemoryStores}=1): single instance only, state is lost on restart`);
83
97
  }
84
98
  }
99
+ // A warning, never a refusal: a development Clerk instance is legitimate for a remote test once
100
+ // its Home URL points at this server. English then French, the boot has no request locale.
101
+ if (isDevClerkInstanceOnRemoteServer(env)) {
102
+ console.warn(`[oauth] ${t("en", "oauth.devInstanceRemote")}\n[oauth] ${t("fr", "oauth.devInstanceRemote")}`);
103
+ }
85
104
  const codeStore = deps.codeStore ?? createInMemoryCodeStore();
86
105
  const registry = deps.clientRegistry ?? createInMemoryClientRegistry();
87
106
  const injectedClerk = deps.clerk;
@@ -102,6 +121,11 @@ export function createAuthorizationServer(deps = {}) {
102
121
  getUser: injectedClerk?.getUser ?? backendClerk.getUser,
103
122
  });
104
123
  const settingsNow = () => readAuthorizeSettings(env, clerkInjected);
124
+ const assertionVerifier = createClientAssertionVerifier({
125
+ fetchImpl: deps.fetchImpl,
126
+ resolveHost: deps.resolveHost,
127
+ now,
128
+ });
105
129
  const lookupClient = async (clientId) => {
106
130
  if (isUrlShapedClientId(clientId)) {
107
131
  return resolveCimdClient(clientId, {
@@ -239,7 +263,8 @@ export function createAuthorizationServer(deps = {}) {
239
263
  }
240
264
  return c.json({
241
265
  ...doc.document,
242
- token_endpoint_auth_methods_supported: ["none"],
266
+ token_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
267
+ token_endpoint_auth_signing_alg_values_supported: [...ASSERTION_SIGNING_ALGS],
243
268
  // RFC 9207 — /authorize appends `iss` to every authorization response.
244
269
  authorization_response_iss_parameter_supported: true,
245
270
  // Client ID Metadata Documents are fetched SSRF-guarded (clientRegistry.ts).
@@ -330,7 +355,38 @@ export function createAuthorizationServer(deps = {}) {
330
355
  if (!result.ok)
331
356
  return unavailable(c, result.missing);
332
357
  const { cfg, authDeps } = runtimeFor(result.settings);
333
- const outcome = await resumeAuthorize({ state: c.req.query("authorize_state") ?? "", sessionToken: sessionTokenFrom(c) }, cfg, authDeps);
358
+ let sessionToken = sessionTokenFrom(c);
359
+ // On return from Clerk's hosted sign-in the session is not on this host yet (on a
360
+ // development instance it lives on accounts.dev). Clerk's own handshake brings it:
361
+ // the SDK answers `handshake` (redirect + Set-Cookie) and comes back with `__session`.
362
+ // An explicit Bearer token is the signed-in case already; injected collaborators (tests,
363
+ // custom deployments) skip the SDK, which needs the secret key.
364
+ const hasBearer = c.req.header("authorization")?.toLowerCase().startsWith("bearer ");
365
+ if (!hasBearer && !clerkInjected && (env[OAUTH_ENV.clerkSecretKey] ?? "") !== "") {
366
+ // A forged or stale `__clerk_handshake` makes the SDK throw. That is no session,
367
+ // never a 500: the request continues signed out and resumeAuthorize refuses it.
368
+ const session = await resolveClerkSessionFromRequest(c.req.raw, {
369
+ secretKey: env[OAUTH_ENV.clerkSecretKey] ?? "",
370
+ publishableKey: publishableKeyFor(env, result.settings.clerkIssuer),
371
+ authorizedParties: result.settings.authorizedParties,
372
+ }).catch(() => ({ kind: "signed-out" }));
373
+ if (session.kind === "handshake") {
374
+ // A raw Response bypasses the context's headers, so no-store is set on it directly.
375
+ const headers = new Headers(session.headers);
376
+ headers.set("Cache-Control", "no-store");
377
+ headers.set("Pragma", "no-cache");
378
+ return new Response(null, { status: session.status, headers });
379
+ }
380
+ sessionToken = session.kind === "signed-in" ? session.sessionToken : undefined;
381
+ // The handshake return carries the cookies (`__session`, `__client_uat`) the picker's
382
+ // form post needs on this host: hand them to the browser with the response.
383
+ if (session.kind === "signed-in") {
384
+ for (const cookie of session.headers.getSetCookie()) {
385
+ c.header("Set-Cookie", cookie, { append: true });
386
+ }
387
+ }
388
+ }
389
+ const outcome = await resumeAuthorize({ state: c.req.query("authorize_state") ?? "", sessionToken }, cfg, authDeps);
334
390
  return respond(c, outcome, result.settings);
335
391
  });
336
392
  // The picker's form post. `consentToken` is the CSRF protection: a cross-site form
@@ -352,7 +408,13 @@ export function createAuthorizationServer(deps = {}) {
352
408
  // session, then answers the redirect-to-client with error=access_denied and the
353
409
  // client's state. `respond` appends `iss` to it like to every authorization response.
354
410
  if (field("approved") === "false") {
355
- const denied = await resumeAuthorize({ state: field("state") ?? "", sessionToken: sessionTokenFrom(c), denied: true }, cfg, authDeps);
411
+ const denied = await resumeAuthorize({
412
+ state: field("state") ?? "",
413
+ sessionToken: sessionTokenFrom(c),
414
+ denied: true,
415
+ // cloud-identity >= 0.18 verifies the consent token on a denial too.
416
+ consentToken: field("consentToken"),
417
+ }, cfg, authDeps);
356
418
  return respond(c, denied, result.settings);
357
419
  }
358
420
  const outcome = await resumeAuthorize({
@@ -386,7 +448,22 @@ export function createAuthorizationServer(deps = {}) {
386
448
  if (body.grant_type !== "authorization_code") {
387
449
  return c.json({ error: "unsupported_grant_type" }, 400);
388
450
  }
389
- const { code, code_verifier: codeVerifier, redirect_uri: redirectUri, client_id: clientId, } = body;
451
+ const { code, code_verifier: codeVerifier, redirect_uri: redirectUri, client_id: sentClientId, } = body;
452
+ // A presented credential is never ignored: an assertion is verified before anything else
453
+ // and yields the client_id. Without an assertion the public-client path applies.
454
+ let clientId = sentClientId;
455
+ if (body.client_assertion !== undefined || body.client_assertion_type !== undefined) {
456
+ const verified = await assertionVerifier.verify({
457
+ assertionType: body.client_assertion_type,
458
+ assertion: body.client_assertion,
459
+ clientId: sentClientId,
460
+ audiences: [`${settings.baseUrl}/token`, settings.baseUrl],
461
+ });
462
+ if (!verified.ok) {
463
+ return c.json({ error: "invalid_client", error_description: verified.reason }, 401);
464
+ }
465
+ clientId = verified.clientId;
466
+ }
390
467
  if (!code || !codeVerifier || !redirectUri || !clientId) {
391
468
  return c.json({
392
469
  error: "invalid_request",
@@ -0,0 +1,28 @@
1
+ import { type CimdOptions } from "./clientRegistry.js";
2
+ export declare const JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
3
+ export declare const ASSERTION_SIGNING_ALGS: readonly ["RS256"];
4
+ export type AssertionResult = {
5
+ ok: true;
6
+ clientId: string;
7
+ } | {
8
+ ok: false;
9
+ reason: string;
10
+ };
11
+ /** Bounded set of accepted jti values, each held until its assertion expires. */
12
+ export declare function createJtiStore(maxEntries?: number): {
13
+ /** True when the jti is new (and is now recorded); false on a replay or when full. */
14
+ accept(clientId: string, jti: string, exp: number, nowSeconds: number): boolean;
15
+ };
16
+ export interface AssertionVerifierOptions extends CimdOptions {
17
+ now?: () => number;
18
+ jtiStore?: ReturnType<typeof createJtiStore>;
19
+ }
20
+ export declare function createClientAssertionVerifier(options?: AssertionVerifierOptions): {
21
+ verify(input: {
22
+ assertionType: string | undefined;
23
+ assertion: string | undefined;
24
+ /** The `client_id` request parameter, when sent. */
25
+ clientId: string | undefined;
26
+ audiences: string[];
27
+ }): Promise<AssertionResult>;
28
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * private_key_jwt client authentication at `/token` (RFC 7523 / OIDC Core 9).
3
+ *
4
+ * A presented assertion is VERIFIED, never ignored and never trusted unverified:
5
+ * - the key set comes from the `jwks_uri` of the client's CIMD document, fetched through the
6
+ * same SSRF guard as the document itself;
7
+ * - alg is pinned to RS256 (a document declaring another signing alg is refused), so `none`
8
+ * and HMAC assertions cannot verify;
9
+ * - `iss` and `sub` equal the client_id; `aud` is this token endpoint or this issuer; `exp`
10
+ * is required (30 s skew); `jti` is required and accepted ONCE.
11
+ */
12
+ import { createLocalJWKSet, decodeJwt, jwtVerify } from "jose";
13
+ import { fetchGuardedJson, isUrlShapedClientId, resolveCimdDocument, } from "./clientRegistry.js";
14
+ export const JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
15
+ export const ASSERTION_SIGNING_ALGS = ["RS256"];
16
+ const SKEW_SECONDS = 30;
17
+ const MAX_JWKS_BYTES = 64 * 1024;
18
+ /** Bounded set of accepted jti values, each held until its assertion expires. */
19
+ export function createJtiStore(maxEntries = 10_000) {
20
+ const seen = new Map(); // `${clientId}\n${jti}` -> exp (epoch seconds)
21
+ return {
22
+ /** True when the jti is new (and is now recorded); false on a replay or when full. */
23
+ accept(clientId, jti, exp, nowSeconds) {
24
+ for (const [k, e] of seen)
25
+ if (e + SKEW_SECONDS <= nowSeconds)
26
+ seen.delete(k);
27
+ const key = `${clientId}\n${jti}`;
28
+ if (seen.has(key))
29
+ return false;
30
+ if (seen.size >= maxEntries)
31
+ return false; // fail closed: never evict a live jti
32
+ seen.set(key, exp);
33
+ return true;
34
+ },
35
+ };
36
+ }
37
+ export function createClientAssertionVerifier(options = {}) {
38
+ const now = options.now ?? (() => Date.now());
39
+ const jtiStore = options.jtiStore ?? createJtiStore();
40
+ const fetchOpts = {
41
+ fetchImpl: options.fetchImpl,
42
+ resolveHost: options.resolveHost,
43
+ maxBytes: MAX_JWKS_BYTES,
44
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
45
+ };
46
+ return {
47
+ async verify(input) {
48
+ const fail = (reason) => ({ ok: false, reason });
49
+ if (input.assertionType !== JWT_BEARER_ASSERTION_TYPE || !input.assertion) {
50
+ return fail("unsupported client assertion");
51
+ }
52
+ // The unverified iss only LOCATES the key set; it is re-checked against the verified claims.
53
+ let claimedIss;
54
+ try {
55
+ claimedIss = decodeJwt(input.assertion).iss;
56
+ }
57
+ catch {
58
+ return fail("malformed client assertion");
59
+ }
60
+ if (!claimedIss || !isUrlShapedClientId(claimedIss))
61
+ return fail("unknown client");
62
+ if (input.clientId !== undefined && input.clientId !== claimedIss) {
63
+ return fail("client_id does not match the assertion issuer");
64
+ }
65
+ const clientId = claimedIss;
66
+ const cimd = await resolveCimdDocument(clientId, options);
67
+ if (!cimd?.jwksUri)
68
+ return fail("client has no verifiable key set");
69
+ if (cimd.signingAlg !== undefined &&
70
+ !ASSERTION_SIGNING_ALGS.includes(cimd.signingAlg)) {
71
+ return fail("unsupported signing algorithm");
72
+ }
73
+ let jwksUrl;
74
+ try {
75
+ jwksUrl = new URL(cimd.jwksUri);
76
+ }
77
+ catch {
78
+ return fail("invalid jwks_uri");
79
+ }
80
+ const jwks = (await fetchGuardedJson(jwksUrl, fetchOpts));
81
+ if (!jwks || !Array.isArray(jwks.keys))
82
+ return fail("key set unavailable");
83
+ let payload;
84
+ try {
85
+ const verified = await jwtVerify(input.assertion, createLocalJWKSet(jwks), {
86
+ algorithms: [...ASSERTION_SIGNING_ALGS],
87
+ issuer: clientId,
88
+ subject: clientId,
89
+ audience: input.audiences,
90
+ clockTolerance: SKEW_SECONDS,
91
+ currentDate: new Date(now()),
92
+ requiredClaims: ["iss", "sub", "aud", "exp", "jti"],
93
+ });
94
+ payload = verified.payload;
95
+ }
96
+ catch {
97
+ return fail("client assertion rejected");
98
+ }
99
+ const exp = payload.exp;
100
+ if (!payload.jti || !jtiStore.accept(clientId, payload.jti, exp, Math.floor(now() / 1000))) {
101
+ return fail("client assertion replayed");
102
+ }
103
+ return { ok: true, clientId };
104
+ },
105
+ };
106
+ }
@@ -50,6 +50,20 @@ export interface CimdOptions {
50
50
  }
51
51
  /** True when a client_id is URL-shaped, i.e. a CIMD identifier (valid or not). */
52
52
  export declare function isUrlShapedClientId(clientId: string): boolean;
53
+ /**
54
+ * The one SSRF-guarded fetch shared by the CIMD document and the client's JWKS: https only,
55
+ * public addresses only, no redirects, size and time capped. Returns the parsed JSON, or null
56
+ * for ANY failure.
57
+ */
58
+ export declare function fetchGuardedJson(url: URL, options?: CimdOptions): Promise<unknown>;
59
+ /** A validated CIMD: the client plus what `/token` needs to verify a private_key_jwt assertion. */
60
+ export interface CimdDocument {
61
+ client: AuthorizeClient;
62
+ /** Present only when the document declares an https `jwks_uri`. */
63
+ jwksUri?: string;
64
+ /** The document's `token_endpoint_auth_signing_alg`, when declared. */
65
+ signingAlg?: string;
66
+ }
53
67
  /**
54
68
  * Fetches and validates a Client ID Metadata Document. Returns null for ANY failure — an
55
69
  * unreachable, oversized, malformed, private-addressed or self-contradicting document is an
@@ -59,4 +73,5 @@ export declare function isUrlShapedClientId(clientId: string): boolean;
59
73
  * the fetch resolves it again, so a DNS-rebinding window exists. A hostile-network deployment
60
74
  * injects a `fetchImpl` that pins the validated address (for example an undici dispatcher).
61
75
  */
76
+ export declare function resolveCimdDocument(clientId: string, options?: CimdOptions): Promise<CimdDocument | null>;
62
77
  export declare function resolveCimdClient(clientId: string, options?: CimdOptions): Promise<AuthorizeClient | null>;
@@ -208,6 +208,38 @@ async function readCappedText(res, maxBytes) {
208
208
  export function isUrlShapedClientId(clientId) {
209
209
  return /^https?:\/\//i.test(clientId);
210
210
  }
211
+ /**
212
+ * The one SSRF-guarded fetch shared by the CIMD document and the client's JWKS: https only,
213
+ * public addresses only, no redirects, size and time capped. Returns the parsed JSON, or null
214
+ * for ANY failure.
215
+ */
216
+ export async function fetchGuardedJson(url, options = {}) {
217
+ const { fetchImpl = fetch, resolveHost = defaultResolveHost } = options;
218
+ const maxBytes = options.maxBytes ?? 16 * 1024;
219
+ const timeoutMs = options.timeoutMs ?? 5000;
220
+ try {
221
+ if (url.protocol !== "https:" || url.username || url.password || url.hash)
222
+ return null;
223
+ const host = url.hostname.replace(/^\[|\]$/g, "");
224
+ const addresses = isIP(host) ? [host] : await resolveHost(host);
225
+ if (addresses.length === 0 || !addresses.every(isPublicAddress))
226
+ return null;
227
+ const res = await fetchImpl(url, {
228
+ redirect: "error",
229
+ headers: { Accept: "application/json" },
230
+ signal: AbortSignal.timeout(timeoutMs),
231
+ });
232
+ if (!res.ok)
233
+ return null;
234
+ const text = await readCappedText(res, maxBytes);
235
+ if (text === null)
236
+ return null;
237
+ return JSON.parse(text);
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ }
211
243
  /**
212
244
  * Fetches and validates a Client ID Metadata Document. Returns null for ANY failure — an
213
245
  * unreachable, oversized, malformed, private-addressed or self-contradicting document is an
@@ -217,10 +249,7 @@ export function isUrlShapedClientId(clientId) {
217
249
  * the fetch resolves it again, so a DNS-rebinding window exists. A hostile-network deployment
218
250
  * injects a `fetchImpl` that pins the validated address (for example an undici dispatcher).
219
251
  */
220
- export async function resolveCimdClient(clientId, options = {}) {
221
- const { fetchImpl = fetch, resolveHost = defaultResolveHost } = options;
222
- const maxBytes = options.maxBytes ?? 16 * 1024;
223
- const timeoutMs = options.timeoutMs ?? 5000;
252
+ export async function resolveCimdDocument(clientId, options = {}) {
224
253
  let url;
225
254
  try {
226
255
  url = new URL(clientId);
@@ -234,39 +263,37 @@ export async function resolveCimdClient(clientId, options = {}) {
234
263
  return null; // the spec requires a path
235
264
  if (url.href !== clientId)
236
265
  return null; // client_id is compared as a string; no normalisation
237
- try {
238
- const host = url.hostname.replace(/^\[|\]$/g, "");
239
- const addresses = isIP(host) ? [host] : await resolveHost(host);
240
- if (addresses.length === 0 || !addresses.every(isPublicAddress))
241
- return null;
242
- const res = await fetchImpl(url, {
243
- redirect: "error",
244
- headers: { Accept: "application/json" },
245
- signal: AbortSignal.timeout(timeoutMs),
246
- });
247
- if (!res.ok)
248
- return null;
249
- const text = await readCappedText(res, maxBytes);
250
- if (text === null)
251
- return null;
252
- const doc = JSON.parse(text);
253
- if (doc.client_id !== clientId)
254
- return null;
255
- const method = doc.token_endpoint_auth_method;
256
- if (method !== undefined && method !== "none")
257
- return null; // public clients only
258
- const uris = doc.redirect_uris;
259
- if (!Array.isArray(uris) || uris.length === 0 || uris.length > 20)
260
- return null;
261
- if (!uris.every(isAllowedRedirectUri))
262
- return null;
263
- return {
266
+ const parsed = await fetchGuardedJson(url, options);
267
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
268
+ return null;
269
+ const doc = parsed;
270
+ if (doc.client_id !== clientId)
271
+ return null;
272
+ // The document must offer "none", either as its method or among the methods it supports (a
273
+ // client such as ChatGPT declares private_key_jwt and lists "none"). A client that sends no
274
+ // assertion is a public client and PKCE carries the proof; one that does is verified at /token.
275
+ const method = doc.token_endpoint_auth_method;
276
+ const supported = doc.token_endpoint_auth_methods_supported;
277
+ const acceptsNone = Array.isArray(supported) && supported.includes("none");
278
+ if (method !== undefined && method !== "none" && !acceptsNone)
279
+ return null;
280
+ const uris = doc.redirect_uris;
281
+ if (!Array.isArray(uris) || uris.length === 0 || uris.length > 20)
282
+ return null;
283
+ if (!uris.every(isAllowedRedirectUri))
284
+ return null;
285
+ return {
286
+ client: {
264
287
  clientId,
265
288
  redirectUris: uris,
266
289
  ...(typeof doc.client_name === "string" ? { clientName: doc.client_name.slice(0, 200) } : {}),
267
- };
268
- }
269
- catch {
270
- return null;
271
- }
290
+ },
291
+ ...(typeof doc.jwks_uri === "string" ? { jwksUri: doc.jwks_uri } : {}),
292
+ ...(typeof doc.token_endpoint_auth_signing_alg === "string"
293
+ ? { signingAlg: doc.token_endpoint_auth_signing_alg }
294
+ : {}),
295
+ };
296
+ }
297
+ export async function resolveCimdClient(clientId, options = {}) {
298
+ return (await resolveCimdDocument(clientId, options))?.client ?? null;
272
299
  }
@@ -53,3 +53,11 @@ export type AuthorizeSettingsResult = {
53
53
  * secret key is then not required.
54
54
  */
55
55
  export declare function readAuthorizeSettings(env: NodeJS.ProcessEnv, clerkCollaboratorsInjected?: boolean): Promise<AuthorizeSettingsResult>;
56
+ /**
57
+ * True when the configured Clerk instance is a DEVELOPMENT one (an `sk_test_` secret key, or a
58
+ * Frontend API domain under `accounts.dev`) while PUBLIC_BASE_URL is not loopback. A development
59
+ * instance's hosted sign-in redirects only to localhost or to the instance Home URL, so a remote
60
+ * server needs that Home URL pointed at it. Absent or malformed values answer `false`: this feeds
61
+ * a warning, and the readiness of the settings is decided by `readAuthorizeSettings`, not here.
62
+ */
63
+ export declare function isDevClerkInstanceOnRemoteServer(env: NodeJS.ProcessEnv): boolean;
@@ -116,3 +116,32 @@ export async function readAuthorizeSettings(env, clerkCollaboratorsInjected = fa
116
116
  },
117
117
  };
118
118
  }
119
+ /**
120
+ * True when the configured Clerk instance is a DEVELOPMENT one (an `sk_test_` secret key, or a
121
+ * Frontend API domain under `accounts.dev`) while PUBLIC_BASE_URL is not loopback. A development
122
+ * instance's hosted sign-in redirects only to localhost or to the instance Home URL, so a remote
123
+ * server needs that Home URL pointed at it. Absent or malformed values answer `false`: this feeds
124
+ * a warning, and the readiness of the settings is decided by `readAuthorizeSettings`, not here.
125
+ */
126
+ export function isDevClerkInstanceOnRemoteServer(env) {
127
+ let baseHost;
128
+ try {
129
+ baseHost = new URL(env[OAUTH_ENV.publicBaseUrl] ?? "").hostname;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ if (baseHost === "" || LOOPBACK_HOSTS.has(baseHost) || LOOPBACK_HOSTS.has(`[${baseHost}]`)) {
135
+ return false;
136
+ }
137
+ const devKey = (env[OAUTH_ENV.clerkSecretKey] ?? "").startsWith("sk_test_");
138
+ let devDomain = false;
139
+ try {
140
+ const host = new URL(env[OAUTH_ENV.clerkIssuer] ?? "").hostname;
141
+ devDomain = host === "accounts.dev" || host.endsWith(".accounts.dev");
142
+ }
143
+ catch {
144
+ devDomain = false;
145
+ }
146
+ return devKey || devDomain;
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-boilerplate",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Clonable MCP server skeleton on the official @modelcontextprotocol/server SDK v2 (protocol 2026-07-28 + 2025 legacy, stdio + stateless Streamable HTTP), with its own OAuth 2.1 authorization server (Clerk person + organisation, cloud-identity), MCP Apps UI, tenant scoping, FR/EN i18n and a declarative eval harness.",
5
5
  "description_fr": "Squelette de serveur MCP clonable sur le SDK officiel @modelcontextprotocol/server v2 (protocole 2026-07-28 + héritage 2025, stdio + Streamable HTTP sans état), avec son propre serveur d'autorisation OAuth 2.1 (personne + organisation Clerk, cloud-identity), UI MCP Apps, scoping tenant, i18n FR/EN et un harnais d'évaluations déclaratif.",
6
6
  "type": "module",
@@ -75,7 +75,7 @@
75
75
  "dependencies": {
76
76
  "@modelcontextprotocol/ext-apps": "^2.0.3",
77
77
  "@modelcontextprotocol/server": "^2.3.1",
78
- "@vantageos/cloud-identity": "^0.17.0",
78
+ "@vantageos/cloud-identity": "^0.19.0",
79
79
  "@vantageos/mosaic-i18n": "^0.1.1",
80
80
  "hono": "^4.13.13",
81
81
  "jose": "^6.2.7",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "vantage-mcp-boilerplate",
4
- "version": "0.2.2",
4
+ "version": "0.2.3",
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"