@vantageos/mcp-boilerplate 0.6.2 → 0.7.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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +36 -0
  3. package/README.fr.md +105 -6
  4. package/README.md +101 -8
  5. package/dist/hostVerification.d.ts +15 -0
  6. package/dist/hostVerification.js +32 -0
  7. package/dist/http.js +4 -1
  8. package/dist/httpApp.d.ts +9 -0
  9. package/dist/httpApp.js +55 -2
  10. package/dist/listCursor.d.ts +10 -0
  11. package/dist/listCursor.js +37 -0
  12. package/dist/locales/en.json +15 -1
  13. package/dist/locales/fr.json +15 -1
  14. package/dist/mcpServerFactory.d.ts +11 -2
  15. package/dist/mcpServerFactory.js +97 -15
  16. package/dist/oauth/authorizationServer.js +77 -16
  17. package/dist/oauth/picker.d.ts +11 -0
  18. package/dist/oauth/picker.js +28 -0
  19. package/dist/oauth/settings.d.ts +7 -0
  20. package/dist/oauth/settings.js +7 -0
  21. package/dist/oauth/tokenIssuer.d.ts +0 -1
  22. package/dist/oauth/tokenIssuer.js +1 -18
  23. package/dist/pagination.d.ts +2 -1
  24. package/dist/pagination.js +2 -1
  25. package/dist/rateLimit.d.ts +33 -0
  26. package/dist/rateLimit.js +48 -0
  27. package/dist/serverManifest.d.ts +9 -1
  28. package/dist/serverManifest.js +10 -1
  29. package/dist/signedCursor.js +6 -1
  30. package/dist/stdio.js +2 -2
  31. package/dist/tenancy.d.ts +4 -14
  32. package/dist/tenancy.js +7 -18
  33. package/dist/toolRecord.d.ts +35 -2
  34. package/dist/toolRecord.js +44 -2
  35. package/dist/toolRoleGate.d.ts +41 -0
  36. package/dist/toolRoleGate.js +94 -0
  37. package/dist/toolRoles.d.ts +57 -0
  38. package/dist/toolRoles.js +113 -0
  39. package/dist/ui/sandboxDomain.d.ts +13 -0
  40. package/dist/ui/sandboxDomain.js +23 -0
  41. package/dist/upstream/paginatedClient.js +1 -0
  42. package/dist/verifiedPerson.d.ts +6 -0
  43. package/dist/verifiedPerson.js +9 -0
  44. package/docs/releasing.md +19 -0
  45. package/mcp-server.json +3 -2
  46. package/package.json +10 -3
  47. package/plugin.json +1 -1
  48. package/scripts/plugin-manifest.source.json +19 -0
  49. package/scripts/readme-fr.mjs +3 -1
  50. package/dist/oauth/clientAssertion.d.ts +0 -28
  51. package/dist/oauth/clientAssertion.js +0 -106
