@auth51/mcp-proxy 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/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # @auth51/mcp-proxy
2
+
3
+ **Least-privilege enforcement + a real audit trail at the MCP tool boundary — for AI coding agents.**
4
+
5
+ Claude Code, Cursor, and Codex run with your full permissions, call MCP tools that touch repos / databases / infra / prod, and leave **no record** that your existing security tooling can see. A staging token with broad rights let one agent [delete a production database *and all backups* in 9 seconds](https://thehackernews.com/2026/03/how-ceros-gives-security-teams.html). Six disclosed exploits broke coding agents by stealing their *credentials*, not their models.
6
+
7
+ `@auth51/mcp-proxy` drops in front of **any** MCP server. It inspects every `tools/call`, **blocks destructive or out-of-scope actions before they reach the tool**, and writes an append-only audit log of everything the agent tried. No account required to start.
8
+
9
+ ```
10
+ coding agent ──► @auth51/mcp-proxy ──► your MCP server
11
+ (Claude/Cursor) ▲ allow / DENY + audit
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Quickstart (≈ 5 minutes)
17
+
18
+ ### 1. See it block a destructive call (no install of your own needed)
19
+
20
+ ```bash
21
+ git clone <this repo> && cd auth51-mcp-proxy
22
+ npm install && npm run build
23
+ node examples/demo-drive.mjs
24
+ ```
25
+
26
+ You'll see a safe `SELECT` pass through, and `DROP TABLE` / `DELETE`-without-`WHERE` **blocked at the boundary** — with the agent told exactly why, and an audit trail at `.auth51/demo-audit.jsonl`.
27
+
28
+ ### 2. Guard your own MCP server
29
+
30
+ Wrap any existing MCP server entry in your client config. **Before:**
31
+
32
+ ```jsonc
33
+ // .cursor/mcp.json (or Claude Code / Codex MCP config)
34
+ {
35
+ "mcpServers": {
36
+ "postgres": {
37
+ "command": "npx",
38
+ "args": ["-y", "@modelcontextprotocol/server-postgres", "postgres://..."]
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ **After** — insert the proxy and move the real command after `--`:
45
+
46
+ ```jsonc
47
+ {
48
+ "mcpServers": {
49
+ "postgres": {
50
+ "command": "npx",
51
+ "args": [
52
+ "-y", "@auth51/mcp-proxy",
53
+ "--policy", "./auth51.policy.json",
54
+ "--label", "postgres",
55
+ "--",
56
+ "npx", "-y", "@modelcontextprotocol/server-postgres", "postgres://..."
57
+ ]
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ Restart your agent. Destructive calls are now blocked, everything is audited. That's it.
64
+
65
+ ---
66
+
67
+ ## Policy
68
+
69
+ A policy is JSON: `default` decision plus `rules`. Each rule matches a tool by name
70
+ (`tool` glob or `toolMatch` regex) and optionally by argument content
71
+ (`denyArgsMatch` — regexes against the call arguments; supports a leading `(?i)`
72
+ for case-insensitivity). First matching rule wins.
73
+
74
+ ```json
75
+ {
76
+ "version": "1",
77
+ "default": "allow",
78
+ "rules": [
79
+ {
80
+ "id": "no-drop-or-truncate",
81
+ "tool": "*",
82
+ "denyArgsMatch": ["(?i)\\bdrop\\s+(table|database)\\b", "(?i)\\btruncate\\s+table\\b"],
83
+ "reason": "Irreversible data destruction requires human approval."
84
+ }
85
+ ]
86
+ }
87
+ ```
88
+
89
+ With **no `--policy`**, a conservative built-in default blocks destructive SQL,
90
+ recursive `rm -rf`, destructive tool names, and reads of `.aws/credentials` /
91
+ `.ssh` keys / `.env`. See [`examples/auth51.policy.json`](examples/auth51.policy.json).
92
+
93
+ ---
94
+
95
+ ## Connect to an auth51 authority (teams & enterprise)
96
+
97
+ Local mode enforces policy and audits to disk — enough for one developer. Point it
98
+ at an **auth51 authority** and every *allowed* call is backed by a **scoped,
99
+ short-lived intent token** (carrying the agent's identity + Proof-of-Possession),
100
+ and decisions are centralized for your org/tenant.
101
+
102
+ ```bash
103
+ export AUTH51_AUTHORITY_URL=https://authority.auth51.com
104
+ export AUTH51_CLIENT_ID=... # your org/app API key
105
+ export AUTH51_CLIENT_SECRET=...
106
+ # add --authority to the proxy args (or set AUTH51_AUTHORITY_URL)
107
+ ```
108
+
109
+ The authority path is **fail-open** in v0: local policy is always the binding
110
+ enforcement point, so an authority outage can never wedge your agent. A local
111
+ *deny* always wins.
112
+
113
+ - **Individual developers:** local mode, or the hosted authority (SaaS) — authority server only.
114
+ - **Enterprise:** the same authority self-hosted (on-prem container) or as a dedicated SaaS tenant.
115
+
116
+ ---
117
+
118
+ ## CLI
119
+
120
+ ```
121
+ auth51-mcp-proxy [options] -- <mcp-server-command> [args...]
122
+
123
+ --policy <path> Policy JSON (default: built-in safe defaults)
124
+ --audit <path> Audit log JSONL (default: ./.auth51/audit.jsonl)
125
+ --label <name> Friendly name for this server in logs/audit
126
+ --agent <id> Agent identity to attribute decisions to
127
+ --authority <url> auth51 authority base URL (or AUTH51_AUTHORITY_URL)
128
+ ```
129
+
130
+ ## How it works
131
+
132
+ - Transparent stdio MCP proxy: spawns your server, relays JSON-RPC line-for-line.
133
+ - Only `tools/call` is intercepted; everything else passes through untouched.
134
+ - On `tools/call`: evaluate policy → **allow** (forward, zero added latency) or
135
+ **deny** (return an MCP `isError` result explaining the block; never forward).
136
+ - Every decision is appended to the audit log; with an authority, allowed calls
137
+ also mint a scoped intent token and record the decision centrally.
138
+
139
+ ## Status
140
+
141
+ v0. Stdio transport (the dominant local coding-agent case). HTTP/SSE transport,
142
+ per-tool scope grants, and the agent-runtime identity surface are next.
143
+
144
+ ## License
145
+
146
+ Apache-2.0
@@ -0,0 +1,13 @@
1
+ import type { AuditEntry } from "./types.js";
2
+ /**
3
+ * Append-only local audit log (JSONL). Every tool-call decision is recorded here
4
+ * regardless of whether an authority is connected, so an individual developer can
5
+ * see exactly what their coding agent tried to do — the trail that today's
6
+ * locally-running agents leave nowhere.
7
+ */
8
+ export declare class Auditor {
9
+ private path;
10
+ constructor(path: string);
11
+ record(entry: AuditEntry): void;
12
+ }
13
+ export declare function nowIso(): string;
package/dist/audit.js ADDED
@@ -0,0 +1,32 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ /**
4
+ * Append-only local audit log (JSONL). Every tool-call decision is recorded here
5
+ * regardless of whether an authority is connected, so an individual developer can
6
+ * see exactly what their coding agent tried to do — the trail that today's
7
+ * locally-running agents leave nowhere.
8
+ */
9
+ export class Auditor {
10
+ path;
11
+ constructor(path) {
12
+ this.path = path;
13
+ try {
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ }
16
+ catch {
17
+ /* best-effort */
18
+ }
19
+ }
20
+ record(entry) {
21
+ try {
22
+ appendFileSync(this.path, JSON.stringify(entry) + "\n", "utf8");
23
+ }
24
+ catch {
25
+ /* never let auditing crash the proxy */
26
+ }
27
+ }
28
+ }
29
+ export function nowIso() {
30
+ return new Date().toISOString();
31
+ }
32
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAGpC;;;;;GAKG;AACH,MAAM,OAAO,OAAO;IACV,IAAI,CAAS;IAErB,YAAY,IAAY;QACtB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC;YACH,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAChD,CAAC;QAAC,MAAM,CAAC;YACP,iBAAiB;QACnB,CAAC;IACH,CAAC;IAED,MAAM,CAAC,KAAiB;QACtB,IAAI,CAAC;YACH,cAAc,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;QAClE,CAAC;QAAC,MAAM,CAAC;YACP,wCAAwC;QAC1C,CAAC;IACH,CAAC;CACF;AAED,MAAM,UAAU,MAAM;IACpB,OAAO,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;AAClC,CAAC"}
@@ -0,0 +1,85 @@
1
+ import type { AuthorityConfig, GrantInfo, RSEndpoint } from "./types.js";
2
+ /**
3
+ * Optional integration with the auth51 authority server.
4
+ *
5
+ * Without it, the proxy enforces policy locally and writes a local audit trail —
6
+ * enough for an individual developer to try in minutes. With it, allowed calls are
7
+ * backed by a *scoped, short-lived intent token* minted by the authority (carrying
8
+ * the agent identity + Proof-of-Possession), and decisions are centralized for an
9
+ * org / enterprise tenant.
10
+ *
11
+ * The authority path is best-effort and FAIL-OPEN in v0: local policy remains the
12
+ * binding enforcement point, so an authority outage cannot wedge a developer's
13
+ * agent. Local-deny always wins regardless of the authority.
14
+ */
15
+ export declare class AuthorityClient {
16
+ private cfg;
17
+ private accessTokens;
18
+ private warned;
19
+ constructor(cfg: AuthorityConfig);
20
+ private base;
21
+ private warnOnce;
22
+ /**
23
+ * Get a client_credentials token carrying `generate:intent-token` (permission
24
+ * to mint) PLUS the scopes being delegated. The authority enforces that an
25
+ * intent token's `requested_scopes` are a subset of the caller's OAuth-token
26
+ * scopes — you can't delegate more than you hold — so the OAuth token must
27
+ * carry both. Cached per distinct scope-set.
28
+ */
29
+ private accessTokenFor;
30
+ /**
31
+ * Fetch this agent's current grant envelope from the authority (Slice 2d):
32
+ * GET /v1/grants/{appId}/{agentId}. The proxy uses it to filter tools/list to
33
+ * the granted surface and to deny out-of-grant calls when mode==="enforce".
34
+ *
35
+ * Fail-open: returns null on any error / 404 (no grant registered) / missing
36
+ * config, so a developer without an authority-side grant keeps working on
37
+ * local policy alone. Local policy + the authority's own mint-time enforcement
38
+ * remain the binding controls regardless.
39
+ */
40
+ fetchGrant(): Promise<GrantInfo | null>;
41
+ /**
42
+ * Mint a scoped intent token for an allowed call (authority TokenRequest shape:
43
+ * grant_type="agent_checksum" + agent_id + computed_checksum + requested_scopes).
44
+ * The minted intent token is returned in the response's `access_token`.
45
+ * Returns the token + its jti for the audit chain, or null if the authority is
46
+ * unreachable / not provisioned (fail-open).
47
+ */
48
+ mintIntent(opts: {
49
+ scope: string;
50
+ workflowId?: string;
51
+ workflowStep?: Record<string, unknown>;
52
+ delegationContext?: Record<string, unknown>;
53
+ }): Promise<{
54
+ jti: string;
55
+ token: string;
56
+ } | null>;
57
+ /**
58
+ * Fetch a resource server's registered endpoint catalog (O6):
59
+ * GET /v1/resource-servers/{rsId}/endpoints → `[{method, path_template, scope,
60
+ * risk_tier}]`. The egress uses it to map a CONCRETE request (`GET /repos/42`)
61
+ * to its route TEMPLATE (`/repos/{id}`) and thus the exact scope the verifier
62
+ * will require. Returns [] on any error / missing catalog (the egress then
63
+ * falls back to deriving the scope from the concrete path).
64
+ */
65
+ fetchEndpoints(rsId: string): Promise<RSEndpoint[]>;
66
+ /**
67
+ * Hop B (RFC 8693): exchange a Hop-A intent token (subject) for a downstream
68
+ * token scoped + audienced for the resource server the MCP server is about to
69
+ * call. Used at the server→RS egress: the proxy derives the endpoint's
70
+ * `a51:rs:*` scope from the concrete method+URL (see scopes.ts) and requests
71
+ * exactly it here. The proxy authenticates as ITSELF (its OAuth client) — that
72
+ * is the verifiable identity on this hop; the agent identity rode in the
73
+ * subject token. `cnfJkt` binds the result to the proxy's DPoP key (RFC 9449),
74
+ * so the downstream RS token is sender-constrained to this proxy.
75
+ *
76
+ * Fail-open like the rest: returns null on any error, so an authority hiccup
77
+ * degrades to forwarding without the RS token rather than wedging the call.
78
+ */
79
+ exchangeToken(opts: {
80
+ subjectToken: string;
81
+ audience: string;
82
+ scope?: string;
83
+ cnfJkt?: string;
84
+ }): Promise<string | null>;
85
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Optional integration with the auth51 authority server.
3
+ *
4
+ * Without it, the proxy enforces policy locally and writes a local audit trail —
5
+ * enough for an individual developer to try in minutes. With it, allowed calls are
6
+ * backed by a *scoped, short-lived intent token* minted by the authority (carrying
7
+ * the agent identity + Proof-of-Possession), and decisions are centralized for an
8
+ * org / enterprise tenant.
9
+ *
10
+ * The authority path is best-effort and FAIL-OPEN in v0: local policy remains the
11
+ * binding enforcement point, so an authority outage cannot wedge a developer's
12
+ * agent. Local-deny always wins regardless of the authority.
13
+ */
14
+ export class AuthorityClient {
15
+ cfg;
16
+ accessTokens = new Map();
17
+ warned = false;
18
+ constructor(cfg) {
19
+ this.cfg = cfg;
20
+ }
21
+ base(path) {
22
+ return this.cfg.baseUrl.replace(/\/$/, "") + path;
23
+ }
24
+ warnOnce(msg) {
25
+ if (this.warned)
26
+ return;
27
+ this.warned = true;
28
+ process.stderr.write(`[auth51] authority unavailable, continuing on local policy: ${msg}\n`);
29
+ }
30
+ /**
31
+ * Get a client_credentials token carrying `generate:intent-token` (permission
32
+ * to mint) PLUS the scopes being delegated. The authority enforces that an
33
+ * intent token's `requested_scopes` are a subset of the caller's OAuth-token
34
+ * scopes — you can't delegate more than you hold — so the OAuth token must
35
+ * carry both. Cached per distinct scope-set.
36
+ */
37
+ async accessTokenFor(scope) {
38
+ const full = ["generate:intent-token", ...scope.split(/\s+/).filter(Boolean)].join(" ");
39
+ const now = Date.now();
40
+ const cached = this.accessTokens.get(full);
41
+ if (cached && now < cached.exp - 5000)
42
+ return cached.token;
43
+ try {
44
+ const res = await fetch(this.base("/v1/oauth/token"), {
45
+ method: "POST",
46
+ headers: { "content-type": "application/x-www-form-urlencoded" },
47
+ body: new URLSearchParams({
48
+ grant_type: "client_credentials",
49
+ client_id: this.cfg.clientId,
50
+ client_secret: this.cfg.clientSecret,
51
+ scope: full,
52
+ }),
53
+ });
54
+ if (!res.ok) {
55
+ this.warnOnce(`token endpoint returned ${res.status}`);
56
+ return null;
57
+ }
58
+ const data = (await res.json());
59
+ if (!data.access_token) {
60
+ this.warnOnce("token endpoint returned no access_token");
61
+ return null;
62
+ }
63
+ this.accessTokens.set(full, { token: data.access_token, exp: now + (data.expires_in ?? 300) * 1000 });
64
+ return data.access_token;
65
+ }
66
+ catch (err) {
67
+ this.warnOnce(err.message);
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * Fetch this agent's current grant envelope from the authority (Slice 2d):
73
+ * GET /v1/grants/{appId}/{agentId}. The proxy uses it to filter tools/list to
74
+ * the granted surface and to deny out-of-grant calls when mode==="enforce".
75
+ *
76
+ * Fail-open: returns null on any error / 404 (no grant registered) / missing
77
+ * config, so a developer without an authority-side grant keeps working on
78
+ * local policy alone. Local policy + the authority's own mint-time enforcement
79
+ * remain the binding controls regardless.
80
+ */
81
+ async fetchGrant() {
82
+ if (!this.cfg.appId || !this.cfg.agentId) {
83
+ this.warnOnce("no appId/agentId configured; cannot fetch agent grant");
84
+ return null;
85
+ }
86
+ const token = await this.accessTokenFor("read:agents");
87
+ if (!token)
88
+ return null;
89
+ try {
90
+ const path = `/v1/grants/${encodeURIComponent(this.cfg.appId)}/${encodeURIComponent(this.cfg.agentId)}`;
91
+ const res = await fetch(this.base(path), {
92
+ headers: { authorization: `Bearer ${token}` },
93
+ });
94
+ if (res.status === 404)
95
+ return null; // no grant registered → fail-open
96
+ if (!res.ok) {
97
+ this.warnOnce(`grant fetch returned ${res.status}`);
98
+ return null;
99
+ }
100
+ const d = (await res.json());
101
+ return {
102
+ allowedScopes: d.allowed_scopes ?? [],
103
+ stepUpScopes: d.step_up_scopes ?? [],
104
+ mode: d.mode === "observe" ? "observe" : "enforce",
105
+ version: d.version ?? 0,
106
+ source: d.source ?? "unknown",
107
+ expiresAt: d.expires_at ?? null,
108
+ };
109
+ }
110
+ catch (err) {
111
+ this.warnOnce(err.message);
112
+ return null;
113
+ }
114
+ }
115
+ /**
116
+ * Mint a scoped intent token for an allowed call (authority TokenRequest shape:
117
+ * grant_type="agent_checksum" + agent_id + computed_checksum + requested_scopes).
118
+ * The minted intent token is returned in the response's `access_token`.
119
+ * Returns the token + its jti for the audit chain, or null if the authority is
120
+ * unreachable / not provisioned (fail-open).
121
+ */
122
+ async mintIntent(opts) {
123
+ const token = await this.accessTokenFor(opts.scope);
124
+ if (!token)
125
+ return null;
126
+ if (!this.cfg.agentId || !this.cfg.agentChecksum) {
127
+ this.warnOnce("no agentId/agentChecksum configured; cannot mint intent token");
128
+ return null;
129
+ }
130
+ try {
131
+ const res = await fetch(this.base("/v1/intent/token"), {
132
+ method: "POST",
133
+ headers: {
134
+ "content-type": "application/json",
135
+ authorization: `Bearer ${token}`,
136
+ },
137
+ body: JSON.stringify({
138
+ grant_type: "agent_checksum",
139
+ agent_id: this.cfg.agentId,
140
+ computed_checksum: this.cfg.agentChecksum,
141
+ requested_scopes: opts.scope ? opts.scope.split(/\s+/).filter(Boolean) : [],
142
+ audience: this.cfg.audience ?? "",
143
+ workflow_id: opts.workflowId,
144
+ workflow_step: opts.workflowStep,
145
+ delegation_context: opts.delegationContext,
146
+ // The MCP proxy does NOT use the authority's pre-registered workflow-DAG
147
+ // authorization (that 403s on any unregistered workflow_id). Our workflow
148
+ // provenance is the prev_jti hash-chain (§4.3); workflow_id/step ride along
149
+ // for the audit + token claims, informational only. Flip to true later if
150
+ // we register workflows in the authority (calibration model, Slice 2+).
151
+ workflow_enabled: false,
152
+ }),
153
+ });
154
+ if (!res.ok) {
155
+ this.warnOnce(`intent mint returned ${res.status}`);
156
+ return null;
157
+ }
158
+ const data = (await res.json());
159
+ const intentToken = data.access_token;
160
+ if (!intentToken)
161
+ return null;
162
+ const jti = parseJti(intentToken) ?? "unknown";
163
+ return { jti, token: intentToken };
164
+ }
165
+ catch (err) {
166
+ this.warnOnce(err.message);
167
+ return null;
168
+ }
169
+ }
170
+ /**
171
+ * Fetch a resource server's registered endpoint catalog (O6):
172
+ * GET /v1/resource-servers/{rsId}/endpoints → `[{method, path_template, scope,
173
+ * risk_tier}]`. The egress uses it to map a CONCRETE request (`GET /repos/42`)
174
+ * to its route TEMPLATE (`/repos/{id}`) and thus the exact scope the verifier
175
+ * will require. Returns [] on any error / missing catalog (the egress then
176
+ * falls back to deriving the scope from the concrete path).
177
+ */
178
+ async fetchEndpoints(rsId) {
179
+ const token = await this.accessTokenFor("read:agents");
180
+ if (!token)
181
+ return [];
182
+ try {
183
+ const path = `/v1/resource-servers/${encodeURIComponent(rsId)}/endpoints`;
184
+ const res = await fetch(this.base(path), {
185
+ headers: { authorization: `Bearer ${token}` },
186
+ });
187
+ if (!res.ok)
188
+ return [];
189
+ const rows = (await res.json());
190
+ return rows.map((r) => ({
191
+ method: (r.method ?? "GET").toUpperCase(),
192
+ pathTemplate: r.path_template ?? "/",
193
+ scope: r.scope ?? "",
194
+ riskTier: r.risk_tier ?? "low",
195
+ }));
196
+ }
197
+ catch (err) {
198
+ this.warnOnce(err.message);
199
+ return [];
200
+ }
201
+ }
202
+ /**
203
+ * Hop B (RFC 8693): exchange a Hop-A intent token (subject) for a downstream
204
+ * token scoped + audienced for the resource server the MCP server is about to
205
+ * call. Used at the server→RS egress: the proxy derives the endpoint's
206
+ * `a51:rs:*` scope from the concrete method+URL (see scopes.ts) and requests
207
+ * exactly it here. The proxy authenticates as ITSELF (its OAuth client) — that
208
+ * is the verifiable identity on this hop; the agent identity rode in the
209
+ * subject token. `cnfJkt` binds the result to the proxy's DPoP key (RFC 9449),
210
+ * so the downstream RS token is sender-constrained to this proxy.
211
+ *
212
+ * Fail-open like the rest: returns null on any error, so an authority hiccup
213
+ * degrades to forwarding without the RS token rather than wedging the call.
214
+ */
215
+ async exchangeToken(opts) {
216
+ try {
217
+ const body = {
218
+ grant_type: TOKEN_EXCHANGE_GRANT,
219
+ subject_token: opts.subjectToken,
220
+ subject_token_type: JWT_TOKEN_TYPE,
221
+ audience: opts.audience,
222
+ };
223
+ if (opts.scope)
224
+ body.scope = opts.scope;
225
+ // Authenticate the exchanging party (the proxy) when credentials exist —
226
+ // this is the proxy's own verifiable identity on the Hop-B exchange.
227
+ if (this.cfg.clientId)
228
+ body.client_id = this.cfg.clientId;
229
+ if (this.cfg.clientSecret)
230
+ body.client_secret = this.cfg.clientSecret;
231
+ if (opts.cnfJkt)
232
+ body.cnf_jkt = opts.cnfJkt;
233
+ const res = await fetch(this.base("/v1/oauth/token"), {
234
+ method: "POST",
235
+ headers: { "content-type": "application/x-www-form-urlencoded" },
236
+ body: new URLSearchParams(body),
237
+ });
238
+ if (!res.ok) {
239
+ this.warnOnce(`token exchange returned ${res.status}`);
240
+ return null;
241
+ }
242
+ const data = (await res.json());
243
+ return data.access_token ?? null;
244
+ }
245
+ catch (err) {
246
+ this.warnOnce(err.message);
247
+ return null;
248
+ }
249
+ }
250
+ }
251
+ const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
252
+ const JWT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt";
253
+ /** Extract the jti claim from a JWT without verifying it (audit display only). */
254
+ function parseJti(jwt) {
255
+ const parts = jwt.split(".");
256
+ if (parts.length < 2)
257
+ return null;
258
+ try {
259
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
260
+ return typeof payload.jti === "string" ? payload.jti : null;
261
+ }
262
+ catch {
263
+ return null;
264
+ }
265
+ }
266
+ //# sourceMappingURL=authority.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authority.js","sourceRoot":"","sources":["../src/authority.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Server→RS egress: the proxy's Hop-B (non-embed parity, DESIGN-proxy-egress-hop-b).
3
+ *
4
+ * The MCP server's outbound RS calls are routed through this reverse-proxy (v1
5
+ * on-ramp: governed RS base URLs point here). For each request it:
6
+ * 1. maps the CONCRETE (method, path) to the registered endpoint TEMPLATE →
7
+ * the exact `a51:rs:*` scope the verifier will require (O6);
8
+ * 2. obtains a Hop-A intent token — the in-flight one the embed put in `_meta`
9
+ * (Case E), or one the proxy mints itself (Case T) — then EXCHANGES it
10
+ * (RFC 8693) for a downstream, DPoP-bound, RS-scoped token;
11
+ * 3. attaches `Authorization: Bearer <rs-token>` (+ DPoP) and forwards.
12
+ * An ungoverned path (no scope match, no catalog) is a transparent passthrough.
13
+ * Fail-open throughout: any hiccup forwards the request unchanged.
14
+ */
15
+ import * as http from "node:http";
16
+ import type { AuthorityClient } from "./authority.js";
17
+ import type { RSEndpoint } from "./types.js";
18
+ export interface EgressConfig {
19
+ /** The governed RS's id — namespaces its scopes (usually === its audience). */
20
+ rsId: string;
21
+ /** Token audience for the exchange (often === rsId). */
22
+ audience: string;
23
+ /** Where to forward the (now-governed) request — the real RS base URL. */
24
+ targetBaseUrl: string;
25
+ /** DPoP-bind the downstream token to the proxy key. Default true. */
26
+ popEnabled?: boolean;
27
+ }
28
+ export declare function setInFlightIntent(token: string | null): void;
29
+ export declare function getInFlightIntent(): string | null;
30
+ /** Compile a route template (`/repos/{id}`) to an anchored matcher. */
31
+ export declare function templateToRegex(template: string): RegExp;
32
+ /** Strip query + trailing slash, collapse `//` — the concrete request path. */
33
+ export declare function cleanPath(p: string): string;
34
+ /**
35
+ * The registered endpoint whose (method, template) matches this concrete
36
+ * request, preferring the most specific (fewest params) on a tie — so a static
37
+ * `/files/latest` wins over `/files/{id}`.
38
+ */
39
+ export declare function matchEndpoint(method: string, path: string, endpoints: RSEndpoint[]): RSEndpoint | null;
40
+ export interface ScopeSelection {
41
+ scope: string;
42
+ matched: boolean;
43
+ }
44
+ /**
45
+ * The `a51:rs` scope to request for this request. Prefer the registered
46
+ * endpoint's stored scope (templated routes need the catalog to be right). If
47
+ * no catalog/match, derive from the concrete path — correct for parameterless
48
+ * routes, best-effort otherwise (a `{id}` route can't be recovered from one
49
+ * concrete path). Returns null only for an underivable request (bad method).
50
+ */
51
+ export declare function selectScope(method: string, path: string, endpoints: RSEndpoint[], rsId: string): ScopeSelection | null;
52
+ export interface ExchangeResult {
53
+ token: string;
54
+ scope: string;
55
+ matched: boolean;
56
+ cnfJkt?: string;
57
+ }
58
+ /**
59
+ * Obtain the downstream RS token for a request: select the scope, get a subject
60
+ * intent token (in-flight from `_meta`, Case E; else mint it, Case T), and
61
+ * exchange it (RFC 8693) for a DPoP-bound RS-scoped token. Null ⇒ passthrough.
62
+ */
63
+ export declare function governedExchange(authority: AuthorityClient, cfg: EgressConfig, method: string, path: string, endpoints: RSEndpoint[]): Promise<ExchangeResult | null>;
64
+ export interface EgressServer {
65
+ server: http.Server;
66
+ port: () => number;
67
+ close: () => Promise<void>;
68
+ }
69
+ /**
70
+ * Start the egress reverse-proxy. Point the MCP server's governed RS base URL at
71
+ * `http://127.0.0.1:<port>`; the proxy governs each call and forwards to
72
+ * `cfg.targetBaseUrl`. `onAudit` (optional) receives one record per request.
73
+ */
74
+ export declare function createEgressServer(authority: AuthorityClient, cfg: EgressConfig, onAudit?: (rec: Record<string, unknown>) => void): EgressServer;