diff --git a/dato-agent/src/entrypoints/AgentFrame.test.tsx b/dato-agent/src/entrypoints/AgentFrame.test.tsx index 03f8cf89..0d1ce536 100644 --- a/dato-agent/src/entrypoints/AgentFrame.test.tsx +++ b/dato-agent/src/entrypoints/AgentFrame.test.tsx @@ -698,11 +698,11 @@ describe('AgentFrame', () => { clientId: `registered-${mocks.registerClient.mock.calls.length}`, clientIdIssuedAt: 1, redirectUri, - registrationVersion: 2, + registrationVersion: 3, })); mocks.authorization.mockReset().mockImplementation(async () => ({ state: `state-${mocks.authorization.mock.calls.length}`, - authorizationUrl: 'https://mcp.datocms.com/authorize', + authorizationUrl: 'https://oauth.datocms.com/oauth/authorize', expiresAt: Date.now() + 300_000, })); mocks.callback @@ -8217,7 +8217,7 @@ describe('AgentFrame', () => { await mocks.surfaceProps?.onConnectDatoCms?.(); }); expect(mocks.registerClient).toHaveBeenCalledOnce(); - expect(store.load()?.credentials.client.registrationVersion).toBe(2); + expect(store.load()?.credentials.client.registrationVersion).toBe(3); await act(async () => { await mocks.surfaceProps?.onConnectDatoCms?.(); }); diff --git a/dato-agent/src/lib/credentials.test.ts b/dato-agent/src/lib/credentials.test.ts index f5bb25c9..06263305 100644 --- a/dato-agent/src/lib/credentials.test.ts +++ b/dato-agent/src/lib/credentials.test.ts @@ -157,7 +157,7 @@ describe('client registration lifecycle', () => { credentials.token, ), ); - expect(store.load()?.credentials.client.registrationVersion).toBe(2); + expect(store.load()?.credentials.client.registrationVersion).toBe(3); }); it('invalidates only the matching token and preserves its registration', () => { diff --git a/dato-agent/src/lib/credentials.ts b/dato-agent/src/lib/credentials.ts index 9497743f..de778287 100644 --- a/dato-agent/src/lib/credentials.ts +++ b/dato-agent/src/lib/credentials.ts @@ -1,5 +1,5 @@ export const OAUTH_CREDENTIALS_VERSION = 1 as const; -export const OAUTH_CLIENT_REGISTRATION_VERSION = 2 as const; +export const OAUTH_CLIENT_REGISTRATION_VERSION = 3 as const; const CREDENTIALS_KEY_PREFIX = 'dato-agent.oauth.credentials.v1'; diff --git a/dato-agent/src/lib/oauth.test.ts b/dato-agent/src/lib/oauth.test.ts index d8708344..0da9c20a 100644 --- a/dato-agent/src/lib/oauth.test.ts +++ b/dato-agent/src/lib/oauth.test.ts @@ -6,6 +6,10 @@ import { exchangeAuthorizationCode, type FetchFunction, MCP_BASE_URL, + MCP_RESOURCE, + navigateOAuthPopup, + OAUTH_BASE_URL, + OAUTH_ORIGIN, OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupMessage, openOAuthPopup, @@ -67,13 +71,13 @@ describe('registerClient', () => { expect(client).toEqual({ clientId: 'mcp_public_client', - registrationVersion: 2, + registrationVersion: 3, clientIdIssuedAt: 123, redirectUri: 'https://plugin.example/callback', }); expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe(`${MCP_BASE_URL}/register`); + expect(url).toBe(`${OAUTH_BASE_URL}/register`); expect(JSON.parse(String(init?.body))).toMatchObject({ redirect_uris: ['https://plugin.example/callback'], grant_types: ['authorization_code'], @@ -113,8 +117,8 @@ describe('authorization request and token exchange', () => { ); const url = new URL(request.authorizationUrl); - expect(url.origin).toBe(MCP_BASE_URL); - expect(url.pathname).toBe('/authorize'); + expect(url.origin).toBe(OAUTH_ORIGIN); + expect(url.pathname).toBe('/oauth/authorize'); expect(url.searchParams.get('code_challenge_method')).toBe('S256'); expect(request.state).toHaveLength(43); expect(request.expiresAt).toBe(1_000 + PENDING_AUTHORIZATION_TTL_MS); @@ -135,8 +139,8 @@ describe('authorization request and token exchange', () => { const [, tokenInit] = fetchMock.mock.calls[0]; const body = new URLSearchParams(String(tokenInit?.body)); - expect(url.searchParams.get('resource')).toBe(MCP_BASE_URL); - expect(body.get('resource')).toBe(MCP_BASE_URL); + expect(url.searchParams.get('resource')).toBe(MCP_RESOURCE); + expect(body.get('resource')).toBe(MCP_RESOURCE); const verifier = body.get('code_verifier'); expect(verifier).toHaveLength(43); const expectedChallenge = await sha256Base64Url(String(verifier)); @@ -199,7 +203,7 @@ describe('authorization request and token exchange', () => { }); const [url, init] = fetchMock.mock.calls[0]; const body = new URLSearchParams(String(init?.body)); - expect(url).toBe(`${MCP_BASE_URL}/token`); + expect(url).toBe(`${OAUTH_BASE_URL}/token`); expect(Object.fromEntries(body)).toMatchObject({ grant_type: 'authorization_code', code: 'one_time_code', @@ -265,6 +269,22 @@ describe('OAuth popup callback', () => { expect(parseOAuthCallback('https://plugin.example/callback')).toBeNull(); }); + it('navigates the popup only to the DatoCMS authorization endpoint', () => { + const assign = vi.fn(); + const popup = { location: { assign } } as unknown as Window; + const authorizationUrl = `${OAUTH_ORIGIN}/oauth/authorize?client_id=x`; + + navigateOAuthPopup(popup, authorizationUrl); + expect(assign).toHaveBeenCalledWith(authorizationUrl); + + expect(() => + navigateOAuthPopup(popup, `${MCP_BASE_URL}/authorize?client_id=x`), + ).toThrow('Invalid OAuth authorization URL'); + expect(() => + navigateOAuthPopup(popup, `${OAUTH_ORIGIN}/authorize?client_id=x`), + ).toThrow('Invalid OAuth authorization URL'); + }); + it('posts callback data only to the callback origin', () => { const postMessage = vi.fn(); @@ -375,7 +395,7 @@ describe('revokeToken', () => { ); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe(`${MCP_BASE_URL}/revoke`); + expect(url).toBe(`${OAUTH_BASE_URL}/revoke`); expect(Object.fromEntries(new URLSearchParams(String(init?.body)))).toEqual( { token: 'dato_access_token', diff --git a/dato-agent/src/lib/oauth.ts b/dato-agent/src/lib/oauth.ts index 0f84fec0..7dd12659 100644 --- a/dato-agent/src/lib/oauth.ts +++ b/dato-agent/src/lib/oauth.ts @@ -6,6 +6,19 @@ import type { import { OAUTH_CLIENT_REGISTRATION_VERSION } from './credentials'; export const MCP_BASE_URL = 'https://mcp.datocms.com'; +/** + * RFC 8707 resource indicator, exactly as advertised (with trailing slash) by + * https://mcp.datocms.com/.well-known/oauth-protected-resource. + */ +export const MCP_RESOURCE = `${MCP_BASE_URL}/`; +/** + * Authorization server advertised by + * https://mcp.datocms.com/.well-known/oauth-protected-resource. The legacy + * mcp.datocms.com/{register,authorize,token,revoke} routes reject new clients. + */ +export const OAUTH_ORIGIN = 'https://oauth.datocms.com'; +export const OAUTH_BASE_URL = `${OAUTH_ORIGIN}/oauth`; +const OAUTH_AUTHORIZE_PATH = '/oauth/authorize'; export const OAUTH_SCOPES = 'read_account read_sites read_organizations'; export const OAUTH_POPUP_MESSAGE_TYPE = 'dato-agent.oauth.callback.v1'; export const PENDING_AUTHORIZATION_TTL_MS = 5 * 60 * 1000; @@ -159,7 +172,7 @@ export async function registerClient( ): Promise { const cleanRedirectUri = validateRedirectUri(redirectUri); const fetchFunction = options.fetch ?? getBrowserFetch(); - const response = await fetchFunction(`${MCP_BASE_URL}/register`, { + const response = await fetchFunction(`${OAUTH_BASE_URL}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -234,7 +247,7 @@ export async function createAuthorizationRequest( const params = new URLSearchParams({ client_id: args.clientId, response_type: 'code', - resource: MCP_BASE_URL, + resource: MCP_RESOURCE, redirect_uri: redirectUri, code_challenge: codeChallenge, code_challenge_method: 'S256', @@ -243,7 +256,7 @@ export async function createAuthorizationRequest( }); return { - authorizationUrl: `${MCP_BASE_URL}/authorize?${params.toString()}`, + authorizationUrl: `${OAUTH_ORIGIN}${OAUTH_AUTHORIZE_PATH}?${params.toString()}`, state, expiresAt: pending.expiresAt, }; @@ -275,7 +288,7 @@ export function navigateOAuthPopup( authorizationUrl: string, ): void { const url = new URL(authorizationUrl); - if (url.origin !== MCP_BASE_URL || url.pathname !== '/authorize') { + if (url.origin !== OAUTH_ORIGIN || url.pathname !== OAUTH_AUTHORIZE_PATH) { throw new RemoteMcpOAuthError('Invalid OAuth authorization URL'); } popup.location.assign(url.href); @@ -484,14 +497,14 @@ export async function exchangeAuthorizationCode( }); const body = new URLSearchParams({ grant_type: 'authorization_code', - resource: MCP_BASE_URL, + resource: MCP_RESOURCE, code: args.code, client_id: pending.clientId, code_verifier: pending.codeVerifier, redirect_uri: pending.redirectUri, }); const response = await (options.fetch ?? getBrowserFetch())( - `${MCP_BASE_URL}/token`, + `${OAUTH_BASE_URL}/token`, { method: 'POST', headers: { @@ -542,7 +555,7 @@ export async function revokeToken( } const response = await (options.fetch ?? getBrowserFetch())( - `${MCP_BASE_URL}/revoke`, + `${OAUTH_BASE_URL}/revoke`, { method: 'POST', headers: {