From b271c9ab7d958c4f76f619b58d119d9ff90d9435 Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 00:25:11 +1200 Subject: [PATCH 1/3] Complete OC-050 auth transport support --- .../demo-api/AuthTransport/api-key-query.yml | 19 + examples/demo-api/AuthTransport/folder.yml | 8 + .../demo-api/AuthTransport/mtls-secure.yml | 13 + .../AuthTransport/oauth-query-token.yml | 33 ++ .../AuthTransport/proxy-through-local.yml | 13 + .../AuthTransport/redirect-follow.yml | 13 + examples/demo-api/fixtures/mtls-client.crt | 18 + examples/demo-api/fixtures/mtls-client.key | 27 + examples/demo-api/mtls-server.js | 48 ++ examples/demo-api/opencollection.yml | 17 + examples/demo-api/proxy-server.js | 39 ++ examples/demo-api/server.js | 56 ++ src/copilot/tools/sendRequestTool.ts | 62 ++- src/models/types.ts | 40 +- src/services/grpcClient.ts | 6 +- src/services/httpClient.ts | 485 ++++++++++++++++-- src/services/oauth2Service.ts | 107 +++- src/services/oauth2TokenHelper.ts | 23 + src/services/webSocketClient.ts | 2 +- test/grpcSupport.test.ts | 16 + test/httpClient.test.ts | 366 +++++++++++++ 21 files changed, 1337 insertions(+), 74 deletions(-) create mode 100644 examples/demo-api/AuthTransport/api-key-query.yml create mode 100644 examples/demo-api/AuthTransport/folder.yml create mode 100644 examples/demo-api/AuthTransport/mtls-secure.yml create mode 100644 examples/demo-api/AuthTransport/oauth-query-token.yml create mode 100644 examples/demo-api/AuthTransport/proxy-through-local.yml create mode 100644 examples/demo-api/AuthTransport/redirect-follow.yml create mode 100644 examples/demo-api/fixtures/mtls-client.crt create mode 100644 examples/demo-api/fixtures/mtls-client.key create mode 100644 examples/demo-api/mtls-server.js create mode 100644 examples/demo-api/proxy-server.js diff --git a/examples/demo-api/AuthTransport/api-key-query.yml b/examples/demo-api/AuthTransport/api-key-query.yml new file mode 100644 index 0000000..04c9623 --- /dev/null +++ b/examples/demo-api/AuthTransport/api-key-query.yml @@ -0,0 +1,19 @@ +info: + name: API Key Query Auth + type: http + seq: 1 + description: Run `node examples/demo-api/server.js`; this request proves API-key query placement reaches the server query string. +http: + method: GET + url: "{{baseUrl}}/auth/api-key-query" +runtime: + auth: + type: apikey + key: demo_key + value: "{{demoToken}}" + placement: query +settings: + encodeUrl: true + timeout: 5000 + followRedirects: true + maxRedirects: 5 diff --git a/examples/demo-api/AuthTransport/folder.yml b/examples/demo-api/AuthTransport/folder.yml new file mode 100644 index 0000000..26c27a7 --- /dev/null +++ b/examples/demo-api/AuthTransport/folder.yml @@ -0,0 +1,8 @@ +info: + name: Auth Transport + type: folder + description: Run `node examples/demo-api/server.js` before sending these auth, redirect, OAuth2, proxy, and mTLS demo requests. +request: + headers: + - name: X-Demo-Folder + value: auth-transport diff --git a/examples/demo-api/AuthTransport/mtls-secure.yml b/examples/demo-api/AuthTransport/mtls-secure.yml new file mode 100644 index 0000000..6a47643 --- /dev/null +++ b/examples/demo-api/AuthTransport/mtls-secure.yml @@ -0,0 +1,13 @@ +info: + name: mTLS Secure + type: http + seq: 4 + description: Run `node examples/demo-api/mtls-server.js` and set `missio.rejectUnauthorized` to false for this self-signed local fixture. +http: + method: GET + url: "{{mtlsBaseUrl}}/secure" +settings: + encodeUrl: true + timeout: 5000 + followRedirects: true + maxRedirects: 5 diff --git a/examples/demo-api/AuthTransport/oauth-query-token.yml b/examples/demo-api/AuthTransport/oauth-query-token.yml new file mode 100644 index 0000000..a642dd0 --- /dev/null +++ b/examples/demo-api/AuthTransport/oauth-query-token.yml @@ -0,0 +1,33 @@ +info: + name: OAuth2 Query Token + type: http + seq: 2 + description: Run `node examples/demo-api/server.js`; this request fetches a local OAuth2 token and sends it as `access_token` query auth. +http: + method: GET + url: "{{baseUrl}}/oauth/resource" +runtime: + auth: + type: oauth2 + flow: client_credentials + accessTokenUrl: "{{baseUrl}}/oauth/token?trace=oauth-demo" + credentials: + clientId: demo-client + clientSecret: demo-secret + placement: body + additionalParameters: + accessTokenRequest: + - name: X-Demo-Tenant + value: demo-tenant + placement: header + - name: audience + value: missio-demo + placement: body + tokenConfig: + placement: + query: access_token +settings: + encodeUrl: true + timeout: 5000 + followRedirects: true + maxRedirects: 5 diff --git a/examples/demo-api/AuthTransport/proxy-through-local.yml b/examples/demo-api/AuthTransport/proxy-through-local.yml new file mode 100644 index 0000000..fef61f4 --- /dev/null +++ b/examples/demo-api/AuthTransport/proxy-through-local.yml @@ -0,0 +1,13 @@ +info: + name: Proxy Through Local + type: http + seq: 5 + description: Run `node examples/demo-api/proxy-server.js`, temporarily set `config.proxy.enabled` to true in `opencollection.yml`, then send this request. +http: + method: GET + url: http://upstream.example.test/proxy/demo +settings: + encodeUrl: true + timeout: 5000 + followRedirects: true + maxRedirects: 5 diff --git a/examples/demo-api/AuthTransport/redirect-follow.yml b/examples/demo-api/AuthTransport/redirect-follow.yml new file mode 100644 index 0000000..9a32c69 --- /dev/null +++ b/examples/demo-api/AuthTransport/redirect-follow.yml @@ -0,0 +1,13 @@ +info: + name: Redirect Follow + type: http + seq: 3 + description: Run `node examples/demo-api/server.js`; this request follows `/redirect/start` to `/redirect/final`. +http: + method: GET + url: "{{baseUrl}}/redirect/start" +settings: + encodeUrl: true + timeout: 5000 + followRedirects: true + maxRedirects: 2 diff --git a/examples/demo-api/fixtures/mtls-client.crt b/examples/demo-api/fixtures/mtls-client.crt new file mode 100644 index 0000000..b68eeaa --- /dev/null +++ b/examples/demo-api/fixtures/mtls-client.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIICzTCCAbWgAwIBAgIIDUFhaZcNjyIwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UE +AxMJbG9jYWxob3N0MB4XDTI2MDYxMzEyMDcyMVoXDTM2MDYxNDEyMDcyMVowFDES +MBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA3hKGbDq1amxdUszvajxU4rKotgpsKnnMOrqiATDFKeVXcZjlqfv8y0uZBVWG +gjgL9b+DQo1FMS7HrhgO78eZxATGW/DrAxpPPR1tC1E0tPOs5ATZntJ+XO/JSrbK +edNjYc+1pKAFn4hyTjuLR5+sGRleg4R4RUNPxTEFraY4FQEd99SadbTSx6li6DTC +kbgBTxE3F/JdqDViaPjO2s8TqutrCRYU4CHBrrNi0pZtTTm7v1SZO2HLHmqKM5AN +g4wSXT6yZpoC6isoupay087cKHd/AdTf1nDGYvlKRLj3gMZ5DZQz4VegvBP4qU6l +d79pd+ipYr7mAtEZuyY31vhNDQIDAQABoyMwITAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwICpDANBgkqhkiG9w0BAQsFAAOCAQEAo/hFkKGTaPqqN9JjH/dA +Zpp5RrMiF2m7IKh8WTFOncbwoImToxKA4KFKJlThLqPw4QSujrggxzc+QM+Tazxl +125tKFKH1ZY0po7KCbYkBEL404JccdD+dDmvU8UEmF+GDMSo1HB2E9y/n46kYRSt +wzR9ksZG2AiWSwgkfzWcO+AKXySY3jpjyHsjD8HEB89XHfAedxOUiZp63DRs4Iu4 +R6TwUvQsVJg9JL/U/znMKxqomqVPZ9u2sVM6u+eceaO3A+Tt6A0QxyIwS1QtJ7o5 +GmcSuy7+TSf5CvEsn5P8G3J47L6EOzvx16qT7VAEW4oenwNxyg8wvL4B033fYYzT +Bw== +-----END CERTIFICATE----- diff --git a/examples/demo-api/fixtures/mtls-client.key b/examples/demo-api/fixtures/mtls-client.key new file mode 100644 index 0000000..3d567f6 --- /dev/null +++ b/examples/demo-api/fixtures/mtls-client.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA3hKGbDq1amxdUszvajxU4rKotgpsKnnMOrqiATDFKeVXcZjl +qfv8y0uZBVWGgjgL9b+DQo1FMS7HrhgO78eZxATGW/DrAxpPPR1tC1E0tPOs5ATZ +ntJ+XO/JSrbKedNjYc+1pKAFn4hyTjuLR5+sGRleg4R4RUNPxTEFraY4FQEd99Sa +dbTSx6li6DTCkbgBTxE3F/JdqDViaPjO2s8TqutrCRYU4CHBrrNi0pZtTTm7v1SZ +O2HLHmqKM5ANg4wSXT6yZpoC6isoupay087cKHd/AdTf1nDGYvlKRLj3gMZ5DZQz +4VegvBP4qU6ld79pd+ipYr7mAtEZuyY31vhNDQIDAQABAoIBAAk1v3lxneCCCgTL +FwrS4bpdKn4SRJYmYv/0iY9/FE4+grflXXEFUGCmC/yapW91H5nbjXgPH9WAWSux +N71eC9SDVi6t+TExwCOKuuEDRypSCNOUF+psVG1KTJDar98Jk0+VK7VeJZ2OLR9t +fMNFrf+Ee9T8g3hr6D0HYXLoN983Dt1sY/rTFfLBt4uVdGfuYbF+W/2id9od+mfn +kDWQID54mNCjWVyiBwIVQr1S2zDzliq6m9gRmjFhLWzhSYTVu+R9xywJesnlFwnI +KjsVP6Jv1kJCINrSZJk4HLfRz/uKuc1IaoCcY0a8vufX2ldW1dbknHc6JN5b6Dj+ ++mWwM8kCgYEA6nm0LQwiYJN0ao7pFngT/xoWTXZn+/dxFK4S15wiPL0MLf66I+KD +E9IocUrYClWRPBhWzTjg7xWcejWDvCI7UQOUozwusO/54iEgVbbzsad95i8tFzst +4i89voK7Iz67o/mBmHLKOTfoTJY6C2SemewNDVjBcj3s8/GT2cBW308CgYEA8nVX +3ZGYZrpcT9Hy8vyOkcJxov3M7QgwMUvy8++3B6lpwqtnPmn+B7SVCcaCn6RS3yDL +0upxk5s7Dhps6oQ8J215fZH2o5oiHjSRrT4PFvFLqZREZ+GYd7w8loBjMWAdiwvz +u+x9l3IVqoMXaPw5YG5t2RZdg4uNcN0MreVoluMCgYEAg/9rnQh9udyI5wv4z/td +Vnk7IPSNaV1NPZUZamOtKoBKgQIri9QScnAW8GBv6rFtB2W0R+fDSRTjeDD0Lk8f +EWZwoMxahKU0CUcYyugpnFNsHs9kFPXtyK1LlxpFe3vvakol2MqWaUu97I+Nsag9 +WO14E5FppYSTBmlzEFylCyUCgYAPGgP5BwKJE36AckFBpT10ErplPo2vDd2ClIpz +azDpR0IRH//0QUHTVQoba8PjEacfwrkvT+73FKoe/MJf8RCWHBl/GsJT+lu5qeiQ +89aYxTrDOzrvhXurqYvUi/ahsqzkZkAuKlLARhjXYAbrQRqJyRcKeHwmn2CV8Q7D +HhDfpQKBgQDe7lorOWe0HvHNoCzgHN2DWNKAsOPcQm5W8q6CGzPETv1GxlDFxpA7 +mMXqBgl4GsZ2R9UDaQ29yGFV9sCg2nTp1AR7nqp4550dSi+pcCEgG0+IMmpDcnp0 +y3drEao15oS8HyXU98l8FoYM2pE+6G/JK8YW2fbGGNdYPRuHhlU+0A== +-----END RSA PRIVATE KEY----- diff --git a/examples/demo-api/mtls-server.js b/examples/demo-api/mtls-server.js new file mode 100644 index 0000000..469321a --- /dev/null +++ b/examples/demo-api/mtls-server.js @@ -0,0 +1,48 @@ +'use strict'; + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const PORT = 3443; +const CERT_PATH = path.join(__dirname, 'fixtures', 'mtls-client.crt'); +const KEY_PATH = path.join(__dirname, 'fixtures', 'mtls-client.key'); + +const cert = fs.readFileSync(CERT_PATH); +const key = fs.readFileSync(KEY_PATH); + +function json(res, status, data) { + const body = JSON.stringify(data, null, 2); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +const server = https.createServer( + { + key, + cert, + ca: cert, + requestCert: true, + rejectUnauthorized: true, + }, + (req, res) => { + if (req.method === 'GET' && req.url === '/secure') { + const peer = req.socket.getPeerCertificate(); + return json(res, req.client.authorized ? 200 : 401, { + ok: req.client.authorized, + route: '/secure', + clientCommonName: peer && peer.subject ? peer.subject.CN : null, + }); + } + + return json(res, 404, { error: 'Not Found', path: req.url }); + }, +); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`Missio mTLS Demo Server -> https://127.0.0.1:${PORT}`); + console.log('Set missio.rejectUnauthorized=false for this self-signed local fixture.'); +}); diff --git a/examples/demo-api/opencollection.yml b/examples/demo-api/opencollection.yml index 4518348..854e6d5 100644 --- a/examples/demo-api/opencollection.yml +++ b/examples/demo-api/opencollection.yml @@ -12,11 +12,28 @@ config: path: proto/services/missio_demo.proto importPaths: - path: proto + proxy: + enabled: false + config: + protocol: http + hostname: 127.0.0.1 + port: 3457 + auth: + username: demo-proxy + password: demo-proxy + bypassProxy: localhost,127.0.0.1 + clientCertificates: + - domain: 127.0.0.1 + type: pem + certificateFilePath: fixtures/mtls-client.crt + privateKeyFilePath: fixtures/mtls-client.key environments: - name: LOCAL variables: - name: baseUrl value: http://localhost:3456 + - name: mtlsBaseUrl + value: https://127.0.0.1:3443 - name: grpcBaseUrl value: localhost:50051 - name: userId diff --git a/examples/demo-api/proxy-server.js b/examples/demo-api/proxy-server.js new file mode 100644 index 0000000..c90d2a1 --- /dev/null +++ b/examples/demo-api/proxy-server.js @@ -0,0 +1,39 @@ +'use strict'; + +const http = require('http'); + +const PORT = 3457; +const REQUIRED_AUTH = 'Basic ' + Buffer.from('demo-proxy:demo-proxy').toString('base64'); + +function json(res, status, data) { + const body = JSON.stringify(data, null, 2); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +const server = http.createServer((req, res) => { + if (req.headers['proxy-authorization'] !== REQUIRED_AUTH) { + res.writeHead(407, { + 'Proxy-Authenticate': 'Basic realm="Missio Demo Proxy"', + 'Content-Length': '0', + }); + res.end(); + return; + } + + json(res, 200, { + ok: true, + route: 'local-proxy', + method: req.method, + targetUrl: req.url, + proxyAuthorized: true, + }); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`Missio Proxy Demo Server -> http://127.0.0.1:${PORT}`); + console.log('Temporarily set config.proxy.enabled=true in examples/demo-api/opencollection.yml to use it.'); +}); diff --git a/examples/demo-api/server.js b/examples/demo-api/server.js index 8f5728d..50a2187 100644 --- a/examples/demo-api/server.js +++ b/examples/demo-api/server.js @@ -141,6 +141,7 @@ const server = http.createServer(async (req, res) => { const { method, url, headers } = req; const contentType = headers['content-type'] || 'application/octet-stream'; + const requestUrl = new URL(url, `http://localhost:${PORT}`); // Pre-flight if (method === 'OPTIONS') { @@ -184,6 +185,57 @@ const server = http.createServer(async (req, res) => { return json(res, 200, buildGraphQLFixture(payload, headers)); } + if (method === 'GET' && requestUrl.pathname === '/auth/api-key-query') { + const key = requestUrl.searchParams.get('demo_key'); + return json(res, key === 'demo-token' ? 200 : 401, { + ok: key === 'demo-token', + route: '/auth/api-key-query', + receivedKey: key, + placement: 'query', + }); + } + + if (method === 'POST' && requestUrl.pathname === '/oauth/token') { + const body = await collectBody(req); + const params = new URLSearchParams(body.toString('utf8')); + return json(res, 200, { + access_token: 'demo-oauth-token', + token_type: 'Bearer', + expires_in: 3600, + echoed: { + queryTrace: requestUrl.searchParams.get('trace'), + tenant: headers['x-demo-tenant'] || null, + audience: params.get('audience'), + grantType: params.get('grant_type'), + }, + }); + } + + if (method === 'GET' && requestUrl.pathname === '/oauth/resource') { + const queryToken = requestUrl.searchParams.get('access_token'); + const headerToken = (headers.authorization || '').replace(/^Bearer\s+/i, ''); + const token = queryToken || headerToken; + return json(res, token === 'demo-oauth-token' ? 200 : 401, { + ok: token === 'demo-oauth-token', + route: '/oauth/resource', + tokenPlacement: queryToken ? 'query' : headerToken ? 'header' : 'missing', + }); + } + + if (method === 'GET' && requestUrl.pathname === '/redirect/start') { + res.writeHead(302, { Location: '/redirect/final' }); + res.end(); + return; + } + + if (method === 'GET' && requestUrl.pathname === '/redirect/final') { + return json(res, 200, { + ok: true, + route: '/redirect/final', + followed: true, + }); + } + // Generic binary upload: accepts anything, returns metadata. if (method === 'POST' && url === '/upload') { const body = await collectBody(req); @@ -404,6 +456,10 @@ server.listen(PORT, '127.0.0.1', () => { console.log(` GET http://localhost:${PORT}/health`); console.log(` GET http://localhost:${PORT}/graphql (GraphQL fixture info)`); console.log(` POST http://localhost:${PORT}/graphql (GraphQL JSON body -> JSON response)`); + console.log(` GET http://localhost:${PORT}/auth/api-key-query?demo_key=demo-token`); + console.log(` POST http://localhost:${PORT}/oauth/token (OAuth2 token fixture)`); + console.log(` GET http://localhost:${PORT}/oauth/resource (OAuth2 protected resource)`); + console.log(` GET http://localhost:${PORT}/redirect/start (302 to /redirect/final)`); console.log(` POST http://localhost:${PORT}/upload (any binary body → JSON info)`); console.log(` POST http://localhost:${PORT}/upload/image (image body → echoed back)`); console.log(` POST http://localhost:${PORT}/upload/pdf (PDF body → JSON info)`); diff --git a/src/copilot/tools/sendRequestTool.ts b/src/copilot/tools/sendRequestTool.ts index 3923735..0adadc5 100644 --- a/src/copilot/tools/sendRequestTool.ts +++ b/src/copilot/tools/sendRequestTool.ts @@ -402,16 +402,26 @@ export class SendRequestTool extends ToolBase { folderDefaults: import('../../models/types').RequestDefaults | undefined, headers: Record, variables: Map, - ): void { + url: string, + ): string { const auth = this._selectEffectiveAuth(request, collection, folderDefaults); - if (!auth || auth === 'inherit' || typeof auth !== 'object') return; + if (!auth || auth === 'inherit' || typeof auth !== 'object') return url; switch ((auth as any).type) { case 'apikey': { const keyRaw = (auth as any).key; const placement = (auth as any).placement; - if (!keyRaw || placement === 'query') return; + if (!keyRaw) return url; const key = this._environmentService.interpolate(String(keyRaw), variables); + if (placement === 'query') { + try { + const parsed = new URL(url); + parsed.searchParams.set(key, '[redacted]'); + return parsed.toString(); + } catch { + return url; + } + } headers[key] = '[redacted]'; break; } @@ -430,6 +440,7 @@ export class SendRequestTool extends ToolBase { break; } } + return url; } async prepareInvocation( @@ -657,19 +668,50 @@ export class SendRequestTool extends ToolBase { for (const [k, v] of extraVariables) variables.set(k, v); } const details = request.http; - const url = details?.url ? this._environmentService.interpolate(details.url, variables) : ''; + let url = details?.url ? this._environmentService.interpolate(details.url, variables) : ''; + if (url && !/^https?:\/\//i.test(url)) { + url = 'http://' + url; + } + if (details?.params?.length) { + for (const param of details.params) { + if (param.disabled || param.type !== 'path') continue; + const name = this._environmentService.interpolate(param.name, variables); + const value = this._environmentService.interpolate(param.value, variables); + url = url.replace(`:${name}`, encodeURIComponent(value)); + } + try { + const parsed = new URL(url); + parsed.search = ''; + for (const param of details.params) { + if (param.disabled) continue; + const name = this._environmentService.interpolate(param.name, variables); + const value = this._environmentService.interpolate(param.value, variables); + if (param.type === 'query' && name && value !== '') { + parsed.searchParams.set(name, value); + } + } + url = parsed.toString(); + } catch { + // Leave the interpolated URL as-is when it cannot be parsed. + } + } const method = details?.method ?? 'GET'; const headers: Record = {}; - for (const h of details?.headers ?? []) { - if (!h.disabled) { - headers[this._environmentService.interpolate(h.name, variables)] = - this._environmentService.interpolate(h.value, variables); + const addHeaders = (entries: import('../../models/types').HttpRequestHeader[] | undefined) => { + for (const h of entries ?? []) { + if (!h.disabled) { + headers[this._environmentService.interpolate(h.name, variables)] = + this._environmentService.interpolate(h.value, variables); + } } - } + }; + addHeaders(collection.data.request?.headers); + addHeaders(folderDefaults?.headers); + addHeaders(details?.headers); // Auth — apply effective auth chain (request → folder → collection), mirroring httpClient selection. // We only synthesize preview headers here. OAuth2 token acquisition and CLI command execution are not performed in dryRun. - this._applyDryRunAuth(request, collection, folderDefaults, headers, variables); + url = this._applyDryRunAuth(request, collection, folderDefaults, headers, variables, url); // Resolve body using the same logic as httpClient._buildBody let body: string | undefined; const rawBody = details?.body; diff --git a/src/models/types.ts b/src/models/types.ts index 808541a..2b284e3 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -225,12 +225,35 @@ export interface OAuth2Settings { autoRefreshToken?: boolean; } +export interface OAuth2TokenPlacedInHeader { header: string; } +export interface OAuth2TokenPlacedInQuery { query: string; } +export type OAuth2TokenPlacement = OAuth2TokenPlacedInHeader | OAuth2TokenPlacedInQuery; + +export interface OAuth2TokenConfig { + id?: string; + placement?: OAuth2TokenPlacement; +} + +export interface OAuth2AdditionalParameter { + name?: string; + value?: string; + placement?: 'header' | 'query' | 'body'; +} + +export interface OAuth2AdditionalParameters { + authorizationRequest?: OAuth2AdditionalParameter[]; + accessTokenRequest?: OAuth2AdditionalParameter[]; + refreshTokenRequest?: OAuth2AdditionalParameter[]; +} + export interface AuthOAuth2Base { type: 'oauth2'; accessTokenUrl?: string; refreshTokenUrl?: string; scope?: string; credentials?: OAuth2Credentials; + tokenConfig?: OAuth2TokenConfig; + additionalParameters?: OAuth2AdditionalParameters; settings?: OAuth2Settings; credentialsId?: string; } @@ -248,13 +271,25 @@ export interface AuthOAuth2AuthorizationCode extends AuthOAuth2Base { flow: 'authorization_code'; authorizationUrl?: string; callbackUrl?: string; + state?: string; pkce?: OAuth2PKCE; } +export interface AuthOAuth2Implicit extends Omit { + flow: 'implicit'; + accessTokenUrl?: never; + refreshTokenUrl?: never; + authorizationUrl?: string; + callbackUrl?: string; + credentials?: { clientId?: string }; + state?: string; +} + export type AuthOAuth2 = | AuthOAuth2ClientCredentials | AuthOAuth2ResourceOwnerPassword - | AuthOAuth2AuthorizationCode; + | AuthOAuth2AuthorizationCode + | AuthOAuth2Implicit; export type Auth = | AuthBasic @@ -554,11 +589,12 @@ export interface ProxyConnectionConfig { protocol?: string; hostname?: string; port?: number; - auth?: { disabled?: boolean; username?: string; password?: string }; + auth?: false | { disabled?: boolean; username?: string; password?: string }; bypassProxy?: string; } export interface Proxy { + enabled?: boolean; disabled?: boolean; inherit?: boolean; config?: ProxyConnectionConfig; diff --git a/src/services/grpcClient.ts b/src/services/grpcClient.ts index 38cb762..d54a208 100644 --- a/src/services/grpcClient.ts +++ b/src/services/grpcClient.ts @@ -862,7 +862,9 @@ export class GrpcClient implements vscode.Disposable { } case 'apikey': { const apiKey = auth as AuthApiKey; - if (apiKey.placement === 'query') return; + if (apiKey.placement === 'query') { + throw new Error('API key query auth is not supported for gRPC requests. Use header placement so the key can be sent as metadata.'); + } const key = this._environmentService.interpolate(apiKey.key ?? '', variables); if (key) metadata.set(key, this._environmentService.interpolate(apiKey.value ?? '', variables)); break; @@ -875,6 +877,8 @@ export class GrpcClient implements vscode.Disposable { metadata.set(headerName, prefix ? `${prefix} ${token}` : token); break; } + default: + throw new Error(`Authentication type "${(auth as any).type ?? 'unknown'}" is not supported for gRPC requests by the Missio runtime yet.`); } } diff --git a/src/services/httpClient.ts b/src/services/httpClient.ts index 207a9c3..a61034b 100644 --- a/src/services/httpClient.ts +++ b/src/services/httpClient.ts @@ -1,14 +1,16 @@ import * as vscode from 'vscode'; +import * as fs from 'fs'; import * as http from 'http'; import * as https from 'https'; import * as path from 'path'; +import * as tls from 'tls'; import { URL } from 'url'; import { exec } from 'child_process'; import { promisify } from 'util'; import type { HttpRequest, HttpRequestDetails, HttpRequestBody, Auth, AuthOAuth2, AuthCli, HttpResponse, HttpRequestSettings, HttpRequestBodyVariant, - MissioCollection, + MissioCollection, ClientCertificate, OAuth2AdditionalParameters, OAuth2AdditionalParameter, } from '../models/types'; import type { EnvironmentService } from './environmentService'; import type { OAuth2Service } from './oauth2Service'; @@ -39,6 +41,20 @@ interface CliTokenCacheEntry { expiresAt: number; // epoch ms } +interface ResolvedHttpRequestSettings { + timeout: number; + followRedirects: boolean; + maxRedirects: number; + encodeUrl: boolean; +} + +interface ResolvedProxyConfig { + protocol: 'http:' | 'https:'; + hostname: string; + port: number; + authHeader?: string; +} + /** Callback to prompt user for CLI command approval. Returns true if approved. */ export type CliApprovalPrompt = (commandTemplate: string, interpolatedCommand: string) => Promise; @@ -88,6 +104,7 @@ export class HttpClient implements vscode.Disposable { if (!details?.url || !details?.method) { throw new Error('Request must have a URL and method'); } + const settings = this._resolveSettings(request.settings, vscode.workspace.getConfiguration('missio')); // Interpolate URL let url = this._environmentService.interpolate(details.url, variables); @@ -104,10 +121,7 @@ export class HttpClient implements vscode.Disposable { if (p.disabled) continue; const resolvedValue = this._environmentService.interpolate(p.value, variables); if (resolvedValue === '') continue; - urlObj.searchParams.set( - this._environmentService.interpolate(p.name, variables), - resolvedValue, - ); + this._setQueryParam(urlObj, this._environmentService.interpolate(p.name, variables), resolvedValue, settings.encodeUrl); } url = urlObj.toString(); } @@ -117,7 +131,7 @@ export class HttpClient implements vscode.Disposable { for (const p of pathParams) { const name = this._environmentService.interpolate(p.name, variables); const value = this._environmentService.interpolate(p.value, variables); - url = url.replace(`:${name}`, encodeURIComponent(value)); + url = url.replace(`:${name}`, settings.encodeUrl ? encodeURIComponent(value) : value); } // Build headers: collection -> folder -> request (each layer overrides) @@ -162,11 +176,11 @@ export class HttpClient implements vscode.Disposable { } if (auth && auth !== 'inherit') { if (auth.type === 'oauth2') { - await this._applyOAuth2(auth as AuthOAuth2, headers, variables, collection, environmentName); + url = await this._applyOAuth2(auth as AuthOAuth2, headers, variables, collection, environmentName, url, settings.encodeUrl); } else if (auth.type === 'cli') { await this._applyCliAuth(auth as AuthCli, headers, variables, collection, cliApprovalPrompt); } else { - this._applyAuth(auth, headers, variables); + url = this._applyAuth(auth, headers, variables, url, settings.encodeUrl); } } } @@ -205,6 +219,8 @@ export class HttpClient implements vscode.Disposable { } } + url = this._normalizeUrl(url, settings.encodeUrl); + return { method: details.method.toUpperCase(), url, headers, body }; } @@ -234,40 +250,75 @@ export class HttpClient implements vscode.Disposable { const settings = this._resolveSettings(request.settings, config); onProgress?.('Sending request…'); - // Execute - const { method, url, headers, body } = resolved; - _log(` executing: ${method} ${url}`); - tPhase = Date.now(); - const parsedUrl = new URL(url); - const isHttps = parsedUrl.protocol === 'https:'; - const requestModule = isHttps ? https : http; + const variables = await this._environmentService.resolveVariables(collection, folderDefaults, environmentName); + if (extraVariables) { + for (const [k, v] of extraVariables) variables.set(k, v); + } + tPhase = Date.now(); const requestId = `${Date.now()}-${Math.random()}`; + const response = await this._sendWithRedirects( + resolved, + settings, + collection, + variables, + environmentName, + requestId, + ); + _mark('HTTP', tPhase); + return { ...response, timing: _timing }; + } - return new Promise((resolve, reject) => { - const startTime = Date.now(); - - const options: http.RequestOptions = { - method, - hostname: parsedUrl.hostname, - port: parsedUrl.port || (isHttps ? 443 : 80), - path: parsedUrl.pathname + parsedUrl.search, - headers, - timeout: settings.timeout, - }; - - if (isHttps) { - (options as https.RequestOptions).rejectUnauthorized = - vscode.workspace.getConfiguration('missio').get('rejectUnauthorized', true); + private async _sendWithRedirects( + resolved: ResolvedRequest, + settings: ResolvedHttpRequestSettings, + collection: MissioCollection, + variables: Map, + environmentName: string | undefined, + requestId: string, + ): Promise { + let current: ResolvedRequest = { + ...resolved, + headers: { ...resolved.headers }, + }; + const startedAt = Date.now(); + let redirects = 0; + + while (true) { + _log(` executing: ${current.method} ${current.url}`); + const response = await this._sendOnce(current, settings, collection, variables, environmentName, requestId, startedAt); + const location = this._getRedirectLocation(response); + if (!location || !settings.followRedirects) { + return response; + } + if (redirects >= settings.maxRedirects) { + throw new Error(`Too many redirects: exceeded maxRedirects (${settings.maxRedirects})`); } + redirects += 1; + current = this._buildRedirectRequest(current, response.status, location, settings.encodeUrl); + } + } + + private async _sendOnce( + resolved: ResolvedRequest, + settings: ResolvedHttpRequestSettings, + collection: MissioCollection, + variables: Map, + environmentName: string | undefined, + requestId: string, + startedAt: number, + ): Promise { + const transport = await this._buildRequestOptions(resolved, settings, collection, variables, environmentName); + const requestModule = transport.module; - const req = requestModule.request(options, (res) => { + return new Promise((resolve, reject) => { + const req = requestModule.request(transport.options, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { this._activeRequests.delete(requestId); const buffer = Buffer.concat(chunks); - const duration = Date.now() - startTime; + const duration = Date.now() - startedAt; const responseHeaders: Record = {}; for (const [key, val] of Object.entries(res.headers)) { if (val) { @@ -275,8 +326,6 @@ export class HttpClient implements vscode.Disposable { } } - _mark('HTTP', tPhase); - // Detect binary content types for preview support const ct = (responseHeaders['content-type'] ?? '').toLowerCase(); const isBinary = /^(image\/|application\/pdf|application\/octet-stream)/.test(ct); @@ -289,7 +338,6 @@ export class HttpClient implements vscode.Disposable { bodyBase64: isBinary ? buffer.toString('base64') : undefined, duration, size: buffer.length, - timing: _timing, } as any); }); }); @@ -305,8 +353,8 @@ export class HttpClient implements vscode.Disposable { this._activeRequests.set(requestId, req); - if (body !== undefined) { - const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf-8'); + if (resolved.body !== undefined) { + const bodyBuffer = Buffer.isBuffer(resolved.body) ? resolved.body : Buffer.from(resolved.body, 'utf-8'); req.setHeader('Content-Length', bodyBuffer.length); _log(` body: ${bodyBuffer.length} bytes`); req.write(bodyBuffer); @@ -324,14 +372,277 @@ export class HttpClient implements vscode.Disposable { // ── Private ────────────────────────────────────────────────────── + private async _buildRequestOptions( + resolved: ResolvedRequest, + settings: ResolvedHttpRequestSettings, + collection: MissioCollection, + variables: Map, + environmentName: string | undefined, + ): Promise<{ module: typeof http | typeof https; options: http.RequestOptions | https.RequestOptions }> { + const parsedUrl = new URL(resolved.url); + const isHttps = parsedUrl.protocol === 'https:'; + const headers = { ...resolved.headers }; + const tlsOptions = isHttps + ? await this._resolveTlsOptions(collection, variables, environmentName, parsedUrl.hostname) + : undefined; + const proxy = this._resolveProxy(collection, variables, parsedUrl); + + if (proxy && isHttps) { + const socket = await this._createProxyTunnel(parsedUrl, proxy, settings, tlsOptions); + return { + module: https, + options: { + method: resolved.method, + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + headers, + timeout: settings.timeout, + agent: false, + createConnection: () => socket, + ...tlsOptions, + } as https.RequestOptions, + }; + } + + if (proxy) { + if (!this._hasHeader(headers, 'host')) { + headers.Host = parsedUrl.host; + } + if (proxy.authHeader) { + headers['Proxy-Authorization'] = proxy.authHeader; + } + return { + module: proxy.protocol === 'https:' ? https : http, + options: { + method: resolved.method, + hostname: proxy.hostname, + port: proxy.port, + path: parsedUrl.toString(), + headers, + timeout: settings.timeout, + ...(proxy.protocol === 'https:' + ? { rejectUnauthorized: vscode.workspace.getConfiguration('missio').get('rejectUnauthorized', true) } + : {}), + }, + }; + } + + return { + module: isHttps ? https : http, + options: { + method: resolved.method, + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + headers, + timeout: settings.timeout, + ...(tlsOptions ?? {}), + }, + }; + } + + private _resolveProxy( + collection: MissioCollection, + variables: Map, + targetUrl: URL, + ): ResolvedProxyConfig | undefined { + const proxy = collection.data.config?.proxy; + if (!proxy || proxy.disabled || proxy.enabled === false) return undefined; + const config = proxy.config; + if (!config || !config.hostname || !config.port) return undefined; + if (this._isProxyBypassed(config.bypassProxy, targetUrl.hostname, variables)) return undefined; + + const rawProtocol = this._environmentService.interpolate(config.protocol || 'http', variables).toLowerCase(); + const protocol = rawProtocol.endsWith(':') ? rawProtocol : `${rawProtocol}:`; + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error(`Proxy protocol "${rawProtocol}" is not supported. Use http or https.`); + } + + let authHeader: string | undefined; + const auth = config.auth; + if (auth && typeof auth === 'object' && !auth.disabled) { + const username = this._environmentService.interpolate(auth.username ?? '', variables); + const password = this._environmentService.interpolate(auth.password ?? '', variables); + authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + } + + return { + protocol, + hostname: this._environmentService.interpolate(config.hostname, variables), + port: config.port, + authHeader, + }; + } + + private _isProxyBypassed(bypassProxy: string | undefined, hostname: string, variables: Map): boolean { + if (!bypassProxy) return false; + const host = hostname.toLowerCase(); + const entries = this._environmentService.interpolate(bypassProxy, variables) + .split(/[,\s]+/) + .map(entry => entry.trim().toLowerCase()) + .filter(Boolean); + return entries.some(entry => { + if (entry === '*') return true; + if (entry === '') return !host.includes('.'); + if (entry.startsWith('*.')) return host.endsWith(entry.slice(1)); + return host === entry; + }); + } + + private async _createProxyTunnel( + targetUrl: URL, + proxy: ResolvedProxyConfig, + settings: ResolvedHttpRequestSettings, + tlsOptions: tls.ConnectionOptions | undefined, + ): Promise { + const proxyModule = proxy.protocol === 'https:' ? https : http; + const targetPort = targetUrl.port || '443'; + const tunnelHeaders: Record = { + Host: `${targetUrl.hostname}:${targetPort}`, + }; + if (proxy.authHeader) { + tunnelHeaders['Proxy-Authorization'] = proxy.authHeader; + } + + return new Promise((resolve, reject) => { + const req = proxyModule.request({ + method: 'CONNECT', + hostname: proxy.hostname, + port: proxy.port, + path: `${targetUrl.hostname}:${targetPort}`, + headers: tunnelHeaders, + timeout: settings.timeout, + }); + + req.on('connect', (res, socket) => { + if (res.statusCode !== 200) { + socket.destroy(); + reject(new Error(`Proxy CONNECT failed with status ${res.statusCode ?? 0}`)); + return; + } + + const tlsSocket = tls.connect({ + socket, + servername: targetUrl.hostname, + ...(tlsOptions ?? {}), + }, () => resolve(tlsSocket)); + tlsSocket.once('error', reject); + }); + req.on('timeout', () => req.destroy(new Error('Proxy CONNECT timed out'))); + req.on('error', reject); + req.end(); + }); + } + + private async _resolveTlsOptions( + collection: MissioCollection, + variables: Map, + environmentName: string | undefined, + hostname: string, + ): Promise { + const options: tls.ConnectionOptions = { + rejectUnauthorized: vscode.workspace.getConfiguration('missio').get('rejectUnauthorized', true), + }; + const certificate = this._selectClientCertificate(collection, variables, environmentName, hostname); + if (!certificate) return options; + + if (certificate.type === 'pem') { + options.cert = await fs.promises.readFile(this._resolveCollectionPath(collection.rootDir, this._environmentService.interpolate(certificate.certificateFilePath, variables))); + options.key = await fs.promises.readFile(this._resolveCollectionPath(collection.rootDir, this._environmentService.interpolate(certificate.privateKeyFilePath, variables))); + } else { + options.pfx = await fs.promises.readFile(this._resolveCollectionPath(collection.rootDir, this._environmentService.interpolate(certificate.pkcs12FilePath, variables))); + } + if (certificate.passphrase) { + options.passphrase = this._environmentService.interpolate(certificate.passphrase, variables); + } + return options; + } + + private _selectClientCertificate( + collection: MissioCollection, + variables: Map, + environmentName: string | undefined, + hostname: string, + ): ClientCertificate | undefined { + const envName = environmentName ?? this._environmentService.getActiveEnvironmentName(collection.id); + const env = envName + ? collection.data.config?.environments?.find(candidate => candidate.name === envName) + : undefined; + const candidates = [ + ...(env?.clientCertificates ?? []), + ...(collection.data.config?.clientCertificates ?? []), + ]; + return candidates.find(certificate => this._certificateMatches(certificate, hostname, variables)); + } + + private _certificateMatches(certificate: ClientCertificate, hostname: string, variables: Map): boolean { + const domain = this._environmentService.interpolate(certificate.domain, variables).toLowerCase(); + const host = hostname.toLowerCase(); + if (domain === '*') return true; + if (domain.startsWith('*.')) return host.endsWith(domain.slice(1)); + return domain === host; + } + + private _resolveCollectionPath(rootDir: string, filePath: string): string { + return path.isAbsolute(filePath) ? filePath : path.resolve(rootDir, filePath); + } + + private _getRedirectLocation(response: HttpResponse): string | undefined { + if (![301, 302, 303, 307, 308].includes(response.status)) return undefined; + return response.headers.location || response.headers.Location; + } + + private _buildRedirectRequest(current: ResolvedRequest, status: number, location: string, encodeUrl: boolean): ResolvedRequest { + const url = this._normalizeUrl(new URL(location, current.url).toString(), encodeUrl); + const headers = { ...current.headers }; + let method = current.method; + let body = current.body; + + if ([301, 302, 303].includes(status) && method !== 'GET' && method !== 'HEAD') { + method = 'GET'; + body = undefined; + this._deleteHeader(headers, 'content-length'); + } + + return { method, url, headers, body }; + } + + private _setQueryParam(url: URL, name: string, value: string, _encodeUrl = true): void { + if (name) { + url.searchParams.set(name, value); + } + } + + private _normalizeUrl(url: string, encodeUrl: boolean): string { + if (!encodeUrl) return url; + try { + return new URL(url).toString(); + } catch { + return url; + } + } + + private _hasHeader(headers: Record, name: string): boolean { + const lower = name.toLowerCase(); + return Object.keys(headers).some(header => header.toLowerCase() === lower); + } + + private _deleteHeader(headers: Record, name: string): void { + const lower = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lower) delete headers[key]; + } + } + private _resolveSettings( settings: HttpRequestSettings | undefined, config: vscode.WorkspaceConfiguration, - ) { + ): ResolvedHttpRequestSettings { return { - timeout: (settings?.timeout !== 'inherit' && settings?.timeout) || config.get('timeout', 30000), + timeout: (settings?.timeout !== 'inherit' ? settings?.timeout : undefined) ?? config.get('timeout', 30000), followRedirects: (settings?.followRedirects !== 'inherit' && settings?.followRedirects) ?? config.get('followRedirects', true), - maxRedirects: (settings?.maxRedirects !== 'inherit' && settings?.maxRedirects) || config.get('maxRedirects', 5), + maxRedirects: (settings?.maxRedirects !== 'inherit' ? settings?.maxRedirects : undefined) ?? config.get('maxRedirects', 5), encodeUrl: (settings?.encodeUrl !== 'inherit' && settings?.encodeUrl) ?? true, }; } @@ -420,7 +731,9 @@ export class HttpClient implements vscode.Disposable { variables: Map, collection: MissioCollection, environmentName?: string, - ): Promise { + url = '', + encodeUrl = true, + ): Promise { if (!this._oauth2Service) { throw new Error('OAuth2 service not available'); } @@ -439,8 +752,8 @@ export class HttpClient implements vscode.Disposable { const creds = auth.credentials; const interpolatedCreds = creds ? { clientId: await resolve(creds.clientId), - clientSecret: await resolve(creds.clientSecret), - placement: creds.placement, + clientSecret: 'clientSecret' in creds ? await resolve(creds.clientSecret) : undefined, + placement: 'placement' in creds ? creds.placement : undefined, } : undefined; const base: any = { @@ -450,6 +763,8 @@ export class HttpClient implements vscode.Disposable { refreshTokenUrl: await resolve(auth.refreshTokenUrl), scope: await resolve(auth.scope), credentials: interpolatedCreds, + tokenConfig: await this._resolveOAuth2TokenConfig(auth.tokenConfig, resolve), + additionalParameters: await this._resolveOAuth2AdditionalParameters(auth.additionalParameters, resolve), settings: auth.settings, credentialsId: auth.credentialsId, }; @@ -467,7 +782,13 @@ export class HttpClient implements vscode.Disposable { const ac = auth as import('../models/types').AuthOAuth2AuthorizationCode; base.authorizationUrl = await resolve(ac.authorizationUrl); base.callbackUrl = ac.callbackUrl; + base.state = await resolve(ac.state); base.pkce = ac.pkce; + } else if (auth.flow === 'implicit') { + const implicit = auth as import('../models/types').AuthOAuth2Implicit; + base.authorizationUrl = await resolve(implicit.authorizationUrl); + base.callbackUrl = implicit.callbackUrl; + base.state = await resolve(implicit.state); } const interpolated: AuthOAuth2 = base; @@ -476,8 +797,68 @@ export class HttpClient implements vscode.Disposable { const token = await this._oauth2Service.getToken(interpolated, collection.id, envName); if (token) { - headers['Authorization'] = `Bearer ${token}`; + return this._applyOAuth2Token(interpolated, headers, token, url, encodeUrl); } + return url; + } + + private _applyOAuth2Token( + auth: AuthOAuth2, + headers: Record, + token: string, + url: string, + encodeUrl: boolean, + ): string { + const placement = auth.tokenConfig?.placement; + if (placement && 'query' in placement) { + const parsed = new URL(url); + this._setQueryParam(parsed, placement.query || 'access_token', token, encodeUrl); + return parsed.toString(); + } + + const headerName = placement && 'header' in placement ? placement.header : 'Authorization'; + headers[headerName || 'Authorization'] = (headerName || 'Authorization').toLowerCase() === 'authorization' + ? `Bearer ${token}` + : token; + return url; + } + + private async _resolveOAuth2TokenConfig( + tokenConfig: AuthOAuth2['tokenConfig'], + resolve: (value: string | undefined) => Promise, + ): Promise { + if (!tokenConfig) return undefined; + const placement = tokenConfig.placement; + let resolvedPlacement = placement; + if (placement && 'header' in placement) { + resolvedPlacement = { header: await resolve(placement.header) ?? placement.header }; + } else if (placement && 'query' in placement) { + resolvedPlacement = { query: await resolve(placement.query) ?? placement.query }; + } + return { + id: await resolve(tokenConfig.id), + placement: resolvedPlacement, + }; + } + + private async _resolveOAuth2AdditionalParameters( + params: OAuth2AdditionalParameters | undefined, + resolve: (value: string | undefined) => Promise, + ): Promise { + if (!params) return undefined; + const resolveEntries = async (entries: OAuth2AdditionalParameter[] | undefined): Promise => { + if (!entries) return undefined; + return Promise.all(entries.map(async entry => ({ + ...entry, + name: await resolve(entry.name), + value: await resolve(entry.value), + }))); + }; + return { + authorizationRequest: await resolveEntries(params.authorizationRequest), + accessTokenRequest: await resolveEntries(params.accessTokenRequest), + refreshTokenRequest: await resolveEntries(params.refreshTokenRequest), + }; } private _isAuthComplete(auth: Exclude): boolean { @@ -659,31 +1040,37 @@ export class HttpClient implements vscode.Disposable { auth: Exclude, headers: Record, variables: Map, - ): void { + url: string, + encodeUrl = true, + ): string { switch (auth.type) { case 'basic': headers['Authorization'] = 'Basic ' + Buffer.from( `${this._environmentService.interpolate(auth.username || '', variables)}:${this._environmentService.interpolate(auth.password || '', variables)}` ).toString('base64'); - break; + return url; case 'bearer': { const token = this._environmentService.interpolate(auth.token ?? '', variables); headers['Authorization'] = `Bearer ${token}`; - break; + return url; } case 'apikey': { const key = this._environmentService.interpolate(auth.key ?? '', variables); const value = this._environmentService.interpolate(auth.value ?? '', variables); + if (!key) return url; if (auth.placement === 'query') { + const parsed = new URL(url); + this._setQueryParam(parsed, key, value, encodeUrl); + return parsed.toString(); // Handled elsewhere — would need URL mutation } else { headers[key] = value; } - break; + return url; } // digest, ntlm, wsse, awsv4 — complex auth flows, stub for now default: - break; + throw new Error(`Authentication type "${(auth as any).type ?? 'unknown'}" is not supported by the Missio runtime yet.`); } } diff --git a/src/services/oauth2Service.ts b/src/services/oauth2Service.ts index 3fe9ad4..7b2e4c2 100644 --- a/src/services/oauth2Service.ts +++ b/src/services/oauth2Service.ts @@ -3,7 +3,7 @@ import * as http from 'http'; import * as https from 'https'; import * as crypto from 'crypto'; import { URL } from 'url'; -import type { AuthOAuth2 } from '../models/types'; +import type { AuthOAuth2, OAuth2AdditionalParameter } from '../models/types'; /** * Stored token data from an OAuth2 token endpoint response. @@ -42,6 +42,10 @@ export class OAuth2Service implements vscode.Disposable { envName: string | undefined, ): Promise { const flow = auth.flow ?? 'client_credentials'; + if (flow === 'implicit') { + throw new Error('OAuth2: implicit flow is not supported by the Missio runtime because it requires browser fragment token capture. Use authorization_code with PKCE instead.'); + } + const accessTokenUrl = auth.accessTokenUrl; if (!accessTokenUrl) { throw new Error('OAuth2: Access Token URL is required'); @@ -200,7 +204,9 @@ export class OAuth2Service implements vscode.Disposable { this._applyCredentials(creds, headers, params); - return this._postTokenRequest(auth.accessTokenUrl!, headers, params.toString()); + const tokenUrl = this._applyAdditionalParameters(auth.additionalParameters?.accessTokenRequest, headers, params, auth.accessTokenUrl!); + + return this._postTokenRequest(tokenUrl, headers, params.toString()); } private async _fetchPassword(auth: import('../models/types').AuthOAuth2ResourceOwnerPassword): Promise { @@ -226,7 +232,9 @@ export class OAuth2Service implements vscode.Disposable { this._applyCredentials(creds, headers, params); - return this._postTokenRequest(auth.accessTokenUrl!, headers, params.toString()); + const tokenUrl = this._applyAdditionalParameters(auth.additionalParameters?.accessTokenRequest, headers, params, auth.accessTokenUrl!); + + return this._postTokenRequest(tokenUrl, headers, params.toString()); } private async _fetchAuthorizationCode(auth: import('../models/types').AuthOAuth2AuthorizationCode): Promise { @@ -235,6 +243,7 @@ export class OAuth2Service implements vscode.Disposable { if (!auth.authorizationUrl) throw new Error('OAuth2: Authorization URL is required for authorization_code flow'); const usePkce = auth.pkce?.enabled !== false; // default true for auth code flow + const pkceMethod = auth.pkce?.method ?? 'S256'; let codeVerifier: string | undefined; let codeChallenge: string | undefined; @@ -242,10 +251,12 @@ export class OAuth2Service implements vscode.Disposable { // Generate PKCE code_verifier (43–128 chars, URL-safe) codeVerifier = crypto.randomBytes(32).toString('base64url'); // S256 challenge - codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); + codeChallenge = pkceMethod === 'plain' + ? codeVerifier + : crypto.createHash('sha256').update(codeVerifier).digest('base64url'); } - const state = crypto.randomBytes(16).toString('hex'); + const state = auth.state || crypto.randomBytes(16).toString('hex'); // Start a temporary local HTTP server to receive the callback, with cancel support const { code, callbackUrl } = await vscode.window.withProgress( @@ -254,7 +265,7 @@ export class OAuth2Service implements vscode.Disposable { title: 'OAuth2: Waiting for browser authorization…', cancellable: true, }, - (_progress, cancelToken) => this._waitForAuthorizationCode(auth, state, codeChallenge, cancelToken), + (_progress, cancelToken) => this._waitForAuthorizationCode(auth, state, codeChallenge, usePkce ? pkceMethod : undefined, cancelToken), ); // Exchange authorization code for tokens @@ -274,7 +285,9 @@ export class OAuth2Service implements vscode.Disposable { this._applyCredentials(creds, headers, params); - return this._postTokenRequest(auth.accessTokenUrl!, headers, params.toString()); + const tokenUrl = this._applyAdditionalParameters(auth.additionalParameters?.accessTokenRequest, headers, params, auth.accessTokenUrl!); + + return this._postTokenRequest(tokenUrl, headers, params.toString()); } /** @@ -285,6 +298,7 @@ export class OAuth2Service implements vscode.Disposable { auth: import('../models/types').AuthOAuth2AuthorizationCode, state: string, codeChallenge?: string, + codeChallengeMethod?: import('../models/types').OAuth2PKCE['method'], cancelToken?: vscode.CancellationToken, ): Promise<{ code: string; callbackUrl: string }> { return new Promise((resolve, reject) => { @@ -304,9 +318,13 @@ export class OAuth2Service implements vscode.Disposable { }); } - server.listen(0, '127.0.0.1', () => { + const callbackConfig = this._resolveCallbackConfig(auth.callbackUrl); + + server.listen(callbackConfig.port, callbackConfig.hostname, () => { const addr = server.address() as { port: number }; - const callbackUrl = `http://localhost:${addr.port}`; + const callbackUrl = callbackConfig.callbackUrl + ? callbackConfig.callbackUrl + : `http://localhost:${addr.port}${callbackConfig.pathname}`; // Build authorization URL const authUrl = new URL(auth.authorizationUrl!); @@ -317,14 +335,20 @@ export class OAuth2Service implements vscode.Disposable { if (auth.scope) authUrl.searchParams.set('scope', auth.scope); if (codeChallenge) { authUrl.searchParams.set('code_challenge', codeChallenge); - authUrl.searchParams.set('code_challenge_method', 'S256'); + authUrl.searchParams.set('code_challenge_method', codeChallengeMethod ?? 'S256'); } + this._applyAuthorizationRequestParameters(auth.additionalParameters?.authorizationRequest, authUrl); // Open browser vscode.env.openExternal(vscode.Uri.parse(authUrl.toString())); server.on('request', (req, res) => { const reqUrl = new URL(req.url ?? '/', `http://localhost:${addr.port}`); + if (callbackConfig.pathname !== '/' && reqUrl.pathname !== callbackConfig.pathname) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + return; + } const error = reqUrl.searchParams.get('error'); if (error) { @@ -384,6 +408,39 @@ export class OAuth2Service implements vscode.Disposable {
MISSIO
${statusIcon}

