gateway-oauth-bootstrap.test.mjs
241 lines 13.4 KB
Raw
sha256:da72236a2c7a121dcdc60fcca47a8ff86cc1176f6076d8d4cbc0614569737d33 fix(gateway): mount OAuth routers before API fallback Human minor ⚠ breaking 27 days ago
1 import { test } from 'node:test';
2 import assert from 'node:assert/strict';
3 import { fork } from 'node:child_process';
4 import { mkdtemp, rm } from 'node:fs/promises';
5 import { tmpdir } from 'node:os';
6 import path from 'node:path';
7 import { randomBytes, createHash } from 'node:crypto';
8
9 const native = '/api/v1/auth/native';
10 const device = '/api/v1/auth/device';
11
12 async function start(t, env = {}) {
13 const dir = await mkdtemp(path.join(tmpdir(), 'knowtation-gateway-bootstrap-'));
14 const child = fork(new URL('./helpers/gateway-bootstrap-child.mjs', import.meta.url), [], {
15 env: { KNOWTATION_GATEWAY_DATA_DIR: dir, ...env },
16 execArgv: ['--no-warnings', '--experimental-loader', new URL('./helpers/gateway-bootstrap-loader.mjs', import.meta.url).href],
17 stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
18 });
19 // No raw runtime output: assertions never print credentials or token responses.
20 child.stdout.resume();
21 child.stderr.resume();
22 const exited = new Promise((resolve) => child.once('exit', (code) => resolve(code)));
23 t.after(async () => {
24 if (child.exitCode === null && child.signalCode === null) child.kill();
25 await exited;
26 await rm(dir, { recursive: true, force: true });
27 });
28 const pending = new Map();
29 let sequence = 0;
30 child.on('message', (message) => {
31 if (message.id) {
32 const entry = pending.get(message.id);
33 pending.delete(message.id);
34 if (message.error) entry?.reject(new Error(message.error));
35 else entry?.resolve(message.result);
36 }
37 });
38 const boot = await new Promise((resolve, reject) => {
39 const timeout = setTimeout(() => reject(new Error('Gateway startup timed out')), 15000);
40 child.on('message', (message) => {
41 if (message.event) { clearTimeout(timeout); resolve(message); }
42 });
43 child.once('exit', () => { clearTimeout(timeout); reject(new Error('Gateway exited before startup result')); });
44 });
45 return {
46 ...boot, exited,
47 rpc(action, values = {}) {
48 const id = ++sequence;
49 return new Promise((resolve, reject) => {
50 pending.set(id, { resolve, reject });
51 child.send({ id, action, ...values });
52 });
53 },
54 async request(urlPath, body, headers = {}, method = body ? 'POST' : 'GET') {
55 const response = await fetch(boot.baseUrl + urlPath, {
56 method, headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },
57 body: body ? JSON.stringify(body) : undefined, redirect: 'manual',
58 signal: AbortSignal.timeout(10000),
59 });
60 const text = await response.text();
61 let json;
62 try { json = JSON.parse(text); } catch { /* redirects / SSE */ }
63 return { status: response.status, headers: response.headers, json, text };
64 },
65 };
66 }
67
68 test('real gateway: public OAuth routers precede catch-all at listen, including delayed imports', async (t) => {
69 const gw = await start(t, { GATEWAY_TEST_MODULE: 'native-oauth-provider' });
70 assert.equal(gw.event, 'ready');
71 assert.ok(gw.order.native >= 0 && gw.order.native < gw.order.catchAll);
72 assert.ok(gw.order.device >= 0 && gw.order.device < gw.order.catchAll);
73 for (const prefix of ['', native, device]) {
74 const r = await gw.request(prefix + '/.well-known/oauth-authorization-server');
75 assert.equal(r.status, 200, `public discovery: ${prefix || 'MCP'}`);
76 assert.equal(r.json.issuer, 'http://localhost:3340' + (prefix || '/'));
77 }
78 assert.equal((await gw.request('/health')).status, 200);
79 assert.equal((await gw.request('/api/v1/unhandled-test')).status, 401);
80 assert.equal(await gw.rpc('upstreamHits'), 0);
81 const restToken = await gw.rpc('sign', { sub: 'google:bootstrap-test' });
82 const proxied = await gw.request('/api/v1/unhandled-test', undefined, { Authorization: `Bearer ${restToken}` });
83 assert.equal(proxied.status, 200);
84 assert.equal(proxied.json.mock_upstream, true, 'authenticated fallback still reaches the upstream');
85 assert.equal((await gw.request('/api/v1/health-canister')).status, 200, 'last specific route also precedes fallback');
86
87 await t.test('registration accepts literal loopback and rejects remote/malformed redirects', async () => {
88 for (const uri of ['http://127.0.0.1:53112/cb', 'http://[::1]:53113/cb']) {
89 const r = await gw.request(native + '/register', { redirect_uris: [uri] });
90 assert.equal(r.status, 201);
91 assert.equal(r.json.token_endpoint_auth_method, 'none');
92 assert.ok(!r.json.client_secret);
93 }
94 for (const uri of ['https://example.com/cb', 'http://localhost:53112/cb', 'not-a-uri', 'http://[::1', 'http://127.0.0.1:99999/cb', 'javascript:alert(1)']) {
95 assert.equal((await gw.request(native + '/register', { redirect_uris: [uri] })).status, 400);
96 }
97 assert.equal((await gw.request(native + '/register', { redirect_uris: [] })).status, 400);
98 });
99
100 async function authorization() {
101 const redirect = 'http://127.0.0.1:53114/callback';
102 const reg = await gw.request(native + '/register', { redirect_uris: [redirect] });
103 assert.equal(reg.status, 201);
104 const clientId = reg.json.client_id;
105 const verifier = randomBytes(32).toString('base64url');
106 const state = randomBytes(24).toString('base64url');
107 const q = new URLSearchParams({
108 client_id: clientId, redirect_uri: redirect, response_type: 'code', state,
109 code_challenge: createHash('sha256').update(verifier).digest('base64url'),
110 code_challenge_method: 'S256', scope: 'vault:read vault:write admin',
111 });
112 const r = await gw.request(native + '/authorize?' + q);
113 assert.equal(r.status, 302);
114 const location = await gw.rpc('complete', {
115 state: new URL(r.headers.get('location')).searchParams.get('native_state'), sub: 'google:bootstrap-test',
116 });
117 const callback = new URL(location);
118 assert.ok(callback.searchParams.get('state') === state, 'state preserved');
119 assert.equal(callback.searchParams.get('iss'), 'http://localhost:3340' + native);
120 return { grant_type: 'authorization_code', client_id: clientId, code: callback.searchParams.get('code'), code_verifier: verifier, redirect_uri: redirect };
121 }
122
123 await t.test('PKCE, exact redirect, code single-use, role ceiling, rotation and reuse revocation', async () => {
124 const registration = await gw.request(native + '/register', { redirect_uris: ['http://127.0.0.1:53114/callback'] });
125 assert.equal(registration.status, 201);
126 for (const method of ['', 'plain']) {
127 const query = new URLSearchParams({ client_id: registration.json.client_id, redirect_uri: 'http://127.0.0.1:53114/callback', code_challenge: 'test', code_challenge_method: method });
128 const rejected = await gw.request(native + '/authorize?' + query);
129 assert.equal(rejected.status, 400);
130 assert.equal(rejected.json.error, 'invalid_request');
131 }
132 const wrongRedirect = await authorization();
133 const mismatch = await gw.request(native + '/token', { ...wrongRedirect, redirect_uri: 'http://127.0.0.1:53115/callback' });
134 assert.equal(mismatch.status, 400);
135 assert.equal(mismatch.json.error, 'invalid_grant');
136 const badPkce = await authorization();
137 assert.equal((await gw.request(native + '/token', { ...badPkce, code_verifier: randomBytes(32).toString('base64url') })).status, 400);
138 const grant = await authorization();
139 const tokens = await gw.request(native + '/token', grant);
140 assert.equal(tokens.status, 200);
141 assert.equal(tokens.json.scope, 'vault:read vault:write');
142 const payload = JSON.parse(Buffer.from(tokens.json.access_token.split('.')[1], 'base64url'));
143 assert.equal(payload.type, 'session');
144 assert.equal(payload.role, 'member');
145 assert.equal((await gw.request(native + '/token', grant)).status, 400);
146 const refresh = { grant_type: 'refresh_token', client_id: grant.client_id, refresh_token: tokens.json.refresh_token };
147 const rotated = await gw.request(native + '/token', refresh);
148 assert.equal(rotated.status, 200);
149 assert.ok(rotated.json.refresh_token !== tokens.json.refresh_token, 'refresh rotated');
150 assert.equal(rotated.json.scope, 'vault:read vault:write');
151 const replay = await gw.request(native + '/token', refresh);
152 assert.equal(replay.status, 401);
153 assert.equal(replay.json.code, 'REFRESH_REUSE');
154 assert.equal((await gw.request(native + '/token', { ...refresh, refresh_token: rotated.json.refresh_token })).status, 401);
155 const revokeGrant = await authorization();
156 const revocable = await gw.request(native + '/token', revokeGrant);
157 assert.equal(revocable.status, 200);
158 assert.equal((await gw.request(native + '/revoke', { token: revocable.json.refresh_token })).status, 200);
159 assert.equal((await gw.request(native + '/token', { grant_type: 'refresh_token', client_id: revokeGrant.client_id, refresh_token: revocable.json.refresh_token })).status, 401);
160 });
161
162 await t.test('device initiation/polling public; user operations authenticated', async () => {
163 const r = await gw.request(device + '/authorize', { client_id: 'bootstrap-test', scope: 'vault:read' });
164 assert.equal(r.status, 200);
165 const poll = await gw.request(device + '/token', { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: r.json.device_code });
166 assert.equal(poll.status, 400);
167 assert.equal(poll.json.error, 'authorization_pending');
168 for (const action of ['approve', 'deny']) {
169 assert.equal((await gw.request(device + '/' + action, { user_code: r.json.user_code })).status, 401);
170 }
171 assert.equal((await gw.request(device + '/pending')).status, 401);
172 const token = await gw.rpc('sign', { sub: 'google:bootstrap-test' });
173 assert.equal((await gw.request(device + '/pending', undefined, { Authorization: `Bearer ${token}` })).status, 200);
174 });
175
176 await t.test('OAuth CORS permits configured apex/www; no-origin discovery stays public', async () => {
177 for (const Origin of ['https://knowtation.store', 'https://www.knowtation.store']) {
178 for (const prefix of [native, device]) {
179 const r = await gw.request(prefix + '/token', undefined, { Origin, 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'authorization,content-type' }, 'OPTIONS');
180 // A reached Express router answers automatic OPTIONS with 200 (the old
181 // shadowing catch-all answered 204); both are valid CORS preflights.
182 assert.equal(r.status, 200);
183 assert.equal(r.headers.get('access-control-allow-origin'), Origin);
184 assert.equal(r.headers.get('access-control-allow-credentials'), 'true');
185 }
186 }
187 });
188
189 await t.test('MCP authentication, initialization and session ownership remain enforced', async () => {
190 assert.equal((await gw.request('/mcp')).status, 401);
191 assert.equal((await gw.request('/mcp', undefined, { Authorization: 'Bearer invalid' })).status, 401);
192 const token = await gw.rpc('sign', { sub: 'google:bootstrap-test' });
193 const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json, text/event-stream' };
194 const init = await gw.request('/mcp', { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'bootstrap-test', version: '1' } } }, headers);
195 assert.equal(init.status, 200);
196 const session = init.headers.get('mcp-session-id');
197 assert.ok(session, 'MCP session initialized');
198 const list = await gw.request('/mcp', { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, { ...headers, 'mcp-session-id': session });
199 assert.equal(list.status, 200);
200 assert.ok(list.text.includes('get_note'), 'existing tool catalog available');
201 const other = await gw.rpc('sign', { sub: 'google:another-test-user' });
202 const stolen = await gw.request('/mcp', { jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }, { ...headers, Authorization: `Bearer ${other}`, 'mcp-session-id': session });
203 assert.equal(stolen.status, 404);
204 assert.equal((await gw.request('/mcp', undefined, { ...headers, 'mcp-session-id': session }, 'DELETE')).status, 200);
205 });
206 });
207
208 for (const mode of ['netlify', 'offline-locked']) {
209 test(`real gateway: ${mode} keeps durable OAuth disabled`, async (t) => {
210 const gw = await start(t, mode === 'netlify' ? { NETLIFY: '1' } : { KNOWTATION_OFFLINE_LOCKED_AUTH: 'enabled' });
211 assert.equal(gw.event, 'ready');
212 assert.equal(gw.order.native, -1);
213 assert.equal(gw.order.device, -1);
214 assert.equal((await gw.request('/.well-known/oauth-authorization-server')).status, 404);
215 for (const prefix of [native, device]) assert.equal((await gw.request(prefix + '/.well-known/oauth-authorization-server')).status, 401);
216 if (mode === 'netlify') {
217 const r = await gw.request('/mcp');
218 assert.equal(r.status, 503);
219 assert.equal(r.json.code, 'MCP_NETLIFY_UNSUPPORTED');
220 assert.equal((await gw.request('/.netlify/functions/gateway/health')).status, 200);
221 }
222 });
223 }
224
225 for (const module of ['mcp-oauth-provider', 'native-oauth-provider', 'device-oauth-provider', 'mcp-proxy']) {
226 test(`startup aborts before listen when ${module} cannot load`, async (t) => {
227 const gw = await start(t, { GATEWAY_TEST_MODULE: module, GATEWAY_TEST_FAULT: 'import' });
228 assert.equal(gw.event, 'startup-failed');
229 assert.equal(gw.listenCalls, 0);
230 assert.equal(await gw.exited, 1);
231 });
232 }
233
234 for (const module of ['native-oauth-provider', 'agent-credential-routes']) {
235 test(`startup aborts before listen when ${module} construction fails`, async (t) => {
236 const gw = await start(t, { GATEWAY_TEST_MODULE: module, GATEWAY_TEST_FAULT: 'factory' });
237 assert.equal(gw.event, 'startup-failed');
238 assert.equal(gw.listenCalls, 0);
239 assert.equal(await gw.exited, 1);
240 });
241 }
File History 1 commit
sha256:da72236a2c7a121dcdc60fcca47a8ff86cc1176f6076d8d4cbc0614569737d33 fix(gateway): mount OAuth routers before API fallback Human minor ⚠ 27 days ago