Skip to content

Commit 6db50a5

Browse files
author
Matteo Morelli
authored
Stop Test connection from spending OAuth2 refresh tokens (#916)
* Stop Test connection from spending OAuth2 refresh tokens Test connection, which the dashboard runs for every connector on each load, refreshed OAuth2 tokens without the connector's id. The new tokens were never saved, so a provider that rotates refresh tokens (JTL, Sage, DATEV) revoked the one the connector kept, and its next refresh failed with "Token has been revoked". The caller only saw the API's bare 401. - Test connection and MCP tool discovery pass the connector id, so a refresh there is saved like any other. - One OAuth2TokenService for the app. ConnectorsModule had its own instance, with its own cache and refresh mutex. - A config without an id is keyed on its credentials, not on the token URL, which every workspace using the provider shares. - The retry after a 401 goes through the refresh mutex and reuses a token obtained after the request was sent, instead of refreshing once per failed call. - When that renewal fails, the error says what the token endpoint answered. - Tokens are renewed at half their lifetime when that is under five minutes: Sage's 5-minute tokens were refreshed on every call. - Storing a new authorization drops the cached token and last error. - Sage Business Cloud can be authorized with the provider; the refresh token becomes optional. * Key unsaved OAuth2 configs by object, not by a hash of their secrets CodeQL read the SHA-256 of the client secret as an insecure password hash. The key only has to keep one config's token away from another's, and the config object already does that without touching the secret.
1 parent 5e5bf11 commit 6db50a5

14 files changed

Lines changed: 426 additions & 34 deletions

‎packages/backend/src/adapters/intl/sage-business-cloud.json‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,31 @@
22
"slug": "sage-business-cloud",
33
"name": "Sage Business Cloud Accounting",
44
"description": "Manage Sage Business Cloud Accounting (contacts, products, invoices, bank accounts, transactions) from any AI agent. 9 tools, OAuth2 auth.",
5-
"instructions": "This connector wraps the Sage Business Cloud Accounting API v3.1 (api.accounting.sage.com).\n\n**Setup — OAuth2**:\n1. Register at https://developer.sage.com/accounting → **Create app**. Set callback URL.\n2. Complete the auth flow at `https://www.sageone.com/oauth2/auth/central?response_type=code&client_id=...&redirect_uri=...&scope=full_access&country=gb` (use the country code matching your Sage subscription — gb, us, ca, de, fr, ie, es).\n3. Exchange the code at `https://oauth.accounting.sage.com/token`.\n4. Set `SAGE_CLIENT_ID`, `SAGE_CLIENT_SECRET`, `SAGE_REFRESH_TOKEN`.\n\n**Authentication**: OAuth2 — engine handles refresh. Sends `Authorization: Bearer ${ACCESS_TOKEN}`.\n\n**Business ID header**: Sage scopes everything to a business. Pass `X-Business: <BUSINESS_ID>` header on each call OR rely on the default business of the user. To discover IDs: `GET /v3.1/businesses`.\n\n**Pagination**: `page` (1-based) + `items_per_page` (max 200).\n\n**Rate limits**: 100 req/min per token, 10k/day. 429 with `Retry-After`.\n\n**Out of scope here**: VAT return submission, Stripe-connect setup, multi-currency journal lines beyond standard, asset register.",
5+
"instructions": "This connector wraps the Sage Business Cloud Accounting API v3.1 (api.accounting.sage.com). You authorize it once, in AnythingMCP; from then on the access token (5 minutes) is renewed automatically, and the refresh token Sage replaces on every renewal is stored for you.\n\n**Setup — OAuth2**:\n1. Register at https://developer.sage.com/accounting → **Create app**.\n2. In the app, add this **callback URL**: `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `<your AnythingMCP server URL>/api/mcp-oauth/callback` when you host AnythingMCP yourself.\n3. Enter the app's client ID and client secret as `SAGE_CLIENT_ID` and `SAGE_CLIENT_SECRET`, leave `SAGE_REFRESH_TOKEN` empty, and install.\n4. Open the connector and click **Authorize with Provider**. Sign in to Sage, pick the business, approve, and you land back on the connector page with the tools ready.\n\n**Scopes**: the connector asks for `full_access`, which `sage_create_contact` needs. For a read-only connector, set the scope to `readonly` in the connector's OAuth settings before you authorize.\n\n**Already have a refresh token?** You can paste it into `SAGE_REFRESH_TOKEN` instead of step 4. Sage replaces the refresh token every time it is used, so paste one you have not used yet: a token you already exchanged by hand (for example with curl to test it) is refused with `invalid_grant`. The token Sage returned from that exchange is the one to paste.\n\n**Authentication**: OAuth2 — engine handles refresh. Sends `Authorization: Bearer ${ACCESS_TOKEN}`.\n\n**Business ID header**: Sage scopes everything to a business. Pass `X-Business: <BUSINESS_ID>` header on each call OR rely on the default business of the user. To discover IDs: `GET /v3.1/businesses`.\n\n**Pagination**: `page` (1-based) + `items_per_page` (max 200).\n\n**Rate limits**: 100 req/min per token, 10k/day. 429 with `Retry-After`.\n\n**Out of scope here**: VAT return submission, Stripe-connect setup, multi-currency journal lines beyond standard, asset register.",
66
"region": "intl",
77
"category": "accounting",
88
"icon": "sage-business-cloud",
99
"docsUrl": "https://developer.sage.com/accounting/reference/",
10-
"requiredEnvVars": ["SAGE_CLIENT_ID", "SAGE_CLIENT_SECRET", "SAGE_REFRESH_TOKEN"],
10+
"requiredEnvVars": ["SAGE_CLIENT_ID", "SAGE_CLIENT_SECRET"],
11+
"optionalEnvVars": ["SAGE_REFRESH_TOKEN"],
12+
"envVarMeta": {
13+
"SAGE_CLIENT_ID": {
14+
"label": "Client ID",
15+
"kind": "credential",
16+
"help": "Sage Developer → your app: the Client ID.",
17+
"link": "https://developer.sage.com/accounting/"
18+
},
19+
"SAGE_CLIENT_SECRET": {
20+
"label": "Client secret",
21+
"help": "Same app, the Client secret. Paste it exactly as shown, special characters included.",
22+
"link": "https://developer.sage.com/accounting/"
23+
},
24+
"SAGE_REFRESH_TOKEN": {
25+
"label": "Refresh token",
26+
"help": "Leave empty: the authorization with Sage fills it in. Sage replaces it on every use, so a token you already exchanged yourself no longer works.",
27+
"advanced": true
28+
}
29+
},
1130
"connector": {
1231
"name": "Sage Business Cloud Accounting v3.1",
1332
"type": "REST",
@@ -17,7 +36,9 @@
1736
"clientId": "{{SAGE_CLIENT_ID}}",
1837
"clientSecret": "{{SAGE_CLIENT_SECRET}}",
1938
"refreshToken": "{{SAGE_REFRESH_TOKEN}}",
20-
"tokenUrl": "https://oauth.accounting.sage.com/token"
39+
"authorizationUrl": "https://www.sageone.com/oauth2/auth/central?filter=apiv3.1",
40+
"tokenUrl": "https://oauth.accounting.sage.com/token",
41+
"scopes": "full_access"
2142
}
2243
},
2344
"tools": [

‎packages/backend/src/connectors/connectors.controller.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,7 @@ export class ConnectorsController {
13101310
authType: connector.authType,
13111311
authConfig,
13121312
headers: connector.headers as Record<string, string>,
1313+
connectorId: connector.id,
13131314
});
13141315

