Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
3 changed files with 69131 additions and 9001 deletions
|
|
@ -397,4 +397,78 @@ describe('Authorization Handler', () => {
|
|||
expect(location.searchParams.has('code')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RFC 9207 iss parameter', () => {
|
||||
const ISSUER = 'https://auth.example.com/';
|
||||
let issApp: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
issApp = express();
|
||||
issApp.use('/authorize', authorizationHandler({ provider: mockProvider, issuerUrl: new URL(ISSUER) }));
|
||||
});
|
||||
|
||||
it("appends iss to the provider's success redirect and supplies issuer to provider.authorize()", async () => {
|
||||
// mockProvider.authorize() does NOT set `iss` itself — the handler must add it.
|
||||
const spy = vi.spyOn(mockProvider, 'authorize');
|
||||
const response = await supertest(issApp).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location!);
|
||||
expect(location.searchParams.get('code')).toBe('mock_auth_code');
|
||||
expect(location.searchParams.get('iss')).toBe(ISSUER);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ issuer: ISSUER }), expect.anything());
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('leaves redirects to non-callback targets untouched', async () => {
|
||||
// A provider that hops to an upstream authorize endpoint (proxy pattern) — the
|
||||
// handler must not append `iss` to that hop.
|
||||
const upstream = 'https://upstream.example.com/authorize?client_id=x';
|
||||
const proxyLike: OAuthServerProvider = {
|
||||
...mockProvider,
|
||||
async authorize(_client, _params, res) {
|
||||
res.redirect(upstream);
|
||||
}
|
||||
};
|
||||
const proxyApp = express();
|
||||
proxyApp.use('/authorize', authorizationHandler({ provider: proxyLike, issuerUrl: new URL(ISSUER) }));
|
||||
const response = await supertest(proxyApp).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.header.location).toBe(upstream);
|
||||
});
|
||||
|
||||
it('appends iss to error redirects', async () => {
|
||||
const response = await supertest(issApp).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'token' // invalid → error redirect
|
||||
});
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location!);
|
||||
expect(location.searchParams.get('error')).toBe('invalid_request');
|
||||
expect(location.searchParams.get('iss')).toBe(ISSUER);
|
||||
});
|
||||
|
||||
it('omits iss when issuerUrl is not configured', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'token'
|
||||
});
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location!);
|
||||
expect(location.searchParams.has('iss')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -106,6 +106,14 @@ describe('Proxy OAuth Server Provider', () => {
|
|||
|
||||
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
|
||||
});
|
||||
|
||||
it('reports authorizationResponseIssParameterSupported = false (upstream issues the callback)', () => {
|
||||
// The proxy cannot guarantee its own `iss` on the callback, so the metadata flag
|
||||
// derived from the provider must be false — otherwise RFC 9207 clients reject any
|
||||
// callback that arrives *without* `iss`. (A present upstream `iss` still mismatches
|
||||
// the proxy issuer regardless of this flag.)
|
||||
expect(provider.authorizationResponseIssParameterSupported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token exchange', () => {
|
||||
|
|
|
|||
|
|
@ -218,6 +218,22 @@ describe('MCP Auth Router', () => {
|
|||
|
||||
// Verify optional fields
|
||||
expect(response.body.service_documentation).toBe('https://docs.example.com/');
|
||||
|
||||
// RFC 9207: the bundled authorize handler emits `iss`, so the metadata advertises it.
|
||||
expect(response.body.authorization_response_iss_parameter_supported).toBe(true);
|
||||
});
|
||||
|
||||
it('derives authorization_response_iss_parameter_supported from the provider', async () => {
|
||||
const optOutApp = express();
|
||||
optOutApp.use(
|
||||
mcpAuthRouter({
|
||||
provider: { ...mockProvider, authorizationResponseIssParameterSupported: false },
|
||||
issuerUrl: new URL('https://auth.example.com')
|
||||
})
|
||||
);
|
||||
const response = await supertest(optOutApp).get('/.well-known/oauth-authorization-server');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.authorization_response_iss_parameter_supported).toBe(false);
|
||||
});
|
||||
|
||||
it('returns minimal metadata for minimal router', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue