@jskit-ai/connectors-core 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +551 -0
- package/docs/oauth-callbacks.md +65 -0
- package/docs/online-setup.md +56 -0
- package/docs/setup-command.md +178 -0
- package/migrations/connectors_core_initial.cjs +17 -0
- package/package.json +65 -0
- package/src/server/ConnectorsFeature.js +36 -0
- package/src/server/connectionService.js +664 -0
- package/src/server/credentialProtection.js +34 -0
- package/src/server/environmentReferences.js +13 -0
- package/src/server/errors.js +27 -0
- package/src/server/fileConnectionStore.js +86 -0
- package/src/server/fileStorage.js +2 -0
- package/src/server/index.js +4 -0
- package/src/server/knexConnectionStore.js +65 -0
- package/src/server/storage.js +2 -0
- package/src/shared/configuration.js +225 -0
- package/test/connectionService.test.js +1231 -0
- package/test/fileConnectionStore.test.js +165 -0
- package/test/knexConnectionStore.test.js +178 -0
- package/test/serviceAccount.test.js +192 -0
- package/test/setupCommand.test.js +364 -0
|
@@ -0,0 +1,1231 @@
|
|
|
1
|
+
import { resendProvider } from "../../connectors-catalog/src/server/resend.js";
|
|
2
|
+
import { firecrawlProvider } from "../../connectors-catalog/src/server/firecrawl.js";
|
|
3
|
+
import { mailgunDefinition } from "../../connectors-catalog/src/shared/tokens.js";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
7
|
+
import { createActionProvider } from "@jskit-ai/kernel/server/actions";
|
|
8
|
+
import { googleCalendarProvider } from "../../connector-google-calendar/src/server/provider.js";
|
|
9
|
+
import { createConnectionService, createConnectorsFeature, createEnvironmentReferenceResolver } from "../src/server/index.js";
|
|
10
|
+
import { parseIntegrationConfiguration, validateIntegrationConfiguration } from "../src/shared/configuration.js";
|
|
11
|
+
import { clickhouseProvider } from "../../connectors-catalog/src/server/clickhouse.js";
|
|
12
|
+
|
|
13
|
+
const listScope = "https://www.googleapis.com/auth/calendar.calendarlist.readonly";
|
|
14
|
+
const eventsScope = "https://www.googleapis.com/auth/calendar.events.readonly";
|
|
15
|
+
const owner = { applicationId: "app-1", subjectId: "user-1" };
|
|
16
|
+
const callback = "http://127.0.0.1:8080/connections/callback";
|
|
17
|
+
|
|
18
|
+
function databaseConfiguration(authentication) {
|
|
19
|
+
return { schemaVersion: 1, registrations: {}, integrations: { database: {
|
|
20
|
+
provider: "clickhouse", accountMode: "shared", scopes: [], authentication,
|
|
21
|
+
settings: { httpUrl: "https://database.example:8443/" }
|
|
22
|
+
} } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("credential-free and optional-secret configuration are explicit provider capabilities", () => {
|
|
26
|
+
const parse = (config, provider = clickhouseProvider) => validateIntegrationConfiguration(config, { providers: [provider] });
|
|
27
|
+
const config = databaseConfiguration({ method: "none" });
|
|
28
|
+
assert.deepEqual(parse(config), config);
|
|
29
|
+
for (const field of ["secretRef", "registrationRef"]) {
|
|
30
|
+
const mixed = structuredClone(config);
|
|
31
|
+
mixed.integrations.database.authentication[field] = field === "secretRef" ? "env:UNUSED" : "registration";
|
|
32
|
+
assert.throws(() => parse(mixed), (error) => Boolean(error.fieldErrors[`integrations.database.authentication.${field}`]));
|
|
33
|
+
}
|
|
34
|
+
config.integrations.database.settings.username = "ignored-user";
|
|
35
|
+
assert.throws(() => parse(config), (error) => Boolean(error.fieldErrors["integrations.database.settings.username"]));
|
|
36
|
+
delete config.integrations.database.settings.username;
|
|
37
|
+
assert.throws(() => parse(config, { ...clickhouseProvider, authenticationMethods: ["api-key"] }), (error) => Boolean(error.fieldErrors["integrations.database.authentication.method"]));
|
|
38
|
+
const optional = databaseConfiguration({ method: "api-key" });
|
|
39
|
+
assert.deepEqual(parse(optional), optional);
|
|
40
|
+
assert.throws(() => parse(optional, { ...clickhouseProvider, apiKeySecretOptional: false }), (error) => Boolean(error.fieldErrors["integrations.database.authentication.secretRef"]));
|
|
41
|
+
optional.integrations.database.authentication.secretRef = "raw-password";
|
|
42
|
+
assert.throws(() => parse(optional), (error) => Boolean(error.fieldErrors["integrations.database.authentication.secretRef"]));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("credential-free grants require verification, authorize access and invalidate both directions of a mode change", async () => {
|
|
46
|
+
const requests = [];
|
|
47
|
+
let resolutions = 0;
|
|
48
|
+
const options = {
|
|
49
|
+
configuration: databaseConfiguration({ method: "none" }), providers: [clickhouseProvider],
|
|
50
|
+
store: memoryStore(), authorize: async (context) => context,
|
|
51
|
+
resolveReference: async () => { resolutions++; return "secret"; },
|
|
52
|
+
fetchImpl: async (url, init) => { requests.push({ url, init }); return Response.json({ meta: [], data: [{ ok: 1, user: "default" }], rows: 1 }); }
|
|
53
|
+
};
|
|
54
|
+
const service = createConnectionService(options);
|
|
55
|
+
const input = { context: owner, integrationId: "database" };
|
|
56
|
+
await assert.rejects(service.invoke({ ...input, operation: "connection.check" }), { code: "connector_reconnect_required" });
|
|
57
|
+
await assert.rejects(service.connectApiKey(input), { code: "connector_mode_unavailable" });
|
|
58
|
+
await service.connectWithoutCredentials(input);
|
|
59
|
+
assert.equal(resolutions, 0);
|
|
60
|
+
assert.equal(new Headers(requests[0].init.headers).has("authorization"), false);
|
|
61
|
+
const restart = createConnectionService(options);
|
|
62
|
+
assert.equal((await restart.status(input)).status, "connected");
|
|
63
|
+
await restart.invoke({ ...input, operation: "connection.check" });
|
|
64
|
+
await assert.rejects(restart.invoke({ ...input, context: { ...owner, subjectId: "other" }, operation: "connection.check" }), { code: "connector_reconnect_required" });
|
|
65
|
+
const keyed = createConnectionService({ ...options, configuration: databaseConfiguration({ method: "api-key", secretRef: "env:PASSWORD" }) });
|
|
66
|
+
assert.equal((await keyed.status(input)).status, "reconnect-required");
|
|
67
|
+
await assert.rejects(keyed.invoke({ ...input, operation: "connection.check" }), { code: "connector_reconnect_required" });
|
|
68
|
+
await assert.rejects(keyed.connectWithoutCredentials(input), { code: "connector_mode_unavailable" });
|
|
69
|
+
assert.equal(resolutions, 0);
|
|
70
|
+
await keyed.connectApiKey(input);
|
|
71
|
+
assert.equal(resolutions, 1);
|
|
72
|
+
assert.equal((await restart.status(input)).status, "reconnect-required");
|
|
73
|
+
await assert.rejects(restart.invoke({ ...input, operation: "connection.check" }), { code: "connector_reconnect_required" });
|
|
74
|
+
assert.equal(requests.length, 3);
|
|
75
|
+
await restart.connectWithoutCredentials(input);
|
|
76
|
+
assert.equal(new Headers(requests.at(-1).init.headers).has("authorization"), false);
|
|
77
|
+
await restart.disconnect(input);
|
|
78
|
+
assert.equal((await restart.status(input)).status, "disconnected");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("the ordinary credential-free Feature action verifies without resolving references", async () => {
|
|
82
|
+
const contexts = [];
|
|
83
|
+
let actions;
|
|
84
|
+
const options = {
|
|
85
|
+
configuration: databaseConfiguration({ method: "none" }), providers: [clickhouseProvider], store: memoryStore(),
|
|
86
|
+
authorize: async (context) => { contexts.push(context); return context; },
|
|
87
|
+
resolveReference: async () => { throw new Error("Must not resolve a reference"); },
|
|
88
|
+
fetchImpl: async (_url, init) => {
|
|
89
|
+
assert.equal(new Headers(init.headers).has("authorization"), false);
|
|
90
|
+
return Response.json({ meta: [], data: [{ ok: 1, user: "default" }], rows: 1 });
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const runtime = createCapabilityRuntime({ providers: [
|
|
94
|
+
createActionProvider(), createConnectorsFeature(options),
|
|
95
|
+
defineProvider({ id: "test.credential-free-observer", requires: { catalogue: "runtime.actions" }, setup({ catalogue }) { actions = catalogue; } })
|
|
96
|
+
] });
|
|
97
|
+
await runtime.start();
|
|
98
|
+
try {
|
|
99
|
+
const result = await actions.execute({ actionId: "connectors.verifyWithoutCredentials", input: { integrationId: "database" },
|
|
100
|
+
context: { ...owner, channel: "api", surface: "app" } });
|
|
101
|
+
assert.equal(result.status, "connected");
|
|
102
|
+
assert.equal(contexts.length, 1);
|
|
103
|
+
assert.equal(contexts[0].subjectId, owner.subjectId);
|
|
104
|
+
} finally { await runtime.shutdown(); }
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function configuration() {
|
|
108
|
+
return {
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
integrations: {
|
|
111
|
+
calendar: {
|
|
112
|
+
provider: "google-calendar", accountMode: "per-user", scopes: [listScope, eventsScope],
|
|
113
|
+
authentication: { method: "oauth2", registrationRef: "google" },
|
|
114
|
+
settings: {}, extensions: { appOwned: { color: "blue" } }
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
registrations: { google: { source: "own", clientId: "client-1", clientSecretRef: "env:GOOGLE_SECRET", callbackUrlRef: "env:CALLBACK" } }
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Deliberately test-only: applications supply their existing durable, transactional store.
|
|
122
|
+
function memoryStore() {
|
|
123
|
+
const attempts = new Map();
|
|
124
|
+
const connections = new Map();
|
|
125
|
+
const locks = new Map();
|
|
126
|
+
const key = ({ owner, integrationId }) => JSON.stringify([owner.applicationId, owner.subjectId, integrationId]);
|
|
127
|
+
return {
|
|
128
|
+
attempts, connections,
|
|
129
|
+
async withConnection(input, run) {
|
|
130
|
+
const id = key(input);
|
|
131
|
+
const prior = locks.get(id) || Promise.resolve();
|
|
132
|
+
let release;
|
|
133
|
+
const lock = new Promise((resolve) => { release = resolve; });
|
|
134
|
+
locks.set(id, lock);
|
|
135
|
+
await prior;
|
|
136
|
+
try {
|
|
137
|
+
let pending = structuredClone(connections.get(id) || null);
|
|
138
|
+
const pendingAttempts = new Map([...attempts].filter(([, attempt]) => key(attempt) === id));
|
|
139
|
+
const result = await run({
|
|
140
|
+
connection: pending,
|
|
141
|
+
save: async (value) => { pending = structuredClone(value); },
|
|
142
|
+
remove: async () => { pending = null; pendingAttempts.clear(); },
|
|
143
|
+
putAttempt: async (attempt) => pendingAttempts.set(attempt.state, structuredClone(attempt)),
|
|
144
|
+
consumeAttempt: async (state) => {
|
|
145
|
+
const attempt = pendingAttempts.get(state);
|
|
146
|
+
pendingAttempts.delete(state);
|
|
147
|
+
return attempt ? structuredClone(attempt) : null;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
for (const [state, attempt] of attempts) if (key(attempt) === id) attempts.delete(state);
|
|
151
|
+
for (const [state, attempt] of pendingAttempts) attempts.set(state, attempt);
|
|
152
|
+
if (pending) connections.set(id, pending);
|
|
153
|
+
else connections.delete(id);
|
|
154
|
+
return result;
|
|
155
|
+
} finally {
|
|
156
|
+
release();
|
|
157
|
+
if (locks.get(id) === lock) locks.delete(id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function setup({ config = configuration(), tokenScopes = `${listScope} ${eventsScope}`, tokenError, providerStatus = 200 } = {}) {
|
|
164
|
+
const store = memoryStore();
|
|
165
|
+
const requests = [];
|
|
166
|
+
let time = 1_000_000;
|
|
167
|
+
const options = {
|
|
168
|
+
configuration: config,
|
|
169
|
+
providers: [googleCalendarProvider], store,
|
|
170
|
+
authorize: async (context) => context,
|
|
171
|
+
resolveReference: createEnvironmentReferenceResolver({ GOOGLE_SECRET: "never-return-this-secret", CALLBACK: callback }),
|
|
172
|
+
now: () => time,
|
|
173
|
+
async fetchImpl(url, init) {
|
|
174
|
+
requests.push({ url: String(url), init });
|
|
175
|
+
if (String(url) === "https://oauth2.googleapis.com/token") {
|
|
176
|
+
if (tokenError) return Response.json({ error: tokenError, error_description: "never-return-this-secret" }, { status: 400 });
|
|
177
|
+
const refresh = new URLSearchParams(init.body).get("grant_type") === "refresh_token";
|
|
178
|
+
return Response.json({
|
|
179
|
+
token_type: "Bearer", access_token: refresh ? "new-access-token" : "access-token",
|
|
180
|
+
refresh_token: refresh ? "rotated-refresh-token" : "refresh-token",
|
|
181
|
+
expires_in: 3600, scope: tokenScopes
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return Response.json(providerStatus === 200 ? {
|
|
185
|
+
kind: String(url).includes("/events") ? "calendar#events" : "calendar#calendarList",
|
|
186
|
+
items: [{ id: "item-1" }], nextPageToken: "next-page"
|
|
187
|
+
} : {
|
|
188
|
+
error: { message: "never-return-this-secret" }
|
|
189
|
+
}, { status: providerStatus });
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const service = createConnectionService(options);
|
|
193
|
+
async function connect(context = owner) {
|
|
194
|
+
const start = await service.beginAuthorization({ context, integrationId: "calendar" });
|
|
195
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
196
|
+
const callbackUrl = `${callback}?code=returned-code&state=${state}`;
|
|
197
|
+
return { callbackUrl, result: await service.completeAuthorization({ context, integrationId: "calendar", callbackUrl }) };
|
|
198
|
+
}
|
|
199
|
+
return { options, service, store, requests, connect, advance: (ms) => { time += ms; } };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Controlled OAuth server using the existing read-operation fixture, not a Google grant capability.
|
|
203
|
+
function serviceAccountSetup() {
|
|
204
|
+
const fixture = setup();
|
|
205
|
+
const config = configuration();
|
|
206
|
+
config.integrations.calendar.provider = "test-service";
|
|
207
|
+
config.integrations.calendar.accountMode = "shared";
|
|
208
|
+
config.registrations.google.grantType = "client_credentials";
|
|
209
|
+
config.registrations.google.tokenEndpointAuthMethod = "client_secret_basic";
|
|
210
|
+
delete config.registrations.google.callbackUrlRef;
|
|
211
|
+
const provider = { ...googleCalendarProvider, id: "test-service", oauthGrantTypes: ["authorization_code", "client_credentials"],
|
|
212
|
+
oauthClientAuthenticationMethods: ["client_secret_basic"] };
|
|
213
|
+
const options = { ...fixture.options, configuration: config, providers: [provider],
|
|
214
|
+
resolveReference: async (reference) => {
|
|
215
|
+
assert.equal(reference, "env:GOOGLE_SECRET", "Service accounts must not resolve a callback binding.");
|
|
216
|
+
return "never-return-this-secret";
|
|
217
|
+
} };
|
|
218
|
+
return { ...fixture, options, provider, config, service: createConnectionService(options) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
test("client credentials are explicit, confidential, callback-free and cannot impersonate each app user", () => {
|
|
222
|
+
const { config, provider } = serviceAccountSetup();
|
|
223
|
+
const validate = (value, definition = provider) => validateIntegrationConfiguration(value, { providers: [definition] });
|
|
224
|
+
assert.deepEqual(validate(config), config);
|
|
225
|
+
const invalid = (mutate, field) => {
|
|
226
|
+
const value = structuredClone(config);
|
|
227
|
+
mutate(value);
|
|
228
|
+
assert.throws(() => validate(value), (error) => Boolean(error.fieldErrors[field]));
|
|
229
|
+
};
|
|
230
|
+
invalid((value) => { value.registrations.google.callbackUrlRef = "env:CALLBACK"; }, "registrations.google.callbackUrlRef");
|
|
231
|
+
invalid((value) => { delete value.registrations.google.clientSecretRef; }, "registrations.google.clientSecretRef");
|
|
232
|
+
invalid((value) => { value.registrations.google.tokenEndpointAuthMethod = "none"; }, "registrations.google.tokenEndpointAuthMethod");
|
|
233
|
+
invalid((value) => { value.integrations.calendar.accountMode = "per-user"; }, "integrations.calendar.accountMode");
|
|
234
|
+
invalid((value) => { value.registrations.google.grantType = "password"; }, "registrations.google.grantType");
|
|
235
|
+
assert.throws(() => validate(config, { ...provider, oauthGrantTypes: undefined }), (error) => Boolean(error.fieldErrors["registrations.google.grantType"]));
|
|
236
|
+
assert.throws(() => validate(config, { ...provider, scopesForGrantType: (grant) => grant === "client_credentials" ? [{ value: listScope }] : provider.scopes }),
|
|
237
|
+
(error) => Boolean(error.fieldErrors["integrations.calendar.scopes"]));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("service account verification issues a confidential grant without browser state and isolates owners", async () => {
|
|
241
|
+
const { service, requests, store } = serviceAccountSetup();
|
|
242
|
+
const input = { context: owner, integrationId: "calendar" };
|
|
243
|
+
await assert.rejects(service.invoke({ ...input, operation: "calendars.list" }), { code: "connector_reconnect_required" });
|
|
244
|
+
await assert.rejects(service.beginAuthorization(input), { code: "connector_mode_unavailable" });
|
|
245
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: callback }), { code: "connector_mode_unavailable" });
|
|
246
|
+
assert.equal(requests.length, 0);
|
|
247
|
+
const result = await service.connectClientCredentials(input);
|
|
248
|
+
assert.equal(result.status, "connected");
|
|
249
|
+
assert.equal(requests.length, 2);
|
|
250
|
+
const parameters = new URLSearchParams(requests[0].init.body);
|
|
251
|
+
assert.equal(parameters.get("grant_type"), "client_credentials");
|
|
252
|
+
assert.equal(parameters.get("scope"), `${listScope} ${eventsScope}`);
|
|
253
|
+
for (const field of ["client_secret", "redirect_uri", "code", "code_verifier", "refresh_token"]) assert.equal(parameters.has(field), false);
|
|
254
|
+
const authorization = new Headers(requests[0].init.headers).get("authorization");
|
|
255
|
+
assert.equal(authorization.split(" ")[0], "Basic");
|
|
256
|
+
assert.deepEqual(Buffer.from(authorization.split(" ")[1], "base64").toString().split(":").map(decodeURIComponent), ["client-1", "never-return-this-secret"]);
|
|
257
|
+
assert.equal(store.attempts.size, 0);
|
|
258
|
+
assert.equal([...store.connections.values()][0].tokens.refreshToken, null);
|
|
259
|
+
assert.equal(/token|secret|client-1/u.test(JSON.stringify(result)), false);
|
|
260
|
+
await assert.rejects(service.invoke({ ...input, context: { ...owner, subjectId: "another" }, operation: "calendars.list" }), { code: "connector_reconnect_required" });
|
|
261
|
+
await service.disconnect(input);
|
|
262
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("service account renewal is serialized, requests only its verified grant and survives a failed API read", async () => {
|
|
266
|
+
const { options, requests, advance, store, provider } = serviceAccountSetup();
|
|
267
|
+
const optionsWithVerification = { ...options, providers: [{ ...provider, grantedScopesFromVerification: () => [listScope] }] };
|
|
268
|
+
const service = createConnectionService(optionsWithVerification);
|
|
269
|
+
const input = { context: owner, integrationId: "calendar" };
|
|
270
|
+
await service.connectClientCredentials(input);
|
|
271
|
+
advance(3_600_000);
|
|
272
|
+
await Promise.all([service.invoke({ ...input, operation: "calendars.list" }), service.invoke({ ...input, operation: "calendars.list" })]);
|
|
273
|
+
const grants = requests.filter((entry) => entry.url.endsWith("/token"));
|
|
274
|
+
assert.equal(grants.length, 2);
|
|
275
|
+
assert.equal(new URLSearchParams(grants[1].init.body).get("scope"), listScope);
|
|
276
|
+
assert.deepEqual((await service.status(input)).grantedScopes, [listScope]);
|
|
277
|
+
await assert.rejects(service.invoke({ ...input, operation: "events.list", input: { calendarId: "primary" } }), { code: "connector_scope_missing" });
|
|
278
|
+
advance(3_600_000);
|
|
279
|
+
const failing = createConnectionService({ ...optionsWithVerification, fetchImpl: async (url, init) =>
|
|
280
|
+
String(url).endsWith("/token") ? options.fetchImpl(url, init) : Response.json({ message: "secret" }, { status: 403 }) });
|
|
281
|
+
await assert.rejects(failing.invoke({ ...input, operation: "calendars.list" }), { code: "connector_permission_denied" });
|
|
282
|
+
assert.equal([...store.connections.values()][0].tokens.expiresAt, options.now() + 3_600_000);
|
|
283
|
+
const before = requests.length;
|
|
284
|
+
await service.invoke({ ...input, operation: "calendars.list" });
|
|
285
|
+
assert.equal(requests.length, before + 1);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("changing service account scopes or OAuth flow requires an explicit reconnection", async () => {
|
|
289
|
+
const { service, options, config, requests } = serviceAccountSetup();
|
|
290
|
+
const input = { context: owner, integrationId: "calendar" };
|
|
291
|
+
await service.connectClientCredentials(input);
|
|
292
|
+
const scoped = structuredClone(config);
|
|
293
|
+
scoped.integrations.calendar.scopes = [listScope];
|
|
294
|
+
const reduced = createConnectionService({ ...options, configuration: scoped });
|
|
295
|
+
assert.equal((await reduced.status(input)).status, "reconnect-required");
|
|
296
|
+
await assert.rejects(reduced.invoke({ ...input, operation: "calendars.list" }), { code: "connector_reconnect_required" });
|
|
297
|
+
const code = structuredClone(config);
|
|
298
|
+
code.registrations.google.grantType = "authorization_code";
|
|
299
|
+
code.registrations.google.callbackUrlRef = "env:CALLBACK";
|
|
300
|
+
const browser = createConnectionService({ ...options, configuration: code,
|
|
301
|
+
resolveReference: async (ref) => ref === "env:CALLBACK" ? callback : "never-return-this-secret" });
|
|
302
|
+
assert.equal((await browser.status(input)).status, "reconnect-required");
|
|
303
|
+
await assert.rejects(browser.invoke({ ...input, operation: "calendars.list" }), { code: "connector_reconnect_required" });
|
|
304
|
+
await assert.rejects(browser.connectClientCredentials(input), { code: "connector_mode_unavailable" });
|
|
305
|
+
assert.equal(requests.length, 2);
|
|
306
|
+
const start = await browser.beginAuthorization(input);
|
|
307
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
308
|
+
await browser.completeAuthorization({ ...input, callbackUrl: `${callback}?state=${state}&code=code` });
|
|
309
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
310
|
+
await assert.rejects(service.invoke({ ...input, operation: "calendars.list" }), { code: "connector_reconnect_required" });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("revoked service account credentials invalidate the grant without exposing the response or retrying", async () => {
|
|
314
|
+
const { service, options, advance } = serviceAccountSetup();
|
|
315
|
+
const input = { context: owner, integrationId: "calendar" };
|
|
316
|
+
await service.connectClientCredentials(input);
|
|
317
|
+
advance(3_600_000);
|
|
318
|
+
let requests = 0;
|
|
319
|
+
const broken = createConnectionService({ ...options, fetchImpl: async () => {
|
|
320
|
+
requests++;
|
|
321
|
+
return Response.json({ error: "invalid_client", error_description: "private-secret" }, { status: 400 });
|
|
322
|
+
} });
|
|
323
|
+
await assert.rejects(broken.invoke({ ...input, operation: "calendars.list" }), (error) => {
|
|
324
|
+
assert.equal(error.code, "connector_reconnect_required");
|
|
325
|
+
assert.equal(JSON.stringify(error).includes("private-secret"), false);
|
|
326
|
+
return true;
|
|
327
|
+
});
|
|
328
|
+
assert.equal(requests, 1);
|
|
329
|
+
assert.equal((await broken.status(input)).status, "reconnect-required");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("unverified and malformed service grants never replace an existing connection", async () => {
|
|
333
|
+
const { service, options, store } = serviceAccountSetup();
|
|
334
|
+
const input = { context: owner, integrationId: "calendar" };
|
|
335
|
+
await service.connectClientCredentials(input);
|
|
336
|
+
const saved = structuredClone([...store.connections.values()][0]);
|
|
337
|
+
for (const response of [{ token_type: "Bearer", expires_in: 3600 }, { token_type: "mac", access_token: "private-token" }]) {
|
|
338
|
+
const malformed = createConnectionService({ ...options, fetchImpl: async () => Response.json(response) });
|
|
339
|
+
await assert.rejects(malformed.connectClientCredentials(input), { code: "connector_provider_failed" });
|
|
340
|
+
assert.deepEqual([...store.connections.values()][0], saved);
|
|
341
|
+
}
|
|
342
|
+
const denied = createConnectionService({ ...options, fetchImpl: async (url, init) => String(url).endsWith("/token")
|
|
343
|
+
? options.fetchImpl(url, init) : Response.json({ error: "private" }, { status: 403 }) });
|
|
344
|
+
await assert.rejects(denied.connectClientCredentials(input), { code: "connector_permission_denied" });
|
|
345
|
+
assert.deepEqual([...store.connections.values()][0], saved);
|
|
346
|
+
const noAccess = createConnectionService({ ...options, authorize: async () => null, fetchImpl: async () => assert.fail("Denied owner reached provider") });
|
|
347
|
+
await assert.rejects(noAccess.connectClientCredentials(input), { code: "connector_access_denied" });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("cancelling a service token exchange preserves the previous grant without retrying", async () => {
|
|
351
|
+
const { service, options, store } = serviceAccountSetup();
|
|
352
|
+
const input = { context: owner, integrationId: "calendar" };
|
|
353
|
+
await service.connectClientCredentials(input);
|
|
354
|
+
const saved = structuredClone([...store.connections.values()][0]);
|
|
355
|
+
let started;
|
|
356
|
+
const requested = new Promise((resolve) => { started = resolve; });
|
|
357
|
+
let calls = 0;
|
|
358
|
+
const waiting = createConnectionService({ ...options, fetchImpl: async (_url, init) => {
|
|
359
|
+
calls++;
|
|
360
|
+
return new Promise((_resolve, reject) => {
|
|
361
|
+
init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
|
362
|
+
started();
|
|
363
|
+
});
|
|
364
|
+
} });
|
|
365
|
+
const controller = new AbortController();
|
|
366
|
+
const pending = assert.rejects(waiting.connectClientCredentials({ ...input, signal: controller.signal }), { code: "connector_cancelled" });
|
|
367
|
+
await requested;
|
|
368
|
+
controller.abort();
|
|
369
|
+
await pending;
|
|
370
|
+
assert.equal(calls, 1);
|
|
371
|
+
assert.deepEqual([...store.connections.values()][0], saved);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("the client credentials Feature action uses the ordinary application policy and runtime", async () => {
|
|
375
|
+
const { options } = serviceAccountSetup();
|
|
376
|
+
let actions;
|
|
377
|
+
const runtime = createCapabilityRuntime({ providers: [createActionProvider(), createConnectorsFeature(options),
|
|
378
|
+
defineProvider({ id: "test.service-observer", requires: { catalogue: "runtime.actions" }, setup({ catalogue }) { actions = catalogue; } })] });
|
|
379
|
+
await runtime.start();
|
|
380
|
+
try {
|
|
381
|
+
const result = await actions.execute({ actionId: "connectors.verifyClientCredentials", input: { integrationId: "calendar" },
|
|
382
|
+
context: { ...owner, channel: "api", surface: "app" } });
|
|
383
|
+
assert.equal(result.status, "connected");
|
|
384
|
+
} finally { await runtime.shutdown(); }
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("cancelling an HTTP operation preserves its grant and reports interruption without retrying", async () => {
|
|
388
|
+
const { options, connect } = setup();
|
|
389
|
+
await connect();
|
|
390
|
+
let started;
|
|
391
|
+
const requested = new Promise((resolve) => { started = resolve; });
|
|
392
|
+
let requests = 0;
|
|
393
|
+
const service = createConnectionService({
|
|
394
|
+
...options,
|
|
395
|
+
fetchImpl: async (url, init) => {
|
|
396
|
+
requests += 1;
|
|
397
|
+
return new Promise((resolve, reject) => {
|
|
398
|
+
init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
|
399
|
+
started();
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
const pending = service.invoke({ context: owner, integrationId: "calendar", operation: "calendars.list", signal: controller.signal });
|
|
405
|
+
const rejected = assert.rejects(pending, { code: "connector_cancelled" });
|
|
406
|
+
await requested;
|
|
407
|
+
controller.abort();
|
|
408
|
+
await rejected;
|
|
409
|
+
assert.equal(requests, 1);
|
|
410
|
+
assert.equal((await service.status({ context: owner, integrationId: "calendar" })).status, "connected");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("provider verification can reduce comma-separated grants without refresh restoring denied permissions", async () => {
|
|
414
|
+
const { options, advance } = setup({ tokenScopes: `${listScope}, ${eventsScope}` });
|
|
415
|
+
let verified = [listScope];
|
|
416
|
+
const provider = { ...googleCalendarProvider, scopeSeparator: ",", grantedScopesFromVerification: () => verified };
|
|
417
|
+
const service = createConnectionService({ ...options, providers: [provider] });
|
|
418
|
+
async function connect() {
|
|
419
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
420
|
+
const url = new URL(start.authorizationUrl);
|
|
421
|
+
assert.equal(url.searchParams.get("scope"), `${listScope},${eventsScope}`);
|
|
422
|
+
return service.completeAuthorization({ context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=fixture-code&state=${url.searchParams.get("state")}` });
|
|
423
|
+
}
|
|
424
|
+
const connected = await connect();
|
|
425
|
+
assert.deepEqual(connected.grantedScopes, [listScope]);
|
|
426
|
+
advance(3_601_000);
|
|
427
|
+
await service.invoke({ context: owner, integrationId: "calendar", operation: googleCalendarProvider.checkOperation });
|
|
428
|
+
assert.deepEqual((await service.status({ context: owner, integrationId: "calendar" })).grantedScopes, [listScope]);
|
|
429
|
+
for (verified of [null, "not-an-array", [7], [""], []]) {
|
|
430
|
+
await assert.rejects(connect(), { code: Array.isArray(verified) && !verified.length ? "connector_scope_missing" : "connector_response_invalid" });
|
|
431
|
+
assert.deepEqual((await service.status({ context: owner, integrationId: "calendar" })).grantedScopes, [listScope]);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("portable configuration round-trips application extensions and rejects raw credentials", () => {
|
|
436
|
+
const input = configuration();
|
|
437
|
+
assert.deepEqual(parseIntegrationConfiguration(JSON.stringify(input), { providers: [googleCalendarProvider] }), input);
|
|
438
|
+
input.registrations.google.clientSecret = "never-return-this-secret";
|
|
439
|
+
assert.throws(() => validateIntegrationConfiguration(input), (error) => {
|
|
440
|
+
assert.equal(error.code, "integration_configuration_invalid");
|
|
441
|
+
assert.ok(error.fieldErrors["registrations.google.clientSecret"]);
|
|
442
|
+
assert.equal(JSON.stringify(error).includes("never-return-this-secret"), false);
|
|
443
|
+
return true;
|
|
444
|
+
});
|
|
445
|
+
assert.throws(() => parseIntegrationConfiguration("not JSON"), /configuration is invalid/);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("reference fields reject pasted web URLs while preserving environment and custom secret bindings", () => {
|
|
449
|
+
for (const field of ["clientSecretRef", "callbackUrlRef"]) {
|
|
450
|
+
for (const value of ["https://callback.example.test/oauth", "http://127.0.0.1:8080/oauth", "https://user:password@example.test/"]) {
|
|
451
|
+
const input = configuration();
|
|
452
|
+
input.registrations.google[field] = value;
|
|
453
|
+
assert.throws(() => validateIntegrationConfiguration(input, { providers: [googleCalendarProvider] }), (error) => {
|
|
454
|
+
assert.ok(error.fieldErrors[`registrations.google.${field}`]);
|
|
455
|
+
assert.equal(JSON.stringify(error).includes(value), false);
|
|
456
|
+
return true;
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const keyed = databaseConfiguration({ method: "api-key", secretRef: "https://example.test/key" });
|
|
461
|
+
assert.throws(() => validateIntegrationConfiguration(keyed, { providers: [clickhouseProvider] }), (error) => Boolean(error.fieldErrors["integrations.database.authentication.secretRef"]));
|
|
462
|
+
for (const value of ["env:CALLBACK", "vault:applications/twitch/secret", "vault://applications/twitch/secret"]) {
|
|
463
|
+
const input = configuration();
|
|
464
|
+
input.registrations.google.clientSecretRef = value;
|
|
465
|
+
assert.equal(validateIntegrationConfiguration(input, { providers: [googleCalendarProvider] }).registrations.google.clientSecretRef, value);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("configuration rejects missing references and unknown scopes", () => {
|
|
470
|
+
const input = configuration();
|
|
471
|
+
input.integrations.calendar.authentication.registrationRef = "missing";
|
|
472
|
+
input.integrations.calendar.scopes = ["arbitrary-permission"];
|
|
473
|
+
assert.throws(() => validateIntegrationConfiguration(input, { providers: [googleCalendarProvider] }), (error) => {
|
|
474
|
+
assert.ok(error.fieldErrors["integrations.calendar.authentication.registrationRef"]);
|
|
475
|
+
assert.ok(error.fieldErrors["integrations.calendar.scopes"]);
|
|
476
|
+
return true;
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("a different or coerced schema version is not silently rewritten", () => {
|
|
481
|
+
for (const schemaVersion of ["1", 2, null]) {
|
|
482
|
+
assert.throws(() => validateIntegrationConfiguration({ ...configuration(), schemaVersion }), (error) => {
|
|
483
|
+
assert.ok(error.fieldErrors.schemaVersion);
|
|
484
|
+
return true;
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("provider settings use one schema for defaults and field errors while unknown definitions require an explicit editor option", () => {
|
|
490
|
+
const input = { schemaVersion: 1, registrations: {}, integrations: {
|
|
491
|
+
mail: { provider: "mailgun", accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:MAILGUN_KEY" } },
|
|
492
|
+
custom: { provider: "custom", accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:CUSTOM_KEY" }, settings: { region: "custom-region" } }
|
|
493
|
+
} };
|
|
494
|
+
const options = { providers: [mailgunDefinition], allowUnknownProviders: true };
|
|
495
|
+
const result = validateIntegrationConfiguration(input, options);
|
|
496
|
+
assert.deepEqual(result.integrations.mail.settings, { region: "us" });
|
|
497
|
+
assert.deepEqual(result.integrations.custom, input.integrations.custom);
|
|
498
|
+
assert.equal(input.integrations.mail.settings, undefined);
|
|
499
|
+
assert.throws(() => validateIntegrationConfiguration(input, { providers: [mailgunDefinition] }), (error) => Boolean(error.fieldErrors["integrations.custom.provider"]));
|
|
500
|
+
for (const settings of [{ region: "unknown" }, { region: "eu", arbitrary: "no" }]) {
|
|
501
|
+
input.integrations.mail.settings = settings;
|
|
502
|
+
assert.throws(() => validateIntegrationConfiguration(input, options), (error) => {
|
|
503
|
+
assert.ok(Object.keys(error.fieldErrors).some((field) => field.startsWith("integrations.mail.settings.")));
|
|
504
|
+
return true;
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("OAuth attempts and verified grants stay bound to their provider settings", async () => {
|
|
510
|
+
const { service, options, connect, requests } = setup();
|
|
511
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
512
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
513
|
+
const config = configuration();
|
|
514
|
+
config.integrations.calendar.settings = { resource: "another-account" };
|
|
515
|
+
const changed = createConnectionService({ ...options, configuration: config });
|
|
516
|
+
await assert.rejects(changed.completeAuthorization({ context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=x&state=${state}` }), { code: "connector_attempt_invalid" });
|
|
517
|
+
assert.equal(requests.length, 0);
|
|
518
|
+
await connect();
|
|
519
|
+
assert.equal((await changed.status({ context: owner, integrationId: "calendar" })).status, "reconnect-required");
|
|
520
|
+
const count = requests.length;
|
|
521
|
+
await assert.rejects(changed.invoke({ context: owner, integrationId: "calendar", operation: "calendars.list" }), { code: "connector_reconnect_required" });
|
|
522
|
+
assert.equal(requests.length, count);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("OAuth uses unique state and PKCE, checks the account, and returns no credentials", async () => {
|
|
526
|
+
const { service, store, requests } = setup();
|
|
527
|
+
const first = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
528
|
+
const second = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
529
|
+
const url = new URL(first.authorizationUrl);
|
|
530
|
+
assert.notEqual(url.searchParams.get("state"), new URL(second.authorizationUrl).searchParams.get("state"));
|
|
531
|
+
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
|
|
532
|
+
assert.equal(url.searchParams.get("access_type"), "offline");
|
|
533
|
+
assert.equal(url.searchParams.has("client_secret"), false);
|
|
534
|
+
const state = url.searchParams.get("state");
|
|
535
|
+
const verifier = store.attempts.get(state).codeVerifier;
|
|
536
|
+
const result = await service.completeAuthorization({
|
|
537
|
+
context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=code&state=${state}`
|
|
538
|
+
});
|
|
539
|
+
assert.equal(result.status, "connected");
|
|
540
|
+
assert.equal(new URLSearchParams(requests[0].init.body).get("code_verifier"), verifier);
|
|
541
|
+
assert.equal(requests[1].init.redirect, "error");
|
|
542
|
+
assert.equal(requests[1].init.headers.Authorization, "Bearer access-token");
|
|
543
|
+
assert.equal(JSON.stringify(result).includes("token"), false);
|
|
544
|
+
assert.equal(store.attempts.has(state), false);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("initial OAuth grants cannot activate permissions omitted from configuration", async () => {
|
|
548
|
+
const config = configuration(); config.integrations.calendar.scopes = [listScope];
|
|
549
|
+
const { service, connect, requests } = setup({ config });
|
|
550
|
+
const { result } = await connect();
|
|
551
|
+
assert.deepEqual(result.grantedScopes, [listScope]);
|
|
552
|
+
const before = requests.length;
|
|
553
|
+
await assert.rejects(service.invoke({ context: owner, integrationId: "calendar", operation: "events.list", input: { calendarId: "primary" } }), { code: "connector_scope_missing" });
|
|
554
|
+
assert.equal(requests.length, before);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("a provider's confidential non-PKCE exception is explicit and bound to the pending attempt", async () => {
|
|
558
|
+
for (const initialPkce of [true, false]) {
|
|
559
|
+
const { options, requests } = setup();
|
|
560
|
+
const service = createConnectionService({ ...options, providers: [{ ...googleCalendarProvider, oauthPkce: initialPkce }] });
|
|
561
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
562
|
+
const url = new URL(start.authorizationUrl);
|
|
563
|
+
assert.equal(url.searchParams.has("code_challenge"), initialPkce);
|
|
564
|
+
const changed = createConnectionService({ ...options, providers: [{ ...googleCalendarProvider, oauthPkce: !initialPkce }] });
|
|
565
|
+
await assert.rejects(changed.completeAuthorization({ context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=code&state=${url.searchParams.get("state")}` }), { code: "connector_attempt_invalid" });
|
|
566
|
+
assert.equal(requests.length, 0);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("a provider cannot disable PKCE for a public client", async () => {
|
|
571
|
+
const { options } = setup(); const config = configuration();
|
|
572
|
+
config.registrations.google.tokenEndpointAuthMethod = "none";
|
|
573
|
+
delete config.registrations.google.clientSecretRef;
|
|
574
|
+
const service = createConnectionService({ ...options, configuration: config,
|
|
575
|
+
providers: [{ ...googleCalendarProvider, oauthPkce: false, oauthClientAuthenticationMethods: ["none"] }] });
|
|
576
|
+
await assert.rejects(service.beginAuthorization({ context: owner, integrationId: "calendar" }), { code: "connector_mode_unavailable" });
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("another user or app cannot consume a pending authorization or read the connection", async () => {
|
|
580
|
+
const { service } = setup();
|
|
581
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
582
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
583
|
+
const callbackUrl = `${callback}?code=code&state=${state}`;
|
|
584
|
+
for (const context of [{ ...owner, subjectId: "user-2" }, { ...owner, applicationId: "app-2" }]) {
|
|
585
|
+
await assert.rejects(service.completeAuthorization({ context, integrationId: "calendar", callbackUrl }), { code: "connector_attempt_invalid" });
|
|
586
|
+
assert.deepEqual(await service.status({ context, integrationId: "calendar" }), { status: "disconnected", callbackUrl: callback });
|
|
587
|
+
}
|
|
588
|
+
await service.completeAuthorization({ context: owner, integrationId: "calendar", callbackUrl });
|
|
589
|
+
await assert.rejects(service.completeAuthorization({ context: owner, integrationId: "calendar", callbackUrl }), { code: "connector_attempt_invalid" });
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("wrong callback destination and expired consent never connect an account", async () => {
|
|
593
|
+
const { service, advance, requests } = setup();
|
|
594
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
595
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
596
|
+
await assert.rejects(service.completeAuthorization({
|
|
597
|
+
context: owner, integrationId: "calendar", callbackUrl: `https://untrusted.example/callback?code=x&state=${state}`
|
|
598
|
+
}), { code: "connector_callback_invalid" });
|
|
599
|
+
advance(600_001);
|
|
600
|
+
await assert.rejects(service.completeAuthorization({
|
|
601
|
+
context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=x&state=${state}`
|
|
602
|
+
}), { code: "connector_attempt_invalid" });
|
|
603
|
+
assert.equal(requests.length, 0);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("denied or insufficient consent never records Connected", async () => {
|
|
607
|
+
const { service, requests } = setup();
|
|
608
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
609
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
610
|
+
await assert.rejects(service.completeAuthorization({
|
|
611
|
+
context: owner, integrationId: "calendar", callbackUrl: `${callback}?error=access_denied&state=${state}`
|
|
612
|
+
}), { code: "connector_consent_denied" });
|
|
613
|
+
assert.equal(requests.length, 0);
|
|
614
|
+
const partial = setup({ tokenScopes: eventsScope });
|
|
615
|
+
await assert.rejects(partial.connect(), { code: "connector_scope_missing" });
|
|
616
|
+
assert.deepEqual(await partial.service.status({ context: owner, integrationId: "calendar" }), { status: "disconnected", callbackUrl: callback });
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("simultaneous expired requests refresh once and persist the rotated grant", async () => {
|
|
620
|
+
const { service, connect, advance, requests, store } = setup();
|
|
621
|
+
await connect();
|
|
622
|
+
advance(3_600_001);
|
|
623
|
+
const results = await Promise.all([1, 2].map(() => service.invoke({
|
|
624
|
+
context: owner, integrationId: "calendar", operation: "events.list", input: { pageToken: "page-2", calendarId: "person@example.com" }
|
|
625
|
+
})));
|
|
626
|
+
assert.equal(results.length, 2);
|
|
627
|
+
const refreshes = requests.filter(({ url, init }) => url.endsWith("/token") && new URLSearchParams(init.body).get("grant_type") === "refresh_token");
|
|
628
|
+
assert.equal(refreshes.length, 1);
|
|
629
|
+
assert.equal([...store.connections.values()][0].tokens.refreshToken, "rotated-refresh-token");
|
|
630
|
+
assert.ok(requests.some(({ url }) => url.includes("person%40example.com/events") && url.includes("pageToken=page-2")));
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("refresh cannot restore declined scopes or grant unrequested permissions without verification metadata", async () => {
|
|
634
|
+
const { options, connect, advance, store } = setup({ tokenScopes: listScope });
|
|
635
|
+
await connect();
|
|
636
|
+
advance(3_600_001);
|
|
637
|
+
const service = createConnectionService({ ...options, fetchImpl: async (url, init) => {
|
|
638
|
+
if (String(url) === "https://oauth2.googleapis.com/token") return Response.json({
|
|
639
|
+
token_type: "Bearer", access_token: "refreshed", refresh_token: "rotated",
|
|
640
|
+
expires_in: 3600, scope: `${listScope} ${eventsScope} unexpected-admin`
|
|
641
|
+
});
|
|
642
|
+
return options.fetchImpl(url, init);
|
|
643
|
+
} });
|
|
644
|
+
await service.invoke({ context: owner, integrationId: "calendar", operation: "calendars.list" });
|
|
645
|
+
assert.deepEqual([...store.connections.values()][0].grantedScopes, [listScope]);
|
|
646
|
+
await assert.rejects(service.invoke({ context: owner, integrationId: "calendar", operation: "events.list", input: { calendarId: "primary" } }), { code: "connector_scope_missing" });
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("provider failures do not expose response text or return a false connection receipt", async () => {
|
|
650
|
+
const { connect, service } = setup({ tokenError: "invalid_grant" });
|
|
651
|
+
await assert.rejects(connect(), (error) => {
|
|
652
|
+
assert.equal(error.code, "connector_reconnect_required");
|
|
653
|
+
assert.equal(JSON.stringify(error).includes("never-return-this-secret"), false);
|
|
654
|
+
return true;
|
|
655
|
+
});
|
|
656
|
+
assert.deepEqual(await service.status({ context: owner, integrationId: "calendar" }), { status: "disconnected", callbackUrl: callback });
|
|
657
|
+
await assert.rejects(setup({ providerStatus: 429 }).connect(), { code: "connector_rate_limited" });
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("an API failure after refresh commits rotated credentials before reporting failure", async () => {
|
|
661
|
+
const { options, connect, advance, store } = setup();
|
|
662
|
+
await connect();
|
|
663
|
+
advance(3_600_001);
|
|
664
|
+
const service = createConnectionService({ ...options, fetchImpl: async (url, init) => {
|
|
665
|
+
if (String(url).endsWith("/token")) return options.fetchImpl(url, init);
|
|
666
|
+
return Response.json({ error: "temporarily unavailable" }, { status: 503 });
|
|
667
|
+
} });
|
|
668
|
+
await assert.rejects(service.invoke({ context: owner, integrationId: "calendar", operation: "events.list" }), { statusCode: 502 });
|
|
669
|
+
assert.equal([...store.connections.values()][0].tokens.refreshToken, "rotated-refresh-token");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("an invalid refresh grant commits reconnect state rather than rolling it back", async () => {
|
|
673
|
+
const { options, connect, advance } = setup();
|
|
674
|
+
await connect();
|
|
675
|
+
advance(3_600_001);
|
|
676
|
+
const service = createConnectionService({ ...options, fetchImpl: async () => Response.json({ error: "invalid_grant" }, { status: 400 }) });
|
|
677
|
+
await assert.rejects(service.invoke({ context: owner, integrationId: "calendar", operation: "events.list" }), { code: "connector_reconnect_required" });
|
|
678
|
+
assert.equal((await service.status({ context: owner, integrationId: "calendar" })).status, "reconnect-required");
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("removing a local connection does not send a provider-wide revocation", async () => {
|
|
682
|
+
const { service, connect, requests } = setup();
|
|
683
|
+
await connect();
|
|
684
|
+
const count = requests.length;
|
|
685
|
+
assert.deepEqual(await service.disconnect({ context: owner, integrationId: "calendar" }), { status: "disconnected" });
|
|
686
|
+
assert.equal(requests.length, count);
|
|
687
|
+
await assert.rejects(service.invoke({ context: owner, integrationId: "calendar", operation: "events.list" }), { code: "connector_reconnect_required" });
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("cancelling consent preserves an existing connection and prevents late completion", async () => {
|
|
691
|
+
const { service, connect, requests } = setup();
|
|
692
|
+
await connect();
|
|
693
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
694
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
695
|
+
await service.cancelAuthorization({ context: owner, integrationId: "calendar", state });
|
|
696
|
+
const count = requests.length;
|
|
697
|
+
await assert.rejects(service.completeAuthorization({
|
|
698
|
+
context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=x&state=${state}`
|
|
699
|
+
}), { code: "connector_attempt_invalid" });
|
|
700
|
+
assert.equal(requests.length, count);
|
|
701
|
+
assert.equal((await service.status({ context: owner, integrationId: "calendar" })).status, "connected");
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("invalid operation input remains a field error and never reaches the provider", async () => {
|
|
705
|
+
const { service, connect, requests } = setup();
|
|
706
|
+
await connect();
|
|
707
|
+
const count = requests.length;
|
|
708
|
+
await assert.rejects(service.invoke({
|
|
709
|
+
context: owner, integrationId: "calendar", operation: "events.list", input: { maxResults: -1 }
|
|
710
|
+
}), (error) => {
|
|
711
|
+
assert.equal(error.code, "connector_input_invalid");
|
|
712
|
+
assert.ok(error.fieldErrors.maxResults);
|
|
713
|
+
return true;
|
|
714
|
+
});
|
|
715
|
+
assert.equal(requests.length, count);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("saved grants cannot override permissions removed from application source", async () => {
|
|
719
|
+
const { options, connect } = setup();
|
|
720
|
+
await connect();
|
|
721
|
+
const config = configuration();
|
|
722
|
+
config.integrations.calendar.scopes = [listScope];
|
|
723
|
+
const service = createConnectionService({ ...options, configuration: config });
|
|
724
|
+
await assert.rejects(service.invoke({ context: owner, integrationId: "calendar", operation: "events.list" }), { code: "connector_scope_missing" });
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("a changed client registration is immediately reported as needing reconnection", async () => {
|
|
728
|
+
const { options, connect } = setup();
|
|
729
|
+
await connect();
|
|
730
|
+
const config = configuration();
|
|
731
|
+
config.registrations.google.clientId = "another-client";
|
|
732
|
+
const service = createConnectionService({ ...options, configuration: config });
|
|
733
|
+
assert.equal((await service.status({ context: owner, integrationId: "calendar" })).status, "reconnect-required");
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("remote field errors cannot smuggle provider response text into application errors", async () => {
|
|
737
|
+
const { options } = setup();
|
|
738
|
+
const service = createConnectionService({ ...options, fetchImpl: async (url, init) => {
|
|
739
|
+
if (String(url).endsWith("/token")) return options.fetchImpl(url, init);
|
|
740
|
+
return Response.json({ error: "failure", fieldErrors: { token: "never-return-this-secret" } }, { status: 422 });
|
|
741
|
+
} });
|
|
742
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
743
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
744
|
+
await assert.rejects(service.completeAuthorization({ context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=x&state=${state}` }), (error) => {
|
|
745
|
+
assert.equal(error.code, "connector_provider_failed");
|
|
746
|
+
assert.equal(JSON.stringify(error).includes("never-return-this-secret"), false);
|
|
747
|
+
return true;
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("a successful HTTP status with an unexpected payload does not verify a connection", async () => {
|
|
752
|
+
const { options } = setup();
|
|
753
|
+
const service = createConnectionService({ ...options, fetchImpl: async (url, init) => {
|
|
754
|
+
if (String(url).endsWith("/token")) return options.fetchImpl(url, init);
|
|
755
|
+
return Response.json({ message: "not a Calendar response" });
|
|
756
|
+
} });
|
|
757
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
758
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
759
|
+
await assert.rejects(service.completeAuthorization({
|
|
760
|
+
context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=x&state=${state}`
|
|
761
|
+
}), { code: "connector_response_invalid" });
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("configuration rejects gateway registrations before constructing a runtime", () => {
|
|
765
|
+
const config = configuration();
|
|
766
|
+
config.registrations.google.source = "managed";
|
|
767
|
+
assert.throws(() => setup({ config }), (error) => Boolean(error.fieldErrors["registrations.google.source"]));
|
|
768
|
+
for (const field of ["serviceUrlRef", "serviceCredentialRef", "assignmentRef"]) {
|
|
769
|
+
const input = configuration();
|
|
770
|
+
input.registrations.google[field] = "env:OBSOLETE_GATEWAY";
|
|
771
|
+
assert.throws(() => validateIntegrationConfiguration(input, { providers: [googleCalendarProvider] }),
|
|
772
|
+
{ code: "integration_configuration_invalid" });
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("the Feature exposes ordinary JSKIT actions with application authorization", async () => {
|
|
777
|
+
const { options } = setup();
|
|
778
|
+
let actions;
|
|
779
|
+
const runtime = createCapabilityRuntime({ providers: [
|
|
780
|
+
createActionProvider(), createConnectorsFeature(options),
|
|
781
|
+
defineProvider({
|
|
782
|
+
id: "test.observer", requires: { catalogue: "runtime.actions" },
|
|
783
|
+
setup({ catalogue }) { actions = catalogue; }
|
|
784
|
+
})
|
|
785
|
+
] });
|
|
786
|
+
await runtime.start();
|
|
787
|
+
try {
|
|
788
|
+
await assert.rejects(actions.execute({
|
|
789
|
+
actionId: "connectors.status", input: { integrationId: "calendar" }, context: { channel: "api", surface: "app" }
|
|
790
|
+
}), { code: "connector_access_denied" });
|
|
791
|
+
assert.deepEqual(await actions.execute({
|
|
792
|
+
actionId: "connectors.status", input: { integrationId: "calendar" }, context: { ...owner, channel: "api", surface: "app" }
|
|
793
|
+
}), { status: "disconnected", callbackUrl: callback });
|
|
794
|
+
} finally { await runtime.shutdown(); }
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("disconnect invalidates pending consent, including a callback racing with disconnect", async () => {
|
|
798
|
+
const { service, connect, requests } = setup();
|
|
799
|
+
await connect();
|
|
800
|
+
const start = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
801
|
+
const state = new URL(start.authorizationUrl).searchParams.get("state");
|
|
802
|
+
await service.disconnect({ context: owner, integrationId: "calendar" });
|
|
803
|
+
const count = requests.length;
|
|
804
|
+
await assert.rejects(service.completeAuthorization({
|
|
805
|
+
context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=x&state=${state}`
|
|
806
|
+
}), { code: "connector_attempt_invalid" });
|
|
807
|
+
assert.equal(requests.length, count);
|
|
808
|
+
|
|
809
|
+
const next = await service.beginAuthorization({ context: owner, integrationId: "calendar" });
|
|
810
|
+
const nextState = new URL(next.authorizationUrl).searchParams.get("state");
|
|
811
|
+
const completion = service.completeAuthorization({ context: owner, integrationId: "calendar", callbackUrl: `${callback}?code=x&state=${nextState}` });
|
|
812
|
+
const removal = service.disconnect({ context: owner, integrationId: "calendar" });
|
|
813
|
+
await Promise.allSettled([completion, removal]);
|
|
814
|
+
assert.equal((await service.status({ context: owner, integrationId: "calendar" })).status, "disconnected");
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("query credentials are encoded, replace URL values and never escape through destinations or errors", async () => {
|
|
818
|
+
const secret = "fixture secret +&?=#/%";
|
|
819
|
+
let destination = "https://api.example.test/items?token=untrusted&token=duplicate&page=2";
|
|
820
|
+
let failing = false;
|
|
821
|
+
const requests = [];
|
|
822
|
+
const provider = {
|
|
823
|
+
id: "query-service", accountModes: ["shared"], authenticationMethods: ["api-key"], scopes: [],
|
|
824
|
+
apiOrigins: ["https://api.example.test"], apiKey: { queryParameter: "token" }, checkOperation: "items.list",
|
|
825
|
+
operations: { "items.list": { scopes: [], request: () => ({ method: "GET", url: destination }) } }
|
|
826
|
+
};
|
|
827
|
+
const store = memoryStore();
|
|
828
|
+
const service = createConnectionService({
|
|
829
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
830
|
+
source: { provider: provider.id, accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:QUERY_KEY" } }
|
|
831
|
+
} },
|
|
832
|
+
providers: [provider], store, authorize: async (context) => context, resolveReference: async () => secret,
|
|
833
|
+
fetchImpl: async (url, init) => {
|
|
834
|
+
requests.push({ url: new URL(url), headers: new Headers(init.headers), init });
|
|
835
|
+
if (failing) throw new Error(`Connection failed at ${url}`);
|
|
836
|
+
return Response.json({ items: [] });
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
const input = { context: owner, integrationId: "source" };
|
|
840
|
+
const connected = await service.connectApiKey(input);
|
|
841
|
+
assert.deepEqual(requests[0].url.searchParams.getAll("token"), [secret]);
|
|
842
|
+
assert.equal(requests[0].url.searchParams.get("page"), "2");
|
|
843
|
+
assert.equal(requests[0].headers.has("authorization"), false);
|
|
844
|
+
assert.equal(requests[0].init.redirect, "error");
|
|
845
|
+
assert.equal(JSON.stringify(connected).includes(secret), false);
|
|
846
|
+
assert.equal(JSON.stringify([...store.connections]).includes(secret), false);
|
|
847
|
+
failing = true;
|
|
848
|
+
await assert.rejects(service.invoke({ ...input, operation: "items.list" }), (error) => {
|
|
849
|
+
assert.equal(error.code, "connector_provider_failed");
|
|
850
|
+
assert.equal(error.cause, undefined);
|
|
851
|
+
assert.equal(error.message.includes("token="), false);
|
|
852
|
+
assert.equal(error.stack.includes("token="), false);
|
|
853
|
+
return true;
|
|
854
|
+
});
|
|
855
|
+
const count = requests.length;
|
|
856
|
+
for (const url of ["https://attacker.invalid/items", "http://api.example.test/items", "https://user:password@api.example.test/items"]) {
|
|
857
|
+
destination = url;
|
|
858
|
+
await assert.rejects(service.invoke({ ...input, operation: "items.list" }), { code: "connector_destination_invalid" });
|
|
859
|
+
}
|
|
860
|
+
assert.equal(requests.length, count);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test("path credentials preserve the validated origin and query without leaking through storage or errors", async () => {
|
|
864
|
+
const secret = "fixture token /+&?=#%";
|
|
865
|
+
let destination = "https://api.example.test/items?page=2";
|
|
866
|
+
let prefix;
|
|
867
|
+
let prefixCalls = 0;
|
|
868
|
+
let failing = false;
|
|
869
|
+
const requests = [];
|
|
870
|
+
const provider = {
|
|
871
|
+
id: "path-service", accountModes: ["shared"], authenticationMethods: ["api-key"], scopes: [],
|
|
872
|
+
apiOrigins: ["https://api.example.test"], apiKey: { pathPrefix(key) {
|
|
873
|
+
prefixCalls++;
|
|
874
|
+
return prefix === undefined ? `/api/v1/${encodeURIComponent(key)}` : prefix;
|
|
875
|
+
} }, checkOperation: "items.list",
|
|
876
|
+
operations: { "items.list": { scopes: [], request: () => ({ method: "GET", url: destination }) } }
|
|
877
|
+
};
|
|
878
|
+
const store = memoryStore();
|
|
879
|
+
const service = createConnectionService({
|
|
880
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
881
|
+
source: { provider: provider.id, accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:PATH_KEY" } }
|
|
882
|
+
} },
|
|
883
|
+
providers: [provider], store, authorize: async (context) => context, resolveReference: async () => secret,
|
|
884
|
+
fetchImpl: async (url, init) => {
|
|
885
|
+
requests.push({ url: new URL(url), init });
|
|
886
|
+
if (failing) throw new Error(`Connection failed at ${url}`);
|
|
887
|
+
return Response.json({ items: [] });
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
const input = { context: owner, integrationId: "source" };
|
|
891
|
+
const connected = await service.connectApiKey(input);
|
|
892
|
+
assert.equal(requests[0].url.origin, "https://api.example.test");
|
|
893
|
+
assert.equal(requests[0].url.pathname, "/api/v1/fixture%20token%20%2F%2B%26%3F%3D%23%25/items");
|
|
894
|
+
assert.equal(requests[0].url.search, "?page=2");
|
|
895
|
+
assert.equal(new Headers(requests[0].init.headers).has("authorization"), false);
|
|
896
|
+
assert.equal(requests[0].init.redirect, "error");
|
|
897
|
+
for (const value of [connected, [...store.connections]]) {
|
|
898
|
+
assert.equal(JSON.stringify(value).includes(secret), false);
|
|
899
|
+
assert.equal(JSON.stringify(value).includes(encodeURIComponent(secret)), false);
|
|
900
|
+
}
|
|
901
|
+
for (const url of ["https://attacker.invalid/items", "http://api.example.test/items", "https://user:password@api.example.test/items"]) {
|
|
902
|
+
destination = url;
|
|
903
|
+
await assert.rejects(service.invoke({ ...input, operation: "items.list" }), { code: "connector_destination_invalid" });
|
|
904
|
+
}
|
|
905
|
+
assert.equal(prefixCalls, 1);
|
|
906
|
+
destination = "https://api.example.test/items?page=2";
|
|
907
|
+
for (const invalid of [null, 1, "relative", "//attacker.invalid", "/api/../key", "/api/%2E./key", "/api/./key", "/key?override", "/key#fragment", "/key\\other"]) {
|
|
908
|
+
prefix = invalid;
|
|
909
|
+
await assert.rejects(service.invoke({ ...input, operation: "items.list" }), { code: "connector_request_invalid" });
|
|
910
|
+
}
|
|
911
|
+
assert.equal(requests.length, 1);
|
|
912
|
+
prefix = undefined;
|
|
913
|
+
failing = true;
|
|
914
|
+
await assert.rejects(service.invoke({ ...input, operation: "items.list" }), (error) => {
|
|
915
|
+
assert.equal(error.code, "connector_provider_failed");
|
|
916
|
+
assert.equal(error.cause, undefined);
|
|
917
|
+
assert.equal(error.stack.includes(secret), false);
|
|
918
|
+
assert.equal(error.stack.includes(encodeURIComponent(secret)), false);
|
|
919
|
+
return true;
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
test("JSON-body credentials replace supplied values, preserve input and reach the ordinary verification action", async () => {
|
|
924
|
+
const secret = "fixture body token +&?";
|
|
925
|
+
const originalBody = { api_key: "untrusted", nested: { keep: true } };
|
|
926
|
+
let destination = "https://api.example.test/check";
|
|
927
|
+
let body = originalBody;
|
|
928
|
+
let failing = false;
|
|
929
|
+
const requests = [];
|
|
930
|
+
const provider = {
|
|
931
|
+
id: "body-service", accountModes: ["shared"], authenticationMethods: ["api-key"], scopes: [],
|
|
932
|
+
apiOrigins: ["https://api.example.test"], apiKey: { bodyParameter: "api_key" }, checkOperation: "check",
|
|
933
|
+
operations: { check: { scopes: [], request: (input) => ({ method: "POST", url: destination, body: body === originalBody ? { ...body, subject: input.subject } : body }) } }
|
|
934
|
+
};
|
|
935
|
+
const store = memoryStore();
|
|
936
|
+
const options = {
|
|
937
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
938
|
+
source: { provider: provider.id, accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:BODY_KEY" } }
|
|
939
|
+
} },
|
|
940
|
+
providers: [provider], store, authorize: async (context) => context, resolveReference: async () => secret,
|
|
941
|
+
fetchImpl: async (url, init) => {
|
|
942
|
+
requests.push({ url, init });
|
|
943
|
+
if (failing) throw new Error(`Request body contained ${init.body}`);
|
|
944
|
+
return Response.json({ accepted: true });
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
let actions;
|
|
948
|
+
const runtime = createCapabilityRuntime({ providers: [
|
|
949
|
+
createActionProvider(), createConnectorsFeature(options),
|
|
950
|
+
defineProvider({ id: "test.body-observer", requires: { catalogue: "runtime.actions" }, setup({ catalogue }) { actions = catalogue; } })
|
|
951
|
+
] });
|
|
952
|
+
await runtime.start();
|
|
953
|
+
try {
|
|
954
|
+
const result = await actions.execute({ actionId: "connectors.verifyApiKey", input: {
|
|
955
|
+
integrationId: "source", verificationInput: { subject: "verification-user" }
|
|
956
|
+
}, context: { ...owner, channel: "api", surface: "app" } });
|
|
957
|
+
assert.equal(result.status, "connected");
|
|
958
|
+
assert.deepEqual(JSON.parse(requests[0].init.body), { api_key: secret, nested: { keep: true }, subject: "verification-user" });
|
|
959
|
+
assert.deepEqual(originalBody, { api_key: "untrusted", nested: { keep: true } });
|
|
960
|
+
assert.equal(new Headers(requests[0].init.headers).has("authorization"), false);
|
|
961
|
+
assert.equal(new URL(requests[0].url).search, "");
|
|
962
|
+
assert.equal(JSON.stringify([...store.connections]).includes(secret), false);
|
|
963
|
+
assert.equal(JSON.stringify([...store.connections]).includes("verification-user"), false);
|
|
964
|
+
const service = createConnectionService(options);
|
|
965
|
+
const input = { context: owner, integrationId: "source", operation: "check" };
|
|
966
|
+
for (const invalidBody of [undefined, null, [], "plain text"]) {
|
|
967
|
+
body = invalidBody;
|
|
968
|
+
await assert.rejects(service.invoke(input), { code: "connector_request_invalid" });
|
|
969
|
+
}
|
|
970
|
+
assert.equal(requests.length, 1);
|
|
971
|
+
body = originalBody;
|
|
972
|
+
destination = "https://attacker.invalid/check";
|
|
973
|
+
await assert.rejects(service.invoke(input), { code: "connector_destination_invalid" });
|
|
974
|
+
assert.equal(requests.length, 1);
|
|
975
|
+
destination = "https://api.example.test/check";
|
|
976
|
+
failing = true;
|
|
977
|
+
await assert.rejects(service.invoke(input), (error) => {
|
|
978
|
+
assert.equal(error.code, "connector_provider_failed");
|
|
979
|
+
assert.equal(error.cause, undefined);
|
|
980
|
+
assert.equal(error.stack.includes(secret), false);
|
|
981
|
+
return true;
|
|
982
|
+
});
|
|
983
|
+
} finally { await runtime.shutdown(); }
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
for (const [provider, response] of [
|
|
987
|
+
[resendProvider, { object: "list", has_more: true, data: [{ id: "domain-1", status: "verified" }] }],
|
|
988
|
+
[firecrawlProvider, { success: true, data: { remainingCredits: 1000 } }]
|
|
989
|
+
]) {
|
|
990
|
+
function apiFixture() {
|
|
991
|
+
const config = {
|
|
992
|
+
schemaVersion: 1, registrations: {}, integrations: { service: {
|
|
993
|
+
provider: provider.id, accountMode: "shared", scopes: [],
|
|
994
|
+
authentication: { method: "api-key", secretRef: "env:SERVICE_KEY" }
|
|
995
|
+
} }
|
|
996
|
+
};
|
|
997
|
+
const store = memoryStore();
|
|
998
|
+
const requests = [];
|
|
999
|
+
const state = { status: 200, response, key: "test-private-key" };
|
|
1000
|
+
const options = {
|
|
1001
|
+
configuration: config, providers: [provider], store, authorize: async (context) => context,
|
|
1002
|
+
resolveReference: async () => state.key,
|
|
1003
|
+
fetchImpl: async (url, init) => {
|
|
1004
|
+
requests.push({ url: String(url), init });
|
|
1005
|
+
return Response.json(state.response, { status: state.status });
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
return { config, options, store, requests, state, service: createConnectionService(options) };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
test(`${provider.id}: API key verification, rotation, isolation and disconnect`, async () => {
|
|
1012
|
+
const { service, store, requests, state } = apiFixture();
|
|
1013
|
+
const input = { context: owner, integrationId: "service" };
|
|
1014
|
+
await assert.rejects(service.invoke({ ...input, operation: provider.checkOperation }), { code: "connector_reconnect_required" });
|
|
1015
|
+
const result = await service.connectApiKey(input);
|
|
1016
|
+
assert.equal(result.status, "connected");
|
|
1017
|
+
assert.equal(requests.length, 1);
|
|
1018
|
+
assert.equal(requests[0].init.method, "GET");
|
|
1019
|
+
assert.equal(new Headers(requests[0].init.headers).get("authorization"), "Bearer test-private-key");
|
|
1020
|
+
assert.equal(requests[0].init.redirect, "error");
|
|
1021
|
+
assert.equal(JSON.stringify([...store.connections]).includes(state.key), false);
|
|
1022
|
+
assert.equal(JSON.stringify(result).includes(state.key), false);
|
|
1023
|
+
for (const context of [null, { ...owner, subjectId: "someone-else" }, { ...owner, applicationId: "other-app" }]) {
|
|
1024
|
+
await assert.rejects(service.invoke({ context, integrationId: "service", operation: provider.checkOperation }));
|
|
1025
|
+
}
|
|
1026
|
+
assert.equal(requests.length, 1);
|
|
1027
|
+
assert.equal((await service.status(input)).status, "connected");
|
|
1028
|
+
state.key = "rotated-key";
|
|
1029
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
1030
|
+
assert.equal(requests.length, 1);
|
|
1031
|
+
assert.deepEqual(await service.invoke({ ...input, operation: provider.checkOperation }), response);
|
|
1032
|
+
assert.equal(new Headers(requests[1].init.headers).get("authorization"), "Bearer rotated-key");
|
|
1033
|
+
assert.equal((await service.status(input)).status, "connected");
|
|
1034
|
+
await service.disconnect(input);
|
|
1035
|
+
await assert.rejects(service.invoke({ ...input, operation: provider.checkOperation }), { code: "connector_reconnect_required" });
|
|
1036
|
+
assert.equal(requests.length, 2);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
test(`${provider.id}: an unverified replacement key cannot inherit verified status`, async () => {
|
|
1040
|
+
const { service, state, requests, options, store } = apiFixture();
|
|
1041
|
+
const input = { context: owner, integrationId: "service" };
|
|
1042
|
+
await service.connectApiKey(input);
|
|
1043
|
+
const original = state.key;
|
|
1044
|
+
state.key = "unverified-replacement";
|
|
1045
|
+
const reopened = createConnectionService(options);
|
|
1046
|
+
assert.equal((await reopened.status(input)).status, "reconnect-required");
|
|
1047
|
+
assert.equal(requests.length, 1);
|
|
1048
|
+
state.status = 403;
|
|
1049
|
+
await assert.rejects(reopened.connectApiKey(input), { code: "connector_permission_denied" });
|
|
1050
|
+
assert.equal((await reopened.status(input)).status, "reconnect-required");
|
|
1051
|
+
state.key = original;
|
|
1052
|
+
assert.equal((await reopened.status(input)).status, "connected");
|
|
1053
|
+
state.key = "verified-replacement";
|
|
1054
|
+
state.status = 200;
|
|
1055
|
+
const connected = await reopened.connectApiKey(input);
|
|
1056
|
+
assert.equal((await reopened.status(input)).status, "connected");
|
|
1057
|
+
assert.equal(JSON.stringify(connected).includes("credentialFingerprint"), false);
|
|
1058
|
+
assert.equal(JSON.stringify([...store.connections]).includes(state.key), false);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
test(`${provider.id}: failures never claim connected or expose provider secrets`, async () => {
|
|
1062
|
+
const { service, state, requests } = apiFixture();
|
|
1063
|
+
const input = { context: owner, integrationId: "service" };
|
|
1064
|
+
for (const [status, code] of [[401, "connector_reconnect_required"], [403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
|
|
1065
|
+
state.status = status;
|
|
1066
|
+
state.response = { message: state.key };
|
|
1067
|
+
await assert.rejects(service.connectApiKey(input), (error) => {
|
|
1068
|
+
assert.equal(error.code, code);
|
|
1069
|
+
assert.equal(JSON.stringify(error).includes(state.key), false);
|
|
1070
|
+
return true;
|
|
1071
|
+
});
|
|
1072
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
1073
|
+
}
|
|
1074
|
+
state.status = 200;
|
|
1075
|
+
await assert.rejects(service.connectApiKey(input), { code: "connector_response_invalid" });
|
|
1076
|
+
state.key = "";
|
|
1077
|
+
const before = requests.length;
|
|
1078
|
+
await assert.rejects(service.connectApiKey(input), { code: "connector_binding_missing" });
|
|
1079
|
+
assert.equal(requests.length, before);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
test(`${provider.id}: configuration changes and rejected keys require reconnection`, async () => {
|
|
1083
|
+
const { service, options, config, state } = apiFixture();
|
|
1084
|
+
const input = { context: owner, integrationId: "service" };
|
|
1085
|
+
await service.connectApiKey(input);
|
|
1086
|
+
config.integrations.service.authentication.secretRef = "env:DIFFERENT_KEY";
|
|
1087
|
+
const changed = createConnectionService(options);
|
|
1088
|
+
assert.equal((await changed.status(input)).status, "reconnect-required");
|
|
1089
|
+
await assert.rejects(changed.invoke({ ...input, operation: provider.checkOperation }), { code: "connector_reconnect_required" });
|
|
1090
|
+
state.status = 401;
|
|
1091
|
+
await assert.rejects(service.invoke({ ...input, operation: provider.checkOperation }), { code: "connector_reconnect_required" });
|
|
1092
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
test(`${provider.id}: useful operation validates inputs and preserves provider pagination/results`, async () => {
|
|
1096
|
+
const { service, requests, state } = apiFixture();
|
|
1097
|
+
const input = { context: owner, integrationId: "service" };
|
|
1098
|
+
await service.connectApiKey(input);
|
|
1099
|
+
if (provider.id === "resend") {
|
|
1100
|
+
const result = await service.invoke({ ...input, operation: "domains.list", input: { limit: 7, after: "id & next" } });
|
|
1101
|
+
assert.equal(result.has_more, true);
|
|
1102
|
+
const url = new URL(requests.at(-1).url);
|
|
1103
|
+
assert.equal(url.origin, "https://api.resend.com");
|
|
1104
|
+
assert.equal(url.searchParams.get("after"), "id & next");
|
|
1105
|
+
assert.equal(url.searchParams.get("limit"), "7");
|
|
1106
|
+
await assert.rejects(service.invoke({ ...input, operation: "domains.list", input: { limit: 101 } }), { code: "connector_input_invalid" });
|
|
1107
|
+
} else {
|
|
1108
|
+
state.response = { success: true, data: { markdown: "# Result", metadata: { title: "Example" } } };
|
|
1109
|
+
const result = await service.invoke({ ...input, operation: "pages.scrape", input: { url: "https://example.com/article" } });
|
|
1110
|
+
assert.equal(result.data.markdown, "# Result");
|
|
1111
|
+
assert.equal(requests.at(-1).url, "https://api.firecrawl.dev/v2/scrape");
|
|
1112
|
+
assert.equal(requests.at(-1).init.method, "POST");
|
|
1113
|
+
assert.deepEqual(JSON.parse(requests.at(-1).init.body), { url: "https://example.com/article", onlyMainContent: true, formats: ["markdown"] });
|
|
1114
|
+
await assert.rejects(service.invoke({ ...input, operation: "pages.scrape", input: { url: "file:///etc/passwd" } }), { code: "connector_input_invalid" });
|
|
1115
|
+
}
|
|
1116
|
+
assert.equal(requests.length, 2);
|
|
1117
|
+
await assert.rejects(service.invoke({ ...input, operation: "unknown" }), { code: "connector_operation_unknown" });
|
|
1118
|
+
assert.equal(requests.length, 2);
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
test("public OAuth registrations require provider support and reject secrets", () => {
|
|
1123
|
+
const config = configuration();
|
|
1124
|
+
const registration = config.registrations.google;
|
|
1125
|
+
registration.tokenEndpointAuthMethod = "none";
|
|
1126
|
+
delete registration.clientSecretRef;
|
|
1127
|
+
assert.throws(() => validateIntegrationConfiguration(config, { providers: [googleCalendarProvider] }), (error) => Boolean(error.fieldErrors["registrations.google.tokenEndpointAuthMethod"]));
|
|
1128
|
+
const provider = { ...googleCalendarProvider, oauthClientAuthenticationMethods: ["client_secret_post", "none"] };
|
|
1129
|
+
assert.deepEqual(validateIntegrationConfiguration(config, { providers: [provider] }), config);
|
|
1130
|
+
registration.clientSecretRef = "env:UNUSED_SECRET";
|
|
1131
|
+
assert.throws(() => validateIntegrationConfiguration(config, { providers: [provider] }), (error) => Boolean(error.fieldErrors["registrations.google.clientSecretRef"]));
|
|
1132
|
+
delete registration.clientSecretRef;
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
test("changing client authentication invalidates OAuth attempts and stored grants before any exchange", async () => {
|
|
1136
|
+
const { service, connect, options, requests } = setup();
|
|
1137
|
+
const input = { context: owner, integrationId: "calendar" };
|
|
1138
|
+
const start = await service.beginAuthorization(input);
|
|
1139
|
+
await connect();
|
|
1140
|
+
const config = configuration();
|
|
1141
|
+
config.registrations.google.tokenEndpointAuthMethod = "none";
|
|
1142
|
+
delete config.registrations.google.clientSecretRef;
|
|
1143
|
+
const provider = { ...googleCalendarProvider, oauthClientAuthenticationMethods: ["client_secret_post", "none"] };
|
|
1144
|
+
const changed = createConnectionService({ ...options, configuration: config, providers: [provider] });
|
|
1145
|
+
const count = requests.length;
|
|
1146
|
+
assert.equal((await changed.status(input)).status, "reconnect-required");
|
|
1147
|
+
await assert.rejects(changed.invoke({ ...input, operation: googleCalendarProvider.checkOperation }), { code: "connector_reconnect_required" });
|
|
1148
|
+
await assert.rejects(changed.completeAuthorization({ ...input,
|
|
1149
|
+
callbackUrl: `${callback}?code=x&state=${new URL(start.authorizationUrl).searchParams.get("state")}` }), { code: "connector_attempt_invalid" });
|
|
1150
|
+
assert.equal(requests.length, count);
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
test("placeholder environment bindings fail without exposing values", async () => {
|
|
1155
|
+
for (const value of [undefined, "", " ", "MISSING", " MISSING "]) {
|
|
1156
|
+
const resolve = createEnvironmentReferenceResolver({ KEY: value });
|
|
1157
|
+
await assert.rejects(resolve("env:KEY"), { code: "connector_binding_missing" });
|
|
1158
|
+
}
|
|
1159
|
+
assert.equal(await createEnvironmentReferenceResolver({ KEY: "real-value" })("env:KEY"), "real-value");
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
test("OAuth placeholders from custom resolvers never create consent attempts", async () => {
|
|
1163
|
+
for (const value of [undefined, "", " ", "MISSING", " MISSING "]) {
|
|
1164
|
+
const store = memoryStore();
|
|
1165
|
+
let attempts = 0;
|
|
1166
|
+
store.saveAttempt = async () => { attempts++; };
|
|
1167
|
+
const service = createConnectionService({
|
|
1168
|
+
configuration: configuration(), providers: [googleCalendarProvider], store,
|
|
1169
|
+
authorize: async () => owner,
|
|
1170
|
+
resolveReference: async (reference) => reference === "env:CALLBACK" ? callback : value,
|
|
1171
|
+
fetchImpl: async () => { assert.fail("No provider request is permitted."); }
|
|
1172
|
+
});
|
|
1173
|
+
await assert.rejects(service.beginAuthorization({ context: owner, integrationId: "calendar" }), { code: "connector_binding_missing" });
|
|
1174
|
+
assert.equal(attempts, 0);
|
|
1175
|
+
}
|
|
1176
|
+
const config = configuration();
|
|
1177
|
+
config.registrations.google.clientId = "MISSING";
|
|
1178
|
+
const service = createConnectionService({ configuration: config, providers: [googleCalendarProvider], store: memoryStore(),
|
|
1179
|
+
authorize: async () => owner, resolveReference: async () => { assert.fail("No binding resolution is needed."); } });
|
|
1180
|
+
await assert.rejects(service.beginAuthorization({ context: owner, integrationId: "calendar" }), { code: "connector_binding_missing" });
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("API-key placeholders from custom resolvers never reach the provider", async () => {
|
|
1184
|
+
const config = { schemaVersion: 1, registrations: {}, integrations: { mail: {
|
|
1185
|
+
provider: "resend", accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:KEY" }
|
|
1186
|
+
} } };
|
|
1187
|
+
const service = createConnectionService({ configuration: config, providers: [resendProvider], store: memoryStore(),
|
|
1188
|
+
authorize: async () => owner, resolveReference: async () => "MISSING",
|
|
1189
|
+
fetchImpl: async () => { assert.fail("No placeholder may be sent to the provider."); } });
|
|
1190
|
+
await assert.rejects(service.connectApiKey({ context: owner, integrationId: "mail" }), { code: "connector_binding_missing" });
|
|
1191
|
+
assert.deepEqual(await service.status({ context: owner, integrationId: "mail" }), { status: "unconfigured", configurationError: "connector_binding_missing" });
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
test("missing OAuth credentials keep an existing grant visible and removable without provider traffic", async () => {
|
|
1196
|
+
const fixture = setup();
|
|
1197
|
+
await fixture.connect();
|
|
1198
|
+
const count = fixture.requests.length;
|
|
1199
|
+
const service = createConnectionService({ ...fixture.options,
|
|
1200
|
+
resolveReference: async (reference) => reference === "env:CALLBACK" ? callback : "MISSING" });
|
|
1201
|
+
const input = { context: owner, integrationId: "calendar" };
|
|
1202
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
1203
|
+
assert.equal((await fixture.service.status(input)).status, "connected");
|
|
1204
|
+
assert.equal(fixture.requests.length, count);
|
|
1205
|
+
await service.disconnect(input);
|
|
1206
|
+
assert.deepEqual(await service.status(input), { status: "unconfigured", configurationError: "connector_binding_missing" });
|
|
1207
|
+
assert.equal(fixture.requests.length, count);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test("missing API key preserves a verified connection until explicit disconnect", async () => {
|
|
1211
|
+
let key = "fixture-key";
|
|
1212
|
+
let requests = 0;
|
|
1213
|
+
const service = createConnectionService({
|
|
1214
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: { mail: {
|
|
1215
|
+
provider: "resend", accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:KEY" }
|
|
1216
|
+
} } }, providers: [resendProvider], store: memoryStore(), authorize: async () => owner,
|
|
1217
|
+
resolveReference: async () => key,
|
|
1218
|
+
fetchImpl: async () => { requests++; return Response.json({ object: "list", data: [], has_more: false }); }
|
|
1219
|
+
});
|
|
1220
|
+
const input = { context: owner, integrationId: "mail" };
|
|
1221
|
+
await service.connectApiKey(input);
|
|
1222
|
+
key = "MISSING";
|
|
1223
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
1224
|
+
assert.equal(requests, 1);
|
|
1225
|
+
key = "fixture-key";
|
|
1226
|
+
assert.equal((await service.status(input)).status, "connected");
|
|
1227
|
+
key = "";
|
|
1228
|
+
await service.disconnect(input);
|
|
1229
|
+
assert.deepEqual(await service.status(input), { status: "unconfigured", configurationError: "connector_binding_missing" });
|
|
1230
|
+
assert.equal(requests, 1);
|
|
1231
|
+
});
|