13151316
const parsedTools = remoteTools.map((rt) => ({
@@ -1590,6 +1591,7 @@ export class ConnectorsController {
15901591
authConfig,
15911592
headers: connector.headers as Record<string, string>,
15921593
mcpPath: dto.url || '/mcp',
1594+
connectorId: connector.id,
15931595
});
15941596
for (const rt of remoteTools) {
15951597
parsedTools.push({

‎packages/backend/src/connectors/connectors.module.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { GraphqlEngine } from './engines/graphql.engine';
99
import { McpClientEngine } from './engines/mcp-client.engine';
1010
import { DatabaseEngine } from './engines/database.engine';
1111
import { ODataEngine } from './engines/odata.engine';
12-
import { OAuth2TokenService } from './engines/oauth2-token.service';
1312
import { LoginTokenService } from './engines/login-token.service';
1413
import { GraphqlSchemaService } from './engines/graphql-schema.service';
1514
import { OpenApiParser } from './parsers/openapi.parser';
@@ -35,6 +34,10 @@ const ENGINES = [
3534

3635
const PARSERS = [OpenApiParser, WsdlParser, GraphqlParser, PostmanParser, CurlParser];
3736

37+
// OAuth2TokenService comes from McpServerModule: one instance for the whole
38+
// app. A second one here had its own token cache and refresh mutex, so a
39+
// Test connection and a tool call could refresh with the same refresh token
40+
// at once, and a provider that rotates it revoked the connector's token.
3841
@Module({
3942
imports: [McpServerModule, McpServersModule, LicenseModule],
4043
controllers: [ConnectorsController, McpOAuthCallbackController, ToolsController],
@@ -43,7 +46,6 @@ const PARSERS = [OpenApiParser, WsdlParser, GraphqlParser, PostmanParser, CurlPa
4346
McpOAuthService,
4447
CatalogResyncService,
4548
CatalogReconciler,
46-
OAuth2TokenService,
4749
LoginTokenService,
4850
GraphqlSchemaService,
4951
...ENGINES,
@@ -53,7 +55,6 @@ const PARSERS = [OpenApiParser, WsdlParser, GraphqlParser, PostmanParser, CurlPa
5355
ConnectorsService,
5456
McpOAuthService,
5557
CatalogResyncService,
56-
OAuth2TokenService,
5758
LoginTokenService,
5859
GraphqlSchemaService,
5960
...ENGINES,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { ConnectorsService } from './connectors.service';
2+
import { encrypt } from '../common/crypto/encryption.util';
3+
4+
/**
5+
* Test connection runs on every dashboard load (health-check) and refreshes
6+
* OAuth2 tokens like a tool call does. Before this, it did so without the
7+
* connector's id, so the refreshed tokens were never saved: a provider that
8+
* rotates refresh tokens (JTL, Sage, DATEV) revoked the one the connector
9+
* kept, and its next refresh failed with "Token has been revoked".
10+
*/
11+
describe('ConnectorsService OAuth2 token bookkeeping', () => {
12+
const KEY = 'k'.repeat(24) + 'Zq7!pL2@vN9#xR4$';
13+
const authConfig = {
14+
clientId: 'cid',
15+
clientSecret: 'secret',
16+
tokenUrl: 'https://oauth2.example.com/token',
17+
refreshToken: 'rt-1',
18+
accessToken: 'at-1',
19+
};
20+
const row = {
21+
id: 'conn-ffn',
22+
name: 'FFN Connect',
23+
type: 'REST',
24+
baseUrl: 'https://api.example.com',
25+
authType: 'OAUTH2',
26+
authConfig: encrypt(JSON.stringify(authConfig), KEY),
27+
headers: null,
28+
envVars: null,
29+
healthcheckPath: '/merchant/info',
30+
tools: [],
31+
};
32+
33+
let prisma: any;
34+
let restEngine: any;
35+
let graphqlEngine: any;
36+
let mcpClientEngine: any;
37+
let tokens: any;
38+
let service: ConnectorsService;
39+
40+
beforeEach(() => {
41+
prisma = {
42+
connector: {
43+
findUnique: jest.fn().mockResolvedValue(row),
44+
update: jest.fn().mockResolvedValue(row),
45+
},
46+
};
47+
restEngine = { execute: jest.fn().mockResolvedValue({ ok: true }) };
48+
graphqlEngine = { execute: jest.fn().mockResolvedValue({ __typename: 'Query' }) };
49+
mcpClientEngine = { listTools: jest.fn().mockResolvedValue([]) };
50+
tokens = { forget: jest.fn() };
51+
service = new ConnectorsService(
52+
prisma,
53+
{ get: () => KEY } as any,
54+
restEngine,
55+
{} as any,
56+
graphqlEngine,
57+
{} as any,
58+
mcpClientEngine,
59+
undefined,
60+
tokens,
61+
);
62+
});
63+
64+
it.each([
65+
['REST', () => restEngine.execute.mock.calls[0][0]],
66+
['GRAPHQL', () => graphqlEngine.execute.mock.calls[0][0]],
67+
['MCP', () => mcpClientEngine.listTools.mock.calls[0][0]],
68+
])('Test connection of a %s connector names the connector to the engine', async (type, sent) => {
69+
prisma.connector.findUnique.mockResolvedValue({ ...row, type });
70+
71+
await service.testConnection('conn-ffn');
72+
73+
expect(sent()).toEqual(expect.objectContaining({ connectorId: 'conn-ffn' }));
74+
});
75+
76+
it('forgets cached tokens when the authorization is stored', async () => {
77+
await service.updateAuthConfigMerge('conn-ffn', {
78+
accessToken: 'at-2',
79+
refreshToken: 'rt-2',
80+
});
81+
82+
expect(tokens.forget).toHaveBeenCalledWith('conn-ffn');
83+
});
84+
85+
it('forgets cached tokens when the whole auth config is replaced, and only then', async () => {
86+
await service.update('conn-ffn', { name: 'Renamed' });
87+
expect(tokens.forget).not.toHaveBeenCalled();
88+
89+
await service.update('conn-ffn', { authConfig: { ...authConfig, refreshToken: 'rt-9' } });
90+
expect(tokens.forget).toHaveBeenCalledWith('conn-ffn');
91+
});
92+
});

‎packages/backend/src/connectors/connectors.service.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { resolveAdapterIcon } from './connector-icon.util';
2424
import { applySchemaDefaults } from '../common/schema-defaults.util';
2525
import { renderStaticResponse } from './static-response.util';
2626
import { ODataEngine, isODataBuiltinMethod } from './engines/odata.engine';
27+
import { OAuth2TokenService } from './engines/oauth2-token.service';
2728

2829
@Injectable()
2930
export class ConnectorsService {
@@ -39,6 +40,7 @@ export class ConnectorsService {
3940
private readonly databaseEngine: DatabaseEngine,
4041
private readonly mcpClientEngine: McpClientEngine,
4142
@Optional() private readonly odataEngine?: ODataEngine,
43+
@Optional() private readonly oauth2TokenService?: OAuth2TokenService,
4244
) {
4345
this.encryptionKey = getRequiredSecret(
4446
'ENCRYPTION_KEY',
@@ -166,10 +168,12 @@ export class ConnectorsService {
166168
updateData.baseUrl = normalizeConnectorBaseUrl(data.baseUrl, existing.type);
167169
}
168170

169-
return this.prisma.connector.update({
171+
const updated = await this.prisma.connector.update({
170172
where: { id },
171173
data: updateData,
172174
});
175+
if (data.authConfig) this.oauth2TokenService?.forget(id);
176+
return updated;
173177
}
174178

175179
/**
@@ -189,12 +193,16 @@ export class ConnectorsService {
189193
) as Record<string, unknown>)
190194
: {};
191195
const merged = { ...existing, ...patch };
192-
return this.prisma.connector.update({
196+
const updated = await this.prisma.connector.update({
193197
where: { id },
194198
data: {
195199
authConfig: encrypt(JSON.stringify(merged), this.encryptionKey),
196200
},
197201
});
202+
// A new authorization or new client settings: a token cached from the
203+
// old ones, or the error the old refresh token got, no longer applies.
204+
this.oauth2TokenService?.forget(id);
205+
return updated;
198206
}
199207

200208
async remove(id: string): Promise<void> {
@@ -279,6 +287,10 @@ export class ConnectorsService {
279287
authType: connector.authType,
280288
authConfig,
281289
headers,
290+
// Without it an OAuth2 refresh here was never saved: the
291+
// provider rotated the refresh token, the connector kept the
292+
// spent one, and its next refresh was refused as revoked.
293+
connectorId: connector.id,
282294
},
283295
{ method: 'GET', path },
284296
{},
@@ -292,6 +304,7 @@ export class ConnectorsService {
292304
authType: connector.authType,
293305
authConfig,
294306
headers: connector.headers as Record<string, string>,
307+
connectorId: connector.id,
295308
},
296309
{ method: 'query', path: '{ __typename }' },
297310
{},
@@ -331,6 +344,7 @@ export class ConnectorsService {
331344
authType: connector.authType,
332345
authConfig,
333346
headers: connector.headers as Record<string, string>,
347+
connectorId: connector.id,
334348
});
335349
return {
336350
ok: true,
@@ -358,6 +372,7 @@ export class ConnectorsService {
358372
* Throws what the server or the transport threw.
359373
*/
360374
async discoverRemoteMcpTools(connector: {
375+
id?: string;
361376
name: string;
362377
baseUrl: string;
363378
authType: string;
@@ -383,6 +398,7 @@ export class ConnectorsService {
383398
authType: connector.authType,
384399
authConfig,
385400
headers,
401+
connectorId: connector.id,
386402
});
387403
return remote.map((rt) => ({
388404
name: rt.name,

‎packages/backend/src/connectors/engines/graphql.engine.spec.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ describe('GraphqlEngine', () => {
1616
beforeEach(() => {
1717
mockOAuth2TokenService = {
1818
getAccessToken: jest.fn().mockResolvedValue('oauth2-access-token'),
19-
refreshToken: jest.fn().mockResolvedValue('new-access-token'),
19+
renewAfterRejection: jest.fn().mockResolvedValue('new-access-token'),
20+
renewalFailedError: jest.fn().mockReturnValue(undefined),
2021
} as any;
2122
mockLoginTokenService = {
2223
getToken: jest.fn().mockResolvedValue({
@@ -278,7 +279,7 @@ describe('GraphqlEngine', () => {
278279

279280
it('should refresh OAuth2 token and retry on 401', async () => {
280281
mockOAuth2TokenService.getAccessToken.mockResolvedValue('expired-token');
281-
mockOAuth2TokenService.refreshToken.mockResolvedValue('fresh-token');
282+
mockOAuth2TokenService.renewAfterRejection.mockResolvedValue('fresh-token');
282283

283284
// AxiosError is auto-mocked, so create instance and set properties manually
284285
const error401 = new AxiosError() as any;
@@ -299,9 +300,10 @@ describe('GraphqlEngine', () => {
299300
);
300301

301302
expect(result).toEqual({ me: { id: '1' } });
302-
expect(mockOAuth2TokenService.refreshToken).toHaveBeenCalledWith(
303+
expect(mockOAuth2TokenService.renewAfterRejection).toHaveBeenCalledWith(
303304
expect.objectContaining({ refreshToken: 'rt', tokenUrl: 'https://auth/token' }),
304305
'conn-1',
306+
expect.any(Number),
305307
);
306308
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
307309
});

‎packages/backend/src/connectors/engines/graphql.engine.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export class GraphqlEngine {
190190
axiosOpts.proxy = false;
191191
}
192192

193+
const sentAt = Date.now();
193194
try {
194195
const response = await axios.post(config.baseUrl, requestConfig, axiosOpts);
195196

@@ -212,9 +213,10 @@ export class GraphqlEngine {
212213
this.logger.debug(
213214
'OAuth2: access token expired, attempting refresh...',
214215
);
215-
const newToken = await this.oauth2TokenService.refreshToken(
216+
const newToken = await this.oauth2TokenService.renewAfterRejection(
216217
config.authConfig,
217218
config.connectorId,
219+
sentAt,
218220
);
219221
if (newToken) {
220222
headers['Authorization'] = `Bearer ${newToken}`;
@@ -232,6 +234,11 @@ export class GraphqlEngine {
232234

233235
return retryResponse.data.data;
234236
}
237+
const renewalFailed = this.oauth2TokenService.renewalFailedError(
238+
config.authConfig,
239+
config.connectorId,
240+
);
241+
if (renewalFailed) throw renewalFailed;
235242
}
236243
// LOGIN_TOKEN auto-relogin on 401
237244
if (

‎packages/backend/src/connectors/engines/mcp-client.engine.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class McpClientEngine {
5454
version: '1.0.0',
5555
});
5656

57+
const sentAt = Date.now();
5758
try {
5859
await client.connect(transport);
5960

@@ -72,9 +73,10 @@ export class McpClientEngine {
7273
error?.message?.includes?.('401')
7374
) {
7475
this.logger.debug('MCP OAuth2: 401 despite proactive refresh, retrying...');
75-
const newToken = await this.oauth2TokenService.refreshToken(
76+
const newToken = await this.oauth2TokenService.renewAfterRejection(
7677
config.authConfig,
7778
config.connectorId,
79+
sentAt,
7880
);
7981
if (newToken) {
8082
const retryHeaders: Record<string, string> = { ...config.headers };

0 commit comments

Comments
 (0)