diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts index 75b24cf8b1..5e7d582496 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts @@ -92,8 +92,63 @@ describe('OAuthClient helpers', () => { const actual = (OAuthClient as any).b64url(buf) expect(actual).to.equal('aGVsbG8') }) + + describe('validateTokenEndpoint()', () => { + const validate = (endpoint: string) => (OAuthClient as any).validateTokenEndpoint(endpoint) + + for (const endpoint of [ + 'https://auth.example.com/token', + 'http://localhost:8080/token', + 'http://127.0.0.1/token', + 'http://127.0.0.2/token', + 'http://[::1]/token', + ]) { + it(`allows ${endpoint}`, () => { + expect(() => validate(endpoint)).not.to.throw() + }) + } + + for (const endpoint of [ + 'http://auth.example.com/token', + 'http://localhost.example.com/token', + 'ftp://auth.example.com/token', + 'not a url', + ]) { + it(`rejects ${endpoint}`, () => { + expect(() => validate(endpoint)).to.throw() + }) + } + }) }) +describe('OAuthClient.refreshGrant()', () => { + let fetchStub: sinon.SinonStub + + beforeEach(() => { + sinon.restore() + OAuthClient.initialize(fakeWorkspace, fakeLogger as any, fakeLsp) + fetchStub = sinon.stub(OAuthClient as any, 'fetchCompat') + }) + + afterEach(() => sinon.restore()) + + it('rejects a non-loopback HTTP endpoint before exchanging credentials', async () => { + const meta = { + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'http://auth.example.com/token', + } + const reg = { client_id: 'client-id', client_secret: 'client-secret' } + + try { + await (OAuthClient as any).refreshGrant(meta, reg, new URL('https://mcp.example.com/mcp'), 'refresh-token') + expect.fail('should have thrown') + } catch (e: any) { + expect(e.message).to.include('token endpoint must use HTTPS') + } + + expect(fetchStub.called).to.be.false + }) +}) describe('OAuthClient.selectAuthMethod()', () => { const selectAuthMethod = (reg: any, meta?: any) => (OAuthClient as any).selectAuthMethod(reg, meta) @@ -460,6 +515,33 @@ describe('OAuthClient getValidAccessToken()', () => { expect(token).to.be.undefined }) + it('does not send an expired refresh token to a non-loopback HTTP endpoint', async () => { + const expiredToken = { + access_token: 'expired', + expires_in: 1, + refresh_token: 'refresh-token', + obtained_at: now - 10_000, + } + const cachedReg = { + client_id: 'cid', + client_secret: 'csecret', + redirect_uri: 'http://localhost:12345/oauth/callback', + } + stubFileSystem(expiredToken, cachedReg) + sinon.stub(OAuthClient as any, 'discoverAS').resolves({ + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'http://auth.example.com/token', + }) + const fetchStub = sinon.stub(OAuthClient as any, 'fetchCompat') + + const token = await OAuthClient.getValidAccessToken(new URL('https://api.example.com/mcp'), { + interactive: false, + }) + + expect(token).to.be.undefined + expect(fetchStub.called).to.be.false + }) + it('uses scopes from discovery metadata when available', async () => { const expiredToken = { access_token: 'expired', diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts index 62f84606c9..9c9024bbc7 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts @@ -347,6 +347,7 @@ export class OAuthClient { rs: URL, refresh: string ): Promise { + this.validateTokenEndpoint(meta.token_endpoint) const formParams: Record = { grant_type: 'refresh_token', refresh_token: refresh, @@ -423,6 +424,7 @@ export class OAuthClient { if (!code || rxState !== state) throw new Error('Invalid authorization response (state mismatch)') // Exchange code for token using the auth method from DCR + this.validateTokenEndpoint(meta.token_endpoint) const tokenParams: Record = { grant_type: 'authorization_code', code, @@ -539,6 +541,33 @@ export class OAuthClient { } } + /** + * Token exchanges carry refresh tokens, authorization codes, and potentially client credentials. + * Require HTTPS for remote endpoints while retaining the OAuth loopback exception for local development. + */ + private static validateTokenEndpoint(endpoint: string): void { + let url: URL + try { + url = new URL(endpoint) + } catch { + throw new Error('OAuth: token endpoint is not a valid URL') + } + + if (url.protocol === 'https:') { + return + } + + const hostname = url.hostname.toLowerCase() + const isLoopbackHttp = + url.protocol === 'http:' && + (hostname === 'localhost' || hostname === '[::1]' || hostname.startsWith('127.')) + if (isLoopbackHttp) { + return + } + + throw new Error('OAuth: token endpoint must use HTTPS unless it is a loopback address') + } + /** Await server.listen() with error rejection for immediate handling. */ private static listen(server: http.Server, port: number, host: string = 'localhost'): Promise { return new Promise((resolve, reject) => {