Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export class OAuthClient {
rs: URL,
refresh: string
): Promise<Token | undefined> {
this.validateTokenEndpoint(meta.token_endpoint)
const formParams: Record<string, string> = {
grant_type: 'refresh_token',
refresh_token: refresh,
Expand Down Expand Up @@ -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<string, string> = {
grant_type: 'authorization_code',
code,
Expand Down Expand Up @@ -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<void> {
return new Promise((resolve, reject) => {
Expand Down
Loading