From 0c978a2657e5e64079f82c6a6db7a410c4a37846 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Mon, 10 Aug 2026 20:43:02 +0530 Subject: [PATCH] feat: support the RFC 8707 resource indicator in authorize() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getToken() already forwarded `resource`, but authorize() had no way to pass one, so a browser client could not obtain a resource-bound token at all — and Authorizer's own MCP endpoint accepts nothing else. `resource` is now sent on BOTH halves of the code flow. Both are required: the /authorize call is what binds the audience to the authorization code, and the exchange must echo the same value because the token endpoint rejects a code exchange whose resource does not match the authorization request. Sending it in only one place yields either an unbound token or a rejected exchange. Omitted entirely when the caller does not ask for one, so every existing integration keeps the client as its audience — an empty `resource` would be rejected as an invalid target. Not sent on refresh: the server carries the binding across rotation itself, and supplying a stale or guessed value turns a working refresh into invalid_target. Tests mock window.fetch rather than cross-fetch (getFetcher resolves to window.fetch whenever a window exists, so mocking cross-fetch would have left the real fetch in place and asserted an empty call list), run under jsdom with WebCrypto and TextEncoder polyfilled (authorize() is browser-only and derives the PKCE challenge with crypto.subtle, so under the default node environment both tests would have passed vacuously), and were verified to fail when the change is reverted. Full suite: 11 suites, 103 tests passing. --- __test__/resourceIndicator.test.ts | 106 +++++++++++++++++++++++++++++ src/index.ts | 9 +++ src/types.ts | 12 ++++ 3 files changed, 127 insertions(+) create mode 100644 __test__/resourceIndicator.test.ts diff --git a/__test__/resourceIndicator.test.ts b/__test__/resourceIndicator.test.ts new file mode 100644 index 0000000..871d67e --- /dev/null +++ b/__test__/resourceIndicator.test.ts @@ -0,0 +1,106 @@ +/** + * @jest-environment jsdom + */ +// Unit tests (no docker) for the RFC 8707 resource indicator on the browser +// authorization flow. cross-fetch and the iframe helper are mocked so the exact +// outgoing request can be asserted. +import nodeCrypto from 'node:crypto'; +import nodeUtil from 'node:util'; +import { Authorizer, ResponseTypes } from '../src'; +import { executeIframe } from '../src/utils'; + +// Only executeIframe is replaced; the rest of utils (PKCE helpers, encoders) +// must keep working or the authorize() path under test never runs. +jest.mock('../src/utils', () => ({ + ...jest.requireActual('../src/utils'), + executeIframe: jest.fn(), +})); + +// jsdom ships neither WebCrypto nor TextEncoder, and authorize() needs both to +// derive the PKCE code_challenge. Without them the flow throws before it ever +// builds a URL, and both tests below would fail for a reason unrelated to what +// they assert. +Object.defineProperty(globalThis, 'crypto', { + value: nodeCrypto.webcrypto, + configurable: true, +}); +Object.defineProperty(globalThis, 'TextEncoder', { + value: nodeUtil.TextEncoder, + configurable: true, +}); + +// getFetcher() resolves to window.fetch whenever a window exists, so mocking +// cross-fetch would leave the real fetch in place and every assertion on the +// token request would read an empty call list. +const fetchMock = jest.fn(); +Object.defineProperty(globalThis, 'fetch', { value: fetchMock, configurable: true }); +Object.defineProperty(window, 'fetch', { value: fetchMock, configurable: true }); + +const iframeMock = executeIframe as unknown as jest.Mock; + +const MCP_RESOURCE = 'http://localhost:8080/mcp'; + +function newAuthorizer() { + return new Authorizer({ + authorizerURL: 'http://localhost:8080', + redirectURL: 'http://localhost:8080/app', + clientID: 'test-client-id', + }); +} + +function tokenRequestBody(): Record { + const [, init] = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + return Object.fromEntries(new URLSearchParams(init.body as string)); +} + +beforeEach(() => { + fetchMock.mockReset(); + iframeMock.mockReset(); +}); + +describe('authorize — RFC 8707 resource indicator', () => { + it('sends resource on BOTH the authorization request and the code exchange', async () => { + // Both halves matter. The /authorize call is what binds the audience to the + // authorization code; the exchange must then echo the same value, because + // the token endpoint rejects a code exchange whose resource does not match + // the one the authorization request named. Sending it in only one place + // yields either an unbound token or a rejected exchange. + iframeMock.mockResolvedValueOnce({ code: 'the-code', state: 'st' }); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ access_token: 'at', expires_in: 900 }), + }); + + await newAuthorizer().authorize({ + response_type: ResponseTypes.Code, + resource: MCP_RESOURCE, + }); + + const authorizeURL = new URL(iframeMock.mock.calls[0][0]); + expect(authorizeURL.pathname).toBe('/authorize'); + expect(authorizeURL.searchParams.get('resource')).toBe(MCP_RESOURCE); + // Sanity that the assertion reads the right URL: PKCE is known to be there. + expect(authorizeURL.searchParams.get('code_challenge_method')).toBe('S256'); + + expect(tokenRequestBody().resource).toBe(MCP_RESOURCE); + }); + + it('omits resource entirely when the caller did not ask for one', async () => { + // The regression guard for every existing integration: an ordinary login + // must keep the client as its audience. Sending an empty `resource` would + // be rejected by the server as an invalid target, breaking all of them. + iframeMock.mockResolvedValueOnce({ code: 'the-code', state: 'st' }); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ access_token: 'at', expires_in: 900 }), + }); + + await newAuthorizer().authorize({ response_type: ResponseTypes.Code }); + + const authorizeURL = new URL(iframeMock.mock.calls[0][0]); + expect(authorizeURL.searchParams.has('resource')).toBe(false); + expect(tokenRequestBody()).not.toHaveProperty('resource'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 98d1889..adf6c90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -142,6 +142,11 @@ export class Authorizer { requestData.code_challenge_method = 'S256'; } + // RFC 8707 resource indicator. Sent only when the caller asked for one: + // omitting it leaves the audience as the client, which is what every + // ordinary login wants. + if (data.resource) requestData.resource = data.resource; + const authorizeURL = `${ this.config.authorizerURL }/authorize?${createQueryParams(requestData)}`; @@ -160,9 +165,13 @@ export class Authorizer { if (data.response_type === Types.ResponseTypes.Code) { // get token and return it + // `resource` must be echoed on the exchange: the token endpoint binds + // it to the authorization code and rejects a code exchange whose + // resource does not match the one the /authorize request named. const tokenResp: Types.ApiResponse = await this.getToken({ code: iframeRes.code, + ...(data.resource ? { resource: data.resource } : {}), }); return tokenResp.errors.length ? this.errorResponse(tokenResp.errors) diff --git a/src/types.ts b/src/types.ts index 888cb3e..f34265e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -513,6 +513,18 @@ export interface AuthorizeRequest { response_type: ResponseTypes; use_refresh_token?: boolean; response_mode?: string; + /** + * RFC 8707 resource indicator: the resource server this token is for. + * + * When set it is sent on BOTH the authorization request and the code + * exchange, and the issued access token's `aud` becomes this value, so the + * token is usable only at that resource server. Authorizer's own MCP endpoint + * requires it — pass `/mcp`. + * + * Must be an absolute URI with no fragment. Omit it for ordinary logins, + * where the audience is the client. + */ + resource?: string; } // Keep AuthorizeInput as alias for backward compatibility