@vantageos/mcp-boilerplate 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +161 -0
- package/README.fr.md +323 -15
- package/README.md +306 -15
- package/dist/buildIdentity.d.ts +19 -0
- package/dist/buildIdentity.js +62 -0
- package/dist/declaredVariables.d.ts +205 -0
- package/dist/declaredVariables.js +451 -0
- package/dist/exampleItems.d.ts +15 -0
- package/dist/exampleItems.js +28 -1
- package/dist/httpApp.d.ts +17 -0
- package/dist/httpApp.js +19 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/lake/convex.d.ts +101 -0
- package/dist/lake/convex.js +224 -0
- package/dist/lake/reader.d.ts +88 -0
- package/dist/lake/reader.js +120 -0
- package/dist/locales/en.json +42 -0
- package/dist/locales/fr.json +42 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +116 -48
- package/dist/oauth/authorizationServer.js +64 -18
- package/dist/oauth/clerk.d.ts +2 -0
- package/dist/oauth/clerk.js +15 -5
- package/dist/oauth/clientRegistry.d.ts +19 -4
- package/dist/oauth/clientRegistry.js +20 -10
- package/dist/oauth/picker.d.ts +7 -2
- package/dist/oauth/picker.js +90 -13
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +4 -0
- package/dist/oauth/settings.js +4 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/serverManifest.d.ts +32 -0
- package/dist/serverManifest.js +68 -0
- package/dist/stdio.js +5 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +94 -0
- package/dist/toolRecord.js +52 -0
- package/dist/ui/uiResource.d.ts +11 -4
- package/dist/ui/uiResource.js +8 -4
- package/dist/upstream/clientCredentials.d.ts +106 -0
- package/dist/upstream/clientCredentials.js +283 -0
- package/dist/upstream/paginatedClient.d.ts +142 -0
- package/dist/upstream/paginatedClient.js +422 -0
- package/dist/verifiedPerson.d.ts +85 -0
- package/dist/verifiedPerson.js +112 -0
- package/docs/oauth-stores.md +2 -1
- package/examples/oauth.env.example +7 -0
- package/mcp-server.json +13 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- package/skills/connect-mcp-server/SKILL.md +5 -5
|
@@ -20,20 +20,24 @@
|
|
|
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,
|
|
23
|
+
import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resolveClerkSessionFromRequest, resumeAuthorize, startAuthorize, } 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
|
-
import { detectLocaleFromAcceptLanguage, t } from "../i18n.js";
|
|
27
|
+
import { detectLocale, detectLocaleFromAcceptLanguage, t } from "../i18n.js";
|
|
28
28
|
import { createClerkBackendCollaborators } from "./clerk.js";
|
|
29
29
|
import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier } from "./clientAssertion.js";
|
|
30
|
-
import { createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
|
|
30
|
+
import { RegistryFullError, createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
|
|
31
31
|
import { createInMemoryCodeStore } from "./codeStore.js";
|
|
32
32
|
import { pickerContentSecurityPolicy, renderOrgPicker } from "./picker.js";
|
|
33
|
+
import { createRegisterLimiter } from "./registerLimiter.js";
|
|
33
34
|
import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, isDevClerkInstanceOnRemoteServer, readAuthorizeSettings, } from "./settings.js";
|
|
34
35
|
import { ACCESS_TOKEN_TTL_SECONDS, createTokenIssuer } from "./tokenIssuer.js";
|
|
35
36
|
const REGISTER_LIMIT_PER_MINUTE = 20;
|
|
37
|
+
/** Default of OAUTH_ENV.registerLimitPer48: five /64 budgets for a whole /48. */
|
|
38
|
+
const REGISTER_LIMIT_PER_48_PER_MINUTE = 100;
|
|
36
39
|
const MAX_REDIRECT_URIS = 10;
|
|
40
|
+
const REGISTRY_FULL_RETRY_AFTER_SECONDS = 300;
|
|
37
41
|
function noStore(c) {
|
|
38
42
|
c.header("Cache-Control", "no-store");
|
|
39
43
|
c.header("Pragma", "no-cache");
|
|
@@ -84,11 +88,16 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
84
88
|
const env = deps.env ?? process.env;
|
|
85
89
|
// Operator knobs are validated here, at construction: a bad value stops the boot.
|
|
86
90
|
const trustedProxyCount = integerKnob(env, OAUTH_ENV.trustedProxyCount, 0, 10) ?? 0;
|
|
91
|
+
const registerLimitPer48 = integerKnob(env, OAUTH_ENV.registerLimitPer48, 1) ?? REGISTER_LIMIT_PER_48_PER_MINUTE;
|
|
87
92
|
const registerOrigins = originListKnob(env, OAUTH_ENV.registerCorsOrigins);
|
|
88
93
|
const allowInMemory = flagKnob(env, OAUTH_ENV.allowInMemoryStores) === true;
|
|
89
94
|
const now = deps.now ?? (() => Date.now());
|
|
90
95
|
// Fail at boot, loudly, rather than lose every code and client on the first restart.
|
|
91
96
|
if (!deps.codeStore || !deps.clientRegistry) {
|
|
97
|
+
if (env.NODE_ENV !== "production" && !allowInMemory) {
|
|
98
|
+
// Development, test or unset: start, but never silently. One line, both languages.
|
|
99
|
+
console.warn(`[oauth] ${t("en", "oauth.inMemoryWarning").replace("{knob}", OAUTH_ENV.allowInMemoryStores)} / ${t("fr", "oauth.inMemoryWarning").replace("{knob}", OAUTH_ENV.allowInMemoryStores)}`);
|
|
100
|
+
}
|
|
92
101
|
if (env.NODE_ENV === "production") {
|
|
93
102
|
if (!allowInMemory) {
|
|
94
103
|
throw new Error(`NODE_ENV=production refuses the in-memory OAuth stores (codes and registered clients are lost on restart and not shared between instances). Inject durable adapters (docs/oauth-stores.md), or set ${OAUTH_ENV.allowInMemoryStores}=1 to accept a single-instance deployment that forgets them on restart.`);
|
|
@@ -177,6 +186,18 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
177
186
|
error_description: t("en", "oauth.unavailable"),
|
|
178
187
|
}, 503);
|
|
179
188
|
};
|
|
189
|
+
/**
|
|
190
|
+
* The redirect URI(s) the picker's form may end up at. cloud-identity hands the page only the
|
|
191
|
+
* ORIGIN of the validated redirect_uri: for http(s) that is the destination itself; for a
|
|
192
|
+
* custom scheme the origin is the opaque "null", so the scheme is read from the client's
|
|
193
|
+
* registered URIs of that scheme (the validated one is among them).
|
|
194
|
+
*/
|
|
195
|
+
const redirectTargetsOf = async (model) => {
|
|
196
|
+
if (/^https?:\/\//.test(model.redirectOrigin))
|
|
197
|
+
return [model.redirectOrigin];
|
|
198
|
+
const client = await lookupClient(model.client.clientId);
|
|
199
|
+
return (client?.redirectUris ?? []).filter((u) => !/^https?:/i.test(u));
|
|
200
|
+
};
|
|
180
201
|
const respond = async (c, outcome, settings) => {
|
|
181
202
|
noStore(c);
|
|
182
203
|
c.header("Referrer-Policy", "no-referrer");
|
|
@@ -190,11 +211,21 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
190
211
|
return c.redirect(back.toString(), 302);
|
|
191
212
|
}
|
|
192
213
|
case "org-picker": {
|
|
193
|
-
|
|
214
|
+
let csp;
|
|
215
|
+
try {
|
|
216
|
+
csp = await pickerContentSecurityPolicy(await redirectTargetsOf(outcome.model));
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
// Fail closed: without a parseable destination the page is not rendered at all.
|
|
220
|
+
console.error("[oauth] picker refused:", err instanceof Error ? err.message : err);
|
|
221
|
+
return c.json({ error: "server_error", error_description: "picker unavailable" }, 500);
|
|
222
|
+
}
|
|
223
|
+
c.header("Content-Security-Policy", csp);
|
|
194
224
|
const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
195
225
|
return c.html(renderOrgPicker(outcome.model, locale, {
|
|
196
226
|
// Only a URL-shaped (CIMD) client_id is bound to a domain its owner controls.
|
|
197
227
|
unverified: !isUrlShapedClientId(outcome.model.client.clientId),
|
|
228
|
+
brandName: env[OAUTH_ENV.brandName]?.trim() || undefined,
|
|
198
229
|
}));
|
|
199
230
|
}
|
|
200
231
|
case "refused": {
|
|
@@ -203,19 +234,11 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
203
234
|
}
|
|
204
235
|
}
|
|
205
236
|
};
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
registerHits.set(key, { windowStart: t0, count: 1 });
|
|
212
|
-
if (registerHits.size > 5000)
|
|
213
|
-
registerHits.clear();
|
|
214
|
-
return true;
|
|
215
|
-
}
|
|
216
|
-
hit.count += 1;
|
|
217
|
-
return hit.count <= REGISTER_LIMIT_PER_MINUTE;
|
|
218
|
-
};
|
|
237
|
+
const registerLimiter = createRegisterLimiter({
|
|
238
|
+
limitPerMinute: REGISTER_LIMIT_PER_MINUTE,
|
|
239
|
+
prefix48LimitPerMinute: registerLimitPer48,
|
|
240
|
+
now,
|
|
241
|
+
});
|
|
219
242
|
return {
|
|
220
243
|
async resourceServer() {
|
|
221
244
|
const result = await settingsNow();
|
|
@@ -281,7 +304,7 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
281
304
|
});
|
|
282
305
|
app.post("/register", async (c) => {
|
|
283
306
|
const key = clientKeyOf(c, trustedProxyCount);
|
|
284
|
-
if (!
|
|
307
|
+
if (!registerLimiter.allow(key)) {
|
|
285
308
|
c.header("Retry-After", "60");
|
|
286
309
|
return c.json({ error: "too_many_requests" }, 429);
|
|
287
310
|
}
|
|
@@ -325,6 +348,19 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
325
348
|
});
|
|
326
349
|
}
|
|
327
350
|
catch (err) {
|
|
351
|
+
if (err instanceof RegistryFullError) {
|
|
352
|
+
// RFC 7591 style JSON error; the caller retries, no live client was displaced.
|
|
353
|
+
const locale = c.req.header("x-mcp-locale")
|
|
354
|
+
? detectLocale(c.req.header("x-mcp-locale"))
|
|
355
|
+
: detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
356
|
+
console.error("[oauth] /register refused: client registry is full");
|
|
357
|
+
c.header("Retry-After", String(REGISTRY_FULL_RETRY_AFTER_SECONDS));
|
|
358
|
+
noStore(c);
|
|
359
|
+
return c.json({
|
|
360
|
+
error: "temporarily_unavailable",
|
|
361
|
+
error_description: t(locale, "oauth.registryFull"),
|
|
362
|
+
}, 503);
|
|
363
|
+
}
|
|
328
364
|
console.error("[oauth] /register failed:", err instanceof Error ? err.message : err);
|
|
329
365
|
return c.json({ error: "server_error", error_description: "failed to persist client" }, 500);
|
|
330
366
|
}
|
|
@@ -408,6 +444,16 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
408
444
|
// session, then answers the redirect-to-client with error=access_denied and the
|
|
409
445
|
// client's state. `respond` appends `iss` to it like to every authorization response.
|
|
410
446
|
if (field("approved") === "false") {
|
|
447
|
+
// The denial is a state-changing POST like the approval. An absent token is
|
|
448
|
+
// refused here with a localized 400; a present one is verified by the package
|
|
449
|
+
// (cloud-identity >= 0.18) with the approve-path function, and a forged one is
|
|
450
|
+
// refused with no redirect.
|
|
451
|
+
if (!field("consentToken")) {
|
|
452
|
+
return c.json({
|
|
453
|
+
error: "invalid_request",
|
|
454
|
+
error_description: t(detectLocaleFromAcceptLanguage(c.req.header("accept-language")), "oauth.consentTokenRequired"),
|
|
455
|
+
}, 400);
|
|
456
|
+
}
|
|
411
457
|
const denied = await resumeAuthorize({
|
|
412
458
|
state: field("state") ?? "",
|
|
413
459
|
sessionToken: sessionTokenFrom(c),
|
package/dist/oauth/clerk.d.ts
CHANGED
|
@@ -15,5 +15,7 @@ export interface ClerkCollaborators {
|
|
|
15
15
|
export interface ClerkBackendOptions {
|
|
16
16
|
/** Per-call time budget (JWKS, memberships, user). Default 5 s. */
|
|
17
17
|
timeoutMs?: number;
|
|
18
|
+
/** Total budget of ONE lookup across all its pages and calls. Default 15 s. */
|
|
19
|
+
totalTimeoutMs?: number;
|
|
18
20
|
}
|
|
19
21
|
export declare function createClerkBackendCollaborators(secretKey: string, fetchImpl?: typeof fetch, options?: ClerkBackendOptions): ClerkCollaborators;
|
package/dist/oauth/clerk.js
CHANGED
|
@@ -12,13 +12,22 @@ const MEMBERSHIP_PAGE_LIMIT = 100;
|
|
|
12
12
|
/** 50 pages x 100 = 5 000 memberships: past it the lookup FAILS rather than truncate. */
|
|
13
13
|
const MEMBERSHIP_MAX_PAGES = 50;
|
|
14
14
|
const CLERK_TIMEOUT_MS = 5000;
|
|
15
|
+
const CLERK_TOTAL_TIMEOUT_MS = 15_000;
|
|
15
16
|
export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, options = {}) {
|
|
16
17
|
const timeoutMs = options.timeoutMs ?? CLERK_TIMEOUT_MS;
|
|
17
18
|
let cachedJwks = null;
|
|
18
|
-
const
|
|
19
|
+
const totalMs = options.totalTimeoutMs ?? CLERK_TOTAL_TIMEOUT_MS;
|
|
20
|
+
/** Per-call timeout AND what is left of the lookup's total deadline, whichever is sooner. */
|
|
21
|
+
const signalFor = (deadline) => {
|
|
22
|
+
const left = deadline - Date.now();
|
|
23
|
+
if (left <= 0)
|
|
24
|
+
throw new Error(`Clerk lookup exceeded its total deadline of ${totalMs} ms`);
|
|
25
|
+
return AbortSignal.any([AbortSignal.timeout(timeoutMs), AbortSignal.timeout(left)]);
|
|
26
|
+
};
|
|
27
|
+
const api = async (path, deadline) => {
|
|
19
28
|
const res = await fetchImpl(`${CLERK_API}${path}`, {
|
|
20
29
|
headers: { Authorization: `Bearer ${secretKey}`, Accept: "application/json" },
|
|
21
|
-
signal:
|
|
30
|
+
signal: signalFor(deadline),
|
|
22
31
|
});
|
|
23
32
|
if (!res.ok)
|
|
24
33
|
throw new Error(`Clerk Backend API answered ${res.status}`);
|
|
@@ -31,7 +40,7 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, op
|
|
|
31
40
|
return cachedJwks.keys;
|
|
32
41
|
}
|
|
33
42
|
const res = await fetchImpl(`${issuer}/.well-known/jwks.json`, {
|
|
34
|
-
signal:
|
|
43
|
+
signal: signalFor(now + totalMs),
|
|
35
44
|
});
|
|
36
45
|
if (!res.ok)
|
|
37
46
|
throw new Error(`Clerk JWKS answered ${res.status}`);
|
|
@@ -41,9 +50,10 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, op
|
|
|
41
50
|
},
|
|
42
51
|
async listMemberships(userId) {
|
|
43
52
|
const all = [];
|
|
53
|
+
const deadline = Date.now() + totalMs;
|
|
44
54
|
for (let page = 0; page < MEMBERSHIP_MAX_PAGES; page++) {
|
|
45
55
|
const offset = page * MEMBERSHIP_PAGE_LIMIT;
|
|
46
|
-
const body = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}&offset=${offset}
|
|
56
|
+
const body = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}&offset=${offset}`, deadline);
|
|
47
57
|
all.push(...body.data);
|
|
48
58
|
const last = body.data.length < MEMBERSHIP_PAGE_LIMIT ||
|
|
49
59
|
(typeof body.total_count === "number" && all.length >= body.total_count);
|
|
@@ -62,7 +72,7 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, op
|
|
|
62
72
|
throw new Error(`Clerk memberships exceed ${MEMBERSHIP_MAX_PAGES * MEMBERSHIP_PAGE_LIMIT}`);
|
|
63
73
|
},
|
|
64
74
|
async getUser(userId) {
|
|
65
|
-
const user = await api(`/users/${encodeURIComponent(userId)}
|
|
75
|
+
const user = await api(`/users/${encodeURIComponent(userId)}`, Date.now() + totalMs);
|
|
66
76
|
return {
|
|
67
77
|
id: user.id,
|
|
68
78
|
primaryEmailAddressId: user.primary_email_address_id ?? null,
|
|
@@ -25,20 +25,35 @@ export interface ClientRegistry {
|
|
|
25
25
|
register(client: RegisteredClient): Promise<void>;
|
|
26
26
|
}
|
|
27
27
|
export interface InMemoryClientRegistryOptions {
|
|
28
|
-
/** Hard cap on stored clients; at the cap
|
|
28
|
+
/** Hard cap on stored clients; at the cap a new registration is REFUSED. Default 10 000. */
|
|
29
29
|
maxClients?: number;
|
|
30
30
|
/** Lifetime of a registration, counted from `issuedAt`. Default 30 days. */
|
|
31
31
|
ttlSeconds?: number;
|
|
32
32
|
now?: () => number;
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
35
|
+
* Thrown by `register` when the registry is full of live clients. The route answers 503 with
|
|
36
|
+
* `Retry-After`; a durable adapter may throw the same error for its own quota.
|
|
37
|
+
*/
|
|
38
|
+
export declare class RegistryFullError extends Error {
|
|
39
|
+
readonly code = "registry_full";
|
|
40
|
+
constructor();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit.
|
|
44
|
+
* Expired clients are pruned on every registration BEFORE the cap is judged; at the cap a new
|
|
45
|
+
* registration is refused with `RegistryFullError`. A live client is never evicted: an
|
|
46
|
+
* unauthenticated flood must not be able to push a real client out (eviction, introduced for
|
|
47
|
+
* issue #9, allowed exactly that).
|
|
38
48
|
*/
|
|
39
49
|
export declare function createInMemoryClientRegistry(options?: InMemoryClientRegistryOptions): ClientRegistry;
|
|
40
50
|
/** True for an https URL, or an http URL on a loopback host (native-app redirects). */
|
|
41
51
|
export declare function isAllowedRedirectUri(raw: unknown): raw is string;
|
|
52
|
+
/**
|
|
53
|
+
* The eight 16-bit groups of a SYNTACTICALLY VALID IPv6 literal (`isIP` has already accepted
|
|
54
|
+
* it). Only used to read bits that no BlockList prefix can express.
|
|
55
|
+
*/
|
|
56
|
+
export declare function ipv6Groups(ip: string): number[];
|
|
42
57
|
/** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
|
|
43
58
|
export declare function isPublicAddress(ip: string): boolean;
|
|
44
59
|
export declare function defaultResolveHost(host: string): Promise<string[]>;
|
|
@@ -13,9 +13,22 @@
|
|
|
13
13
|
import { lookup } from "node:dns/promises";
|
|
14
14
|
import { BlockList, isIP } from "node:net";
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
16
|
+
* Thrown by `register` when the registry is full of live clients. The route answers 503 with
|
|
17
|
+
* `Retry-After`; a durable adapter may throw the same error for its own quota.
|
|
18
|
+
*/
|
|
19
|
+
export class RegistryFullError extends Error {
|
|
20
|
+
code = "registry_full";
|
|
21
|
+
constructor() {
|
|
22
|
+
super("client registry is full");
|
|
23
|
+
this.name = "RegistryFullError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit.
|
|
28
|
+
* Expired clients are pruned on every registration BEFORE the cap is judged; at the cap a new
|
|
29
|
+
* registration is refused with `RegistryFullError`. A live client is never evicted: an
|
|
30
|
+
* unauthenticated flood must not be able to push a real client out (eviction, introduced for
|
|
31
|
+
* issue #9, allowed exactly that).
|
|
19
32
|
*/
|
|
20
33
|
export function createInMemoryClientRegistry(options = {}) {
|
|
21
34
|
const maxClients = options.maxClients ?? 10_000;
|
|
@@ -38,12 +51,9 @@ export function createInMemoryClientRegistry(options = {}) {
|
|
|
38
51
|
for (const [id, row] of clients)
|
|
39
52
|
if (expired(row))
|
|
40
53
|
clients.delete(id);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (oldest.done)
|
|
45
|
-
break;
|
|
46
|
-
clients.delete(oldest.value);
|
|
54
|
+
// Re-registering an id already held replaces it: that is not a new slot.
|
|
55
|
+
if (!clients.has(client.clientId) && clients.size >= maxClients) {
|
|
56
|
+
throw new RegistryFullError();
|
|
47
57
|
}
|
|
48
58
|
clients.set(client.clientId, client);
|
|
49
59
|
},
|
|
@@ -132,7 +142,7 @@ for (const [addr, len] of BLOCKED_V6)
|
|
|
132
142
|
* The eight 16-bit groups of a SYNTACTICALLY VALID IPv6 literal (`isIP` has already accepted
|
|
133
143
|
* it). Only used to read bits that no BlockList prefix can express.
|
|
134
144
|
*/
|
|
135
|
-
function ipv6Groups(ip) {
|
|
145
|
+
export function ipv6Groups(ip) {
|
|
136
146
|
let text = ip.toLowerCase();
|
|
137
147
|
const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text);
|
|
138
148
|
if (dotted) {
|
package/dist/oauth/picker.d.ts
CHANGED
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { OrgPickerModel } from "@vantageos/cloud-identity";
|
|
8
8
|
import { type McpLocale } from "../i18n.js";
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* CSP for the picker page: no scripts, no external assets, one hashed inline style, and forms
|
|
11
|
+
* that post to this server and are redirected to the client's `redirect_uri` only.
|
|
12
|
+
*/
|
|
13
|
+
export declare function pickerContentSecurityPolicy(redirectUris: readonly string[]): Promise<string>;
|
|
11
14
|
/** What the consent page must tell the person beyond the model: whether the client is trusted. */
|
|
12
15
|
export interface ConsentContext {
|
|
13
16
|
/** True for a Dynamic Client Registration client: it vouched for itself, nobody else did. */
|
|
14
17
|
unverified: boolean;
|
|
18
|
+
/** The deployment's display name (AUTHORIZE_BRAND_NAME), shown above the title; none by default. */
|
|
19
|
+
brandName?: string;
|
|
15
20
|
}
|
|
16
21
|
export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale, consent: ConsentContext): string;
|
package/dist/oauth/picker.js
CHANGED
|
@@ -13,46 +13,123 @@ function escapeHtml(value) {
|
|
|
13
13
|
.replace(/"/g, """)
|
|
14
14
|
.replace(/'/g, "'");
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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("");
|
|
64
|
+
/**
|
|
65
|
+
* The `form-action` sources for the redirect URIs of THIS authorization. Chromium enforces
|
|
66
|
+
* `form-action` on the redirect that follows a form POST, and POST /authorize/org answers a
|
|
67
|
+
* 302 to the client's `redirect_uri` (always another origin), so that destination MUST be
|
|
68
|
+
* listed or the client never receives its code. Built from `new URL(...)`, never from raw
|
|
69
|
+
* input: an http(s) URI contributes its origin (loopback keeps its port), any other URI its
|
|
70
|
+
* scheme (`cursor:`). Fail closed: no URI, or one that does not parse, throws; the page is
|
|
71
|
+
* never rendered with `'self'` alone.
|
|
72
|
+
*/
|
|
73
|
+
function formActionSources(redirectUris) {
|
|
74
|
+
if (redirectUris.length === 0)
|
|
75
|
+
throw new Error("picker CSP: no redirect_uri to permit");
|
|
76
|
+
const sources = new Set();
|
|
77
|
+
for (const raw of redirectUris) {
|
|
78
|
+
let url;
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(raw);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error("picker CSP: redirect_uri does not parse");
|
|
84
|
+
}
|
|
85
|
+
sources.add(url.protocol === "https:" || url.protocol === "http:" ? url.origin : url.protocol);
|
|
86
|
+
}
|
|
87
|
+
return [...sources];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* CSP for the picker page: no scripts, no external assets, one hashed inline style, and forms
|
|
91
|
+
* that post to this server and are redirected to the client's `redirect_uri` only.
|
|
92
|
+
*/
|
|
93
|
+
export async function pickerContentSecurityPolicy(redirectUris) {
|
|
94
|
+
const formAction = ["'self'", ...formActionSources(redirectUris)].join(" ");
|
|
21
95
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(PICKER_STYLE));
|
|
22
96
|
const hash = btoa(String.fromCharCode(...new Uint8Array(digest)));
|
|
23
97
|
return [
|
|
24
98
|
"default-src 'none'",
|
|
25
99
|
`style-src 'sha256-${hash}'`,
|
|
26
100
|
"base-uri 'none'",
|
|
101
|
+
`form-action ${formAction}`,
|
|
27
102
|
"frame-ancestors 'none'",
|
|
28
103
|
].join("; ");
|
|
29
104
|
}
|
|
30
105
|
export function renderOrgPicker(model, locale, consent) {
|
|
31
106
|
const destination = escapeHtml(model.redirectOrigin);
|
|
32
107
|
const unverified = consent.unverified
|
|
33
|
-
? `<p role="alert"><strong>${escapeHtml(t(locale, "oauth.pickerUnverified"))}</strong></p>\n`
|
|
108
|
+
? `<p class="alert" role="alert"><strong>${escapeHtml(t(locale, "oauth.pickerUnverified"))}</strong></p>\n`
|
|
34
109
|
: "";
|
|
35
110
|
const clientName = model.client.clientName ?? model.client.clientId;
|
|
36
111
|
const only = model.organizations.length === 1;
|
|
37
112
|
const orgs = model.organizations
|
|
38
|
-
.map((o, i) => `<label><input type="radio" name="orgId" value="${escapeHtml(o.id)}" required${only && i === 0 ? " checked" : ""}
|
|
113
|
+
.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
114
|
.join("\n");
|
|
40
|
-
const title = escapeHtml(t(locale, "oauth.pickerTitle").replace("{client}", clientName));
|
|
41
|
-
const intro = escapeHtml(t(locale, "oauth.pickerIntro").replaceAll("{client}", clientName));
|
|
115
|
+
const title = escapeHtml(t(locale, "oauth.pickerTitle").replace("{client}", () => clientName));
|
|
116
|
+
const intro = escapeHtml(t(locale, "oauth.pickerIntro").replaceAll("{client}", () => clientName));
|
|
117
|
+
const brand = consent.brandName ? `<p class="brand">${escapeHtml(consent.brandName)}</p>\n` : "";
|
|
42
118
|
return `<!doctype html>
|
|
43
119
|
<html lang="${locale}">
|
|
44
120
|
<head>
|
|
45
121
|
<meta charset="utf-8">
|
|
46
122
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
47
123
|
<meta name="referrer" content="no-referrer">
|
|
124
|
+
<meta name="color-scheme" content="dark light">
|
|
48
125
|
<title>${title}</title>
|
|
49
126
|
<style>${PICKER_STYLE}</style>
|
|
50
127
|
</head>
|
|
51
128
|
<body>
|
|
52
129
|
<main>
|
|
53
|
-
<h1>${title}</h1>
|
|
130
|
+
${brand}<h1>${title}</h1>
|
|
54
131
|
<p>${intro}</p>
|
|
55
|
-
<p>${escapeHtml(t(locale, "oauth.pickerDestination"))} <strong>${destination}</strong><br>
|
|
132
|
+
<p class="muted">${escapeHtml(t(locale, "oauth.pickerDestination"))} <strong>${destination}</strong><br>
|
|
56
133
|
${escapeHtml(t(locale, "oauth.pickerScope"))} <code>${escapeHtml(model.scope)}</code></p>
|
|
57
134
|
${unverified}<form method="post" action="/authorize/org">
|
|
58
135
|
<input type="hidden" name="state" value="${escapeHtml(model.state)}">
|
|
@@ -61,8 +138,8 @@ ${unverified}<form method="post" action="/authorize/org">
|
|
|
61
138
|
<legend>${escapeHtml(t(locale, "oauth.pickerOrganisation"))}</legend>
|
|
62
139
|
${orgs}
|
|
63
140
|
</fieldset>
|
|
64
|
-
<p>
|
|
65
|
-
<button type="submit" name="approved" value="true">${escapeHtml(t(locale, "oauth.approve"))}</button>
|
|
141
|
+
<p class="actions">
|
|
142
|
+
<button class="primary" type="submit" name="approved" value="true">${escapeHtml(t(locale, "oauth.approve"))}</button>
|
|
66
143
|
<button type="submit" name="approved" value="false" formnovalidate>${escapeHtml(t(locale, "oauth.deny"))}</button>
|
|
67
144
|
</p>
|
|
68
145
|
</form>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiter of the unauthenticated `POST /register`.
|
|
3
|
+
*
|
|
4
|
+
* Keys are NORMALISED so one host cannot multiply its budget: an IPv6 client is keyed on its
|
|
5
|
+
* /64 prefix (a host owns a whole /64, so per-address keys are free), an IPv4-mapped IPv6
|
|
6
|
+
* address is keyed as the IPv4 address it carries, IPv4 keeps the full address. Parsing is
|
|
7
|
+
* `node:net`'s (`isIP`) plus the group reader the SSRF guard already uses.
|
|
8
|
+
*
|
|
9
|
+
* A /48 holds 65536 /64s, each with its own budget, so an IPv6 client is ALSO counted against
|
|
10
|
+
* its /48 when a `/48` budget is configured: a request passes only while both its /64 and its
|
|
11
|
+
* /48 are under their limits. A request already refused by its /64 is not charged to the /48. IPv4 and IPv4-mapped addresses have no /48 and are unaffected.
|
|
12
|
+
*
|
|
13
|
+
* Memory is bounded without ever handing an attacker a reset. When the table is full the
|
|
14
|
+
* least-recently-used key is evicted, but keys still inside a window and not yet limited go
|
|
15
|
+
* first: a key that already hit the limit is the last thing dropped, so a flood of fresh keys
|
|
16
|
+
* cannot wipe a counter that is doing its job. (A bare `clear()` did exactly that.)
|
|
17
|
+
*/
|
|
18
|
+
/** The counter key of a client address. A value that is not an IP literal is returned as is. */
|
|
19
|
+
export declare function normaliseClientKey(address: string): string;
|
|
20
|
+
/** The /48 aggregate key of a native IPv6 address, or null for IPv4, IPv4-mapped or non-IP. */
|
|
21
|
+
export declare function prefix48Key(address: string): string | null;
|
|
22
|
+
export interface RegisterLimiter {
|
|
23
|
+
/** Counts one request for `address`; false when it is over the limit for the window. */
|
|
24
|
+
allow(address: string): boolean;
|
|
25
|
+
/** Keys currently tracked (tests and diagnostics). */
|
|
26
|
+
size(): number;
|
|
27
|
+
}
|
|
28
|
+
export declare function createRegisterLimiter(options: {
|
|
29
|
+
limitPerMinute: number;
|
|
30
|
+
/** Aggregate budget of every /64 inside one IPv6 /48; absent means no /48 budget. */
|
|
31
|
+
prefix48LimitPerMinute?: number;
|
|
32
|
+
maxKeys?: number;
|
|
33
|
+
now: () => number;
|
|
34
|
+
}): RegisterLimiter;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiter of the unauthenticated `POST /register`.
|
|
3
|
+
*
|
|
4
|
+
* Keys are NORMALISED so one host cannot multiply its budget: an IPv6 client is keyed on its
|
|
5
|
+
* /64 prefix (a host owns a whole /64, so per-address keys are free), an IPv4-mapped IPv6
|
|
6
|
+
* address is keyed as the IPv4 address it carries, IPv4 keeps the full address. Parsing is
|
|
7
|
+
* `node:net`'s (`isIP`) plus the group reader the SSRF guard already uses.
|
|
8
|
+
*
|
|
9
|
+
* A /48 holds 65536 /64s, each with its own budget, so an IPv6 client is ALSO counted against
|
|
10
|
+
* its /48 when a `/48` budget is configured: a request passes only while both its /64 and its
|
|
11
|
+
* /48 are under their limits. A request already refused by its /64 is not charged to the /48. IPv4 and IPv4-mapped addresses have no /48 and are unaffected.
|
|
12
|
+
*
|
|
13
|
+
* Memory is bounded without ever handing an attacker a reset. When the table is full the
|
|
14
|
+
* least-recently-used key is evicted, but keys still inside a window and not yet limited go
|
|
15
|
+
* first: a key that already hit the limit is the last thing dropped, so a flood of fresh keys
|
|
16
|
+
* cannot wipe a counter that is doing its job. (A bare `clear()` did exactly that.)
|
|
17
|
+
*/
|
|
18
|
+
import { isIP } from "node:net";
|
|
19
|
+
import { ipv6Groups } from "./clientRegistry.js";
|
|
20
|
+
const WINDOW_MS = 60_000;
|
|
21
|
+
/** The counter key of a client address. A value that is not an IP literal is returned as is. */
|
|
22
|
+
export function normaliseClientKey(address) {
|
|
23
|
+
const bare = address.replace(/^\[|\]$/g, "").split("%")[0] ?? address;
|
|
24
|
+
const version = isIP(bare);
|
|
25
|
+
if (version === 4)
|
|
26
|
+
return bare;
|
|
27
|
+
if (version !== 6)
|
|
28
|
+
return address;
|
|
29
|
+
const g = ipv6Groups(bare);
|
|
30
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;
|
|
31
|
+
if (mapped)
|
|
32
|
+
return `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
|
|
33
|
+
return `${g
|
|
34
|
+
.slice(0, 4)
|
|
35
|
+
.map((x) => x.toString(16))
|
|
36
|
+
.join(":")}::/64`;
|
|
37
|
+
}
|
|
38
|
+
/** The /48 aggregate key of a native IPv6 address, or null for IPv4, IPv4-mapped or non-IP. */
|
|
39
|
+
export function prefix48Key(address) {
|
|
40
|
+
const bare = address.replace(/^\[|\]$/g, "").split("%")[0] ?? address;
|
|
41
|
+
if (isIP(bare) !== 6)
|
|
42
|
+
return null;
|
|
43
|
+
const g = ipv6Groups(bare);
|
|
44
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;
|
|
45
|
+
if (mapped)
|
|
46
|
+
return null;
|
|
47
|
+
return `${g
|
|
48
|
+
.slice(0, 3)
|
|
49
|
+
.map((x) => x.toString(16))
|
|
50
|
+
.join(":")}::/48`;
|
|
51
|
+
}
|
|
52
|
+
export function createRegisterLimiter(options) {
|
|
53
|
+
const { limitPerMinute, prefix48LimitPerMinute, now } = options;
|
|
54
|
+
const maxKeys = options.maxKeys ?? 5000;
|
|
55
|
+
// Two LRU tables (Map iteration order = insertion order, re-inserted on every hit): keys
|
|
56
|
+
// still allowed, and keys already limited. Eviction drains the first before the second.
|
|
57
|
+
const open = new Map();
|
|
58
|
+
const limited = new Map();
|
|
59
|
+
const evictOne = () => {
|
|
60
|
+
const table = open.size > 0 ? open : limited;
|
|
61
|
+
const oldest = table.keys().next();
|
|
62
|
+
if (!oldest.done)
|
|
63
|
+
table.delete(oldest.value);
|
|
64
|
+
};
|
|
65
|
+
/** Counts one hit on `key`; true when that key is now over `limit` for its window. */
|
|
66
|
+
const bump = (key, limit, t0) => {
|
|
67
|
+
const prior = open.get(key) ?? limited.get(key);
|
|
68
|
+
const expired = prior !== undefined && t0 - prior.windowStart >= WINDOW_MS;
|
|
69
|
+
open.delete(key);
|
|
70
|
+
limited.delete(key);
|
|
71
|
+
if (!prior || expired) {
|
|
72
|
+
while (open.size + limited.size >= maxKeys)
|
|
73
|
+
evictOne();
|
|
74
|
+
const fresh = { windowStart: t0, count: 1 };
|
|
75
|
+
(fresh.count > limit ? limited : open).set(key, fresh);
|
|
76
|
+
return fresh.count > limit;
|
|
77
|
+
}
|
|
78
|
+
prior.count += 1;
|
|
79
|
+
const over = prior.count > limit;
|
|
80
|
+
(over ? limited : open).set(key, prior);
|
|
81
|
+
return over;
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
allow(address) {
|
|
85
|
+
const t0 = now();
|
|
86
|
+
const overHost = bump(normaliseClientKey(address), limitPerMinute, t0);
|
|
87
|
+
// A host already over its own limit is refused without spending the shared /48 budget,
|
|
88
|
+
// so one hammering /64 cannot lock out the other /64s of its /48.
|
|
89
|
+
const wide = overHost || prefix48LimitPerMinute === undefined ? null : prefix48Key(address);
|
|
90
|
+
const overWide = wide === null ? false : bump(wide, prefix48LimitPerMinute, t0);
|
|
91
|
+
return !overHost && !overWide;
|
|
92
|
+
},
|
|
93
|
+
size: () => open.size + limited.size,
|
|
94
|
+
};
|
|
95
|
+
}
|
package/dist/oauth/settings.d.ts
CHANGED
|
@@ -16,10 +16,14 @@ export declare const OAUTH_ENV: {
|
|
|
16
16
|
readonly signingJwk: "MCP_OAUTH_SIGNING_JWK";
|
|
17
17
|
/** Number of trusted reverse proxies in front of this server (default 0). */
|
|
18
18
|
readonly trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT";
|
|
19
|
+
/** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
|
|
20
|
+
readonly registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE";
|
|
19
21
|
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
20
22
|
readonly registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS";
|
|
21
23
|
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
|
22
24
|
readonly allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES";
|
|
25
|
+
/** Optional display name shown above the consent page title (default: none). */
|
|
26
|
+
readonly brandName: "AUTHORIZE_BRAND_NAME";
|
|
23
27
|
};
|
|
24
28
|
/** Scope granted when a client names none: MCP access plus the OIDC subject. */
|
|
25
29
|
export declare const MCP_SCOPE = "mcp:full";
|
package/dist/oauth/settings.js
CHANGED
|
@@ -16,10 +16,14 @@ export const OAUTH_ENV = {
|
|
|
16
16
|
signingJwk: "MCP_OAUTH_SIGNING_JWK",
|
|
17
17
|
/** Number of trusted reverse proxies in front of this server (default 0). */
|
|
18
18
|
trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT",
|
|
19
|
+
/** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
|
|
20
|
+
registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE",
|
|
19
21
|
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
20
22
|
registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS",
|
|
21
23
|
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
|
22
24
|
allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES",
|
|
25
|
+
/** Optional display name shown above the consent page title (default: none). */
|
|
26
|
+
brandName: "AUTHORIZE_BRAND_NAME",
|
|
23
27
|
};
|
|
24
28
|
/** Scope granted when a client names none: MCP access plus the OIDC subject. */
|
|
25
29
|
export const MCP_SCOPE = "mcp:full";
|