${message}

`; } + private _resolveCallbackConfig(callbackUrl: string | undefined): { hostname: string; port: number; pathname: string; callbackUrl?: string } { + if (!callbackUrl) { + return { hostname: '127.0.0.1', port: 0, pathname: '/' }; + } + + const parsed = new URL(callbackUrl); + const hostname = parsed.hostname.toLowerCase(); + if (parsed.protocol !== 'http:' || (hostname !== 'localhost' && hostname !== '127.0.0.1')) { + throw new Error('OAuth2: callbackUrl must be an http://localhost or http://127.0.0.1 URL so Missio can receive the authorization callback locally.'); + } + if (!parsed.port) { + throw new Error('OAuth2: callbackUrl must include an explicit port.'); + } + + return { + hostname: parsed.hostname, + port: Number(parsed.port), + pathname: parsed.pathname || '/', + callbackUrl: parsed.toString(), + }; + } + + private _applyAuthorizationRequestParameters(entries: OAuth2AdditionalParameter[] | undefined, authUrl: URL): void { + for (const entry of entries ?? []) { + if (!entry.name) continue; + const placement = entry.placement ?? 'query'; + if (placement !== 'query') { + throw new Error(`OAuth2: authorizationRequest additional parameter "${entry.name}" uses unsupported ${placement} placement. Browser authorization requests can only send query parameters.`); + } + authUrl.searchParams.set(entry.name, entry.value ?? ''); + } + } + /** Apply client credentials to headers/params based on placement setting. */ private _applyCredentials( creds: import('../models/types').OAuth2Credentials, @@ -420,7 +477,35 @@ export class OAuth2Service implements vscode.Disposable { this._applyCredentials(creds, headers, params); } - return this._postTokenRequest(url, headers, params.toString()); + const refreshUrl = this._applyAdditionalParameters(auth.additionalParameters?.refreshTokenRequest, headers, params, url); + + return this._postTokenRequest(refreshUrl, headers, params.toString()); + } + + private _applyAdditionalParameters( + entries: OAuth2AdditionalParameter[] | undefined, + headers: Record, + bodyParams: URLSearchParams, + url: string, + ): string { + if (!entries?.length) return url; + const parsed = new URL(url); + for (const entry of entries) { + if (!entry.name) continue; + const value = entry.value ?? ''; + switch (entry.placement ?? 'body') { + case 'header': + headers[entry.name] = value; + break; + case 'query': + parsed.searchParams.set(entry.name, value); + break; + case 'body': + bodyParams.set(entry.name, value); + break; + } + } + return parsed.toString(); } // ── Private: HTTP ─────────────────────────────────────────────────── diff --git a/src/services/oauth2TokenHelper.ts b/src/services/oauth2TokenHelper.ts index 953e022..2e70ad1 100644 --- a/src/services/oauth2TokenHelper.ts +++ b/src/services/oauth2TokenHelper.ts @@ -60,6 +60,8 @@ export async function handleOAuth2TokenMessage( refreshTokenUrl: await resolve(auth.refreshTokenUrl), scope: await resolve(auth.scope), credentials: interpolatedCreds, + tokenConfig: auth.tokenConfig, + additionalParameters: await resolveAdditionalParameters(auth.additionalParameters, resolve), settings: { ...auth.settings, autoFetchToken: true }, credentialsId: auth.credentialsId, }; @@ -77,6 +79,7 @@ export async function handleOAuth2TokenMessage( const ac = auth as import('../models/types').AuthOAuth2AuthorizationCode; base.authorizationUrl = await resolve(ac.authorizationUrl); base.callbackUrl = ac.callbackUrl; + base.state = await resolve(ac.state); base.pkce = ac.pkce; } @@ -91,3 +94,23 @@ export async function handleOAuth2TokenMessage( webview.postMessage({ type: 'oauth2Progress', message: `Error: ${e.message}` }); } } + +async function resolveAdditionalParameters( + params: import('../models/types').OAuth2AdditionalParameters | undefined, + resolve: (value: string | undefined) => Promise, +): Promise { + if (!params) return undefined; + const resolveEntries = async (entries: import('../models/types').OAuth2AdditionalParameter[] | undefined) => { + if (!entries) return undefined; + return Promise.all(entries.map(async entry => ({ + ...entry, + name: await resolve(entry.name), + value: await resolve(entry.value), + }))); + }; + return { + authorizationRequest: await resolveEntries(params.authorizationRequest), + accessTokenRequest: await resolveEntries(params.accessTokenRequest), + refreshTokenRequest: await resolveEntries(params.refreshTokenRequest), + }; +} diff --git a/src/services/webSocketClient.ts b/src/services/webSocketClient.ts index 26cdc0a..aa1d698 100644 --- a/src/services/webSocketClient.ts +++ b/src/services/webSocketClient.ts @@ -305,7 +305,7 @@ export class WebSocketClient implements vscode.Disposable { return url; } default: - return url; + throw new Error(`Authentication type "${(auth as any).type ?? 'unknown'}" is not supported for WebSocket requests by the Missio runtime yet.`); } } diff --git a/test/grpcSupport.test.ts b/test/grpcSupport.test.ts index 26b6f50..7248a5a 100644 --- a/test/grpcSupport.test.ts +++ b/test/grpcSupport.test.ts @@ -326,6 +326,22 @@ describe('gRPC execution', () => { }, collection)).rejects.toThrow(/was not found/); }); + it('fails unsupported schema auth loudly', async () => { + const environmentService = makeEnvironmentService(); + const collection = makeCollection(); + const client = new GrpcClient(environmentService); + + await expect(client.send({ + ...makeUnaryRequest(address), + runtime: { auth: { type: 'digest', username: 'u', password: 'p' } as any }, + }, collection)).rejects.toThrow(/digest.*not supported for gRPC/); + + await expect(client.send({ + ...makeUnaryRequest(address), + runtime: { auth: { type: 'apikey', key: 'api_key', value: 'secret', placement: 'query' } }, + }, collection)).rejects.toThrow(/API key query auth is not supported for gRPC/); + }); + it('executes server-streaming calls and returns ordered response events', async () => { const environmentService = makeEnvironmentService(); const collection = makeCollection(); diff --git a/test/httpClient.test.ts b/test/httpClient.test.ts index 77d7964..df7c939 100644 --- a/test/httpClient.test.ts +++ b/test/httpClient.test.ts @@ -1,8 +1,13 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; import * as os from 'os'; import * as path from 'path'; +import * as vscode from 'vscode'; import { HttpClient } from '../src/services/httpClient'; +import { OAuth2Service } from '../src/services/oauth2Service'; +import { exportRequest } from '../src/services/snippetExporter'; import type { AuthOAuth2, MissioCollection } from '../src/models/types'; function makeCollection(): MissioCollection { @@ -32,6 +37,66 @@ function makeOAuth2Auth(): AuthOAuth2 { }; } +async function listen(server: http.Server | https.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Unexpected server address'); + return address.port; +} + +async function close(server: http.Server | https.Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +const TEST_CERT = `-----BEGIN CERTIFICATE----- +MIICzTCCAbWgAwIBAgIIDUFhaZcNjyIwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UE +AxMJbG9jYWxob3N0MB4XDTI2MDYxMzEyMDcyMVoXDTM2MDYxNDEyMDcyMVowFDES +MBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA3hKGbDq1amxdUszvajxU4rKotgpsKnnMOrqiATDFKeVXcZjlqfv8y0uZBVWG +gjgL9b+DQo1FMS7HrhgO78eZxATGW/DrAxpPPR1tC1E0tPOs5ATZntJ+XO/JSrbK +edNjYc+1pKAFn4hyTjuLR5+sGRleg4R4RUNPxTEFraY4FQEd99SadbTSx6li6DTC +kbgBTxE3F/JdqDViaPjO2s8TqutrCRYU4CHBrrNi0pZtTTm7v1SZO2HLHmqKM5AN +g4wSXT6yZpoC6isoupay087cKHd/AdTf1nDGYvlKRLj3gMZ5DZQz4VegvBP4qU6l +d79pd+ipYr7mAtEZuyY31vhNDQIDAQABoyMwITAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwICpDANBgkqhkiG9w0BAQsFAAOCAQEAo/hFkKGTaPqqN9JjH/dA +Zpp5RrMiF2m7IKh8WTFOncbwoImToxKA4KFKJlThLqPw4QSujrggxzc+QM+Tazxl +125tKFKH1ZY0po7KCbYkBEL404JccdD+dDmvU8UEmF+GDMSo1HB2E9y/n46kYRSt +wzR9ksZG2AiWSwgkfzWcO+AKXySY3jpjyHsjD8HEB89XHfAedxOUiZp63DRs4Iu4 +R6TwUvQsVJg9JL/U/znMKxqomqVPZ9u2sVM6u+eceaO3A+Tt6A0QxyIwS1QtJ7o5 +GmcSuy7+TSf5CvEsn5P8G3J47L6EOzvx16qT7VAEW4oenwNxyg8wvL4B033fYYzT +Bw== +-----END CERTIFICATE----- +`; + +const TEST_KEY = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA3hKGbDq1amxdUszvajxU4rKotgpsKnnMOrqiATDFKeVXcZjl +qfv8y0uZBVWGgjgL9b+DQo1FMS7HrhgO78eZxATGW/DrAxpPPR1tC1E0tPOs5ATZ +ntJ+XO/JSrbKedNjYc+1pKAFn4hyTjuLR5+sGRleg4R4RUNPxTEFraY4FQEd99Sa +dbTSx6li6DTCkbgBTxE3F/JdqDViaPjO2s8TqutrCRYU4CHBrrNi0pZtTTm7v1SZ +O2HLHmqKM5ANg4wSXT6yZpoC6isoupay087cKHd/AdTf1nDGYvlKRLj3gMZ5DZQz +4VegvBP4qU6ld79pd+ipYr7mAtEZuyY31vhNDQIDAQABAoIBAAk1v3lxneCCCgTL +FwrS4bpdKn4SRJYmYv/0iY9/FE4+grflXXEFUGCmC/yapW91H5nbjXgPH9WAWSux +N71eC9SDVi6t+TExwCOKuuEDRypSCNOUF+psVG1KTJDar98Jk0+VK7VeJZ2OLR9t +fMNFrf+Ee9T8g3hr6D0HYXLoN983Dt1sY/rTFfLBt4uVdGfuYbF+W/2id9od+mfn +kDWQID54mNCjWVyiBwIVQr1S2zDzliq6m9gRmjFhLWzhSYTVu+R9xywJesnlFwnI +KjsVP6Jv1kJCINrSZJk4HLfRz/uKuc1IaoCcY0a8vufX2ldW1dbknHc6JN5b6Dj+ ++mWwM8kCgYEA6nm0LQwiYJN0ao7pFngT/xoWTXZn+/dxFK4S15wiPL0MLf66I+KD +E9IocUrYClWRPBhWzTjg7xWcejWDvCI7UQOUozwusO/54iEgVbbzsad95i8tFzst +4i89voK7Iz67o/mBmHLKOTfoTJY6C2SemewNDVjBcj3s8/GT2cBW308CgYEA8nVX +3ZGYZrpcT9Hy8vyOkcJxov3M7QgwMUvy8++3B6lpwqtnPmn+B7SVCcaCn6RS3yDL +0upxk5s7Dhps6oQ8J215fZH2o5oiHjSRrT4PFvFLqZREZ+GYd7w8loBjMWAdiwvz +u+x9l3IVqoMXaPw5YG5t2RZdg4uNcN0MreVoluMCgYEAg/9rnQh9udyI5wv4z/td +Vnk7IPSNaV1NPZUZamOtKoBKgQIri9QScnAW8GBv6rFtB2W0R+fDSRTjeDD0Lk8f +EWZwoMxahKU0CUcYyugpnFNsHs9kFPXtyK1LlxpFe3vvakol2MqWaUu97I+Nsag9 +WO14E5FppYSTBmlzEFylCyUCgYAPGgP5BwKJE36AckFBpT10ErplPo2vDd2ClIpz +azDpR0IRH//0QUHTVQoba8PjEacfwrkvT+73FKoe/MJf8RCWHBl/GsJT+lu5qeiQ +89aYxTrDOzrvhXurqYvUi/ahsqzkZkAuKlLARhjXYAbrQRqJyRcKeHwmn2CV8Q7D +HhDfpQKBgQDe7lorOWe0HvHNoCzgHN2DWNKAsOPcQm5W8q6CGzPETv1GxlDFxpA7 +mMXqBgl4GsZ2R9UDaQ29yGFV9sCg2nTp1AR7nqp4550dSi+pcCEgG0+IMmpDcnp0 +y3drEao15oS8HyXU98l8FoYM2pE+6G/JK8YW2fbGGNdYPRuHhlU+0A== +-----END RSA PRIVATE KEY----- +`; + describe('HttpClient OAuth2 environment scoping', () => { it('uses per-request environment override when acquiring OAuth2 token', async () => { const envService = { @@ -80,6 +145,306 @@ describe('HttpClient OAuth2 environment scoping', () => { }); }); +describe('HttpClient OC-050 auth and transport behavior', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('places API key auth in the query string and exports the resolved URL', async () => { + const client = new HttpClient(makeEnvService()); + const resolved = await client.buildResolvedRequest( + { + http: { method: 'GET', url: 'https://example.com/users' }, + runtime: { auth: { type: 'apikey', key: 'api_key', value: 'secret', placement: 'query' } }, + }, + makeCollection(), + ); + + expect(new URL(resolved.url).searchParams.get('api_key')).toBe('secret'); + expect(resolved.headers.api_key).toBeUndefined(); + expect(exportRequest(resolved, 'shell:curl')).toContain('api_key=secret'); + }); + + it('places OAuth2 tokens in configured query parameters', async () => { + const client = new HttpClient(makeEnvService()); + const getToken = vi.fn().mockResolvedValue('oauth-token'); + client.setOAuth2Service({ getToken } as any); + + const resolved = await client.buildResolvedRequest( + { + http: { method: 'GET', url: 'https://example.com/resource' }, + runtime: { + auth: { + ...makeOAuth2Auth(), + tokenConfig: { placement: { query: 'access_token' } }, + }, + }, + }, + makeCollection(), + ); + + expect(new URL(resolved.url).searchParams.get('access_token')).toBe('oauth-token'); + expect(resolved.headers.Authorization).toBeUndefined(); + }); + + it('sends OAuth2 additional token request parameters by header, query, and body placement', async () => { + let observed: { url: string; headers: http.IncomingHttpHeaders; body: string } | undefined; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => { + observed = { + url: req.url ?? '', + headers: req.headers, + body: Buffer.concat(chunks).toString('utf8'), + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'fixture-token', expires_in: 3600 })); + }); + }); + const port = await listen(server); + const secrets = new Map(); + const service = new OAuth2Service({ + get: async key => secrets.get(key), + store: async (key, value) => { secrets.set(key, value); }, + delete: async key => { secrets.delete(key); }, + } as any); + + try { + const token = await service.getToken( + { + type: 'oauth2', + flow: 'client_credentials', + accessTokenUrl: `http://127.0.0.1:${port}/token`, + credentials: { clientId: 'client-id', clientSecret: 'client-secret', placement: 'body' }, + additionalParameters: { + accessTokenRequest: [ + { name: 'X-Tenant', value: 'tenant-a', placement: 'header' }, + { name: 'audience', value: 'missio-api', placement: 'body' }, + { name: 'trace', value: 'query-trace', placement: 'query' }, + ], + }, + }, + 'collection-1', + 'LOCAL', + ); + + expect(token).toBe('fixture-token'); + expect(observed?.headers['x-tenant']).toBe('tenant-a'); + expect(new URL(observed!.url, `http://127.0.0.1:${port}`).searchParams.get('trace')).toBe('query-trace'); + const body = new URLSearchParams(observed?.body); + expect(body.get('client_id')).toBe('client-id'); + expect(body.get('client_secret')).toBe('client-secret'); + expect(body.get('audience')).toBe('missio-api'); + } finally { + await close(server); + } + }); + + it('follows redirects up to maxRedirects', async () => { + const server = http.createServer((req, res) => { + if (req.url === '/start') { + res.writeHead(302, { Location: '/final' }); + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ path: req.url, method: req.method })); + }); + const port = await listen(server); + + try { + const client = new HttpClient(makeEnvService()); + const response = await client.send( + { + http: { method: 'POST', url: `http://127.0.0.1:${port}/start`, body: { type: 'text', data: 'payload' } }, + settings: { followRedirects: true, maxRedirects: 2 }, + }, + makeCollection(), + ); + + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ path: '/final', method: 'GET' }); + } finally { + await close(server); + } + }); + + it('fails clearly when redirects exceed maxRedirects', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(302, { Location: '/loop' }); + res.end(); + }); + const port = await listen(server); + + try { + const client = new HttpClient(makeEnvService()); + await expect(client.send( + { + http: { method: 'GET', url: `http://127.0.0.1:${port}/start` }, + settings: { followRedirects: true, maxRedirects: 1 }, + }, + makeCollection(), + )).rejects.toThrow(/maxRedirects \(1\)/); + } finally { + await close(server); + } + }); + + it('routes HTTP requests through a configured proxy with basic proxy auth', async () => { + const proxy = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + url: req.url, + proxyAuth: req.headers['proxy-authorization'], + })); + }); + const proxyPort = await listen(proxy); + + try { + const client = new HttpClient(makeEnvService()); + const collection = makeCollection(); + collection.data.config = { + proxy: { + enabled: true, + config: { + protocol: 'http', + hostname: '127.0.0.1', + port: proxyPort, + auth: { username: 'proxy-user', password: 'proxy-pass' }, + }, + }, + } as any; + + const response = await client.send( + { http: { method: 'GET', url: 'http://upstream.example.test/items?x=1' } }, + collection, + ); + const body = JSON.parse(response.body); + + expect(body.url).toBe('http://upstream.example.test/items?x=1'); + expect(body.proxyAuth).toBe(`Basic ${Buffer.from('proxy-user:proxy-pass').toString('base64')}`); + } finally { + await close(proxy); + } + }); + + it('honors proxy bypass rules', async () => { + const target = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ direct: true })); + }); + const targetPort = await listen(target); + + try { + const client = new HttpClient(makeEnvService()); + const collection = makeCollection(); + collection.data.config = { + proxy: { + enabled: true, + config: { + protocol: 'http', + hostname: '127.0.0.1', + port: 9, + bypassProxy: '127.0.0.1', + }, + }, + } as any; + + const response = await client.send( + { http: { method: 'GET', url: `http://127.0.0.1:${targetPort}/health` } }, + collection, + ); + + expect(JSON.parse(response.body)).toEqual({ direct: true }); + } finally { + await close(target); + } + }); + + it('presents matching collection client certificates for mTLS requests', async () => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + get: (key: string, defaultValue: unknown) => key === 'rejectUnauthorized' ? false : defaultValue, + } as any); + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'missio-mtls-')); + fs.writeFileSync(path.join(rootDir, 'client.crt'), TEST_CERT); + fs.writeFileSync(path.join(rootDir, 'client.key'), TEST_KEY); + + const server = https.createServer( + { key: TEST_KEY, cert: TEST_CERT, ca: TEST_CERT, requestCert: true, rejectUnauthorized: true }, + (req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + authorized: req.client.authorized, + cn: req.socket.getPeerCertificate().subject?.CN, + })); + }, + ); + const port = await listen(server); + + try { + const client = new HttpClient(makeEnvService()); + const collection = makeFileCollection(rootDir); + collection.data.config = { + clientCertificates: [ + { + domain: '127.0.0.1', + type: 'pem', + certificateFilePath: 'client.crt', + privateKeyFilePath: 'client.key', + }, + ], + } as any; + + const response = await client.send( + { http: { method: 'GET', url: `https://127.0.0.1:${port}/secure` } }, + collection, + ); + + expect(JSON.parse(response.body)).toEqual({ authorized: true, cn: 'localhost' }); + } finally { + await close(server); + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('fails mTLS requests when no matching client certificate is configured', async () => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + get: (key: string, defaultValue: unknown) => key === 'rejectUnauthorized' ? false : defaultValue, + } as any); + const server = https.createServer( + { key: TEST_KEY, cert: TEST_CERT, ca: TEST_CERT, requestCert: true, rejectUnauthorized: true }, + (_req, res) => { + res.writeHead(200); + res.end('should not succeed'); + }, + ); + const port = await listen(server); + + try { + const client = new HttpClient(makeEnvService()); + await expect(client.send( + { http: { method: 'GET', url: `https://127.0.0.1:${port}/secure` } }, + makeCollection(), + )).rejects.toThrow(); + } finally { + await close(server); + } + }); + + it('fails unsupported schema auth loudly', async () => { + const client = new HttpClient(makeEnvService()); + + await expect(client.buildResolvedRequest( + { + http: { method: 'GET', url: 'https://example.com' }, + runtime: { auth: { type: 'digest', username: 'u', password: 'p' } }, + }, + makeCollection(), + )).rejects.toThrow(/digest.*not supported/); + }); +}); + describe('HttpClient CLI cache expiry', () => { it('expires long-lived tokens 60 seconds early', () => { const client = new HttpClient({} as any); @@ -120,6 +485,7 @@ function makeEnvService() { return { resolveVariables: vi.fn().mockResolvedValue(new Map()), interpolate: (v: string) => v, + getActiveEnvironmentName: () => undefined, } as any; } From 4bcd234e21451fe9ddc0f5bbb9e323276bec408d Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 00:26:51 +1200 Subject: [PATCH 2/3] Record OC-050 completion --- .../AGENT_PROGRESS.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 17195fb..7f07e59 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -62,11 +62,11 @@ Use full branch names for stacking existing branches with `but move