mcp-authz 0.1.0 → 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/dist/policy.js CHANGED
@@ -1,203 +1,2 @@
1
- //#region src/policy.ts
2
- /**
3
- * A policy from a plain object.
4
- *
5
- * Written inline as a literal, the permission names become a string-literal
6
- * union, so a tool requiring `cases:wrtie` fails to compile. Handed
7
- * `JSON.parse(process.env.MCP_POLICY)` or a parsed YAML file the same call
8
- * still validates its shape and still reconciles against the tools at boot —
9
- * it just cannot type-check names TypeScript never sees. Nothing here reads the
10
- * environment or the filesystem: how an application loads configuration is the
11
- * application's business.
12
- *
13
- * There is deliberately no `default` setting. Matching no rule means no
14
- * permissions, and that is not configurable — a default that can be widened
15
- * eventually is, and the failure is silent.
16
- */
17
- function definePolicy(spec) {
18
- if (!isRecord(spec)) throw new Error("Policy must be an object.");
19
- for (const key of Object.keys(spec)) if (![
20
- "permissions",
21
- "roles",
22
- "rules"
23
- ].includes(key)) throw new Error(`Policy has unknown field '${key}'.`);
24
- if (!isRecord(spec.roles)) throw new Error("Policy 'roles' must be an object.");
25
- if (!Array.isArray(spec.rules)) throw new Error("Policy 'rules' must be an array.");
26
- const catalogue = spec.permissions === void 0 ? void 0 : stringList(spec.permissions, "Policy 'permissions'");
27
- if (catalogue?.includes("*")) throw new Error("Policy 'permissions' must name concrete permissions, not '*'.");
28
- const known = catalogue ? new Set(catalogue) : void 0;
29
- const roles = /* @__PURE__ */ new Map();
30
- for (const [role, permissions] of Object.entries(spec.roles ?? {})) {
31
- if (!role) throw new Error("Policy role names must not be empty.");
32
- const validated = stringList(permissions, `Policy role '${role}'`);
33
- if (validated.includes("*") && validated.length > 1) throw new Error(`Policy role '${role}' grants '*' and must not list redundant permissions.`);
34
- for (const permission of validated) if (permission !== "*" && known && !known.has(permission)) throw new Error(`Policy role '${role}' grants '${permission}', which is absent from the permission catalogue.`);
35
- roles.set(role, validated);
36
- }
37
- const rules = (spec.rules ?? []).map((rule, index) => {
38
- const at = `Policy rule ${index}`;
39
- if (!isRecord(rule)) throw new Error(`${at} must be an object.`);
40
- for (const key of Object.keys(rule)) if (![
41
- "match",
42
- "role",
43
- "deny"
44
- ].includes(key)) throw new Error(`${at} has unknown field '${key}'.`);
45
- if (rule.deny !== void 0 && typeof rule.deny !== "boolean") throw new Error(`${at} 'deny' must be a boolean.`);
46
- if (!rule.deny && rule.role === void 0) throw new Error(`${at} grants nothing: it needs a 'role' or 'deny: true'.`);
47
- const named = roleNames(rule.role, at);
48
- for (const role of named) if (!roles.has(role)) throw new Error(`${at} names role '${role}', which is not defined.`);
49
- return {
50
- match: validateMatch(rule.match, at),
51
- deny: rule.deny === true,
52
- roles: named
53
- };
54
- });
55
- const policy = (identity) => {
56
- const matched = rules.filter((rule) => matches(rule.match, identity));
57
- const denied = matched.some((rule) => rule.deny);
58
- const held = denied ? [] : [...new Set(matched.flatMap((rule) => rule.roles))];
59
- return createPrincipal(identity, held, denied ? [] : [...new Set(held.flatMap((role) => [...roles.get(role) ?? []]))]);
60
- };
61
- const concrete = catalogue ?? [...new Set([...roles.values()].flat().filter((permission) => permission !== "*"))];
62
- return Object.assign(policy, {
63
- roles,
64
- permissions: concrete
65
- });
66
- }
67
- /** A typed permission vocabulary for async authorizers that do not use a static policy. */
68
- function definePermissions(permissions) {
69
- const catalogue = stringList(permissions, "Permission catalogue");
70
- if (catalogue.includes("*")) throw new Error("Permission catalogue must name concrete permissions, not '*'.");
71
- return { permissions: catalogue };
72
- }
73
- /** Build a sound principal from an external entitlement decision. */
74
- function createPrincipal(identity, roles, permissions) {
75
- if (!isRecord(identity) || typeof identity.sub !== "string" || identity.sub.length === 0) throw new Error("Principal identity needs a non-empty subject.");
76
- if (typeof identity.issuer !== "string" || identity.issuer.length === 0) throw new Error("Principal identity needs a non-empty issuer.");
77
- if (identity.email !== void 0 && identity.email.length === 0) throw new Error("Principal identity email must not be empty.");
78
- const heldRoles = [...new Set(stringList(roles, "Principal roles"))];
79
- const heldPermissions = [...new Set(stringList(permissions, "Principal permissions"))];
80
- const wildcard = heldPermissions.includes("*");
81
- return {
82
- issuer: identity.issuer,
83
- sub: identity.sub,
84
- email: identity.email,
85
- domain: identity.domain,
86
- roles: heldRoles,
87
- permissions: heldPermissions,
88
- can: (permission) => wildcard || heldPermissions.includes(permission)
89
- };
90
- }
91
- function isRecord(value) {
92
- return typeof value === "object" && value !== null && !Array.isArray(value);
93
- }
94
- function stringList(value, at) {
95
- if (!Array.isArray(value)) throw new Error(`${at} must be an array of permission strings.`);
96
- const result = [];
97
- for (const entry of value) {
98
- if (typeof entry !== "string" || entry.length === 0) throw new Error(`${at} must be an array of non-empty permission strings.`);
99
- if (!result.includes(entry)) result.push(entry);
100
- }
101
- return result;
102
- }
103
- function roleNames(value, at) {
104
- if (value === void 0) return [];
105
- const values = Array.isArray(value) ? value : [value];
106
- if (values.length === 0 || values.some((role) => typeof role !== "string" || role.length === 0)) throw new Error(`${at} 'role' must be a non-empty role name or array of role names.`);
107
- return [...new Set(values)];
108
- }
109
- function validateMatch(value, at) {
110
- if (value === void 0) return {};
111
- if (!isRecord(value)) throw new Error(`${at} 'match' must be an object.`);
112
- for (const key of Object.keys(value)) if (![
113
- "issuer",
114
- "sub",
115
- "email",
116
- "domain",
117
- "claim"
118
- ].includes(key)) throw new Error(`${at} match has unknown field '${key}'.`);
119
- for (const key of [
120
- "issuer",
121
- "sub",
122
- "email",
123
- "domain"
124
- ]) {
125
- const field = value[key];
126
- if (field !== void 0 && (typeof field !== "string" || field.length === 0)) throw new Error(`${at} match '${key}' must be a non-empty string.`);
127
- }
128
- const claim = value.claim;
129
- if (claim !== void 0) {
130
- if (!isRecord(claim)) throw new Error(`${at} match 'claim' must be an object.`);
131
- for (const [path, expected] of Object.entries(claim)) if (!path || typeof expected !== "string" || !expected) throw new Error(`${at} match claim entries must have non-empty string names and values.`);
132
- }
133
- return value;
134
- }
135
- function matches(match, identity) {
136
- if (match.issuer !== void 0 && match.issuer !== identity.issuer) return false;
137
- if (match.sub !== void 0 && match.sub !== identity.sub) return false;
138
- if (match.email !== void 0 && (!identity.emailVerified || !equalsFold(match.email, identity.email))) return false;
139
- if (match.domain !== void 0 && !equalsFold(match.domain, domainOf(identity))) return false;
140
- for (const [path, expected] of Object.entries(match.claim ?? {})) {
141
- const actual = claimAt(identity.claims, path);
142
- if (!(Array.isArray(actual) ? actual.includes(expected) : actual === expected)) return false;
143
- }
144
- return true;
145
- }
146
- function equalsFold(a, b) {
147
- return b !== void 0 && a.toLowerCase() === b.toLowerCase();
148
- }
149
- /** The `hd` claim when the AS passes it, otherwise whatever follows the `@`. */
150
- function domainOf(identity) {
151
- if (!identity.emailVerified || !identity.email) return void 0;
152
- if (identity.domain) return identity.domain;
153
- const at = identity.email.lastIndexOf("@");
154
- return at === -1 ? void 0 : identity.email.slice(at + 1);
155
- }
156
- /**
157
- * A literal claim name wins over a dotted path, because the namespaced claims
158
- * an IdP actually emits are full of dots — Auth0 writes group memberships to
159
- * `https://acme.com/groups`, and splitting that on `.` finds nothing.
160
- */
161
- function claimAt(claims, path) {
162
- if (path in claims) return claims[path];
163
- let value = claims;
164
- for (const key of path.split(".")) {
165
- if (typeof value !== "object" || value === null) return void 0;
166
- value = value[key];
167
- }
168
- return value;
169
- }
170
- /**
171
- * Compare what the tools demand against what the roles grant.
172
- *
173
- * A gateway cannot run this check at all — it does not learn the tool list
174
- * until traffic arrives, and its rules were never compiled alongside the
175
- * implementation.
176
- */
177
- function reconcile(roles, required) {
178
- const grantedBy = /* @__PURE__ */ new Map();
179
- for (const [role, permissions] of roles) for (const permission of permissions) grantedBy.set(permission, [...grantedBy.get(permission) ?? [], role]);
180
- const unreachable = grantedBy.has("*") ? [] : [...required].filter(([, permission]) => !grantedBy.has(permission));
181
- const unused = [...grantedBy].filter(([permission]) => permission !== "*").filter(([permission]) => ![...required.values()].includes(permission));
182
- const drift = {};
183
- if (unreachable.length > 0) {
184
- const report = [
185
- "MCP policy validation failed",
186
- "",
187
- "Unreachable capability:"
188
- ];
189
- for (const [tool, permission] of unreachable) report.push(` ${tool}`, ` requires: ${permission}`, " granted by: no role", "");
190
- drift.error = report.join("\n");
191
- }
192
- if (unused.length > 0) {
193
- const report = ["MCP policy has unused permissions:"];
194
- for (const [permission, byRoles] of unused) {
195
- const by = byRoles.map((role) => `"${role}"`).join(", ");
196
- report.push(` ${permission}`, ` granted by: ${by}`, " required by: no registered capability", "");
197
- }
198
- drift.warning = report.join("\n");
199
- }
200
- return drift;
201
- }
202
- //#endregion
1
+ import { i as reconcile, n as definePermissions, r as definePolicy, t as createPrincipal } from "./policy-BBp3Jq6G.js";
203
2
  export { createPrincipal, definePermissions, definePolicy, reconcile };
