mcp-oauth-provider 0.0.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 +668 -0
- package/dist/__tests__/config.test.js +56 -0
- package/dist/__tests__/config.test.js.map +1 -0
- package/dist/__tests__/integration.test.js +341 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/oauth-flow.test.js +201 -0
- package/dist/__tests__/oauth-flow.test.js.map +1 -0
- package/dist/__tests__/server.test.js +271 -0
- package/dist/__tests__/server.test.js.map +1 -0
- package/dist/__tests__/storage.test.js +256 -0
- package/dist/__tests__/storage.test.js.map +1 -0
- package/dist/client/config.js +30 -0
- package/dist/client/config.js.map +1 -0
- package/dist/client/factory.js +16 -0
- package/dist/client/factory.js.map +1 -0
- package/dist/client/index.js +237 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/oauth-flow.js +73 -0
- package/dist/client/oauth-flow.js.map +1 -0
- package/dist/client/storage.js +237 -0
- package/dist/client/storage.js.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/server/callback.js +164 -0
- package/dist/server/callback.js.map +1 -0
- package/dist/server/index.js +8 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/templates.js +245 -0
- package/dist/server/templates.js.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/config.test.ts +78 -0
- package/src/__tests__/integration.test.ts +398 -0
- package/src/__tests__/oauth-flow.test.ts +276 -0
- package/src/__tests__/server.test.ts +391 -0
- package/src/__tests__/storage.test.ts +329 -0
- package/src/client/config.ts +134 -0
- package/src/client/factory.ts +19 -0
- package/src/client/index.ts +361 -0
- package/src/client/oauth-flow.ts +115 -0
- package/src/client/storage.ts +335 -0
- package/src/index.ts +31 -0
- package/src/server/callback.ts +257 -0
- package/src/server/index.ts +21 -0
- package/src/server/templates.ts +271 -0
@@ -0,0 +1,276 @@
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
2
|
+
import type { OAuthTokens } from '../client/config.js';
|
3
|
+
import {
|
4
|
+
areTokensExpired,
|
5
|
+
calculateTokenExpiry,
|
6
|
+
refreshTokensWithRetry,
|
7
|
+
} from '../client/oauth-flow.js';
|
8
|
+
|
9
|
+
describe('oauth-flow utilities', () => {
|
10
|
+
describe('areTokensExpired', () => {
|
11
|
+
test('should return true for undefined tokens', () => {
|
12
|
+
expect(areTokensExpired(undefined)).toBe(true);
|
13
|
+
});
|
14
|
+
|
15
|
+
test('should return true when tokenExpiryTime is in the past', () => {
|
16
|
+
const tokens: OAuthTokens = {
|
17
|
+
access_token: 'test-token',
|
18
|
+
token_type: 'Bearer',
|
19
|
+
expires_in: 3600,
|
20
|
+
};
|
21
|
+
const pastExpiryTime = Date.now() - 1000;
|
22
|
+
|
23
|
+
expect(areTokensExpired(tokens, pastExpiryTime)).toBe(true);
|
24
|
+
});
|
25
|
+
|
26
|
+
test('should return true when tokenExpiryTime is within buffer period', () => {
|
27
|
+
const tokens: OAuthTokens = {
|
28
|
+
access_token: 'test-token',
|
29
|
+
token_type: 'Bearer',
|
30
|
+
expires_in: 3600,
|
31
|
+
};
|
32
|
+
// Expiry time 2 minutes in future (less than 5 minute buffer)
|
33
|
+
const soonExpiryTime = Date.now() + 120 * 1000;
|
34
|
+
|
35
|
+
expect(areTokensExpired(tokens, soonExpiryTime, 300)).toBe(true);
|
36
|
+
});
|
37
|
+
|
38
|
+
test('should return false when tokenExpiryTime is beyond buffer period', () => {
|
39
|
+
const tokens: OAuthTokens = {
|
40
|
+
access_token: 'test-token',
|
41
|
+
token_type: 'Bearer',
|
42
|
+
expires_in: 3600,
|
43
|
+
};
|
44
|
+
// Expiry time 10 minutes in future (beyond 5 minute buffer)
|
45
|
+
const futureExpiryTime = Date.now() + 600 * 1000;
|
46
|
+
|
47
|
+
expect(areTokensExpired(tokens, futureExpiryTime, 300)).toBe(false);
|
48
|
+
});
|
49
|
+
|
50
|
+
test('should return false when no expiry info and no tokenExpiryTime', () => {
|
51
|
+
const tokens: OAuthTokens = {
|
52
|
+
access_token: 'test-token',
|
53
|
+
token_type: 'Bearer',
|
54
|
+
};
|
55
|
+
|
56
|
+
expect(areTokensExpired(tokens)).toBe(false);
|
57
|
+
});
|
58
|
+
|
59
|
+
test('should return true when expires_in is less than buffer', () => {
|
60
|
+
const tokens: OAuthTokens = {
|
61
|
+
access_token: 'test-token',
|
62
|
+
token_type: 'Bearer',
|
63
|
+
expires_in: 100, // 100 seconds
|
64
|
+
};
|
65
|
+
|
66
|
+
expect(areTokensExpired(tokens, undefined, 300)).toBe(true);
|
67
|
+
});
|
68
|
+
|
69
|
+
test('should return false when expires_in is greater than buffer', () => {
|
70
|
+
const tokens: OAuthTokens = {
|
71
|
+
access_token: 'test-token',
|
72
|
+
token_type: 'Bearer',
|
73
|
+
expires_in: 600, // 600 seconds
|
74
|
+
};
|
75
|
+
|
76
|
+
expect(areTokensExpired(tokens, undefined, 300)).toBe(false);
|
77
|
+
});
|
78
|
+
|
79
|
+
test('should use custom buffer period', () => {
|
80
|
+
const tokens: OAuthTokens = {
|
81
|
+
access_token: 'test-token',
|
82
|
+
token_type: 'Bearer',
|
83
|
+
expires_in: 150,
|
84
|
+
};
|
85
|
+
|
86
|
+
expect(areTokensExpired(tokens, undefined, 100)).toBe(false);
|
87
|
+
expect(areTokensExpired(tokens, undefined, 200)).toBe(true);
|
88
|
+
});
|
89
|
+
});
|
90
|
+
|
91
|
+
describe('calculateTokenExpiry', () => {
|
92
|
+
test('should calculate correct expiry timestamp', () => {
|
93
|
+
const expiresIn = 3600; // 1 hour
|
94
|
+
const before = Date.now();
|
95
|
+
const expiry = calculateTokenExpiry(expiresIn);
|
96
|
+
const after = Date.now();
|
97
|
+
const expectedMin = before + expiresIn * 1000;
|
98
|
+
const expectedMax = after + expiresIn * 1000;
|
99
|
+
|
100
|
+
expect(expiry).toBeGreaterThanOrEqual(expectedMin);
|
101
|
+
expect(expiry).toBeLessThanOrEqual(expectedMax);
|
102
|
+
});
|
103
|
+
|
104
|
+
test('should handle different expiry values', () => {
|
105
|
+
const testCases = [60, 300, 3600, 7200];
|
106
|
+
|
107
|
+
for (const expiresIn of testCases) {
|
108
|
+
const expiry = calculateTokenExpiry(expiresIn);
|
109
|
+
const expectedApprox = Date.now() + expiresIn * 1000;
|
110
|
+
// Allow 100ms tolerance
|
111
|
+
|
112
|
+
expect(Math.abs(expiry - expectedApprox)).toBeLessThan(100);
|
113
|
+
}
|
114
|
+
});
|
115
|
+
});
|
116
|
+
|
117
|
+
describe('refreshTokensWithRetry', () => {
|
118
|
+
test('should successfully refresh tokens on first attempt', async () => {
|
119
|
+
const mockTokens: OAuthTokens = {
|
120
|
+
access_token: 'new-access-token',
|
121
|
+
token_type: 'Bearer',
|
122
|
+
expires_in: 3600,
|
123
|
+
refresh_token: 'new-refresh-token',
|
124
|
+
};
|
125
|
+
const mockFetch = mock(() =>
|
126
|
+
Promise.resolve(
|
127
|
+
new Response(JSON.stringify(mockTokens), {
|
128
|
+
status: 200,
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
130
|
+
})
|
131
|
+
)
|
132
|
+
);
|
133
|
+
const clientInfo = {
|
134
|
+
client_id: 'test-client-id',
|
135
|
+
client_secret: 'test-client-secret',
|
136
|
+
};
|
137
|
+
|
138
|
+
const result = await refreshTokensWithRetry(
|
139
|
+
'https://auth.example.com',
|
140
|
+
clientInfo,
|
141
|
+
'old-refresh-token',
|
142
|
+
undefined,
|
143
|
+
3,
|
144
|
+
100,
|
145
|
+
mockFetch as unknown as typeof fetch
|
146
|
+
);
|
147
|
+
|
148
|
+
expect(result).toEqual(mockTokens);
|
149
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
150
|
+
});
|
151
|
+
|
152
|
+
test('should retry on failure and eventually succeed', async () => {
|
153
|
+
const mockTokens: OAuthTokens = {
|
154
|
+
access_token: 'new-access-token',
|
155
|
+
token_type: 'Bearer',
|
156
|
+
expires_in: 3600,
|
157
|
+
refresh_token: 'refresh-token', // Include the refresh token that was sent
|
158
|
+
};
|
159
|
+
let callCount = 0;
|
160
|
+
const mockFetch = mock(() => {
|
161
|
+
callCount++;
|
162
|
+
if (callCount < 2) {
|
163
|
+
return Promise.reject(new Error('Network error'));
|
164
|
+
}
|
165
|
+
|
166
|
+
return Promise.resolve(
|
167
|
+
new Response(JSON.stringify(mockTokens), {
|
168
|
+
status: 200,
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
170
|
+
})
|
171
|
+
);
|
172
|
+
});
|
173
|
+
const clientInfo = { client_id: 'test-client-id' };
|
174
|
+
|
175
|
+
const result = await refreshTokensWithRetry(
|
176
|
+
'https://auth.example.com',
|
177
|
+
clientInfo,
|
178
|
+
'refresh-token',
|
179
|
+
undefined,
|
180
|
+
3,
|
181
|
+
10, // Short delay for testing
|
182
|
+
mockFetch as unknown as typeof fetch
|
183
|
+
);
|
184
|
+
|
185
|
+
expect(result).toEqual(mockTokens);
|
186
|
+
expect(callCount).toBe(2);
|
187
|
+
});
|
188
|
+
|
189
|
+
test('should throw error after max retries', async () => {
|
190
|
+
const mockFetch = mock(() => Promise.reject(new Error('Network error')));
|
191
|
+
const clientInfo = { client_id: 'test-client-id' };
|
192
|
+
|
193
|
+
await expect(
|
194
|
+
refreshTokensWithRetry(
|
195
|
+
'https://auth.example.com',
|
196
|
+
clientInfo,
|
197
|
+
'refresh-token',
|
198
|
+
undefined,
|
199
|
+
3,
|
200
|
+
10,
|
201
|
+
mockFetch as unknown as typeof fetch
|
202
|
+
)
|
203
|
+
).rejects.toThrow('Token refresh failed after 3 attempts');
|
204
|
+
|
205
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
206
|
+
});
|
207
|
+
|
208
|
+
test('should use exponential backoff for retries', async () => {
|
209
|
+
const callTimes: number[] = [];
|
210
|
+
const mockFetch = mock(() => {
|
211
|
+
callTimes.push(Date.now());
|
212
|
+
|
213
|
+
return Promise.reject(new Error('Network error'));
|
214
|
+
});
|
215
|
+
const clientInfo = { client_id: 'test-client-id' };
|
216
|
+
const baseDelay = 50;
|
217
|
+
|
218
|
+
try {
|
219
|
+
await refreshTokensWithRetry(
|
220
|
+
'https://auth.example.com',
|
221
|
+
clientInfo,
|
222
|
+
'refresh-token',
|
223
|
+
undefined,
|
224
|
+
3,
|
225
|
+
baseDelay,
|
226
|
+
mockFetch as unknown as typeof fetch
|
227
|
+
);
|
228
|
+
} catch {
|
229
|
+
// Expected to fail
|
230
|
+
}
|
231
|
+
|
232
|
+
// Should have made 3 attempts
|
233
|
+
expect(callTimes.length).toBe(3);
|
234
|
+
|
235
|
+
// Calculate delays between attempts
|
236
|
+
const delay1 = callTimes[1]! - callTimes[0]!;
|
237
|
+
const delay2 = callTimes[2]! - callTimes[1]!;
|
238
|
+
|
239
|
+
// Verify exponential backoff (delays should increase)
|
240
|
+
// First retry should be approximately baseDelay * 1
|
241
|
+
expect(delay1).toBeGreaterThanOrEqual(baseDelay);
|
242
|
+
// Second retry should be approximately baseDelay * 2 and greater than first
|
243
|
+
expect(delay2).toBeGreaterThan(delay1);
|
244
|
+
expect(delay2).toBeGreaterThanOrEqual(baseDelay * 2);
|
245
|
+
});
|
246
|
+
|
247
|
+
test('should pass custom addClientAuth function', async () => {
|
248
|
+
const mockTokens: OAuthTokens = {
|
249
|
+
access_token: 'new-token',
|
250
|
+
token_type: 'Bearer',
|
251
|
+
};
|
252
|
+
const mockFetch = mock(() =>
|
253
|
+
Promise.resolve(
|
254
|
+
new Response(JSON.stringify(mockTokens), { status: 200 })
|
255
|
+
)
|
256
|
+
);
|
257
|
+
const mockAddClientAuth = mock(() => {
|
258
|
+
// Mock implementation
|
259
|
+
});
|
260
|
+
const clientInfo = { client_id: 'test-client-id' };
|
261
|
+
|
262
|
+
await refreshTokensWithRetry(
|
263
|
+
'https://auth.example.com',
|
264
|
+
clientInfo,
|
265
|
+
'refresh-token',
|
266
|
+
mockAddClientAuth,
|
267
|
+
3,
|
268
|
+
10,
|
269
|
+
mockFetch as unknown as typeof fetch
|
270
|
+
);
|
271
|
+
|
272
|
+
// The SDK's refreshAuthorization should have been called with addClientAuth
|
273
|
+
expect(mockFetch).toHaveBeenCalled();
|
274
|
+
});
|
275
|
+
});
|
276
|
+
});
|
@@ -0,0 +1,391 @@
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
2
|
+
import { OAuthCallbackServer } from '../server/callback.js';
|
3
|
+
|
4
|
+
describe('OAuthCallbackServer', () => {
|
5
|
+
let server: OAuthCallbackServer;
|
6
|
+
let testPort = 9876;
|
7
|
+
const testHostname = 'localhost';
|
8
|
+
|
9
|
+
const startServer = async () => {
|
10
|
+
if (server) {
|
11
|
+
await server.stop();
|
12
|
+
}
|
13
|
+
|
14
|
+
// Use a new port for each test to avoid port conflicts
|
15
|
+
testPort++;
|
16
|
+
|
17
|
+
server = new OAuthCallbackServer();
|
18
|
+
|
19
|
+
return server.start({
|
20
|
+
port: testPort,
|
21
|
+
hostname: testHostname,
|
22
|
+
});
|
23
|
+
};
|
24
|
+
|
25
|
+
afterEach(async () => {
|
26
|
+
if (server) {
|
27
|
+
try {
|
28
|
+
await server.stop();
|
29
|
+
} catch (error) {
|
30
|
+
// Ignore "Server stopped" errors from rejected promises
|
31
|
+
if (
|
32
|
+
error instanceof Error &&
|
33
|
+
!error.message.includes('Server stopped')
|
34
|
+
) {
|
35
|
+
throw error;
|
36
|
+
}
|
37
|
+
}
|
38
|
+
}
|
39
|
+
});
|
40
|
+
|
41
|
+
describe('server lifecycle', () => {
|
42
|
+
test('should start server successfully', async () => {
|
43
|
+
await startServer();
|
44
|
+
|
45
|
+
// Verify server is running by making a request
|
46
|
+
const response = await fetch(
|
47
|
+
`http://${testHostname}:${testPort}/unknown`
|
48
|
+
);
|
49
|
+
|
50
|
+
expect(response.status).toBe(404);
|
51
|
+
});
|
52
|
+
|
53
|
+
test('should stop server successfully', async () => {
|
54
|
+
await startServer();
|
55
|
+
|
56
|
+
expect(server.isRunning()).toBe(true);
|
57
|
+
|
58
|
+
await server.stop();
|
59
|
+
|
60
|
+
expect(server.isRunning()).toBe(false);
|
61
|
+
});
|
62
|
+
|
63
|
+
test('should throw error when starting already running server', async () => {
|
64
|
+
await startServer();
|
65
|
+
|
66
|
+
await expect(
|
67
|
+
server.start({
|
68
|
+
port: testPort + 1,
|
69
|
+
hostname: testHostname,
|
70
|
+
})
|
71
|
+
).rejects.toThrow('Server is already running');
|
72
|
+
});
|
73
|
+
|
74
|
+
test('should handle abort signal during start', async () => {
|
75
|
+
const abortController = new AbortController();
|
76
|
+
|
77
|
+
abortController.abort();
|
78
|
+
|
79
|
+
await expect(
|
80
|
+
server.start({
|
81
|
+
port: testPort,
|
82
|
+
hostname: testHostname,
|
83
|
+
signal: abortController.signal,
|
84
|
+
})
|
85
|
+
).rejects.toThrow('Operation aborted');
|
86
|
+
});
|
87
|
+
|
88
|
+
test('should stop server when abort signal is triggered', async () => {
|
89
|
+
const abortController = new AbortController();
|
90
|
+
|
91
|
+
await server.start({
|
92
|
+
port: testPort,
|
93
|
+
hostname: testHostname,
|
94
|
+
signal: abortController.signal,
|
95
|
+
});
|
96
|
+
|
97
|
+
expect(server.isRunning()).toBe(true);
|
98
|
+
|
99
|
+
// Trigger abort
|
100
|
+
abortController.abort();
|
101
|
+
|
102
|
+
// Give it a moment to cleanup
|
103
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
104
|
+
|
105
|
+
expect(server.isRunning()).toBe(false);
|
106
|
+
});
|
107
|
+
});
|
108
|
+
|
109
|
+
describe('callback handling', () => {
|
110
|
+
test('should handle successful OAuth callback', async () => {
|
111
|
+
await startServer();
|
112
|
+
const callbackPath = '/callback/success';
|
113
|
+
const expectedCode = 'test-auth-code-12345';
|
114
|
+
const expectedState = 'test-state-67890';
|
115
|
+
|
116
|
+
// Start waiting for callback first
|
117
|
+
const callbackPromise = server.waitForCallback(callbackPath, 5000);
|
118
|
+
|
119
|
+
// Delay to ensure listener registration completes
|
120
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
121
|
+
|
122
|
+
// Make the HTTP request
|
123
|
+
const fetchPromise = fetch(
|
124
|
+
`http://${testHostname}:${testPort}${callbackPath}?code=${expectedCode}&state=${expectedState}`
|
125
|
+
);
|
126
|
+
|
127
|
+
const [result, response] = await Promise.all([
|
128
|
+
callbackPromise,
|
129
|
+
fetchPromise,
|
130
|
+
]);
|
131
|
+
|
132
|
+
// Should get success page
|
133
|
+
expect(response.status).toBe(200);
|
134
|
+
|
135
|
+
const html = await response.text();
|
136
|
+
|
137
|
+
expect(html).toContain('Authorization Successful');
|
138
|
+
|
139
|
+
// Should resolve with callback data
|
140
|
+
expect(result).toMatchObject({
|
141
|
+
code: expectedCode,
|
142
|
+
state: expectedState,
|
143
|
+
});
|
144
|
+
});
|
145
|
+
|
146
|
+
test('should handle OAuth error callback', async () => {
|
147
|
+
await startServer();
|
148
|
+
const callbackPath = '/callback/error';
|
149
|
+
const expectedError = 'access_denied';
|
150
|
+
const expectedDescription = 'User denied authorization';
|
151
|
+
|
152
|
+
const callbackPromise = server.waitForCallback(callbackPath, 5000);
|
153
|
+
|
154
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
155
|
+
|
156
|
+
const fetchPromise = fetch(
|
157
|
+
`http://${testHostname}:${testPort}${callbackPath}?error=${expectedError}&error_description=${encodeURIComponent(expectedDescription)}`
|
158
|
+
);
|
159
|
+
|
160
|
+
const [result, response] = await Promise.all([
|
161
|
+
callbackPromise,
|
162
|
+
fetchPromise,
|
163
|
+
]);
|
164
|
+
|
165
|
+
expect(response.status).toBe(400);
|
166
|
+
|
167
|
+
const html = await response.text();
|
168
|
+
|
169
|
+
expect(html).toContain('Authorization Failed');
|
170
|
+
|
171
|
+
expect(result).toMatchObject({
|
172
|
+
error: expectedError,
|
173
|
+
error_description: expectedDescription,
|
174
|
+
});
|
175
|
+
});
|
176
|
+
|
177
|
+
test('should timeout when callback takes too long', async () => {
|
178
|
+
await startServer();
|
179
|
+
const callbackPath = '/callback/timeout';
|
180
|
+
|
181
|
+
// Wait with very short timeout
|
182
|
+
await expect(server.waitForCallback(callbackPath, 100)).rejects.toThrow(
|
183
|
+
'OAuth callback timeout'
|
184
|
+
);
|
185
|
+
});
|
186
|
+
|
187
|
+
test('should handle multiple callback parameters', async () => {
|
188
|
+
await startServer();
|
189
|
+
const callbackPath = '/oauth/callback';
|
190
|
+
const params = {
|
191
|
+
code: 'auth-code',
|
192
|
+
state: 'state-value',
|
193
|
+
scope: 'openid profile email',
|
194
|
+
session_state: 'session-123',
|
195
|
+
};
|
196
|
+
const queryString = new URLSearchParams(params).toString();
|
197
|
+
|
198
|
+
const callbackPromise = server.waitForCallback(callbackPath, 5000);
|
199
|
+
|
200
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
201
|
+
|
202
|
+
const fetchPromise = fetch(
|
203
|
+
`http://${testHostname}:${testPort}${callbackPath}?${queryString}`
|
204
|
+
);
|
205
|
+
|
206
|
+
const [result] = await Promise.all([callbackPromise, fetchPromise]);
|
207
|
+
|
208
|
+
expect(result).toMatchObject(params);
|
209
|
+
});
|
210
|
+
|
211
|
+
test('should return 404 for unknown paths', async () => {
|
212
|
+
await startServer();
|
213
|
+
const response = await fetch(
|
214
|
+
`http://${testHostname}:${testPort}/unknown-path`
|
215
|
+
);
|
216
|
+
|
217
|
+
expect(response.status).toBe(404);
|
218
|
+
|
219
|
+
const text = await response.text();
|
220
|
+
|
221
|
+
expect(text).toBe('Not Found');
|
222
|
+
});
|
223
|
+
|
224
|
+
test('should cleanup listeners after successful callback', async () => {
|
225
|
+
await startServer();
|
226
|
+
const callbackPath = '/callback/cleanup';
|
227
|
+
|
228
|
+
// First callback
|
229
|
+
await Promise.all([
|
230
|
+
server.waitForCallback(callbackPath, 5000),
|
231
|
+
fetch(
|
232
|
+
`http://${testHostname}:${testPort}${callbackPath}?code=code1&state=state1`
|
233
|
+
),
|
234
|
+
]);
|
235
|
+
|
236
|
+
// Second callback to same path should work
|
237
|
+
const [result2] = await Promise.all([
|
238
|
+
server.waitForCallback(callbackPath, 5000),
|
239
|
+
fetch(
|
240
|
+
`http://${testHostname}:${testPort}${callbackPath}?code=code2&state=state2`
|
241
|
+
),
|
242
|
+
]);
|
243
|
+
|
244
|
+
expect(result2.code).toBe('code2');
|
245
|
+
});
|
246
|
+
|
247
|
+
test('should use custom success HTML template', async () => {
|
248
|
+
await startServer();
|
249
|
+
const customServer = new OAuthCallbackServer();
|
250
|
+
|
251
|
+
await customServer.start({
|
252
|
+
hostname: testHostname,
|
253
|
+
port: testPort + 1,
|
254
|
+
successHtml: '<html><body>Custom Success!</body></html>',
|
255
|
+
});
|
256
|
+
|
257
|
+
const callbackPath = '/callback/custom-success';
|
258
|
+
|
259
|
+
const [, response] = await Promise.all([
|
260
|
+
customServer.waitForCallback(callbackPath, 5000),
|
261
|
+
fetch(
|
262
|
+
`http://${testHostname}:${testPort + 1}${callbackPath}?code=test`
|
263
|
+
),
|
264
|
+
]);
|
265
|
+
|
266
|
+
const html = await response.text();
|
267
|
+
|
268
|
+
expect(html).toContain('Custom Success!');
|
269
|
+
|
270
|
+
await customServer.stop();
|
271
|
+
});
|
272
|
+
|
273
|
+
test('should use custom error HTML template', async () => {
|
274
|
+
await startServer();
|
275
|
+
const customServer = new OAuthCallbackServer();
|
276
|
+
|
277
|
+
await customServer.start({
|
278
|
+
hostname: testHostname,
|
279
|
+
port: testPort + 2,
|
280
|
+
errorHtml: '<html><body>Custom Error!</body></html>',
|
281
|
+
});
|
282
|
+
|
283
|
+
const callbackPath = '/callback/custom-error';
|
284
|
+
|
285
|
+
const [, response] = await Promise.all([
|
286
|
+
customServer.waitForCallback(callbackPath, 5000),
|
287
|
+
fetch(
|
288
|
+
`http://${testHostname}:${testPort + 2}${callbackPath}?error=test`
|
289
|
+
),
|
290
|
+
]);
|
291
|
+
|
292
|
+
const html = await response.text();
|
293
|
+
|
294
|
+
expect(html).toContain('Custom Error!');
|
295
|
+
|
296
|
+
await customServer.stop();
|
297
|
+
});
|
298
|
+
|
299
|
+
test('should call onRequest callback', async () => {
|
300
|
+
await startServer();
|
301
|
+
await server.stop();
|
302
|
+
|
303
|
+
const requests: Request[] = [];
|
304
|
+
|
305
|
+
// Use a new port for this second server instance
|
306
|
+
testPort++;
|
307
|
+
|
308
|
+
await server.start({
|
309
|
+
port: testPort,
|
310
|
+
hostname: testHostname,
|
311
|
+
onRequest: req => requests.push(req),
|
312
|
+
});
|
313
|
+
|
314
|
+
await fetch(`http://${testHostname}:${testPort}/test`);
|
315
|
+
|
316
|
+
expect(requests).toHaveLength(1);
|
317
|
+
expect(requests[0]).toBeInstanceOf(Request);
|
318
|
+
});
|
319
|
+
});
|
320
|
+
|
321
|
+
describe('edge cases', () => {
|
322
|
+
test('should handle callback with no query parameters', async () => {
|
323
|
+
await startServer();
|
324
|
+
const callbackPath = '/callback/no-params';
|
325
|
+
|
326
|
+
// Start waiting for callback first to ensure listener is registered
|
327
|
+
const callbackPromise = server.waitForCallback(callbackPath, 5000);
|
328
|
+
|
329
|
+
// Small delay to ensure listener registration completes
|
330
|
+
await new Promise(resolve => setImmediate(resolve));
|
331
|
+
|
332
|
+
// Now make the HTTP request
|
333
|
+
const fetchPromise = fetch(
|
334
|
+
`http://${testHostname}:${testPort}${callbackPath}`
|
335
|
+
);
|
336
|
+
|
337
|
+
const [result] = await Promise.all([callbackPromise, fetchPromise]);
|
338
|
+
|
339
|
+
expect(result).toEqual({});
|
340
|
+
});
|
341
|
+
|
342
|
+
test('should handle special characters in parameters', async () => {
|
343
|
+
await startServer();
|
344
|
+
const callbackPath = '/callback/special-chars';
|
345
|
+
const specialState = 'state-with-special-chars_123';
|
346
|
+
|
347
|
+
const callbackPromise = server.waitForCallback(callbackPath, 5000);
|
348
|
+
|
349
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
350
|
+
|
351
|
+
const fetchPromise = fetch(
|
352
|
+
`http://${testHostname}:${testPort}${callbackPath}?code=test&state=${encodeURIComponent(specialState)}`
|
353
|
+
);
|
354
|
+
|
355
|
+
const [result] = await Promise.all([callbackPromise, fetchPromise]);
|
356
|
+
|
357
|
+
expect(result.state).toBe(specialState);
|
358
|
+
});
|
359
|
+
|
360
|
+
test('should handle concurrent callbacks to different paths', async () => {
|
361
|
+
await startServer();
|
362
|
+
const path1 = '/callback1';
|
363
|
+
const path2 = '/callback2';
|
364
|
+
|
365
|
+
// Register both listeners first
|
366
|
+
const promise1 = server.waitForCallback(path1, 5000);
|
367
|
+
const promise2 = server.waitForCallback(path2, 5000);
|
368
|
+
|
369
|
+
// Use a longer delay to ensure both listeners are registered
|
370
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
371
|
+
|
372
|
+
// Now make both HTTP requests
|
373
|
+
const fetch1 = fetch(
|
374
|
+
`http://${testHostname}:${testPort}${path1}?code=code1&state=state1`
|
375
|
+
);
|
376
|
+
const fetch2 = fetch(
|
377
|
+
`http://${testHostname}:${testPort}${path2}?code=code2&state=state2`
|
378
|
+
);
|
379
|
+
|
380
|
+
const [result1, result2] = await Promise.all([
|
381
|
+
promise1,
|
382
|
+
promise2,
|
383
|
+
fetch1,
|
384
|
+
fetch2,
|
385
|
+
]);
|
386
|
+
|
387
|
+
expect(result1.code).toBe('code1');
|
388
|
+
expect(result2.code).toBe('code2');
|
389
|
+
});
|
390
|
+
});
|
391
|
+
});
|