Skip to content
Merged
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
6 changes: 3 additions & 3 deletions dato-agent/src/entrypoints/AgentFrame.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?.();
});
Expand Down
2 changes: 1 addition & 1 deletion dato-agent/src/lib/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion dato-agent/src/lib/credentials.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
36 changes: 28 additions & 8 deletions dato-agent/src/lib/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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',
Expand Down
27 changes: 20 additions & 7 deletions dato-agent/src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -159,7 +172,7 @@ export async function registerClient(
): Promise<OAuthClientCredentials> {
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({
Expand Down Expand Up @@ -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',
Expand All @@ -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,
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down