@vantageos/mcp-boilerplate 0.2.2 → 0.2.4

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.4",
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,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.4] - 2026-10-11
11
+
12
+ ### Changed
13
+
14
+ - The consent / organisation picker page uses the VantagePeers dashboard design tokens: dark by
15
+ default, light under `prefers-color-scheme`, one option card per organisation with its role
16
+ (`admin`, not `(org:admin)`), a primary Approve button. It still loads no external asset; the
17
+ single inline style is hashed into the page CSP.
18
+
19
+ ### Added
20
+
21
+ - `AUTHORIZE_BRAND_NAME` (optional): a display name shown above the consent page title. None by
22
+ default, so a fork carries no brand it did not set.
23
+
24
+ ## [0.2.3] - 2026-10-11
25
+
26
+ ### Added
27
+
28
+ - `/authorize/callback` resolves the person's Clerk session with Clerk's own handshake
29
+ (`@vantageos/cloud-identity` `^0.19.0`, `resolveClerkSessionFromRequest`, built on
30
+ `@clerk/backend` `authenticateRequest()`). A return from Clerk's hosted sign-in with no session
31
+ on this host used to answer `403 session-required`; it now redirects through the handshake and
32
+ continues. An explicit Bearer token and injected Clerk collaborators keep their path. The
33
+ publishable key is `CLERK_PUBLISHABLE_KEY` or derived from `CLERK_DOMAIN`.
34
+ - README section "Sign-in with ChatGPT and Claude" (EN + FR): the production instance on the
35
+ server's own registrable domain, the three URLs including `AUTHORIZE_SIGN_IN_URL`, why no
36
+ redirect URL is registered, development-instance limits, how the session arrives, what is and
37
+ is not yet measured.
38
+ - The handshake redirect carries `Cache-Control: no-store`, and a forged or stale
39
+ `__clerk_handshake` continues signed out instead of answering 500.
40
+ - Boot warning (EN + FR) when the Clerk instance is a development one (`sk_test_` key or an
41
+ `accounts.dev` domain) and `PUBLIC_BASE_URL` is not localhost: it names the Home URL
42
+ requirement. A warning, never a refusal.
43
+
44
+ ### Fixed
45
+
46
+ - The Node adapter emitted only the last `Set-Cookie` of a response. A response with N cookies
47
+ now yields N (Clerk's handshake return sets five).
48
+ - The deny route (`POST /authorize/org` with `approved=false`) forwards `consentToken`;
49
+ cloud-identity 0.18 and later verify it on a denial too.
50
+
51
+ - CIMD: a Client ID Metadata Document that declares `token_endpoint_auth_method` other than
52
+ `none` (for example ChatGPT's `private_key_jwt`) is now accepted when its
53
+ `token_endpoint_auth_methods_supported` includes `none`. A document with neither is still
54
+ refused. All other checks (client_id equality, https, SSRF guard, size cap, redirect_uris)
55
+ are unchanged. `/authorize` no longer answers `unknown-client` for ChatGPT.
56
+ - `/token` verifies a `private_key_jwt` client assertion instead of ignoring it: keys from the
57
+ CIMD document's `jwks_uri` (same SSRF-guarded fetch), RS256 only, `iss` = `sub` = `client_id`,
58
+ `aud` = the token endpoint or the issuer, `exp` required (30 s skew), `jti` required and
59
+ accepted once (bounded in-memory set, fail closed when full). Any failure answers 401
60
+ `invalid_client`. A request without an assertion keeps the public-client path (`client_id` +
61
+ PKCE). AS metadata advertises `["none","private_key_jwt"]` and RS256.
62
+
10
63
  ## [0.2.2] - 2026-10-10
11
64
 
12
65
  ### Security
package/README.fr.md CHANGED
@@ -148,6 +148,58 @@ 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
+ 6. **La page de consentement.** Le choix d'organisation reprend le design du tableau de bord
198
+ VantagePeers : sombre par défaut, clair quand le système de la personne le demande, une
199
+ carte par organisation avec son rôle. Elle ne charge aucune ressource externe et porte donc
200
+ ses propres tokens. Réglez `AUTHORIZE_BRAND_NAME` pour afficher le nom de votre produit
201
+ au-dessus du titre ; rien n'est affiché par défaut.
202
+
151
203
  ### Multi-tenance
152
204
 
153
205
  `list_items` filtre les lignes par leur `orgId` stocké face à l'organisation
@@ -242,7 +294,7 @@ en un seul fichier HTML autonome (un plugin d'inlining local dans `vite.config.t
242
294
  `dist/ui-bundle/widget.html`) par `npm run build:ui`, sur un chemin qui n'entre
243
295
  jamais en collision avec `dist/ui/` de tsc.
244
296
 
245
- **Couche d'identité** : `@vantageos/cloud-identity` (^0.17.0) est le seul paquet
297
+ **Couche d'identité** : `@vantageos/cloud-identity` (^0.19.0) est le seul paquet
246
298
  d'identité. Son point d'entrée resource-server (`verifyMcpAccessToken`,
247
299
  `buildProtectedResourceMetadata`) vérifie le jeton Bearer sur `/mcp` ; `jose` ne sert
248
300
  plus qu'à signer les jetons d'accès du serveur d'autorisation. Un jeu de clés
package/README.md CHANGED
@@ -141,6 +141,53 @@ 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
+ 6. **The consent page.** The organisation picker uses the VantagePeers dashboard design: dark
187
+ by default, light when the person's system asks for it, one card per organisation with its
188
+ role. It loads no external asset, so it carries its own tokens. Set `AUTHORIZE_BRAND_NAME`
189
+ to show your product's name above the title; nothing is shown by default.
190
+
144
191
  ### Multi-tenancy
145
192
 
146
193
  `list_items` filters rows by their stored `orgId` against the caller's verified
@@ -234,7 +281,7 @@ one self-contained HTML file (an in-repo inlining plugin in `vite.config.ts`,
234
281
  `dist/ui-bundle/widget.html`) by `npm run build:ui`, on a path that never
235
282
  collides with tsc's `dist/ui/`.
236
283
 
237
- **Identity layer**: `@vantageos/cloud-identity` (^0.17.0) is the only identity
284
+ **Identity layer**: `@vantageos/cloud-identity` (^0.19.0) is the only identity
238
285
  package. Its resource-server entry point (`verifyMcpAccessToken`,
239
286
  `buildProtectedResourceMetadata`) verifies the Bearer token on `/mcp`; `jose` is
240
287
  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, {
@@ -171,6 +195,7 @@ export function createAuthorizationServer(deps = {}) {
171
195
  return c.html(renderOrgPicker(outcome.model, locale, {
172
196
  // Only a URL-shaped (CIMD) client_id is bound to a domain its owner controls.
173
197
  unverified: !isUrlShapedClientId(outcome.model.client.clientId),
198
+ brandName: env[OAUTH_ENV.brandName]?.trim() || undefined,
174
199
  }));
175
200
  }
176
201
  case "refused": {
@@ -239,7 +264,8 @@ export function createAuthorizationServer(deps = {}) {
239
264
  }
240
265
  return c.json({
241
266
  ...doc.document,
242
- token_endpoint_auth_methods_supported: ["none"],
267
+ token_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
268
+ token_endpoint_auth_signing_alg_values_supported: [...ASSERTION_SIGNING_ALGS],
243
269
  // RFC 9207 — /authorize appends `iss` to every authorization response.
244
270
  authorization_response_iss_parameter_supported: true,
245
271
  // Client ID Metadata Documents are fetched SSRF-guarded (clientRegistry.ts).
@@ -330,7 +356,38 @@ export function createAuthorizationServer(deps = {}) {
330
356
  if (!result.ok)
331
357
  return unavailable(c, result.missing);
332
358
  const { cfg, authDeps } = runtimeFor(result.settings);
333
- const outcome = await resumeAuthorize({ state: c.req.query("authorize_state") ?? "", sessionToken: sessionTokenFrom(c) }, cfg, authDeps);
359
+ let sessionToken = sessionTokenFrom(c);
360
+ // On return from Clerk's hosted sign-in the session is not on this host yet (on a
361
+ // development instance it lives on accounts.dev). Clerk's own handshake brings it:
362
+ // the SDK answers `handshake` (redirect + Set-Cookie) and comes back with `__session`.
363
+ // An explicit Bearer token is the signed-in case already; injected collaborators (tests,
364
+ // custom deployments) skip the SDK, which needs the secret key.
365
+ const hasBearer = c.req.header("authorization")?.toLowerCase().startsWith("bearer ");
366
+ if (!hasBearer && !clerkInjected && (env[OAUTH_ENV.clerkSecretKey] ?? "") !== "") {
367
+ // A forged or stale `__clerk_handshake` makes the SDK throw. That is no session,
368
+ // never a 500: the request continues signed out and resumeAuthorize refuses it.
369
+ const session = await resolveClerkSessionFromRequest(c.req.raw, {
370
+ secretKey: env[OAUTH_ENV.clerkSecretKey] ?? "",
371
+ publishableKey: publishableKeyFor(env, result.settings.clerkIssuer),
372
+ authorizedParties: result.settings.authorizedParties,
373
+ }).catch(() => ({ kind: "signed-out" }));
374
+ if (session.kind === "handshake") {
375
+ // A raw Response bypasses the context's headers, so no-store is set on it directly.
376
+ const headers = new Headers(session.headers);
377
+ headers.set("Cache-Control", "no-store");
378
+ headers.set("Pragma", "no-cache");
379
+ return new Response(null, { status: session.status, headers });
380
+ }
381
+ sessionToken = session.kind === "signed-in" ? session.sessionToken : undefined;
382
+ // The handshake return carries the cookies (`__session`, `__client_uat`) the picker's
383
+ // form post needs on this host: hand them to the browser with the response.
384
+ if (session.kind === "signed-in") {
385
+ for (const cookie of session.headers.getSetCookie()) {
386
+ c.header("Set-Cookie", cookie, { append: true });
387
+ }
388
+ }
389
+ }
390
+ const outcome = await resumeAuthorize({ state: c.req.query("authorize_state") ?? "", sessionToken }, cfg, authDeps);
334
391
  return respond(c, outcome, result.settings);
335
392
  });
336
393
  // The picker's form post. `consentToken` is the CSRF protection: a cross-site form
@@ -352,7 +409,13 @@ export function createAuthorizationServer(deps = {}) {
352
409
  // session, then answers the redirect-to-client with error=access_denied and the
353
410
  // client's state. `respond` appends `iss` to it like to every authorization response.
354
411
  if (field("approved") === "false") {
355
- const denied = await resumeAuthorize({ state: field("state") ?? "", sessionToken: sessionTokenFrom(c), denied: true }, cfg, authDeps);
412
+ const denied = await resumeAuthorize({
413
+ state: field("state") ?? "",
414
+ sessionToken: sessionTokenFrom(c),
415
+ denied: true,
416
+ // cloud-identity >= 0.18 verifies the consent token on a denial too.
417
+ consentToken: field("consentToken"),
418
+ }, cfg, authDeps);
356
419
  return respond(c, denied, result.settings);
357
420
  }
358
421
  const outcome = await resumeAuthorize({
@@ -386,7 +449,22 @@ export function createAuthorizationServer(deps = {}) {
386
449
  if (body.grant_type !== "authorization_code") {
387
450
  return c.json({ error: "unsupported_grant_type" }, 400);
388
451
  }
389
- const { code, code_verifier: codeVerifier, redirect_uri: redirectUri, client_id: clientId, } = body;
452
+ const { code, code_verifier: codeVerifier, redirect_uri: redirectUri, client_id: sentClientId, } = body;
453
+ // A presented credential is never ignored: an assertion is verified before anything else
454
+ // and yields the client_id. Without an assertion the public-client path applies.
455
+ let clientId = sentClientId;
456
+ if (body.client_assertion !== undefined || body.client_assertion_type !== undefined) {
457
+ const verified = await assertionVerifier.verify({
458
+ assertionType: body.client_assertion_type,
459
+ assertion: body.client_assertion,
460
+ clientId: sentClientId,
461
+ audiences: [`${settings.baseUrl}/token`, settings.baseUrl],
462
+ });
463
+ if (!verified.ok) {
464
+ return c.json({ error: "invalid_client", error_description: verified.reason }, 401);
465
+ }
466
+ clientId = verified.clientId;
467
+ }
390
468
  if (!code || !codeVerifier || !redirectUri || !clientId) {
391
469
  return c.json({
392
470
  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
  }
@@ -12,5 +12,7 @@ export declare function pickerContentSecurityPolicy(): Promise<string>;
12
12
  export interface ConsentContext {
13
13
  /** True for a Dynamic Client Registration client: it vouched for itself, nobody else did. */
14
14
  unverified: boolean;
15
+ /** The deployment's display name (AUTHORIZE_BRAND_NAME), shown above the title; none by default. */
16
+ brandName?: string;
15
17
  }
16
18
  export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale, consent: ConsentContext): string;
@@ -13,9 +13,54 @@ function escapeHtml(value) {
13
13
  .replace(/"/g, "&quot;")
14
14
  .replace(/'/g, "&#39;");
15
15
  }
16
- const PICKER_STYLE = "body{font-family:system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem;line-height:1.5}" +
17
- "fieldset{border:1px solid #888;padding:1rem}label{display:block;margin:.5rem 0}" +
18
- "button{font:inherit;padding:.5rem 1rem;margin-right:.5rem}";
16
+ /**
17
+ * The VantagePeers dashboard design tokens (vantage-peers-dashboard styles/presets/base.css at
18
+ * 9ca86e8a): dark first, light under `prefers-color-scheme`. Copied as values because the page
19
+ * loads no external asset (CSP below); a change to the dashboard preset is carried here by hand.
20
+ * Geist is the dashboard font; with no external fonts allowed, the system stack stands in.
21
+ */
22
+ const PICKER_STYLE = [
23
+ ":root{color-scheme:dark light;--background:oklch(0.08 0.005 265);--foreground:oklch(0.95 0.005 265);" +
24
+ "--card:oklch(0.11 0.005 265);--card-hover:oklch(0.14 0.005 265);--primary:oklch(0.95 0.005 265);" +
25
+ "--primary-foreground:oklch(0.08 0.005 265);--secondary:oklch(0.16 0.005 265);" +
26
+ "--muted-foreground:oklch(0.55 0.005 265);--border:oklch(0.22 0.005 265);" +
27
+ "--border-hover:oklch(0.30 0.005 265);--ring:oklch(0.55 0.005 265);--warning:oklch(0.75 0.18 85);" +
28
+ "--radius:0.75rem}",
29
+ "@media (prefers-color-scheme:light){:root{--background:oklch(0.98 0.003 265);" +
30
+ "--foreground:oklch(0.10 0.005 265);--card:oklch(1 0 0);--card-hover:oklch(0.96 0.003 265);" +
31
+ "--primary:oklch(0.10 0.005 265);--primary-foreground:oklch(0.98 0.003 265);" +
32
+ "--secondary:oklch(0.94 0.003 265);--muted-foreground:oklch(0.45 0.005 265);" +
33
+ "--border:oklch(0.88 0.003 265);--border-hover:oklch(0.78 0.003 265);--ring:oklch(0.45 0.005 265);" +
34
+ "--warning:oklch(0.65 0.18 85)}}",
35
+ "*{box-sizing:border-box}",
36
+ "body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem;" +
37
+ "background:var(--background);color:var(--foreground);" +
38
+ "font:15px/1.55 ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif}",
39
+ "main{width:100%;max-width:28rem;background:var(--card);border:1px solid var(--border);" +
40
+ "border-radius:var(--radius);padding:2rem}",
41
+ ".brand{margin:0 0 1.25rem;font-size:.8rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;" +
42
+ "color:var(--muted-foreground)}",
43
+ "h1{margin:0 0 .5rem;font-size:1.25rem;font-weight:600;letter-spacing:-.01em}",
44
+ "p{margin:0 0 1rem}.muted{color:var(--muted-foreground);font-size:.875rem}",
45
+ "code{font-family:ui-monospace,'SF Mono',monospace;font-size:.8125rem;background:var(--secondary);" +
46
+ "padding:.1rem .35rem;border-radius:.375rem}",
47
+ ".alert{border:1px solid var(--warning);border-radius:.5rem;padding:.75rem;color:var(--warning);font-size:.875rem}",
48
+ "fieldset{border:0;margin:0 0 1.5rem;padding:0}",
49
+ "legend{font-size:.8rem;font-weight:600;color:var(--muted-foreground);margin-bottom:.5rem;padding:0}",
50
+ ".org{display:flex;align-items:center;gap:.75rem;padding:.75rem .875rem;margin-bottom:.5rem;" +
51
+ "border:1px solid var(--border);border-radius:.5rem;cursor:pointer}",
52
+ ".org:hover{border-color:var(--border-hover);background:var(--card-hover)}",
53
+ ".org:has(input:checked){border-color:var(--primary)}",
54
+ ".org input{accent-color:var(--primary);margin:0}",
55
+ ".name{flex:1;font-weight:500}",
56
+ ".role{font-size:.75rem;color:var(--muted-foreground);background:var(--secondary);" +
57
+ "padding:.125rem .5rem;border-radius:999px}",
58
+ ".actions{display:flex;gap:.5rem}",
59
+ "button{flex:1;font:inherit;font-weight:500;padding:.625rem 1rem;border-radius:.5rem;cursor:pointer;" +
60
+ "border:1px solid var(--border);background:transparent;color:var(--foreground)}",
61
+ "button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}",
62
+ "button:hover{opacity:.9}button:focus-visible,.org:focus-within{outline:2px solid var(--ring);outline-offset:2px}",
63
+ ].join("");
19
64
  /** CSP for the picker page: no scripts, no external assets, one hashed inline style. */
20
65
  export async function pickerContentSecurityPolicy() {
21
66
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(PICKER_STYLE));
@@ -30,29 +75,31 @@ export async function pickerContentSecurityPolicy() {
30
75
  export function renderOrgPicker(model, locale, consent) {
31
76
  const destination = escapeHtml(model.redirectOrigin);
32
77
  const unverified = consent.unverified
33
- ? `<p role="alert"><strong>${escapeHtml(t(locale, "oauth.pickerUnverified"))}</strong></p>\n`
78
+ ? `<p class="alert" role="alert"><strong>${escapeHtml(t(locale, "oauth.pickerUnverified"))}</strong></p>\n`
34
79
  : "";
35
80
  const clientName = model.client.clientName ?? model.client.clientId;
36
81
  const only = model.organizations.length === 1;
37
82
  const orgs = model.organizations
38
- .map((o, i) => `<label><input type="radio" name="orgId" value="${escapeHtml(o.id)}" required${only && i === 0 ? " checked" : ""}> ${escapeHtml(o.name)} <small>(${escapeHtml(o.role)})</small></label>`)
83
+ .map((o, i) => `<label class="org"><input type="radio" name="orgId" value="${escapeHtml(o.id)}" required${only && i === 0 ? " checked" : ""}><span class="name">${escapeHtml(o.name)}</span><span class="role">${escapeHtml(o.role.replace(/^org:/, ""))}</span></label>`)
39
84
  .join("\n");
40
85
  const title = escapeHtml(t(locale, "oauth.pickerTitle").replace("{client}", clientName));
41
86
  const intro = escapeHtml(t(locale, "oauth.pickerIntro").replaceAll("{client}", clientName));
87
+ const brand = consent.brandName ? `<p class="brand">${escapeHtml(consent.brandName)}</p>\n` : "";
42
88
  return `<!doctype html>
43
89
  <html lang="${locale}">
44
90
  <head>
45
91
  <meta charset="utf-8">
46
92
  <meta name="viewport" content="width=device-width, initial-scale=1">
47
93
  <meta name="referrer" content="no-referrer">
94
+ <meta name="color-scheme" content="dark light">
48
95
  <title>${title}</title>
49
96
  <style>${PICKER_STYLE}</style>
50
97
  </head>
51
98
  <body>
52
99
  <main>
53
- <h1>${title}</h1>
100
+ ${brand}<h1>${title}</h1>
54
101
  <p>${intro}</p>
55
- <p>${escapeHtml(t(locale, "oauth.pickerDestination"))} <strong>${destination}</strong><br>
102
+ <p class="muted">${escapeHtml(t(locale, "oauth.pickerDestination"))} <strong>${destination}</strong><br>
56
103
  ${escapeHtml(t(locale, "oauth.pickerScope"))} <code>${escapeHtml(model.scope)}</code></p>
57
104
  ${unverified}<form method="post" action="/authorize/org">
58
105
  <input type="hidden" name="state" value="${escapeHtml(model.state)}">
@@ -61,8 +108,8 @@ ${unverified}<form method="post" action="/authorize/org">
61
108
  <legend>${escapeHtml(t(locale, "oauth.pickerOrganisation"))}</legend>
62
109
  ${orgs}
63
110
  </fieldset>
64
- <p>
65
- <button type="submit" name="approved" value="true">${escapeHtml(t(locale, "oauth.approve"))}</button>
111
+ <p class="actions">
112
+ <button class="primary" type="submit" name="approved" value="true">${escapeHtml(t(locale, "oauth.approve"))}</button>
66
113
  <button type="submit" name="approved" value="false" formnovalidate>${escapeHtml(t(locale, "oauth.deny"))}</button>
67
114
  </p>
68
115
  </form>
@@ -20,6 +20,8 @@ export declare const OAUTH_ENV: {
20
20
  readonly registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS";
21
21
  /** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
22
22
  readonly allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES";
23
+ /** Optional display name shown above the consent page title (default: none). */
24
+ readonly brandName: "AUTHORIZE_BRAND_NAME";
23
25
  };
24
26
  /** Scope granted when a client names none: MCP access plus the OIDC subject. */
25
27
  export declare const MCP_SCOPE = "mcp:full";
@@ -53,3 +55,11 @@ export type AuthorizeSettingsResult = {
53
55
  * secret key is then not required.
54
56
  */
55
57
  export declare function readAuthorizeSettings(env: NodeJS.ProcessEnv, clerkCollaboratorsInjected?: boolean): Promise<AuthorizeSettingsResult>;
58
+ /**
59
+ * True when the configured Clerk instance is a DEVELOPMENT one (an `sk_test_` secret key, or a
60
+ * Frontend API domain under `accounts.dev`) while PUBLIC_BASE_URL is not loopback. A development
61
+ * instance's hosted sign-in redirects only to localhost or to the instance Home URL, so a remote
62
+ * server needs that Home URL pointed at it. Absent or malformed values answer `false`: this feeds
63
+ * a warning, and the readiness of the settings is decided by `readAuthorizeSettings`, not here.
64
+ */
65
+ export declare function isDevClerkInstanceOnRemoteServer(env: NodeJS.ProcessEnv): boolean;
@@ -20,6 +20,8 @@ export const OAUTH_ENV = {
20
20
  registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS",
21
21
  /** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
22
22
  allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES",
23
+ /** Optional display name shown above the consent page title (default: none). */
24
+ brandName: "AUTHORIZE_BRAND_NAME",
23
25
  };
24
26
  /** Scope granted when a client names none: MCP access plus the OIDC subject. */
25
27
  export const MCP_SCOPE = "mcp:full";
@@ -116,3 +118,32 @@ export async function readAuthorizeSettings(env, clerkCollaboratorsInjected = fa
116
118
  },
117
119
  };
118
120
  }
121
+ /**
122
+ * True when the configured Clerk instance is a DEVELOPMENT one (an `sk_test_` secret key, or a
123
+ * Frontend API domain under `accounts.dev`) while PUBLIC_BASE_URL is not loopback. A development
124
+ * instance's hosted sign-in redirects only to localhost or to the instance Home URL, so a remote
125
+ * server needs that Home URL pointed at it. Absent or malformed values answer `false`: this feeds
126
+ * a warning, and the readiness of the settings is decided by `readAuthorizeSettings`, not here.
127
+ */
128
+ export function isDevClerkInstanceOnRemoteServer(env) {
129
+ let baseHost;
130
+ try {
131
+ baseHost = new URL(env[OAUTH_ENV.publicBaseUrl] ?? "").hostname;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ if (baseHost === "" || LOOPBACK_HOSTS.has(baseHost) || LOOPBACK_HOSTS.has(`[${baseHost}]`)) {
137
+ return false;
138
+ }
139
+ const devKey = (env[OAUTH_ENV.clerkSecretKey] ?? "").startsWith("sk_test_");
140
+ let devDomain = false;
141
+ try {
142
+ const host = new URL(env[OAUTH_ENV.clerkIssuer] ?? "").hostname;
143
+ devDomain = host === "accounts.dev" || host.endsWith(".accounts.dev");
144
+ }
145
+ catch {
146
+ devDomain = false;
147
+ }
148
+ return devKey || devDomain;
149
+ }
@@ -33,3 +33,5 @@ MCP_OAUTH_SIGNING_JWK=
33
33
  # MCP_MAX_BODY_BYTES=1048576
34
34
  # Browser origins allowed to call /register cross-origin (comma-separated; default none).
35
35
  # MCP_REGISTER_CORS_ORIGINS=https://inspector.example.com
36
+ # Display name shown above the consent page title (default: none).
37
+ # AUTHORIZE_BRAND_NAME=Acme MCP
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.4",
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.4",
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"