@@ -0,0 +1,57 @@
1
+ import type { ToolRole, ToolRoleLookup } from "@vantageos/cloud-identity";
2
+ /**
3
+ * Agent tool roles (mcp-server-standard "Agent tool roles"): which tools exist for the calling agent.
4
+ *
5
+ * The decision is @vantageos/cloud-identity's (`decideToolRole` / `filterGrantedTools`); this module only supplies its
6
+ * input, the role the caller holds, through ONE seam: a `RoleProvider`. The boilerplate ships a data-backed provider
7
+ * (roles in a JSON file named by `MCP_ROLES_FILE`, no business value in code); the Convex agent-roles component plugs
8
+ * into the same seam.
9
+ *
10
+ * Where roles apply (Pi's ruling on task k17103j933e6en0aqyws2v1twx8hc0s3):
11
+ * - the OAuth tier: a provider is REQUIRED. No `MCP_ROLES_FILE`, an unreadable one or a malformed one stops the
12
+ * server at start, naming the variable. An absent store never widens access.
13
+ * - the public HTTP tier and stdio have no caller: roles are NOT APPLICABLE there, said at start and in the identity
14
+ * tool (`rolesApplicable: false`), never a silent pass.
15
+ */
16
+ /** The verified caller a provider reads its role from: what the identity layer resolved, never a claim read here. */
17
+ export interface RoleCaller {
18
+ /** The verified subject (the person or agent behind the credential). */
19
+ subject: string;
20
+ /** The organisation's permanent ID, as resolved by the identity layer. */
21
+ orgClerkId: string;
22
+ }
23
+ /** The role store seam: the role the caller holds, or why there is none. A thrown error is read as unresolved. */
24
+ export type RoleProvider = (caller: RoleCaller) => Promise<ToolRoleLookup>;
25
+ /** A role as the roles file holds it: cloud-identity's `ToolRole` plus a display name for the identity tool. */
26
+ export interface NamedToolRole extends ToolRole {
27
+ name: string;
28
+ }
29
+ /** The roles file: the roles, and the binding of each verified subject to exactly one role. */
30
+ export interface RolesFile {
31
+ roles: NamedToolRole[];
32
+ agents: Record<string, string>;
33
+ }
34
+ /** Where roles apply, decided once at start. */
35
+ export type ToolRolesConfig = {
36
+ applicable: true;
37
+ provider: RoleProvider;
38
+ roleName: (roleId: string) => string | undefined;
39
+ } | {
40
+ applicable: false;
41
+ };
42
+ /** Thrown at start when the OAuth tier has no usable roles file. */
43
+ export declare class ToolRolesConfigError extends Error {
44
+ constructor(message: string);
45
+ }
46
+ /** Every problem with a parsed roles file; empty when sound. A malformed file is refused whole, never half-used. */
47
+ export declare function checkRolesFile(value: unknown): string[];
48
+ /**
49
+ * The provider over a roles file already read and checked. A subject with no binding -> absent ("unassigned"); a
50
+ * binding to a role the file does not hold -> absent ("deleted"). Both deny, by the decision function.
51
+ */
52
+ export declare function fileRoleProvider(file: RolesFile): RoleProvider;
53
+ /**
54
+ * Where roles apply, read at start. OAuth: `MCP_ROLES_FILE` is required and must hold a sound roles file, else the
55
+ * start is refused naming the variable. Public HTTP and stdio: not applicable.
56
+ */
57
+ export declare function loadToolRoles(authMode: "oauth" | "public" | "stdio", env?: NodeJS.ProcessEnv): ToolRolesConfig;
@@ -0,0 +1,113 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { tBoth } from "./i18n.js";
3
+ const ROLES_FILE_VARIABLE = "MCP_ROLES_FILE";
4
+ /** Thrown at start when the OAuth tier has no usable roles file. */
5
+ export class ToolRolesConfigError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "ToolRolesConfigError";
9
+ }
10
+ }
11
+ const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === "string" && x.length > 0);
12
+ /** Every problem with a parsed roles file; empty when sound. A malformed file is refused whole, never half-used. */
13
+ export function checkRolesFile(value) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value))
15
+ return ["not a JSON object"];
16
+ const { roles, agents } = value;
17
+ const problems = [];
18
+ if (!Array.isArray(roles))
19
+ problems.push("`roles` is not a list");
20
+ if (!agents || typeof agents !== "object" || Array.isArray(agents))
21
+ problems.push("`agents` is not an object");
22
+ if (problems.length > 0)
23
+ return problems;
24
+ const ids = new Set();
25
+ roles.forEach((r, i) => {
26
+ const role = r;
27
+ const at = `roles[${i}]`;
28
+ if (!role || typeof role !== "object")
29
+ return void problems.push(`${at} is not an object`);
30
+ for (const key of ["roleId", "orgClerkId", "name"])
31
+ if (typeof role[key] !== "string" || role[key].trim() === "")
32
+ problems.push(`${at}.${key} is missing`);
33
+ if (typeof role.roleId === "string") {
34
+ if (ids.has(role.roleId))
35
+ problems.push(`${at}.roleId ${role.roleId} is declared twice`);
36
+ ids.add(role.roleId);
37
+ }
38
+ if (!Array.isArray(role.domains))
39
+ problems.push(`${at}.domains is not a list`);
40
+ else
41
+ role.domains.forEach((d, k) => {
42
+ if (typeof d?.domain !== "string" || d.domain.trim() === "")
43
+ problems.push(`${at}.domains[${k}].domain is missing`);
44
+ if (typeof d?.read !== "boolean" || typeof d?.write !== "boolean")
45
+ problems.push(`${at}.domains[${k}] needs boolean read and write`);
46
+ });
47
+ if (!isStringList(role.add ?? []))
48
+ problems.push(`${at}.add is not a list of tool names`);
49
+ if (!isStringList(role.remove ?? []))
50
+ problems.push(`${at}.remove is not a list of tool names`);
51
+ });
52
+ for (const [subject, roleId] of Object.entries(agents))
53
+ if (typeof roleId !== "string" || roleId === "")
54
+ problems.push(`agents["${subject}"] is not a role ID`);
55
+ return problems;
56
+ }
57
+ /**
58
+ * The provider over a roles file already read and checked. A subject with no binding -> absent ("unassigned"); a
59
+ * binding to a role the file does not hold -> absent ("deleted"). Both deny, by the decision function.
60
+ */
61
+ export function fileRoleProvider(file) {
62
+ const byId = new Map(file.roles.map((r) => [r.roleId, r]));
63
+ return async ({ subject }) => {
64
+ const roleId = Object.hasOwn(file.agents, subject) ? file.agents[subject] : undefined;
65
+ if (roleId === undefined)
66
+ return { status: "absent", cause: "unassigned" };
67
+ const role = byId.get(roleId);
68
+ if (!role)
69
+ return { status: "absent", cause: "deleted" };
70
+ return {
71
+ status: "resolved",
72
+ role: {
73
+ roleId: role.roleId,
74
+ orgClerkId: role.orgClerkId,
75
+ domains: role.domains,
76
+ add: role.add ?? [],
77
+ remove: role.remove ?? [],
78
+ },
79
+ };
80
+ };
81
+ }
82
+ /**
83
+ * Where roles apply, read at start. OAuth: `MCP_ROLES_FILE` is required and must hold a sound roles file, else the
84
+ * start is refused naming the variable. Public HTTP and stdio: not applicable.
85
+ */
86
+ export function loadToolRoles(authMode, env = process.env) {
87
+ if (authMode !== "oauth")
88
+ return { applicable: false };
89
+ const path = env[ROLES_FILE_VARIABLE]?.trim();
90
+ if (!path)
91
+ throw new ToolRolesConfigError(tBoth("toolRoles.configMissing", { variable: ROLES_FILE_VARIABLE }));
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(readFileSync(path, "utf8"));
95
+ }
96
+ catch (e) {
97
+ throw new ToolRolesConfigError(tBoth("toolRoles.configInvalid", {
98
+ variable: ROLES_FILE_VARIABLE,
99
+ path,
100
+ problem: e.message,
101
+ }));
102
+ }
103
+ const problems = checkRolesFile(parsed);
104
+ if (problems.length > 0)
105
+ throw new ToolRolesConfigError(tBoth("toolRoles.configInvalid", {
106
+ variable: ROLES_FILE_VARIABLE,
107
+ path,
108
+ problem: problems.join("; "),
109
+ }));
110
+ const file = parsed;
111
+ const names = new Map(file.roles.map((r) => [r.roleId, r.name]));
112
+ return { applicable: true, provider: fileRoleProvider(file), roleName: (id) => names.get(id) };
113
+ }
@@ -0,0 +1,13 @@
1
+ /** The suffix of every Claude MCP Apps sandbox origin. Half of the derivation, never a whole domain. */
2
+ export declare const CLAUDE_SANDBOX_SUFFIX = ".claudemcpcontent.com";
3
+ /**
4
+ * The sandbox domain Claude serves this server's widget from (mcp-app-standard A-11): the first 32 hexadecimal
5
+ * characters of SHA-256 of the served MCP URL, followed by `.claudemcpcontent.com`. Computed at start from the URL
6
+ * the server is reached at, never written as a literal: a typed domain is wrong as soon as the URL changes.
7
+ */
8
+ export declare function claudeSandboxDomain(servedMcpUrl: string): string;
9
+ /**
10
+ * The served MCP URL: `PUBLIC_BASE_URL` (the public origin, a path prefix allowed) joined to the `/mcp` endpoint.
11
+ * Unset, there is no served URL to derive from and the widget declares no domain (development).
12
+ */
13
+ export declare function servedMcpUrl(env: NodeJS.ProcessEnv): string | undefined;
@@ -0,0 +1,23 @@
1
+ import { createHash } from "node:crypto";
2
+ /** The suffix of every Claude MCP Apps sandbox origin. Half of the derivation, never a whole domain. */
3
+ export const CLAUDE_SANDBOX_SUFFIX = ".claudemcpcontent.com";
4
+ /**
5
+ * The sandbox domain Claude serves this server's widget from (mcp-app-standard A-11): the first 32 hexadecimal
6
+ * characters of SHA-256 of the served MCP URL, followed by `.claudemcpcontent.com`. Computed at start from the URL
7
+ * the server is reached at, never written as a literal: a typed domain is wrong as soon as the URL changes.
8
+ */
9
+ // allow-local-identity: hashes the PUBLIC served MCP URL to name a sandbox origin; no credential, no caller, no tenant
10
+ export function claudeSandboxDomain(servedMcpUrl) {
11
+ const label = createHash("sha256").update(servedMcpUrl).digest("hex").slice(0, 32);
12
+ return `${label}${CLAUDE_SANDBOX_SUFFIX}`;
13
+ }
14
+ /**
15
+ * The served MCP URL: `PUBLIC_BASE_URL` (the public origin, a path prefix allowed) joined to the `/mcp` endpoint.
16
+ * Unset, there is no served URL to derive from and the widget declares no domain (development).
17
+ */
18
+ export function servedMcpUrl(env) {
19
+ const base = env.PUBLIC_BASE_URL;
20
+ if (!base)
21
+ return undefined;
22
+ return new URL("mcp", base.endsWith("/") ? base : `${base}/`).href;
23
+ }
@@ -133,6 +133,7 @@ function canonicalJson(value) {
133
133
  }
