mcp-proxy 5.11.2 → 5.12.1
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 +71 -49
- package/dist/bin/mcp-proxy.js +31 -35
- package/dist/bin/mcp-proxy.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +516 -295
- package/dist/index.js.map +1 -1
- package/dist/{stdio-DQCs94rj.js → stdio-BAyQuMKu.js} +11135 -8848
- package/dist/stdio-BAyQuMKu.js.map +1 -0
- package/jsr.json +1 -1
- package/package.json +19 -19
- package/src/InMemoryEventStore.test.ts +58 -50
- package/src/InMemoryEventStore.ts +2 -3
- package/src/authentication.test.ts +275 -18
- package/src/authentication.ts +57 -5
- package/src/bin/mcp-proxy.ts +2 -1
- package/src/proxyServer.test.ts +40 -22
- package/src/startHTTPServer.test.ts +41 -23
- package/src/startHTTPServer.ts +132 -52
- package/src/startStdioServer.test.ts +8 -5
- package/dist/stdio-DQCs94rj.js.map +0 -1
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-proxy",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.12.1",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "tsdown",
|
|
@@ -38,27 +38,27 @@
|
|
|
38
38
|
]
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@eslint/js": "^9.
|
|
42
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
43
|
-
"@sebbo2002/semantic-release-jsr": "^3.0
|
|
44
|
-
"@tsconfig/node22": "^22.0.
|
|
45
|
-
"@types/express": "^5.0.
|
|
46
|
-
"@types/node": "^24.
|
|
47
|
-
"@types/yargs": "^17.0.
|
|
48
|
-
"eslint": "^9.
|
|
41
|
+
"@eslint/js": "^9.39.1",
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
43
|
+
"@sebbo2002/semantic-release-jsr": "^3.1.0",
|
|
44
|
+
"@tsconfig/node22": "^22.0.5",
|
|
45
|
+
"@types/express": "^5.0.6",
|
|
46
|
+
"@types/node": "^24.10.1",
|
|
47
|
+
"@types/yargs": "^17.0.35",
|
|
48
|
+
"eslint": "^9.39.1",
|
|
49
49
|
"eslint-config-prettier": "^10.1.8",
|
|
50
|
-
"eslint-plugin-perfectionist": "^4.15.
|
|
51
|
-
"eventsource": "^4.
|
|
52
|
-
"express": "^5.
|
|
50
|
+
"eslint-plugin-perfectionist": "^4.15.1",
|
|
51
|
+
"eventsource": "^4.1.0",
|
|
52
|
+
"express": "^5.2.1",
|
|
53
53
|
"get-port-please": "^3.2.0",
|
|
54
|
-
"jiti": "^2.
|
|
54
|
+
"jiti": "^2.6.1",
|
|
55
55
|
"jsr": "^0.13.5",
|
|
56
|
-
"prettier": "^3.
|
|
57
|
-
"semantic-release": "^24.2.
|
|
58
|
-
"tsdown": "^0.15.
|
|
59
|
-
"tsx": "^4.
|
|
60
|
-
"typescript": "^5.9.
|
|
61
|
-
"typescript-eslint": "^8.
|
|
56
|
+
"prettier": "^3.7.4",
|
|
57
|
+
"semantic-release": "^24.2.9",
|
|
58
|
+
"tsdown": "^0.15.12",
|
|
59
|
+
"tsx": "^4.21.0",
|
|
60
|
+
"typescript": "^5.9.3",
|
|
61
|
+
"typescript-eslint": "^8.48.1",
|
|
62
62
|
"vitest": "^3.2.4",
|
|
63
63
|
"yargs": "^18.0.0"
|
|
64
64
|
},
|
|
@@ -8,7 +8,7 @@ describe("InMemoryEventStore", () => {
|
|
|
8
8
|
it("stores events and replays them after a specific event ID", async () => {
|
|
9
9
|
const store = new InMemoryEventStore();
|
|
10
10
|
const streamId = "test-stream-123";
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
// Create test messages
|
|
13
13
|
const messages: JSONRPCMessage[] = [
|
|
14
14
|
{ id: 1, jsonrpc: "2.0", method: "initialize" },
|
|
@@ -17,7 +17,7 @@ describe("InMemoryEventStore", () => {
|
|
|
17
17
|
{ id: 3, jsonrpc: "2.0", result: { success: true } },
|
|
18
18
|
{ id: 4, jsonrpc: "2.0", method: "shutdown" },
|
|
19
19
|
];
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
// Store all events and keep track of event IDs
|
|
22
22
|
// Add small delays to ensure different timestamps for proper ordering
|
|
23
23
|
const eventIds: string[] = [];
|
|
@@ -26,32 +26,33 @@ describe("InMemoryEventStore", () => {
|
|
|
26
26
|
expect(eventId).toContain(streamId);
|
|
27
27
|
eventIds.push(eventId);
|
|
28
28
|
// Small delay to ensure different timestamps
|
|
29
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
// Test replaying events after the second event
|
|
33
|
-
const replayedEvents: Array<{ eventId: string; message: JSONRPCMessage }> =
|
|
33
|
+
const replayedEvents: Array<{ eventId: string; message: JSONRPCMessage }> =
|
|
34
|
+
[];
|
|
34
35
|
const sendMock = vi.fn(async (eventId: string, message: JSONRPCMessage) => {
|
|
35
36
|
replayedEvents.push({ eventId, message });
|
|
36
37
|
});
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
const returnedStreamId = await store.replayEventsAfter(
|
|
39
40
|
eventIds[1], // Replay after the second event
|
|
40
|
-
{ send: sendMock }
|
|
41
|
+
{ send: sendMock },
|
|
41
42
|
);
|
|
42
|
-
|
|
43
|
+
|
|
43
44
|
// Verify the correct stream ID was returned
|
|
44
45
|
expect(returnedStreamId).toBe(streamId);
|
|
45
|
-
|
|
46
|
+
|
|
46
47
|
// Verify that events 3, 4, and 5 were replayed (after event 2)
|
|
47
48
|
expect(replayedEvents).toHaveLength(3);
|
|
48
49
|
expect(sendMock).toHaveBeenCalledTimes(3);
|
|
49
|
-
|
|
50
|
+
|
|
50
51
|
// Verify the replayed messages are correct and in order
|
|
51
52
|
expect(replayedEvents[0].message).toEqual(messages[2]);
|
|
52
53
|
expect(replayedEvents[1].message).toEqual(messages[3]);
|
|
53
54
|
expect(replayedEvents[2].message).toEqual(messages[4]);
|
|
54
|
-
|
|
55
|
+
|
|
55
56
|
// Verify event IDs are preserved
|
|
56
57
|
expect(replayedEvents[0].eventId).toBe(eventIds[2]);
|
|
57
58
|
expect(replayedEvents[1].eventId).toBe(eventIds[3]);
|
|
@@ -62,99 +63,106 @@ describe("InMemoryEventStore", () => {
|
|
|
62
63
|
const store = new InMemoryEventStore();
|
|
63
64
|
const streamId1 = "stream-alpha";
|
|
64
65
|
const streamId2 = "stream-beta";
|
|
65
|
-
|
|
66
|
+
|
|
66
67
|
// Create messages for two different streams
|
|
67
68
|
const stream1Messages: JSONRPCMessage[] = [
|
|
68
69
|
{ id: 1, jsonrpc: "2.0", method: "stream1.init" },
|
|
69
70
|
{ id: 2, jsonrpc: "2.0", method: "stream1.process" },
|
|
70
71
|
{ id: 3, jsonrpc: "2.0", method: "stream1.complete" },
|
|
71
72
|
];
|
|
72
|
-
|
|
73
|
+
|
|
73
74
|
const stream2Messages: JSONRPCMessage[] = [
|
|
74
75
|
{ id: 10, jsonrpc: "2.0", method: "stream2.init" },
|
|
75
76
|
{ id: 20, jsonrpc: "2.0", method: "stream2.process" },
|
|
76
77
|
{ id: 30, jsonrpc: "2.0", method: "stream2.complete" },
|
|
77
78
|
];
|
|
78
|
-
|
|
79
|
+
|
|
79
80
|
// Interleave storing events from both streams with small delays
|
|
80
81
|
const stream1EventIds: string[] = [];
|
|
81
82
|
const stream2EventIds: string[] = [];
|
|
82
|
-
|
|
83
|
+
|
|
83
84
|
// Store first event from each stream
|
|
84
85
|
stream1EventIds.push(await store.storeEvent(streamId1, stream1Messages[0]));
|
|
85
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
86
87
|
stream2EventIds.push(await store.storeEvent(streamId2, stream2Messages[0]));
|
|
87
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
88
|
-
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
89
|
+
|
|
89
90
|
// Store second event from each stream
|
|
90
91
|
stream1EventIds.push(await store.storeEvent(streamId1, stream1Messages[1]));
|
|
91
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
92
93
|
stream2EventIds.push(await store.storeEvent(streamId2, stream2Messages[1]));
|
|
93
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
94
|
-
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
95
|
+
|
|
95
96
|
// Store third event from each stream
|
|
96
97
|
stream1EventIds.push(await store.storeEvent(streamId1, stream1Messages[2]));
|
|
97
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
98
99
|
stream2EventIds.push(await store.storeEvent(streamId2, stream2Messages[2]));
|
|
99
|
-
|
|
100
|
+
|
|
100
101
|
// Replay events from stream 1 after its first event
|
|
101
|
-
const stream1ReplayedEvents: Array<{
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
102
|
+
const stream1ReplayedEvents: Array<{
|
|
103
|
+
eventId: string;
|
|
104
|
+
message: JSONRPCMessage;
|
|
105
|
+
}> = [];
|
|
106
|
+
const stream1SendMock = vi.fn(
|
|
107
|
+
async (eventId: string, message: JSONRPCMessage) => {
|
|
108
|
+
stream1ReplayedEvents.push({ eventId, message });
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
106
112
|
const returnedStreamId1 = await store.replayEventsAfter(
|
|
107
113
|
stream1EventIds[0],
|
|
108
|
-
{ send: stream1SendMock }
|
|
114
|
+
{ send: stream1SendMock },
|
|
109
115
|
);
|
|
110
|
-
|
|
116
|
+
|
|
111
117
|
// Verify only stream 1 events were replayed
|
|
112
118
|
expect(returnedStreamId1).toBe(streamId1);
|
|
113
119
|
expect(stream1ReplayedEvents).toHaveLength(2);
|
|
114
120
|
expect(stream1ReplayedEvents[0].message).toEqual(stream1Messages[1]);
|
|
115
121
|
expect(stream1ReplayedEvents[1].message).toEqual(stream1Messages[2]);
|
|
116
|
-
|
|
122
|
+
|
|
117
123
|
// Verify no stream 2 events were included
|
|
118
124
|
for (const event of stream1ReplayedEvents) {
|
|
119
125
|
expect(event.eventId).toContain(streamId1);
|
|
120
126
|
expect(event.eventId).not.toContain(streamId2);
|
|
121
127
|
}
|
|
122
|
-
|
|
128
|
+
|
|
123
129
|
// Now replay events from stream 2 after its first event
|
|
124
|
-
const stream2ReplayedEvents: Array<{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
130
|
+
const stream2ReplayedEvents: Array<{
|
|
131
|
+
eventId: string;
|
|
132
|
+
message: JSONRPCMessage;
|
|
133
|
+
}> = [];
|
|
134
|
+
const stream2SendMock = vi.fn(
|
|
135
|
+
async (eventId: string, message: JSONRPCMessage) => {
|
|
136
|
+
stream2ReplayedEvents.push({ eventId, message });
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
129
140
|
const returnedStreamId2 = await store.replayEventsAfter(
|
|
130
141
|
stream2EventIds[0],
|
|
131
|
-
{ send: stream2SendMock }
|
|
142
|
+
{ send: stream2SendMock },
|
|
132
143
|
);
|
|
133
|
-
|
|
144
|
+
|
|
134
145
|
// Verify only stream 2 events were replayed
|
|
135
146
|
expect(returnedStreamId2).toBe(streamId2);
|
|
136
147
|
expect(stream2ReplayedEvents).toHaveLength(2);
|
|
137
148
|
expect(stream2ReplayedEvents[0].message).toEqual(stream2Messages[1]);
|
|
138
149
|
expect(stream2ReplayedEvents[1].message).toEqual(stream2Messages[2]);
|
|
139
|
-
|
|
150
|
+
|
|
140
151
|
// Verify no stream 1 events were included
|
|
141
152
|
for (const event of stream2ReplayedEvents) {
|
|
142
153
|
expect(event.eventId).toContain(streamId2);
|
|
143
154
|
expect(event.eventId).not.toContain(streamId1);
|
|
144
155
|
}
|
|
145
|
-
|
|
156
|
+
|
|
146
157
|
// Test edge case: replay with non-existent event ID returns empty string
|
|
147
158
|
const invalidResult = await store.replayEventsAfter(
|
|
148
159
|
"non-existent-event-id",
|
|
149
|
-
{ send: vi.fn() }
|
|
160
|
+
{ send: vi.fn() },
|
|
150
161
|
);
|
|
151
162
|
expect(invalidResult).toBe("");
|
|
152
|
-
|
|
163
|
+
|
|
153
164
|
// Test edge case: replay with empty event ID returns empty string
|
|
154
|
-
const emptyResult = await store.replayEventsAfter(
|
|
155
|
-
"",
|
|
156
|
-
{ send: vi.fn() }
|
|
157
|
-
);
|
|
165
|
+
const emptyResult = await store.replayEventsAfter("", { send: vi.fn() });
|
|
158
166
|
expect(emptyResult).toBe("");
|
|
159
167
|
});
|
|
160
168
|
|
|
@@ -188,12 +196,12 @@ describe("InMemoryEventStore", () => {
|
|
|
188
196
|
|
|
189
197
|
const timestampParts = parts.map(([, timestamp]) => timestamp);
|
|
190
198
|
expect(timestampParts).toEqual(
|
|
191
|
-
Array(messages.length).fill(fixedTimestamp.toString())
|
|
199
|
+
Array(messages.length).fill(fixedTimestamp.toString()),
|
|
192
200
|
);
|
|
193
201
|
|
|
194
202
|
const counterSuffixes = parts.map(([, , counter]) => counter);
|
|
195
203
|
expect(counterSuffixes).toEqual(
|
|
196
|
-
messages.map((_, index) =>
|
|
204
|
+
messages.map((_, index) => index.toString(36).padStart(4, "0")),
|
|
197
205
|
);
|
|
198
206
|
|
|
199
207
|
// Random parts should be 3 base36 characters each (due to substring(2, 5))
|
|
@@ -229,4 +237,4 @@ describe("InMemoryEventStore", () => {
|
|
|
229
237
|
secondSpy.mockRestore();
|
|
230
238
|
}
|
|
231
239
|
});
|
|
232
|
-
});
|
|
240
|
+
});
|
|
@@ -81,13 +81,12 @@ export class InMemoryEventStore implements EventStore {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Generates a monotonic unique event ID in
|
|
84
|
+
* Generates a monotonic unique event ID in
|
|
85
85
|
* `${streamId}_${timestamp}_${counter}_${random}` format.
|
|
86
86
|
*/
|
|
87
87
|
private generateEventId(streamId: string): string {
|
|
88
|
-
|
|
89
88
|
const now = Date.now();
|
|
90
|
-
|
|
89
|
+
|
|
91
90
|
if (now === this.lastTimestamp) {
|
|
92
91
|
this.lastTimestampCounter++;
|
|
93
92
|
} else {
|
|
@@ -4,7 +4,9 @@ import { describe, expect, it } from "vitest";
|
|
|
4
4
|
import { AuthenticationMiddleware } from "./authentication.js";
|
|
5
5
|
|
|
6
6
|
describe("AuthenticationMiddleware", () => {
|
|
7
|
-
const createMockRequest = (
|
|
7
|
+
const createMockRequest = (
|
|
8
|
+
headers: Record<string, string> = {},
|
|
9
|
+
): IncomingMessage => {
|
|
8
10
|
// Simulate Node.js http module behavior which converts all header names to lowercase
|
|
9
11
|
const lowercaseHeaders: Record<string, string> = {};
|
|
10
12
|
for (const [key, value] of Object.entries(headers)) {
|
|
@@ -98,7 +100,9 @@ describe("AuthenticationMiddleware", () => {
|
|
|
98
100
|
|
|
99
101
|
const body = JSON.parse(response.body);
|
|
100
102
|
expect(body.error.code).toBe(401);
|
|
101
|
-
expect(body.error.message).toBe(
|
|
103
|
+
expect(body.error.message).toBe(
|
|
104
|
+
"Unauthorized: Invalid or missing API key",
|
|
105
|
+
);
|
|
102
106
|
expect(body.jsonrpc).toBe("2.0");
|
|
103
107
|
expect(body.id).toBe(null);
|
|
104
108
|
});
|
|
@@ -207,8 +211,12 @@ describe("AuthenticationMiddleware", () => {
|
|
|
207
211
|
});
|
|
208
212
|
const response = middleware.getUnauthorizedResponse();
|
|
209
213
|
|
|
210
|
-
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
211
|
-
|
|
214
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
215
|
+
'realm="example-realm"',
|
|
216
|
+
);
|
|
217
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
218
|
+
'resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
|
|
219
|
+
);
|
|
212
220
|
});
|
|
213
221
|
|
|
214
222
|
it("should include custom error in WWW-Authenticate header via options", () => {
|
|
@@ -225,8 +233,12 @@ describe("AuthenticationMiddleware", () => {
|
|
|
225
233
|
error_description: "The request requires higher privileges",
|
|
226
234
|
});
|
|
227
235
|
|
|
228
|
-
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
229
|
-
|
|
236
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
237
|
+
'error="insufficient_scope"',
|
|
238
|
+
);
|
|
239
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
240
|
+
'error_description="The request requires higher privileges"',
|
|
241
|
+
);
|
|
230
242
|
});
|
|
231
243
|
|
|
232
244
|
it("should include scope in WWW-Authenticate header", () => {
|
|
@@ -241,7 +253,9 @@ describe("AuthenticationMiddleware", () => {
|
|
|
241
253
|
});
|
|
242
254
|
const response = middleware.getUnauthorizedResponse();
|
|
243
255
|
|
|
244
|
-
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
256
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
257
|
+
'scope="read write"',
|
|
258
|
+
);
|
|
245
259
|
});
|
|
246
260
|
|
|
247
261
|
it("should override config error with options error", () => {
|
|
@@ -260,10 +274,18 @@ describe("AuthenticationMiddleware", () => {
|
|
|
260
274
|
error_description: "Options error description",
|
|
261
275
|
});
|
|
262
276
|
|
|
263
|
-
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
expect(response.headers["WWW-Authenticate"]).
|
|
277
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
278
|
+
'error="invalid_token"',
|
|
279
|
+
);
|
|
280
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
281
|
+
'error_description="Options error description"',
|
|
282
|
+
);
|
|
283
|
+
expect(response.headers["WWW-Authenticate"]).not.toContain(
|
|
284
|
+
"invalid_request",
|
|
285
|
+
);
|
|
286
|
+
expect(response.headers["WWW-Authenticate"]).not.toContain(
|
|
287
|
+
"Config error description",
|
|
288
|
+
);
|
|
267
289
|
});
|
|
268
290
|
|
|
269
291
|
it("should include error_uri in WWW-Authenticate header", () => {
|
|
@@ -278,7 +300,9 @@ describe("AuthenticationMiddleware", () => {
|
|
|
278
300
|
});
|
|
279
301
|
const response = middleware.getUnauthorizedResponse();
|
|
280
302
|
|
|
281
|
-
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
303
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
304
|
+
'error_uri="https://example.com/errors/auth"',
|
|
305
|
+
);
|
|
282
306
|
});
|
|
283
307
|
|
|
284
308
|
it("should properly escape quotes in error_description", () => {
|
|
@@ -294,7 +318,9 @@ describe("AuthenticationMiddleware", () => {
|
|
|
294
318
|
error_description: 'Token "abc123" is invalid',
|
|
295
319
|
});
|
|
296
320
|
|
|
297
|
-
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
321
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
322
|
+
'error_description="Token \\"abc123\\" is invalid"',
|
|
323
|
+
);
|
|
298
324
|
});
|
|
299
325
|
|
|
300
326
|
it("should include all parameters in correct order", () => {
|
|
@@ -315,16 +341,247 @@ describe("AuthenticationMiddleware", () => {
|
|
|
315
341
|
|
|
316
342
|
const header = response.headers["WWW-Authenticate"];
|
|
317
343
|
expect(header).toContain('realm="my-realm"');
|
|
318
|
-
expect(header).toContain(
|
|
344
|
+
expect(header).toContain(
|
|
345
|
+
'resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
|
|
346
|
+
);
|
|
319
347
|
expect(header).toContain('error="invalid_token"');
|
|
320
348
|
expect(header).toContain('error_description="Token expired"');
|
|
321
349
|
expect(header).toContain('error_uri="https://example.com/errors"');
|
|
322
350
|
expect(header).toContain('scope="read write"');
|
|
323
351
|
|
|
324
352
|
// Check order: realm, resource_metadata, error, error_description, error_uri, scope
|
|
325
|
-
expect(header.indexOf(
|
|
326
|
-
|
|
327
|
-
|
|
353
|
+
expect(header.indexOf("realm=")).toBeLessThan(
|
|
354
|
+
header.indexOf("resource_metadata="),
|
|
355
|
+
);
|
|
356
|
+
expect(header.indexOf("resource_metadata=")).toBeLessThan(
|
|
357
|
+
header.indexOf("error="),
|
|
358
|
+
);
|
|
359
|
+
expect(header.indexOf("error=")).toBeLessThan(
|
|
360
|
+
header.indexOf("error_description="),
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("getScopeChallengeResponse", () => {
|
|
366
|
+
it("should return 403 status code", () => {
|
|
367
|
+
const middleware = new AuthenticationMiddleware({
|
|
368
|
+
oauth: {
|
|
369
|
+
protectedResource: {
|
|
370
|
+
resource: "https://example.com",
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
const response = middleware.getScopeChallengeResponse(["read", "write"]);
|
|
375
|
+
|
|
376
|
+
expect(response.statusCode).toBe(403);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("should include required scopes in WWW-Authenticate header", () => {
|
|
380
|
+
const middleware = new AuthenticationMiddleware({
|
|
381
|
+
oauth: {
|
|
382
|
+
protectedResource: {
|
|
383
|
+
resource: "https://example.com",
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
const response = middleware.getScopeChallengeResponse(["read", "write"]);
|
|
388
|
+
|
|
389
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
390
|
+
'error="insufficient_scope"',
|
|
391
|
+
);
|
|
392
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
393
|
+
'scope="read write"',
|
|
394
|
+
);
|
|
395
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
396
|
+
'resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("should include error_description in WWW-Authenticate header", () => {
|
|
401
|
+
const middleware = new AuthenticationMiddleware({
|
|
402
|
+
oauth: {
|
|
403
|
+
protectedResource: {
|
|
404
|
+
resource: "https://example.com",
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
const response = middleware.getScopeChallengeResponse(
|
|
409
|
+
["admin"],
|
|
410
|
+
"Admin access required",
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
414
|
+
'error_description="Admin access required"',
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("should escape quotes in error_description", () => {
|
|
419
|
+
const middleware = new AuthenticationMiddleware({
|
|
420
|
+
oauth: {
|
|
421
|
+
protectedResource: {
|
|
422
|
+
resource: "https://example.com",
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
const response = middleware.getScopeChallengeResponse(
|
|
427
|
+
["admin"],
|
|
428
|
+
'Requires "admin" scope',
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
432
|
+
'error_description="Requires \\"admin\\" scope"',
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("should include request ID in response body", () => {
|
|
437
|
+
const middleware = new AuthenticationMiddleware({
|
|
438
|
+
oauth: {
|
|
439
|
+
protectedResource: {
|
|
440
|
+
resource: "https://example.com",
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
const response = middleware.getScopeChallengeResponse(
|
|
445
|
+
["read"],
|
|
446
|
+
undefined,
|
|
447
|
+
123,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const body = JSON.parse(response.body);
|
|
451
|
+
expect(body.id).toBe(123);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("should include required scopes in response body data", () => {
|
|
455
|
+
const middleware = new AuthenticationMiddleware({
|
|
456
|
+
oauth: {
|
|
457
|
+
protectedResource: {
|
|
458
|
+
resource: "https://example.com",
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
const response = middleware.getScopeChallengeResponse(["read", "write"]);
|
|
463
|
+
|
|
464
|
+
const body = JSON.parse(response.body);
|
|
465
|
+
expect(body.error.code).toBe(-32001);
|
|
466
|
+
expect(body.error.message).toBe("Insufficient scope");
|
|
467
|
+
expect(body.error.data.error).toBe("insufficient_scope");
|
|
468
|
+
expect(body.error.data.required_scopes).toEqual(["read", "write"]);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("should use custom error_description in response body message", () => {
|
|
472
|
+
const middleware = new AuthenticationMiddleware({
|
|
473
|
+
oauth: {
|
|
474
|
+
protectedResource: {
|
|
475
|
+
resource: "https://example.com",
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
const response = middleware.getScopeChallengeResponse(
|
|
480
|
+
["admin"],
|
|
481
|
+
"Admin privileges required",
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const body = JSON.parse(response.body);
|
|
485
|
+
expect(body.error.message).toBe("Admin privileges required");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("should handle single scope", () => {
|
|
489
|
+
const middleware = new AuthenticationMiddleware({
|
|
490
|
+
oauth: {
|
|
491
|
+
protectedResource: {
|
|
492
|
+
resource: "https://example.com",
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
const response = middleware.getScopeChallengeResponse(["admin"]);
|
|
497
|
+
|
|
498
|
+
expect(response.headers["WWW-Authenticate"]).toContain('scope="admin"');
|
|
499
|
+
const body = JSON.parse(response.body);
|
|
500
|
+
expect(body.error.data.required_scopes).toEqual(["admin"]);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("should handle multiple scopes", () => {
|
|
504
|
+
const middleware = new AuthenticationMiddleware({
|
|
505
|
+
oauth: {
|
|
506
|
+
protectedResource: {
|
|
507
|
+
resource: "https://example.com",
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
const response = middleware.getScopeChallengeResponse([
|
|
512
|
+
"read",
|
|
513
|
+
"write",
|
|
514
|
+
"admin",
|
|
515
|
+
]);
|
|
516
|
+
|
|
517
|
+
expect(response.headers["WWW-Authenticate"]).toContain(
|
|
518
|
+
'scope="read write admin"',
|
|
519
|
+
);
|
|
520
|
+
const body = JSON.parse(response.body);
|
|
521
|
+
expect(body.error.data.required_scopes).toEqual([
|
|
522
|
+
"read",
|
|
523
|
+
"write",
|
|
524
|
+
"admin",
|
|
525
|
+
]);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("should not include WWW-Authenticate header without OAuth config", () => {
|
|
529
|
+
const middleware = new AuthenticationMiddleware({});
|
|
530
|
+
const response = middleware.getScopeChallengeResponse(["admin"]);
|
|
531
|
+
|
|
532
|
+
expect(response.headers["WWW-Authenticate"]).toBeUndefined();
|
|
533
|
+
expect(response.headers["Content-Type"]).toBe("application/json");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("should return proper JSON-RPC 2.0 format", () => {
|
|
537
|
+
const middleware = new AuthenticationMiddleware({
|
|
538
|
+
oauth: {
|
|
539
|
+
protectedResource: {
|
|
540
|
+
resource: "https://example.com",
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
const response = middleware.getScopeChallengeResponse(
|
|
545
|
+
["read"],
|
|
546
|
+
"Description",
|
|
547
|
+
"req-123",
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const body = JSON.parse(response.body);
|
|
551
|
+
expect(body.jsonrpc).toBe("2.0");
|
|
552
|
+
expect(body.id).toBe("req-123");
|
|
553
|
+
expect(body.error).toBeDefined();
|
|
554
|
+
expect(body.error.code).toBe(-32001);
|
|
555
|
+
expect(body.error.message).toBe("Description");
|
|
556
|
+
expect(body.error.data).toBeDefined();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("should include Content-Type header", () => {
|
|
560
|
+
const middleware = new AuthenticationMiddleware({
|
|
561
|
+
oauth: {
|
|
562
|
+
protectedResource: {
|
|
563
|
+
resource: "https://example.com",
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
const response = middleware.getScopeChallengeResponse(["read"]);
|
|
568
|
+
|
|
569
|
+
expect(response.headers["Content-Type"]).toBe("application/json");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("should handle empty scopes array", () => {
|
|
573
|
+
const middleware = new AuthenticationMiddleware({
|
|
574
|
+
oauth: {
|
|
575
|
+
protectedResource: {
|
|
576
|
+
resource: "https://example.com",
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
const response = middleware.getScopeChallengeResponse([]);
|
|
581
|
+
|
|
582
|
+
expect(response.headers["WWW-Authenticate"]).toContain('scope=""');
|
|
583
|
+
const body = JSON.parse(response.body);
|
|
584
|
+
expect(body.error.data.required_scopes).toEqual([]);
|
|
328
585
|
});
|
|
329
586
|
});
|
|
330
|
-
});
|
|
587
|
+
});
|