@@ -0,0 +1,46 @@
1
+ import { _ as Identity, s as Policy } from "./policy-DuZbwrKf.js";
2
+ import { a as AuthorizationDecisionSink, t as VerifierOptions } from "./verifier-DF6gUMQ6.js";
3
+ import { t as CapabilityScopeMap } from "./ladder-D18eJ7tD.js";
4
+ import { AuthInfo, OAuthMetadata, OAuthTokenVerifier } from "@modelcontextprotocol/server";
5
+ //#region src/upstream.d.ts
6
+ type UpstreamConfig = {
7
+ /** Base MCP endpoint the proxy forwards to. */
8
+ url: string | URL;
9
+ bearer: string | (() => string | Promise<string>);
10
+ fetch?: typeof fetch;
11
+ };
12
+ //#endregion
13
+ //#region src/proxy.d.ts
14
+ type McpProxyOptions<P extends string = string> = {
15
+ /** This proxy's public URL, e.g. `https://mcp.acme.com/mcp`. */
16
+ resourceServerUrl: URL;
17
+ /** RFC 8414 metadata for the authorization server in front of the proxy. */
18
+ oauthMetadata: OAuthMetadata;
19
+ verifier?: Partial<VerifierOptions>;
20
+ tokenVerifier?: OAuthTokenVerifier;
21
+ identityFromAuth?: (auth: AuthInfo) => Identity;
22
+ /** URL-only upstream reached with a service credential. */
23
+ upstream: UpstreamConfig;
24
+ /** Flat permission map — same labels as `gate()` and `recordCapabilities`. */
25
+ permissions: Readonly<Record<string, P>> | ReadonlyMap<string, P>;
26
+ /**
27
+ * `resource:<label>` to the URI or URI template it answers on.
28
+ *
29
+ * A listing names a resource; a read names a URI. Only the upstream knows
30
+ * which is which, so the map that prices resources has to carry both.
31
+ * `recordCapabilities` emits this alongside the permission map.
32
+ */
33
+ resourceUris?: Readonly<Record<string, string>>;
34
+ policy: Policy<P>;
35
+ requiredScopes?: string[];
36
+ supportedScopes?: string[];
37
+ capabilityScopes?: CapabilityScopeMap;
38
+ onDecision?: AuthorizationDecisionSink;
39
+ /** Names this deployment on every event it emits. See `McpFetchOptions`. */
40
+ emitter?: string;
41
+ healthPath?: string;
42
+ maxRequestBytes?: number;
43
+ };
44
+ declare function createMcpProxy<P extends string = string>(options: McpProxyOptions<P>): (request: Request) => Promise<Response>;
45
+ //#endregion
46
+ export { McpProxyOptions, createMcpProxy };