mcp-oauth-provider.test.mjs
276 lines 9.1 KB
Raw
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor ⚠ breaking 16 days ago
1 import { describe, it, beforeEach } from 'node:test';
2 import assert from 'node:assert/strict';
3 import { KnowtationOAuthProvider } from '../hub/gateway/mcp-oauth-provider.mjs';
4
5 const TEST_SECRET = 'test-secret-at-least-32-characters-long-for-jwt';
6
7 function createProvider() {
8 return new KnowtationOAuthProvider({
9 sessionSecret: TEST_SECRET,
10 baseUrl: 'http://localhost:3340',
11 });
12 }
13
14 describe('KnowtationOAuthProvider', () => {
15 describe('clientsStore', () => {
16 it('starts with no clients', () => {
17 const provider = createProvider();
18 assert.equal(provider.clientsStore.getClient('nonexistent'), undefined);
19 });
20
21 it('registers and retrieves a client', () => {
22 const provider = createProvider();
23 const registered = provider.clientsStore.registerClient({
24 redirect_uris: [new URL('http://localhost:8080/callback')],
25 client_name: 'Test Client',
26 });
27 assert.ok(registered.client_id);
28 assert.ok(registered.client_id_issued_at);
29 const retrieved = provider.clientsStore.getClient(registered.client_id);
30 assert.equal(retrieved.client_id, registered.client_id);
31 assert.equal(retrieved.client_name, 'Test Client');
32 });
33
34 it('evicts oldest client when limit reached', () => {
35 const provider = createProvider();
36 const ids = [];
37 for (let i = 0; i < 502; i++) {
38 const c = provider.clientsStore.registerClient({
39 redirect_uris: [new URL(`http://localhost:${8000 + i}/cb`)],
40 client_name: `client-${i}`,
41 });
42 ids.push(c.client_id);
43 }
44 assert.equal(provider.clientsStore.getClient(ids[0]), undefined);
45 assert.ok(provider.clientsStore.getClient(ids[ids.length - 1]));
46 });
47 });
48
49 describe('authorize', () => {
50 it('redirects to login page with mcp_state', async () => {
51 const provider = createProvider();
52 const client = provider.clientsStore.registerClient({
53 redirect_uris: [new URL('http://localhost:8080/callback')],
54 });
55
56 let redirectUrl = null;
57 const mockRes = {
58 redirect(url) { redirectUrl = url; },
59 };
60
61 await provider.authorize(
62 client,
63 {
64 codeChallenge: 'test-challenge',
65 redirectUri: 'http://localhost:8080/callback',
66 state: 'client-state-123',
67 scopes: ['vault:read'],
68 },
69 mockRes
70 );
71
72 assert.ok(redirectUrl);
73 assert.ok(redirectUrl.includes('/auth/login'));
74 assert.ok(redirectUrl.includes('mcp_state='));
75 });
76 });
77
78 describe('full authorization code flow', () => {
79 it('exchanges code for tokens after authorization completes', async () => {
80 const provider = createProvider();
81 const client = provider.clientsStore.registerClient({
82 redirect_uris: [new URL('http://localhost:8080/callback')],
83 });
84
85 let redirectUrl = null;
86 const mockRes = {
87 redirect(url) { redirectUrl = url; },
88 };
89
90 await provider.authorize(
91 client,
92 {
93 codeChallenge: 'test-challenge',
94 redirectUri: 'http://localhost:8080/callback',
95 state: 'my-state',
96 scopes: ['vault:read', 'vault:write'],
97 },
98 mockRes
99 );
100
101 const url = new URL(redirectUrl);
102 const mcpState = url.searchParams.get('mcp_state');
103 assert.ok(mcpState);
104
105 let callbackRedirect = null;
106 const callbackRes = {
107 redirect(url) { callbackRedirect = url; },
108 status() { return { json() {} }; },
109 };
110
111 provider.completeMcpAuthorization(mcpState, 'google:12345', callbackRes);
112 assert.ok(callbackRedirect);
113
114 const callbackUrl = new URL(callbackRedirect);
115 const code = callbackUrl.searchParams.get('code');
116 const state = callbackUrl.searchParams.get('state');
117 assert.ok(code);
118 assert.equal(state, 'my-state');
119
120 const tokens = await provider.exchangeAuthorizationCode(client, code);
121 assert.ok(tokens.access_token);
122 assert.equal(tokens.token_type, 'bearer');
123 assert.ok(tokens.expires_in > 0);
124 assert.ok(tokens.refresh_token);
125 assert.ok(tokens.scope.includes('vault:read'));
126 });
127 });
128
129 describe('challengeForAuthorizationCode', () => {
130 it('returns the stored code challenge', async () => {
131 const provider = createProvider();
132 const client = provider.clientsStore.registerClient({
133 redirect_uris: [new URL('http://localhost:8080/callback')],
134 });
135
136 let redirectUrl = null;
137 await provider.authorize(
138 client,
139 {
140 codeChallenge: 'my-challenge-value',
141 redirectUri: 'http://localhost:8080/callback',
142 },
143 { redirect(url) { redirectUrl = url; } }
144 );
145
146 const url = new URL(redirectUrl);
147 const mcpState = url.searchParams.get('mcp_state');
148 const decoded = JSON.parse(Buffer.from(mcpState, 'base64url').toString());
149 const code = decoded.code;
150
151 const challenge = await provider.challengeForAuthorizationCode(client, code);
152 assert.equal(challenge, 'my-challenge-value');
153 });
154 });
155
156 describe('verifyAccessToken', () => {
157 it('verifies a valid MCP access token', async () => {
158 const provider = createProvider();
159 const client = provider.clientsStore.registerClient({
160 redirect_uris: [new URL('http://localhost:8080/callback')],
161 });
162
163 let redirectUrl = null;
164 await provider.authorize(
165 client,
166 {
167 codeChallenge: 'challenge',
168 redirectUri: 'http://localhost:8080/callback',
169 scopes: ['vault:read'],
170 },
171 { redirect(url) { redirectUrl = url; } }
172 );
173
174 const mcpState = new URL(redirectUrl).searchParams.get('mcp_state');
175 let callbackRedirect = null;
176 provider.completeMcpAuthorization(mcpState, 'github:99', {
177 redirect(url) { callbackRedirect = url; },
178 status() { return { json() {} }; },
179 });
180
181 const code = new URL(callbackRedirect).searchParams.get('code');
182 const tokens = await provider.exchangeAuthorizationCode(client, code);
183
184 const authInfo = await provider.verifyAccessToken(tokens.access_token);
185 assert.equal(authInfo.clientId, client.client_id);
186 assert.ok(authInfo.scopes.includes('vault:read'));
187 assert.ok(authInfo.expiresAt);
188 assert.equal(authInfo.extra.sub, 'github:99');
189 });
190
191 it('rejects invalid token', async () => {
192 const provider = createProvider();
193 await assert.rejects(
194 () => provider.verifyAccessToken('invalid-token'),
195 /Invalid access token/
196 );
197 });
198 });
199
200 describe('exchangeRefreshToken', () => {
201 it('issues new tokens from refresh token', async () => {
202 const provider = createProvider();
203 const client = provider.clientsStore.registerClient({
204 redirect_uris: [new URL('http://localhost:8080/callback')],
205 });
206
207 let redirectUrl = null;
208 await provider.authorize(
209 client,
210 { codeChallenge: 'c', redirectUri: 'http://localhost:8080/callback', scopes: ['vault:read'] },
211 { redirect(url) { redirectUrl = url; } }
212 );
213
214 const mcpState = new URL(redirectUrl).searchParams.get('mcp_state');
215 let callbackRedirect = null;
216 provider.completeMcpAuthorization(mcpState, 'google:1', {
217 redirect(url) { callbackRedirect = url; },
218 status() { return { json() {} }; },
219 });
220
221 const code = new URL(callbackRedirect).searchParams.get('code');
222 const tokens = await provider.exchangeRefreshToken(
223 client,
224 (await provider.exchangeAuthorizationCode(client, code)).refresh_token
225 );
226
227 assert.ok(tokens.access_token);
228 assert.ok(tokens.refresh_token);
229 });
230 });
231
232 describe('revokeToken', () => {
233 it('revokes a refresh token', async () => {
234 const provider = createProvider();
235 const client = provider.clientsStore.registerClient({
236 redirect_uris: [new URL('http://localhost:8080/callback')],
237 });
238
239 let redirectUrl = null;
240 await provider.authorize(
241 client,
242 { codeChallenge: 'c', redirectUri: 'http://localhost:8080/callback' },
243 { redirect(url) { redirectUrl = url; } }
244 );
245
246 const mcpState = new URL(redirectUrl).searchParams.get('mcp_state');
247 let callbackRedirect = null;
248 provider.completeMcpAuthorization(mcpState, 'google:1', {
249 redirect(url) { callbackRedirect = url; },
250 status() { return { json() {} }; },
251 });
252
253 const code = new URL(callbackRedirect).searchParams.get('code');
254 const tokens = await provider.exchangeAuthorizationCode(client, code);
255
256 await provider.revokeToken(client, { token: tokens.refresh_token });
257 await assert.rejects(
258 () => provider.exchangeRefreshToken(client, tokens.refresh_token),
259 /Unknown refresh token/
260 );
261 });
262 });
263
264 describe('completeMcpAuthorization', () => {
265 it('rejects invalid mcp_state', () => {
266 const provider = createProvider();
267 let statusCode = null;
268 let body = null;
269 provider.completeMcpAuthorization('not-valid-base64!', 'user:1', {
270 status(code) { statusCode = code; return { json(d) { body = d; } }; },
271 redirect() {},
272 });
273 assert.equal(statusCode, 400);
274 });
275 });
276 });
File History 2 commits
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor 16 days ago