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.
@@ -0,0 +1,176 @@
1
+ import { InMemoryTransport } from "@modelcontextprotocol/server";
2
+ import { createHash } from "node:crypto";
3
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
4
+ //#region src/permissions-module.ts
5
+ /**
6
+ * A permission map to start from, priced so it cannot be forgotten.
7
+ *
8
+ * Every capability gets a placeholder no role grants, which `reconcile` reports
9
+ * as unreachable and the boot refuses. The scaffold is deliberately useless
10
+ * until a person has decided what each capability costs — that decision is the
11
+ * whole point of the file, and a default would quietly make it for them.
12
+ *
13
+ * Returned as source rather than written, so the caller chooses where it lands.
14
+ */
15
+ const UNASSIGNED = "TODO:unassigned";
16
+ function toPermissionsModule(record) {
17
+ return [
18
+ "// Generated from a recorded catalogue. Replace every TODO with a real permission.",
19
+ "",
20
+ "export const PERMISSIONS = {",
21
+ ...record.names.map((label) => ` ${quote(label)}: ${literal(UNASSIGNED)},`),
22
+ "} as const;",
23
+ "",
24
+ "export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];",
25
+ ...fingerprintLines(record),
26
+ ...resourceUriLines(record.resourceUris ?? {}),
27
+ ""
28
+ ].join("\n");
29
+ }
30
+ /**
31
+ * Only emitted when the recorder could produce digests. An OpenAPI document is
32
+ * already a file in the repository, diffed on the pull request by whoever
33
+ * changed it, so there is nothing for a second baseline to catch.
34
+ */
35
+ function fingerprintLines(record) {
36
+ const fingerprints = record.fingerprints ?? {};
37
+ if (Object.keys(fingerprints).length === 0) return [];
38
+ return [
39
+ "",
40
+ "// What each capability looked like when this was recorded. A separate export",
41
+ "// because gate() takes the flat map above; this is the baseline CI compares.",
42
+ "export const FINGERPRINTS = {",
43
+ ...record.names.map((label) => ` ${quote(label)}: ${literal(fingerprints[label] ?? "")},`),
44
+ "} as const;"
45
+ ];
46
+ }
47
+ /**
48
+ * Only emitted when the server has resources, so a tools-only map stays a map.
49
+ * `createMcpProxy` needs this to price a read, which names a URI and never a
50
+ * label; `gate()` never sees it.
51
+ */
52
+ function resourceUriLines(resourceUris) {
53
+ const labels = Object.keys(resourceUris).sort();
54
+ if (labels.length === 0) return [];
55
+ return [
56
+ "",
57
+ "// Where each resource answers. createMcpProxy() matches a read against these,",
58
+ "// templates included, because a resources/read carries a URI and not a label.",
59
+ "export const RESOURCE_URIS = {",
60
+ ...labels.map((label) => ` ${quote(label)}: ${literal(resourceUris[label] ?? "")},`),
61
+ "} as const;"
62
+ ];
63
+ }
64
+ /**
65
+ * Bare where it is a valid identifier, quoted where the label carries a prefix.
66
+ *
67
+ * `__proto__` gets neither. In an object literal it sets the prototype instead
68
+ * of creating a property — as an identifier *and* as a string key — so a
69
+ * capability by that name would silently vanish from the map that prices it.
70
+ * Only a computed key makes an own property.
71
+ */
72
+ function quote(label) {
73
+ if (label === "__proto__") return `[${literal(label)}]`;
74
+ return /^[A-Za-z_$][\w$]*$/.test(label) ? label : literal(label);
75
+ }
76
+ /**
77
+ * A string literal, escaped.
78
+ *
79
+ * Names and URIs come from the server being recorded, not from us. A quote or a
80
+ * backslash in either one would otherwise close the literal early and emit a
81
+ * module that does not parse — or, worse, one that parses into something else.
82
+ */
83
+ function literal(value) {
84
+ return JSON.stringify(value);
85
+ }
86
+ //#endregion
87
+ //#region src/testing.ts
88
+ function digest(parts) {
89
+ return createHash("sha256").update(JSON.stringify(canonical(parts))).digest("hex").slice(0, 16);
90
+ }
91
+ /**
92
+ * Key order is an accident of how a value was built, so sort it away. Without
93
+ * this an SDK that emitted the same definition in a different order would churn
94
+ * every fingerprint in a snapshot and teach people to ignore the diff.
95
+ */
96
+ function canonical(value) {
97
+ if (Array.isArray(value)) return value.map(canonical);
98
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, inner]) => [key, canonical(inner)]));
99
+ return value;
100
+ }
101
+ async function recordCapabilities(factory) {
102
+ const server = await factory();
103
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
104
+ await server.connect(serverTransport);
105
+ const client = new Client({
106
+ name: "mcp-authz-record-capabilities",
107
+ version: "1.0.0"
108
+ });
109
+ await client.connect(clientTransport);
110
+ try {
111
+ return await listFrom(client);
112
+ } finally {
113
+ await client.close();
114
+ await server.close();
115
+ }
116
+ }
117
+ /**
118
+ * Record a server you can only reach by URL.
119
+ *
120
+ * The objection that rules out listing a *gated* server does not apply here: an
121
+ * upstream reached with a service credential answers with everything it has, so
122
+ * the map is complete. Pass `fetch` to drive a handler directly instead of a
123
+ * socket.
124
+ */
125
+ async function recordUpstream(url, options = {}) {
126
+ const transport = new StreamableHTTPClientTransport(new URL(url), {
127
+ ...options.fetch ? { fetch: options.fetch } : {},
128
+ ...options.bearer ? { authProvider: { token: async () => options.bearer } } : {}
129
+ });
130
+ const client = new Client({
131
+ name: "mcp-authz-record-capabilities",
132
+ version: "1.0.0"
133
+ });
134
+ await client.connect(transport);
135
+ try {
136
+ return await listFrom(client);
137
+ } finally {
138
+ await client.close();
139
+ }
140
+ }
141
+ async function listFrom(client) {
142
+ {
143
+ const advertised = client.getServerCapabilities() ?? {};
144
+ const none = {
145
+ tools: [],
146
+ prompts: [],
147
+ resources: [],
148
+ resourceTemplates: []
149
+ };
150
+ const [tools, prompts, resources, templates] = await Promise.all([
151
+ advertised.tools ? client.listTools() : none,
152
+ advertised.prompts ? client.listPrompts() : none,
153
+ advertised.resources ? client.listResources() : none,
154
+ advertised.resources ? client.listResourceTemplates() : none
155
+ ]);
156
+ const labelled = [
157
+ ...tools.tools.map((tool) => [tool.name, tool]),
158
+ ...prompts.prompts.map((prompt) => [`prompt:${prompt.name}`, prompt]),
159
+ ...resources.resources.map((resource) => [`resource:${resource.name}`, resource]),
160
+ ...templates.resourceTemplates.map((template) => [`resource:${template.name}`, template])
161
+ ];
162
+ const resourceUris = Object.fromEntries([...resources.resources.map((resource) => [`resource:${resource.name}`, resource.uri]), ...templates.resourceTemplates.map((template) => [`resource:${template.name}`, template.uriTemplate])]);
163
+ const names = labelled.map(([label]) => label).sort();
164
+ const byLabel = new Map(labelled);
165
+ return {
166
+ names,
167
+ fingerprints: Object.fromEntries(names.map((label) => [label, digest({
168
+ label,
169
+ definition: byLabel.get(label)
170
+ })])),
171
+ resourceUris
172
+ };
173
+ }
174
+ }
175
+ //#endregion
176
+ export { UNASSIGNED, recordCapabilities, recordUpstream, toPermissionsModule };
@@ -0,0 +1,271 @@
1
+ import { a as PermissionCatalog, l as Principal, s as Policy } from "./policy-DuZbwrKf.js";
2
+ import { CallToolResult, GetPromptResult, Icon, Implementation, McpServer, ReadResourceResult, ResourceMetadata, ResourceTemplate, ServerOptions, StandardSchemaV1, StandardSchemaWithJSON, ToolAnnotations } from "@modelcontextprotocol/server";
3
+ //#region src/tools.d.ts
4
+ /**
5
+ * Tools, prompts and resources that carry the permission they require.
6
+ *
7
+ * Declaring it here rather than in a separate rules file is the whole point: it
8
+ * is checked against the policy by the compiler, reconciled against the policy
9
+ * at boot, and it cannot drift when somebody renames the tool.
10
+ *
11
+ * MCP exposes three things a caller can reach, so gating only tools leaves two
12
+ * doors open. All three register the same way: check the permission, skip the
13
+ * registration when the caller lacks it, audit what they do reach.
14
+ */
15
+ /** Which of the three a caller reached. */
16
+ type Capability = 'tool' | 'prompt' | 'resource';
17
+ /** What actually happened, for the audit log the downstream API cannot write. */
18
+ /**
19
+ * What happened, and who it was.
20
+ *
21
+ * `issuer` and `sub` are required because every path that reaches here is
22
+ * downstream of a verified bearer token — they come from a `Principal`, which
23
+ * cannot exist without one. That is what makes this an audit trail rather than
24
+ * a log of claims somebody sent.
25
+ *
26
+ * If a mode ever forwards traffic without verifying identity, **it must omit
27
+ * identity from its events rather than fill these in from decoded token
28
+ * claims.** Decoded claims are attacker-controlled, and putting them in a field
29
+ * named `sub` presents low-integrity data in a high-trust schema: the failure
30
+ * mode is that it reads exactly like evidence. Give that mode its own event
31
+ * type, tagged with whether the identity was verified, rather than widening
32
+ * this one.
33
+ */
34
+ /**
35
+ * What an audit record can be about.
36
+ *
37
+ * Wider than `Capability` because `mcp-authz/openapi` gates HTTP operations
38
+ * through the same events, and an auditor reading one store wants one shape.
39
+ * The definition types stay narrow: an MCP tool cannot declare itself an
40
+ * `operation`, because there is no such thing to declare.
41
+ */
42
+ type AuditedKind = Capability | 'operation';
43
+ type AuditEvent = {
44
+ /**
45
+ * What this record is, and which shape it is in.
46
+ *
47
+ * These events leave the process for somebody's log store and are kept for
48
+ * years, next to `mcp_authz.decision.v1` records that share half their
49
+ * fields. A query that tells them apart by guessing which fields are present
50
+ * breaks the first time either one grows a field.
51
+ *
52
+ * A new optional field does not move the `v1`. Changing what an existing
53
+ * field means does, because a stored query cannot tell that apart.
54
+ */
55
+ type: 'mcp_authz.audit.v1';
56
+ /**
57
+ * The two events of one call, under one id.
58
+ *
59
+ * `attempt` and its terminal event are separate rows in whatever store they
60
+ * land in, and correlating them by identity and timestamp breaks under
61
+ * exactly the concurrency that makes the question worth asking.
62
+ */
63
+ callId: string;
64
+ issuer: string;
65
+ sub: string;
66
+ email?: string;
67
+ /**
68
+ * Verified Workspace domain, when the AS passes `hd` through.
69
+ *
70
+ * The nearest thing to an organisation this library can prove. Key one by
71
+ * `(issuer, domain)`: an issuer alone is right when each customer brings its
72
+ * own authorization server, and wrong when one server serves them all.
73
+ */
74
+ domain?: string;
75
+ /** Which deployment emitted this, when you named it. */
76
+ emitter?: string;
77
+ kind: AuditedKind;
78
+ /** The registered name, e.g. `update_case`. */
79
+ name: string;
80
+ permission: string;
81
+ /** Whatever the definition's own `audit` said the call touched, e.g. `case:C1234`. */
82
+ resource?: string;
83
+ decision: 'allow' | 'deny';
84
+ phase: 'attempt' | 'success' | 'failure' | 'refused';
85
+ /** Who approved it, when the capability asked for a second person. */
86
+ approvedBy?: string;
87
+ at: string;
88
+ durationMs?: number;
89
+ error?: string;
90
+ };
91
+ type AuditSink = (event: AuditEvent) => unknown | Promise<unknown>;
92
+ type AuditDeliveryFailure = {
93
+ error: unknown;
94
+ event: AuditEvent;
95
+ };
96
+ type AuditErrorSink = (failure: AuditDeliveryFailure) => unknown | Promise<unknown>;
97
+ /**
98
+ * What a human is being asked to approve, before it happens.
99
+ *
100
+ * Everything here was proved rather than claimed: the identity came off a
101
+ * verified token and the permission was already checked, so the question left
102
+ * for a person is only whether this particular call should happen.
103
+ */
104
+ type ApprovalRequest<A = unknown> = {
105
+ issuer: string;
106
+ sub: string;
107
+ email?: string;
108
+ kind: Capability;
109
+ name: string;
110
+ permission: string;
111
+ /** The arguments the call would run with, so the message can name them. */
112
+ arguments: A;
113
+ /** Whatever the definition's own `audit` said the call touches. */
114
+ resource?: string;
115
+ at: string;
116
+ };
117
+ /**
118
+ * Approving anonymously is not a second pair of eyes, so `by` is required on
119
+ * the approving branch and the compiler will not let you omit it.
120
+ */
121
+ type ApprovalDecision = {
122
+ approved: true;
123
+ by: string;
124
+ reason?: string;
125
+ } | {
126
+ approved: false;
127
+ by?: string;
128
+ reason?: string;
129
+ };
130
+ type ApprovalSink = (request: ApprovalRequest) => ApprovalDecision | Promise<ApprovalDecision>;
131
+ /** Thrown into the handler's place when a person said no, or said nothing in time. */
132
+ declare class ApprovalRefusedError extends Error {
133
+ readonly capability: string;
134
+ readonly by?: string;
135
+ constructor(capability: string, reason: string, by?: string);
136
+ }
137
+ type InferArgs<S> = S extends StandardSchemaV1<unknown, infer Output> ? Output : undefined;
138
+ /** Shared by all three: what it costs, and what the call touched. */
139
+ type Gated<P extends string, A> = {
140
+ /** Required to reach it. Typed to the policy's permissions, so a typo is a build error. */
141
+ permission: P;
142
+ /**
143
+ * Name the thing the call touched, for the audit event. The library cannot
144
+ * know that `{ caseId: 'C1234' }` means a case; the application always does.
145
+ */
146
+ audit?: (args: A) => string | undefined;
147
+ /**
148
+ * Ask a person before this runs. `true` for every call, or a predicate when
149
+ * only some arguments warrant it — a refund over a threshold, a delete that
150
+ * names production.
151
+ *
152
+ * This is not the permission check repeated. The caller already holds the
153
+ * permission; approval is for actions that want a second person anyway.
154
+ */
155
+ approval?: boolean | ((args: A) => boolean);
156
+ };
157
+ type ToolConfig<P extends string, S> = Gated<P, InferArgs<S>> & {
158
+ title?: string;
159
+ description?: string;
160
+ inputSchema?: S;
161
+ outputSchema?: StandardSchemaWithJSON;
162
+ annotations?: ToolAnnotations;
163
+ icons?: Icon[];
164
+ };
165
+ type PromptConfig<P extends string, S> = Gated<P, InferArgs<S>> & {
166
+ title?: string;
167
+ description?: string;
168
+ argsSchema?: S;
169
+ icons?: Icon[];
170
+ };
171
+ type ResourceConfig<P extends string> = Gated<P, URL> & ResourceMetadata & {
172
+ /** The URI clients read, or a template for a family of them. */
173
+ uri: string | ResourceTemplate;
174
+ };
175
+ /**
176
+ * Erased once built, so a server registers every capability the same way
177
+ * whatever it accepts.
178
+ */
179
+ type Definition<P extends string, C = Principal<P>> = {
180
+ /**
181
+ * Unique key for the boot-time check. Bare for a tool, prefixed otherwise, so
182
+ * a prompt sharing a tool's name stays a separate entry rather than shadowing
183
+ * it.
184
+ */
185
+ label: string;
186
+ kind: Capability;
187
+ /** Exact protocol name used to refuse an unpermitted direct invocation before scope step-up. */
188
+ routeName?: string;
189
+ routeMatches?: (name: string) => boolean;
190
+ permission: P;
191
+ /** Whether this definition can ask for a person, so boot can check a sink exists. */
192
+ approval?: boolean;
193
+ register: (server: McpServer, principal: Principal<P>, context: C, guards: Guards<unknown>) => void;
194
+ };
195
+ /** What every registration needs to record and, sometimes, to ask. */
196
+ type Guards<A> = {
197
+ onAudit?: AuditSink;
198
+ onAuditError?: AuditErrorSink;
199
+ /** Names this deployment on every event it emits. */
200
+ emitter?: string;
201
+ onApproval?: ApprovalSink;
202
+ approvalTimeoutMs: number;
203
+ /** Present only when this capability declared one. */
204
+ needsApproval?: (args: A) => boolean;
205
+ };
206
+ type ServerOptions$1 = Implementation & {
207
+ /** Called on every permitted invocation. The one record tying a person to an action. */
208
+ onAudit?: AuditSink;
209
+ /**
210
+ * Names this deployment on every event it emits.
211
+ *
212
+ * Set the same value in every entry point of one deployment — a dashboard
213
+ * reading several of them cannot otherwise tell which server a call reached.
214
+ * Left unset, the field is absent rather than guessed.
215
+ */
216
+ emitter?: string;
217
+ /** Receives a failed terminal audit write without changing the completed action's result. */
218
+ onAuditError?: AuditErrorSink;
219
+ /**
220
+ * Ask a person. Awaited while the caller's request stays open, so this holds
221
+ * a connection for as long as it takes to answer — required as soon as any
222
+ * capability declares `approval`, and refused at boot when one does and this
223
+ * is missing.
224
+ */
225
+ onApproval?: ApprovalSink;
226
+ /**
227
+ * How long to wait before treating silence as a refusal. Defaults to 45s,
228
+ * which is under the 60s idle timeout most proxies ship with. Raise it only
229
+ * as far as whatever sits in front of this server will actually hold.
230
+ */
231
+ approvalTimeoutMs?: number;
232
+ /** Pass-through SDK options; declared gated capabilities are merged in. */
233
+ mcp?: ServerOptions;
234
+ };
235
+ /**
236
+ * A per-request server factory over a fixed set of capabilities, plus the
237
+ * label-to-permission map that `createMcpFetch` reconciles against the policy
238
+ * at boot.
239
+ */
240
+ type ServerFactory<C> = ((context: C) => McpServer) & {
241
+ permissions: ReadonlyMap<string, string>;
242
+ routePermissions: ReadonlyMap<string, string>;
243
+ permissionForRoute: (kind: Capability, name: string) => string | undefined;
244
+ /** The registered route name a concrete request name resolves to, template included. */
245
+ routeNameFor: (kind: Capability, name: string) => string | undefined;
246
+ };
247
+ /**
248
+ * Everything bound to one policy: `permission` accepts only what that policy can
249
+ * grant, in all four places, from one call.
250
+ *
251
+ * The policy is a type carrier here and is never invoked. Authorization happens
252
+ * once per request when the principal is resolved, not once per definition.
253
+ *
254
+ * ```ts
255
+ * const { tool, prompt, resource, server } = authz(policy);
256
+ * ```
257
+ */
258
+ declare function bindAuthz<P extends string, C, H>(_permissions: Policy<P> | PermissionCatalog<P>, principalOf: (context: C) => Principal<P>, handlerContext: (context: C, principal: Principal<P>) => H): {
259
+ tool: <S extends StandardSchemaWithJSON | undefined = undefined>(name: string, config: ToolConfig<P, S>, handler: (args: InferArgs<S>, context: H) => CallToolResult | Promise<CallToolResult>) => Definition<P, C>;
260
+ prompt: <S extends StandardSchemaWithJSON | undefined = undefined>(name: string, config: PromptConfig<P, S>, handler: (args: InferArgs<S>, context: H) => GetPromptResult | Promise<GetPromptResult>) => Definition<P, C>;
261
+ resource: (name: string, config: ResourceConfig<P>, handler: (uri: URL, context: H) => ReadResourceResult | Promise<ReadResourceResult>) => Definition<P, C>;
262
+ server: (definitions: readonly Definition<P, C>[], options: ServerOptions$1) => ServerFactory<C>;
263
+ };
264
+ declare function authz<P extends string>(policy: Policy<P> | PermissionCatalog<P>): ReturnType<typeof bindAuthz<P, Principal<P>, {
265
+ principal: Principal<P>;
266
+ }>>;
267
+ declare function authz<P extends string, C>(policy: Policy<P> | PermissionCatalog<P>, options: {
268
+ principal: (context: C) => Principal<P>;
269
+ }): ReturnType<typeof bindAuthz<P, C, C>>;
270
+ //#endregion
271
+ export { AuditDeliveryFailure as a, AuditSink as c, PromptConfig as d, ResourceConfig as f, authz as h, ApprovalSink as i, Capability as l, ToolConfig as m, ApprovalRefusedError as n, AuditErrorSink as o, ServerOptions$1 as p, ApprovalRequest as r, AuditEvent as s, ApprovalDecision as t, Definition as u };
@@ -0,0 +1,152 @@
1
+ import { OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server";
2
+ import { createRemoteJWKSet, jwtVerify } from "jose";
3
+ //#region src/identity.ts
4
+ var AccessDeniedError = class AccessDeniedError extends Error {
5
+ email;
6
+ reason;
7
+ constructor(email, reason, message) {
8
+ super(message);
9
+ this.name = "AccessDeniedError";
10
+ this.email = email;
11
+ this.reason = reason;
12
+ }
13
+ static notPermitted(email, policyHint = "the access policy") {
14
+ return new AccessDeniedError(email, "not_permitted", `${email} matches no rule in ${policyHint}, so they hold no permissions. Ask an administrator to grant them a role.`);
15
+ }
16
+ static noCredential(email, credentialHint = "a backend credential") {
17
+ return new AccessDeniedError(email, "no_credential", `${email} is permitted but has no ${credentialHint}, and no shared account is configured.`);
18
+ }
19
+ };
20
+ //#endregion
21
+ //#region src/decision.ts
22
+ function policyDenied(error) {
23
+ return Response.json({
24
+ error: "forbidden",
25
+ reason: "policy_denied",
26
+ error_description: error.message
27
+ }, { status: 403 });
28
+ }
29
+ async function emitDecision(sink, principal, decision, reason, emitter) {
30
+ if (!principal) return;
31
+ await sink?.({
32
+ type: "mcp_authz.decision.v1",
33
+ issuer: principal.issuer,
34
+ sub: principal.sub,
35
+ email: principal.email,
36
+ domain: principal.domain,
37
+ ...emitter ? { emitter } : {},
38
+ decision,
39
+ roles: principal.roles,
40
+ permissions: principal.permissions,
41
+ ...reason ? { reason } : {},
42
+ at: (/* @__PURE__ */ new Date()).toISOString()
43
+ });
44
+ }
45
+ function principalLabel(principal) {
46
+ return principal.email ?? `${principal.issuer}#${principal.sub}`;
47
+ }
48
+ //#endregion
49
+ //#region src/verifier.ts
50
+ const invalidToken = (message) => new OAuthError(OAuthErrorCode.InvalidToken, message);
51
+ /**
52
+ * A JWKS-backed verifier. Keys are fetched once and cached by `jose`, which
53
+ * also handles rotation, so a key roll at the AS does not need a redeploy.
54
+ */
55
+ function jwksVerifier(options) {
56
+ const jwks = createRemoteJWKSet(new URL(options.jwksUri));
57
+ const emailClaim = options.emailClaim ?? "email";
58
+ const emailVerifiedClaim = options.emailVerifiedClaim ?? "email_verified";
59
+ const expectedResource = new URL(options.resource.href).href.split("#")[0];
60
+ return {
61
+ async verifyAccessToken(token) {
62
+ let payload;
63
+ try {
64
+ payload = (await jwtVerify(token, jwks, {
65
+ issuer: options.issuer,
66
+ audience: expectedResource
67
+ })).payload;
68
+ } catch (error) {
69
+ throw invalidToken(`Token rejected: ${error instanceof Error ? error.message : String(error)}`);
70
+ }
71
+ if (typeof payload.exp !== "number") throw invalidToken("Token has no `exp` claim.");
72
+ const claimed = payload[emailClaim];
73
+ const email = typeof claimed === "string" && claimed ? claimed : void 0;
74
+ if (!email && (options.requireEmail ?? true)) throw invalidToken(`Token carries no '${emailClaim}' claim, so there is no identity to map.`);
75
+ if (email && (options.requireEmailVerified ?? true) && payload[emailVerifiedClaim] !== true) throw invalidToken(`Token does not prove '${emailClaim}' with '${emailVerifiedClaim}: true'.`);
76
+ const sub = payload.sub;
77
+ if (typeof sub !== "string" || !sub) throw invalidToken("Token has no `sub` claim, so there is no stable subject to bind to.");
78
+ const domain = typeof payload.hd === "string" ? payload.hd : void 0;
79
+ if (options.allowedDomain && domain?.toLowerCase() !== options.allowedDomain.toLowerCase()) throw invalidToken(`Token is for ${domain ?? "an unknown domain"}, not ${options.allowedDomain}.`);
80
+ return {
81
+ token,
82
+ clientId: typeof payload.client_id === "string" ? payload.client_id : typeof payload.azp === "string" ? payload.azp : sub,
83
+ scopes: scopesOf(payload.scope),
84
+ expiresAt: payload.exp,
85
+ resource: options.resource,
86
+ extra: {
87
+ issuer: options.issuer,
88
+ sub,
89
+ email,
90
+ emailVerified: email !== void 0 && (options.requireEmailVerified === false || payload[emailVerifiedClaim] === true),
91
+ domain,
92
+ claims: payload
93
+ }
94
+ };
95
+ },
96
+ identityOf: identityFromAuth
97
+ };
98
+ }
99
+ /**
100
+ * The verifier a resource server ends up with, however it was configured.
101
+ *
102
+ * Every entry point here faces the same three choices — bring your own
103
+ * verifier, configure the built-in one, or say nothing and let discovery fill
104
+ * it in — and has to refuse the same contradiction between the first two. Doing
105
+ * that in one place is what stops two entry points disagreeing about which
106
+ * issuer a token is checked against, or which audience it must carry.
107
+ */
108
+ function verifierFor(options) {
109
+ if (options.tokenVerifier && options.verifier) throw new Error("Pass either `tokenVerifier` or built-in `verifier` options, not both.");
110
+ if (options.tokenVerifier) return {
111
+ tokenVerifier: options.tokenVerifier,
112
+ mapIdentity: options.identityFromAuth ?? identityFromAuth
113
+ };
114
+ const published = typeof options.oauthMetadata.jwks_uri === "string" ? options.oauthMetadata.jwks_uri : void 0;
115
+ const jwksUri = options.verifier?.jwksUri ?? published;
116
+ if (!jwksUri) throw new Error("No JWKS to verify tokens against. Set `verifier.jwksUri`, use a custom `tokenVerifier`, or use `discoverOAuth(issuer)`, whose metadata carries `jwks_uri`.");
117
+ const builtIn = jwksVerifier({
118
+ ...options.verifier,
119
+ jwksUri,
120
+ issuer: options.verifier?.issuer ?? options.oauthMetadata.issuer,
121
+ resource: options.verifier?.resource ?? options.resourceServerUrl
122
+ });
123
+ return {
124
+ tokenVerifier: builtIn,
125
+ mapIdentity: options.identityFromAuth ?? builtIn.identityOf
126
+ };
127
+ }
128
+ /** Default identity mapper for custom verifiers using `AuthInfo.extra`. */
129
+ function identityFromAuth(auth) {
130
+ const { issuer, sub, email, emailVerified, domain, claims } = auth.extra ?? {};
131
+ if (typeof issuer !== "string" || !issuer || typeof sub !== "string" || !sub) throw invalidToken("Verified token carried no issuer or subject.");
132
+ if (email !== void 0 && (typeof email !== "string" || !email)) throw invalidToken("Verified token carried an invalid email.");
133
+ return {
134
+ issuer,
135
+ sub,
136
+ email: typeof email === "string" ? email : void 0,
137
+ emailVerified: emailVerified === true,
138
+ domain: typeof domain === "string" ? domain : void 0,
139
+ claims: isRecord(claims) ? claims : {}
140
+ };
141
+ }
142
+ function isRecord(value) {
143
+ return typeof value === "object" && value !== null && !Array.isArray(value);
144
+ }
145
+ /** OAuth scope is a space-delimited string; some servers send an array anyway. */
146
+ function scopesOf(scope) {
147
+ if (Array.isArray(scope)) return scope.filter((s) => typeof s === "string");
148
+ if (typeof scope === "string") return scope.split(" ").filter(Boolean);
149
+ return [];
150
+ }
151
+ //#endregion
152
+ export { policyDenied as a, emitDecision as i, jwksVerifier as n, principalLabel as o, verifierFor as r, AccessDeniedError as s, identityFromAuth as t };
@@ -0,0 +1,84 @@
1
+ import { _ as Identity } from "./policy-DuZbwrKf.js";
2
+ import { AuthInfo, OAuthMetadata, OAuthTokenVerifier } from "@modelcontextprotocol/server";
3
+ //#region src/decision.d.ts
4
+ /**
5
+ * The access decision and how it is reported, with nothing protocol-shaped
6
+ * attached.
7
+ *
8
+ * Its own module for the bundle rather than for tidiness: this rides in every
9
+ * enforcement path, while the rest of the ladder is MCP route classification
10
+ * and scope step-up. Left there, an OpenAPI deployment shipped the MCP wire
11
+ * format it never calls.
12
+ */
13
+ type AuthorizationDecisionEvent = {
14
+ /** See `AuditEvent['type']`: same contract, its own shape and version. */
15
+ type: 'mcp_authz.decision.v1';
16
+ issuer: string;
17
+ sub: string;
18
+ email?: string;
19
+ /** See `AuditEvent['domain']`: the nearest thing to an organisation. */
20
+ domain?: string;
21
+ /** Which deployment emitted this, when you named it. */
22
+ emitter?: string;
23
+ decision: 'allow' | 'deny';
24
+ roles: readonly string[];
25
+ permissions: readonly string[];
26
+ reason?: string;
27
+ at: string;
28
+ };
29
+ type AuthorizationDecisionSink = (event: AuthorizationDecisionEvent) => unknown | Promise<unknown>;
30
+ //#endregion
31
+ //#region src/verifier.d.ts
32
+ /**
33
+ * Token verification, the one thing the SDK deliberately leaves to you.
34
+ *
35
+ * `authInfo` is strictly pass-through in the SDK: it is never derived from
36
+ * request headers, and no token is checked unless we check it. So this file is
37
+ * the security boundary of the whole server.
38
+ *
39
+ * The authorization server is something that already exists (WorkOS, Stytch,
40
+ * Auth0) doing dynamic client registration / CIMD and Google Workspace login.
41
+ * Google cannot play that role itself: it has no DCR/CIMD, and it will not
42
+ * mint a token whose audience is this server.
43
+ */
44
+ type VerifierOptions = {
45
+ /** The AS issuer, e.g. `https://auth.acme.com`. Must match the token's `iss`. */
46
+ issuer: string;
47
+ /** Where the AS publishes its signing keys. */
48
+ jwksUri: string;
49
+ /**
50
+ * This server's public URL. A token minted for a different resource is
51
+ * refused even when its signature is valid (RFC 8707).
52
+ */
53
+ resource: URL;
54
+ /**
55
+ * Restrict to one Google Workspace domain, checked against the `hd` claim
56
+ * the AS passes through. Omit to accept any domain the AS admits.
57
+ */
58
+ allowedDomain?: string;
59
+ /** Claim carrying the verified email. Auth0 and WorkOS both use `email`. */
60
+ emailClaim?: string;
61
+ /** Claim proving the email was verified. Defaults to `email_verified`. */
62
+ emailVerifiedClaim?: string;
63
+ /** Require an explicit `true` verified-email claim. Defaults to true. */
64
+ requireEmailVerified?: boolean;
65
+ /**
66
+ * Require an email in every token. Defaults to true. Set false to accept an
67
+ * agent that signs in as itself, such as an OAuth client-credentials token:
68
+ * its identity is its `sub`, which a policy rule names. Email and domain
69
+ * rules never match it, and neither does `allowedDomain`; a rule with no
70
+ * `match` does. A token that does carry an email is checked as before.
71
+ */
72
+ requireEmail?: boolean;
73
+ };
74
+ /**
75
+ * A JWKS-backed verifier. Keys are fetched once and cached by `jose`, which
76
+ * also handles rotation, so a key roll at the AS does not need a redeploy.
77
+ */
78
+ declare function jwksVerifier(options: VerifierOptions): OAuthTokenVerifier & {
79
+ identityOf: (auth: AuthInfo) => Identity;
80
+ };
81
+ /** Default identity mapper for custom verifiers using `AuthInfo.extra`. */
82
+ declare function identityFromAuth(auth: AuthInfo): Identity;
83
+ //#endregion
84
+ export { AuthorizationDecisionSink as a, AuthorizationDecisionEvent as i, identityFromAuth as n, jwksVerifier as r, VerifierOptions as t };