mcp-authz 0.1.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/LICENSE +21 -0
- package/README.md +411 -0
- package/dist/index.d.ts +386 -0
- package/dist/index.js +731 -0
- package/dist/node.d.ts +16 -0
- package/dist/node.js +46 -0
- package/dist/policy-CnQj53Hq.d.ts +144 -0
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js +203 -0
- package/package.json +80 -0
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Server } from "node:http";
|
|
2
|
+
//#region src/node.d.ts
|
|
3
|
+
type ListenMcpOptions = {
|
|
4
|
+
port: number;
|
|
5
|
+
/** Log label. Defaults to `mcp-authz`. */
|
|
6
|
+
name?: string;
|
|
7
|
+
/** Extra lines logged after the listen banner. */
|
|
8
|
+
info?: Record<string, string | undefined>;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Bind a `createMcpFetch` handler to every interface. Safe because the
|
|
12
|
+
* bearer gate is the security boundary, not the bind address.
|
|
13
|
+
*/
|
|
14
|
+
declare function listenMcp(fetch: (request: Request) => Promise<Response>, options: ListenMcpOptions): Promise<Server>;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { ListenMcpOptions, listenMcp };
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
3
|
+
//#region src/node.ts
|
|
4
|
+
/**
|
|
5
|
+
* Bind a `createMcpFetch` handler to every interface. Safe because the
|
|
6
|
+
* bearer gate is the security boundary, not the bind address.
|
|
7
|
+
*/
|
|
8
|
+
async function listenMcp(fetch, options) {
|
|
9
|
+
const { port, name = "mcp-authz", info = {} } = options;
|
|
10
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`PORT must be an integer between 1 and 65535; received ${port}.`);
|
|
11
|
+
const nodeHandler = toNodeHandler({ fetch });
|
|
12
|
+
const server = createServer((request, response) => {
|
|
13
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
14
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
15
|
+
Promise.resolve(nodeHandler(request, response)).catch((error) => {
|
|
16
|
+
console.error(`[${name}]`, error);
|
|
17
|
+
if (!response.headersSent) response.writeHead(500);
|
|
18
|
+
response.end("Internal Server Error");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
await new Promise((resolve, reject) => {
|
|
22
|
+
server.once("error", reject);
|
|
23
|
+
server.listen(port, () => {
|
|
24
|
+
server.off("error", reject);
|
|
25
|
+
resolve();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
console.log(`${name} on :${port}`);
|
|
29
|
+
for (const [key, value] of Object.entries(info)) if (value) console.log(` ${key.padEnd(10)} ${value}`);
|
|
30
|
+
let stopping = false;
|
|
31
|
+
function shutdown(signal) {
|
|
32
|
+
if (stopping) return;
|
|
33
|
+
stopping = true;
|
|
34
|
+
console.error(`[${name}] ${signal}; draining connections`);
|
|
35
|
+
server.close((error) => {
|
|
36
|
+
if (error) console.error(`[${name}] shutdown failed`, error);
|
|
37
|
+
process.exitCode = error ? 1 : 0;
|
|
38
|
+
});
|
|
39
|
+
setTimeout(() => server.closeAllConnections(), 1e4).unref();
|
|
40
|
+
}
|
|
41
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
42
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
43
|
+
return server;
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
export { listenMcp };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
//#region src/identity.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* What the token verifier proved about the caller.
|
|
4
|
+
*
|
|
5
|
+
* Its own file because everything downstream depends on it and nothing it
|
|
6
|
+
* depends on is vendor-specific.
|
|
7
|
+
*/
|
|
8
|
+
type Identity = {
|
|
9
|
+
/** Exact token issuer. Stable identity is the pair `(issuer, sub)`. */
|
|
10
|
+
issuer: string;
|
|
11
|
+
/**
|
|
12
|
+
* The token's `sub`. Stable across an email change and across a rename, which
|
|
13
|
+
* is why policy should prefer it for anything durable.
|
|
14
|
+
*/
|
|
15
|
+
sub: string;
|
|
16
|
+
/** Email asserted by the issuer, when present. Never a client-supplied header. */
|
|
17
|
+
email?: string;
|
|
18
|
+
/** Whether the verifier proved the email claim. */
|
|
19
|
+
emailVerified: boolean;
|
|
20
|
+
/** Google Workspace domain (the `hd` claim), when the AS passes it through. */
|
|
21
|
+
domain?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The remaining verified claims, so policy can match on groups an IdP already
|
|
24
|
+
* maintains rather than a list somebody has to keep in step by hand.
|
|
25
|
+
*/
|
|
26
|
+
claims: Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
/** Why a verified caller was refused. */
|
|
29
|
+
type DenialReason = 'not_permitted' | 'no_credential';
|
|
30
|
+
declare class AccessDeniedError extends Error {
|
|
31
|
+
readonly email: string;
|
|
32
|
+
readonly reason: DenialReason;
|
|
33
|
+
constructor(email: string, reason: DenialReason, message: string);
|
|
34
|
+
static notPermitted(email: string, policyHint?: string): AccessDeniedError;
|
|
35
|
+
static noCredential(email: string, credentialHint?: string): AccessDeniedError;
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/policy.d.ts
|
|
39
|
+
/**
|
|
40
|
+
* Permissions are the authorization primitive. Roles are only a convenient name
|
|
41
|
+
* for a set of them, which is why nothing downstream ever asks what role
|
|
42
|
+
* somebody holds.
|
|
43
|
+
*
|
|
44
|
+
* Nothing in this file imports MCP: an identity in, a principal out. The same
|
|
45
|
+
* policy gates any tool-calling surface, and MCP is one of them.
|
|
46
|
+
*/
|
|
47
|
+
type Principal<P extends string = string> = {
|
|
48
|
+
issuer: string;
|
|
49
|
+
/** Stable subject from the token. Prefer it to email for anything durable. */
|
|
50
|
+
sub: string;
|
|
51
|
+
email?: string;
|
|
52
|
+
domain?: string;
|
|
53
|
+
roles: string[];
|
|
54
|
+
permissions: string[];
|
|
55
|
+
/** A method rather than an array check because it honours a `*` grant. */
|
|
56
|
+
can: (permission: P) => boolean;
|
|
57
|
+
};
|
|
58
|
+
/** Everything a rule can match on. All present fields must match. */
|
|
59
|
+
type Match = {
|
|
60
|
+
issuer?: string;
|
|
61
|
+
sub?: string;
|
|
62
|
+
email?: string;
|
|
63
|
+
domain?: string;
|
|
64
|
+
/**
|
|
65
|
+
* A claim name, or a dotted path into a nested one (`org.id`). A namespaced
|
|
66
|
+
* name containing dots is matched literally first, so Auth0's
|
|
67
|
+
* `https://acme.com/groups` works as written. A scalar claim must equal the
|
|
68
|
+
* value; an array claim must contain it, which is how group lists match.
|
|
69
|
+
*/
|
|
70
|
+
claim?: Record<string, string>;
|
|
71
|
+
};
|
|
72
|
+
type Rule<R extends string = string> = {
|
|
73
|
+
/** Omit to match every authenticated caller. */
|
|
74
|
+
match?: Match;
|
|
75
|
+
role?: R | readonly R[];
|
|
76
|
+
/** Matched by a deny rule and the principal gets nothing, whatever else says. */
|
|
77
|
+
deny?: boolean;
|
|
78
|
+
};
|
|
79
|
+
type PolicySpec = {
|
|
80
|
+
/**
|
|
81
|
+
* Optional source-of-truth permission catalogue. Required for a policy whose
|
|
82
|
+
* roles grant only `*`; otherwise TypeScript has no literal names to infer.
|
|
83
|
+
*/
|
|
84
|
+
permissions?: readonly string[];
|
|
85
|
+
roles: Record<string, readonly string[]>;
|
|
86
|
+
rules: readonly Rule[];
|
|
87
|
+
};
|
|
88
|
+
type Policy<P extends string = string> = ((identity: Identity) => Principal<P>) & {
|
|
89
|
+
/** Role name to the permissions it grants. Reconciled against the tools at boot. */
|
|
90
|
+
readonly roles: ReadonlyMap<string, readonly string[]>;
|
|
91
|
+
/** Concrete permission vocabulary carried for builders and diagnostics. */
|
|
92
|
+
readonly permissions: readonly P[];
|
|
93
|
+
};
|
|
94
|
+
type PermissionCatalog<P extends string = string> = {
|
|
95
|
+
readonly permissions: readonly P[];
|
|
96
|
+
};
|
|
97
|
+
/** The permissions a policy can grant, as a union of string literals. */
|
|
98
|
+
type PermissionOf<T> = T extends Policy<infer P> ? P : never;
|
|
99
|
+
type RolesIn<T extends PolicySpec> = keyof T['roles'] & string;
|
|
100
|
+
type PermissionsIn<T extends PolicySpec> = T extends {
|
|
101
|
+
readonly permissions: readonly string[];
|
|
102
|
+
} ? T['permissions'][number] : Exclude<T['roles'][keyof T['roles']][number], '*'>;
|
|
103
|
+
/**
|
|
104
|
+
* A policy from a plain object.
|
|
105
|
+
*
|
|
106
|
+
* Written inline as a literal, the permission names become a string-literal
|
|
107
|
+
* union, so a tool requiring `cases:wrtie` fails to compile. Handed
|
|
108
|
+
* `JSON.parse(process.env.MCP_POLICY)` or a parsed YAML file the same call
|
|
109
|
+
* still validates its shape and still reconciles against the tools at boot —
|
|
110
|
+
* it just cannot type-check names TypeScript never sees. Nothing here reads the
|
|
111
|
+
* environment or the filesystem: how an application loads configuration is the
|
|
112
|
+
* application's business.
|
|
113
|
+
*
|
|
114
|
+
* There is deliberately no `default` setting. Matching no rule means no
|
|
115
|
+
* permissions, and that is not configurable — a default that can be widened
|
|
116
|
+
* eventually is, and the failure is silent.
|
|
117
|
+
*/
|
|
118
|
+
declare function definePolicy<const T extends PolicySpec>(spec: T & {
|
|
119
|
+
rules: readonly Rule<RolesIn<T>>[];
|
|
120
|
+
}): Policy<PermissionsIn<T>>;
|
|
121
|
+
/** A typed permission vocabulary for async authorizers that do not use a static policy. */
|
|
122
|
+
declare function definePermissions<const P extends readonly string[]>(permissions: P): PermissionCatalog<P[number]>;
|
|
123
|
+
/** Build a sound principal from an external entitlement decision. */
|
|
124
|
+
declare function createPrincipal<P extends string>(identity: Identity, roles: readonly string[], permissions: readonly (P | '*')[]): Principal<P>;
|
|
125
|
+
type Drift = {
|
|
126
|
+
/** A capability no role can reach. Fatal: it is dead code that looks live. */
|
|
127
|
+
error?: string;
|
|
128
|
+
/**
|
|
129
|
+
* A permission no capability requires. A warning rather than a failure, because
|
|
130
|
+
* granting a role ahead of the tool that uses it is how a staged rollout
|
|
131
|
+
* works, and failing the boot on it just teaches people to add dummy tools.
|
|
132
|
+
*/
|
|
133
|
+
warning?: string;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Compare what the tools demand against what the roles grant.
|
|
137
|
+
*
|
|
138
|
+
* A gateway cannot run this check at all — it does not learn the tool list
|
|
139
|
+
* until traffic arrives, and its rules were never compiled alongside the
|
|
140
|
+
* implementation.
|
|
141
|
+
*/
|
|
142
|
+
declare function reconcile(roles: ReadonlyMap<string, readonly string[]>, required: ReadonlyMap<string, string>): Drift;
|
|
143
|
+
//#endregion
|
|
144
|
+
export { Policy as a, Rule as c, definePolicy as d, reconcile as f, Identity as h, PermissionOf as i, createPrincipal as l, DenialReason as m, Match as n, PolicySpec as o, AccessDeniedError as p, PermissionCatalog as r, Principal as s, Drift as t, definePermissions as u };
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as Policy, c as Rule, d as definePolicy, f as reconcile, h as Identity, i as PermissionOf, l as createPrincipal, n as Match, o as PolicySpec, r as PermissionCatalog, s as Principal, t as Drift, u as definePermissions } from "./policy-CnQj53Hq.js";
|
|
2
|
+
export { Drift, type Identity, Match, PermissionCatalog, PermissionOf, Policy, PolicySpec, Principal, Rule, createPrincipal, definePermissions, definePolicy, reconcile };
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
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
|
|
203
|
+
export { createPrincipal, definePermissions, definePolicy, reconcile };
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-authz",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Authorization for MCP servers: OAuth 2.1 resource server, roles/permissions policy, permission-gated tools (2026-07-28)",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/jagreehal/mcp-authz",
|
|
8
|
+
"directory": "packages/mcp-authz"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/jagreehal/mcp-authz#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/jagreehal/mcp-authz/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./node": {
|
|
23
|
+
"types": "./dist/node.d.ts",
|
|
24
|
+
"import": "./dist/node.js"
|
|
25
|
+
},
|
|
26
|
+
"./policy": {
|
|
27
|
+
"types": "./dist/policy.d.ts",
|
|
28
|
+
"import": "./dist/policy.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"mcp",
|
|
40
|
+
"model-context-protocol",
|
|
41
|
+
"oauth",
|
|
42
|
+
"authorization",
|
|
43
|
+
"resource-server",
|
|
44
|
+
"rbac"
|
|
45
|
+
],
|
|
46
|
+
"author": "Jag Reehal <jag@jagreehal.com> (https://jagreehal.com)",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
50
|
+
"jose": "^6.0.11"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@modelcontextprotocol/node": "^2.0.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"@modelcontextprotocol/node": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
62
|
+
"@types/node": "^26.1.2",
|
|
63
|
+
"executable-stories-vitest": "8.6.3",
|
|
64
|
+
"tsdown": "^0.22.14",
|
|
65
|
+
"typescript": "^6.0.3",
|
|
66
|
+
"vitest": "^4.1.10"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": ">=20"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "tsdown",
|
|
73
|
+
"dev": "tsdown --watch",
|
|
74
|
+
"test": "vitest run && prettier --write docs/stories.md",
|
|
75
|
+
"test:watch": "vitest",
|
|
76
|
+
"lint": "eslint src",
|
|
77
|
+
"type-check": "tsc --noEmit",
|
|
78
|
+
"clean": "rm -rf dist"
|
|
79
|
+
}
|
|
80
|
+
}
|