@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/README.fr.md +48 -1
- package/README.md +44 -1
- package/dist/locales/en.json +2 -1
- package/dist/locales/fr.json +2 -1
- package/dist/nodeAdapter.js +9 -1
- package/dist/oauth/authorizationServer.d.ts +6 -0
- package/dist/oauth/authorizationServer.js +83 -6
- package/dist/oauth/clientAssertion.d.ts +28 -0
- package/dist/oauth/clientAssertion.js +106 -0
- package/dist/oauth/clientRegistry.d.ts +15 -0
- package/dist/oauth/clientRegistry.js +63 -36
- package/dist/oauth/settings.d.ts +8 -0
- package/dist/oauth/settings.js +29 -0
- package/package.json +2 -2
- package/plugin.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vantage-mcp-boilerplate",
|
|
3
3
|
"displayName": "VantageOS MCP Boilerplate",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.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.
|
|
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.
|
|
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
|
package/dist/locales/en.json
CHANGED
|
@@ -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
|
}
|
package/dist/locales/fr.json
CHANGED
|
@@ -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
|
}
|
package/dist/nodeAdapter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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({
|
|
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:
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
}
|
package/dist/oauth/settings.d.ts
CHANGED
|
@@ -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;
|
package/dist/oauth/settings.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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"
|