gateway-oauth-bootstrap.test.mjs
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