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