134
134
  return JSON.stringify(value) ?? "null";
135
135
  }
136
+ // allow-local-identity: cache key of an upstream request (path + params); no credential, caller or tenant
136
137
  function fingerprint(path, params) {
137
138
  return createHash("sha256")
138
139
  .update(canonicalJson({ path, params }))
@@ -77,6 +77,12 @@ export interface PersonContext {
77
77
  export type PersonRefusalResult = CallToolResult & {
78
78
  isError: true;
79
79
  };
80
+ /**
81
+ * The identity layer's refusal for a caller it did not resolve (no resolution in the context = no_verified_session),
82
+ * or undefined when the caller is resolved. The agent-roles gate answers with it before deciding a role, so an
83
+ * unresolved caller gets the precise, stable identity code rather than a generic role denial — a refusal either way.
84
+ */
85
+ export declare function callerRefusal(caller: CallerResolution | undefined, locale: McpLocale): PersonRefusalResult | undefined;
80
86
  /**
81
87
  * Declares that a tool needs the verified person: the handler runs only with one, and receives it
82
88
  * as its second argument. No caller resolution in the context (stdio) is `no_verified_session`.
@@ -98,6 +98,15 @@ function personRefusal(caller, locale) {
98
98
  isError: true,
99
99
  };
100
100
  }
101
+ /**
102
+ * The identity layer's refusal for a caller it did not resolve (no resolution in the context = no_verified_session),
103
+ * or undefined when the caller is resolved. The agent-roles gate answers with it before deciding a role, so an
104
+ * unresolved caller gets the precise, stable identity code rather than a generic role denial — a refusal either way.
105
+ */
106
+ export function callerRefusal(caller, locale) {
107
+ const resolution = caller ?? unresolved("no_verified_session");
108
+ return resolution.ok ? undefined : personRefusal(resolution, locale);
109
+ }
101
110
  /**
102
111
  * Declares that a tool needs the verified person: the handler runs only with one, and receives it
103
112
  * as its second argument. No caller resolution in the context (stdio) is `no_verified_session`.
@@ -0,0 +1,19 @@
1
+ # Releasing
2
+
3
+ A release is cut from `main`, in one pull request, after every change in it has merged.
4
+
5
+ 1. **Fold the changelog.** `npm run changelog:release -- --version <x.y.z>` (optionally `--date YYYY-MM-DD`)
6
+ inserts `## [x.y.z] - <date>` below `## [Unreleased]`, with every fragment of `changelog.d/` grouped by
7
+ section, and deletes the folded fragments. `## [Unreleased]` stays, empty. Then add the version's link line
8
+ at the bottom of `CHANGELOG.md` (`[x.y.z]: <url>`, the npm page of the version when it is published without
9
+ a git tag); `tests/changelogLinks.test.ts` refuses a version heading with no link line.
10
+ 2. **Bump the version** in `package.json` and `mcp-server.json` (they must be equal; `npm run build` refuses
11
+ a difference), then `npm install --package-lock-only` so the lockfile carries it.
12
+ 3. **Build and test**: `npm run build && npm run build:ui && npm test`. `prepublishOnly` runs the same chain,
13
+ plus the evals, before any publish.
14
+ 4. **Open the release PR** through the review flow. Publication happens only from the merged `main`, after
15
+ the reviewer's npm verdict, and is proven at the registry (full and abbreviated documents, then a fresh
16
+ consumer install), never by the publish command's output.
17
+
18
+ Between releases, every pull request adds one fragment to `changelog.d/` (see `changelog.d/README.md`) and
19
+ never touches `CHANGELOG.md`, so parallel pull requests do not conflict on it.
package/mcp-server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-boilerplate",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "capabilities": {
5
5
  "tools": {},
6
6
  "resources": {}
@@ -10,5 +10,6 @@
10
10
  "streamableHttp": { "path": "/mcp", "stateless": true }
11
11
  },
12
12
  "features": {},
13
- "contextCeiling": { "global": 1200, "perTool": 700 }
13
+ "contextCeiling": { "global": 1400, "perTool": 700 },
14
+ "distribution": { "targets": ["claude", "claude-code", "vscode", "cursor", "chatgpt"] }
14
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-boilerplate",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
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",
@@ -53,11 +53,15 @@
53
53
  "homepage": "https://github.com/elpiarthera/vantage-mcp-boilerplate#readme",
54
54
  "author": "ElPi Corp / Laurent Perello",
55
55
  "scripts": {
56
- "build": "node scripts/clean-dist.mjs && tsc && mkdir -p dist/locales && cp src/locales/en.json src/locales/fr.json dist/locales/ && node scripts/sync-plugin-version.mjs",
56
+ "build": "node scripts/clean-dist.mjs && tsc && mkdir -p dist/locales && cp src/locales/en.json src/locales/fr.json dist/locales/ && node scripts/sync-plugin-version.mjs && node scripts/listing-kit.mjs --check",
57
+ "listing:write": "node scripts/listing-kit.mjs",
58
+ "changelog:check": "node scripts/changelog-assemble.mjs --check",
59
+ "changelog:release": "node scripts/changelog-assemble.mjs",
57
60
  "prepack": "node scripts/check-dist-orphans.mjs",
58
61
  "build:ui": "vite build && mv dist/ui-bundle/index.html dist/ui-bundle/widget.html",
59
62
  "pretest": "npm run build && npm run build:ui",
60
63
  "test": "vitest run",
64
+ "test:a11y": "vitest run tests/widgetAxe.test.ts",
61
65
  "test:watch": "vitest",
62
66
  "typecheck": "tsc --noEmit",
63
67
  "start:http": "node dist/http.js",
@@ -83,7 +87,7 @@
83
87
  "dependencies": {
84
88
  "@modelcontextprotocol/ext-apps": "^2.0.3",
85
89
  "@modelcontextprotocol/server": "^2.3.1",
86
- "@vantageos/cloud-identity": "^0.20.2",
90
+ "@vantageos/cloud-identity": "^0.34.1",
87
91
  "@vantageos/mosaic-i18n": "^0.1.1",
88
92
  "hono": "^4.13.13",
89
93
  "jose": "^6.2.7",
@@ -100,11 +104,14 @@
100
104
  "devDependencies": {
101
105
  "@biomejs/biome": "1.9.4",
102
106
  "@modelcontextprotocol/client": "^2.3.1",
107
+ "@types/jsdom": "^30.0.0",
103
108
  "@types/node": "^20.14.0",
104
109
  "@types/react": "^19.0.0",
105
110
  "@types/react-dom": "^19.0.0",
106
111
  "@vitejs/plugin-react": "^4.3.0",
112
+ "axe-core": "^4.14.0",
107
113
  "convex": "1.46.0",
114
+ "jsdom": "^30.1.3",
108
115
  "react": "^19.0.0",
109
116
  "react-dom": "^19.0.0",
110
117
  "typescript": "^5.7.2",
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.6.2",
4
+ "version": "0.7.0",
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"
@@ -31,5 +31,24 @@
31
31
  "openai": {
32
32
  "auth": { "type": "oauth", "client": { "mode": "cimd" } },
33
33
  "capabilities": ["Read"]
34
+ },
35
+ "listing": {
36
+ "testPrompts": {
37
+ "positive": [
38
+ "tools-call-ping-happy-path",
39
+ "tools-call-ping-default",
40
+ "tools-call-list-items-public-tier-unscoped",
41
+ "tools-call-list-items-page-truncated",
42
+ "tools-call-list-items-ui-link"
43
+ ],
44
+ "negative": [
45
+ "tools-call-ping-message-too-long-failure",
46
+ "tools-call-list-items-requires-tenant-refusal-failure",
47
+ "tools-call-list-items-invalid-cursor-failure"
48
+ ]
49
+ },
50
+ "reviewerAccount": {
51
+ "howToObtain": "Sign in at the server's /authorize page with any Clerk account; a reviewer organisation is created on request to the maintainers (README, 'Own authorization server'). No credential is stored in this repository."
52
+ }
34
53
  }
35
54
  }
@@ -27,7 +27,9 @@ import { fileURLToPath } from "node:url";
27
27
  export const BANNER =
28
28
  "<!-- Généré depuis README.md par mcp-readme-fr (@vantageos/mcp-boilerplate) : ne pas modifier à la main. -->";
29
29
 
30
- const MARKER = /^\s*<!--\s*(lang:([A-Za-z-]+)|\/lang)\s*-->\s*$/;
30
+ // A marker sits at the start of a line with at most 3 spaces of indent (CommonMark): 4 or more spaces, or a tab, make
31
+ // the line an indented code block, so such a "marker" is text, copied with the part it sits in, like a fenced one.
32
+ const MARKER = /^ {0,3}<!--\s*(lang:([A-Za-z-]+)|\/lang)\s*-->\s*$/;
31
33
  const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
32
34
 
33
35
  /** README.md text -> { fr, errors }. `fr` is the README.fr.md content (banner first). */
@@ -1,28 +0,0 @@
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
- };
@@ -1,106 +0,0 @@
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
- }