@jskit-ai/connectors-core 0.1.61 → 0.1.63
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 +16 -5
- package/package.json +3 -9
- package/src/server/connectionService.js +134 -67
- package/src/server/knexConnectionStore.js +1 -4
- package/src/shared/configuration.js +52 -7
- package/test/connectionService.test.js +30 -0
- package/test/providerSetup.test.js +28 -0
- package/test/refreshTokenSeed.test.js +152 -0
package/README.md
CHANGED
|
@@ -292,6 +292,10 @@ Provider `settingsFields[].authenticationMethods` restricts fields to particular
|
|
|
292
292
|
credential modes; the shared parser rejects values left in an incompatible mode.
|
|
293
293
|
|
|
294
294
|
Provider operations receive `(input, settings)` when constructing a request.
|
|
295
|
+
An operation's `validateResult(result, request)` receives the parsed result and
|
|
296
|
+
the request object its own `request` returned (`method`, `url`, `body`,
|
|
297
|
+
`headers`), so it can confirm the provider returned the record, page or range
|
|
298
|
+
that request asked for; a false return reports `connector_response_invalid`.
|
|
295
299
|
OAuth providers may supply `oauth(settings)` to select regional authorization
|
|
296
300
|
metadata. `oauthResource` may be a string or function of those settings; when
|
|
297
301
|
present, it is sent as `resource` in consent, code exchange and refresh. The
|
|
@@ -392,8 +396,7 @@ the core and provider contracts are verified with controlled service-account fix
|
|
|
392
396
|
## File storage
|
|
393
397
|
|
|
394
398
|
Import `createFileConnectionStore` and `createCredentialProtection` from
|
|
395
|
-
`@jskit-ai/connectors-core/server/file-storage`.
|
|
396
|
-
or require database-runtime. Use `createFileConnectionStore({ directory,
|
|
399
|
+
`@jskit-ai/connectors-core/server/file-storage`. Use `createFileConnectionStore({ directory,
|
|
397
400
|
protection })` with an absolute private directory outside application source.
|
|
398
401
|
Each connection uses an encrypted JSON record containing its grant and pending
|
|
399
402
|
authorization attempts. The same protection contract is described below.
|
|
@@ -434,9 +437,8 @@ an old key before those rows are rewritten makes them unreadable. Applications
|
|
|
434
437
|
with an existing vault can instead provide compatible `seal(value, binding)`
|
|
435
438
|
and `open(ciphertext, binding)` methods.
|
|
436
439
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
database provider. Applications using another store do not need this peer.
|
|
440
|
+
The Knex store uses the application's own Knex client and its `transaction()`;
|
|
441
|
+
editing portable configuration does not install or activate a database provider.
|
|
440
442
|
|
|
441
443
|
The package declares its authoritative migration directory in `package.json`.
|
|
442
444
|
Use the normal app-owned `knexfile.js` and migration scripts described by the
|
|
@@ -551,3 +553,12 @@ authenticationMethod)` filters these choices; configuration validation uses the
|
|
|
551
553
|
selected method. Forms remove incompatible scopes on method changes and select
|
|
552
554
|
recommended method-specific scopes when entering that mode. Granola uses this
|
|
553
555
|
to keep OAuth identity scopes out of API-key configuration.
|
|
556
|
+
|
|
557
|
+
Provider `setup` holds the guide `url` and ordered `steps` shown to whoever
|
|
558
|
+
configures the connection. `urlByAuthentication` and `stepsByAuthentication`
|
|
559
|
+
override them for one authentication method; `urlForSettings(settings, method)`
|
|
560
|
+
and `stepsForSettings(settings, method)` override both and return `undefined`
|
|
561
|
+
to fall through. `getProviderSetup(provider, settings, method)` resolves that
|
|
562
|
+
precedence into `{ url, steps }`. A provider with `configurationOnly`, or whose
|
|
563
|
+
`configurationOnlyForSettings(settings)` returns true, has no account
|
|
564
|
+
connection; `isProviderConfigurationOnly(provider, settings)` resolves both.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/connectors-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.63",
|
|
4
4
|
"description": "Portable integration configuration and server-side account connection runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -53,13 +53,7 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@jskit-ai/
|
|
57
|
-
"@jskit-ai/
|
|
58
|
-
"@jskit-ai/kernel": "0.1.244"
|
|
59
|
-
},
|
|
60
|
-
"peerDependenciesMeta": {
|
|
61
|
-
"@jskit-ai/database-runtime": {
|
|
62
|
-
"optional": true
|
|
63
|
-
}
|
|
56
|
+
"@jskit-ai/http-runtime": "0.1.244",
|
|
57
|
+
"@jskit-ai/kernel": "0.1.246"
|
|
64
58
|
}
|
|
65
59
|
}
|
|
@@ -2,7 +2,7 @@ import * as oauth from "oauth4webapi";
|
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { createHttpClient } from "@jskit-ai/http-runtime/client";
|
|
5
|
-
import { validateIntegrationConfiguration } from "../shared/configuration.js";
|
|
5
|
+
import { registrationClientAuthentication, registrationGrantType, validateIntegrationConfiguration } from "../shared/configuration.js";
|
|
6
6
|
import { ConnectorError, providerError } from "./errors.js";
|
|
7
7
|
|
|
8
8
|
function publicConnection(connection) {
|
|
@@ -13,10 +13,31 @@ function publicConnection(connection) {
|
|
|
13
13
|
integrationId: connection.integrationId,
|
|
14
14
|
grantedScopes: [...connection.grantedScopes],
|
|
15
15
|
verifiedAt: connection.verifiedAt,
|
|
16
|
-
...(connection.accountLabel ? { accountLabel: connection.accountLabel } : {})
|
|
16
|
+
...(connection.accountLabel ? { accountLabel: connection.accountLabel } : {}),
|
|
17
|
+
...(connection.source ? { source: connection.source } : {})
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
const ENV_SEEDED_MESSAGE = "This connection comes from an Env value; unlink or replace that value instead.";
|
|
22
|
+
const VERIFICATION_SCOPE_MESSAGE = "Connection verification requires a permission absent from the application's configuration.";
|
|
23
|
+
|
|
24
|
+
// An operation without scopes always runs; otherwise one of its scopes must be present.
|
|
25
|
+
function requireOperationScope(operation, scopes, message) {
|
|
26
|
+
if (operation.scopes.length && !operation.scopes.some((scope) => scopes.includes(scope))) {
|
|
27
|
+
throw new ConnectorError("connector_scope_missing", message, { statusCode: 403 });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Builds the provider request, reporting schema input errors as connector_input_invalid.
|
|
32
|
+
function operationRequest(operation, input, settings, providerData) {
|
|
33
|
+
try { return operation.request(input, settings || {}, providerData); } catch (error) {
|
|
34
|
+
if (!error.fieldErrors) throw error;
|
|
35
|
+
const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422 });
|
|
36
|
+
failure.fieldErrors = error.fieldErrors;
|
|
37
|
+
throw failure;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
function validateCallbackUrl(value) {
|
|
21
42
|
let url;
|
|
22
43
|
try { url = new URL(value); } catch { /* reported below */ }
|
|
@@ -27,6 +48,29 @@ function validateCallbackUrl(value) {
|
|
|
27
48
|
return url.href;
|
|
28
49
|
}
|
|
29
50
|
|
|
51
|
+
// Whether a stored connection still belongs to the configured integration and
|
|
52
|
+
// OAuth client. `registration` is { registrationRef, clientId, grantType,
|
|
53
|
+
// tokenEndpointAuthMethod } with defaults applied; only OAuth reads it.
|
|
54
|
+
function connectionMatches(connection, integration, providerId, registration) {
|
|
55
|
+
const method = integration.authentication.method;
|
|
56
|
+
return connection.provider === providerId &&
|
|
57
|
+
(connection.method || "oauth2") === method &&
|
|
58
|
+
isDeepStrictEqual(connection.settings || {}, integration.settings || {}) &&
|
|
59
|
+
(!["api-key", "service-account"].includes(method) || connection.secretRef === integration.authentication.secretRef) &&
|
|
60
|
+
(method !== "service-account" || isDeepStrictEqual(connection.requestedScopes, integration.scopes)) &&
|
|
61
|
+
(method !== "oauth2" || (connection.registrationRef === registration.registrationRef &&
|
|
62
|
+
connection.clientId === registration.clientId &&
|
|
63
|
+
registrationGrantType(connection) === registration.grantType &&
|
|
64
|
+
(registration.grantType !== "client_credentials" || isDeepStrictEqual(connection.requestedScopes, integration.scopes)) &&
|
|
65
|
+
registrationClientAuthentication(connection) === registration.tokenEndpointAuthMethod));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Providers that resend redirect_uri on refresh need the callback the grant was issued for.
|
|
69
|
+
function callbackMatches(connection, provider, registration) {
|
|
70
|
+
return registration.grantType !== "authorization_code" || !provider.refreshRequiresRedirectUri ||
|
|
71
|
+
connection.callbackUrl === registration.callbackUrl;
|
|
72
|
+
}
|
|
73
|
+
|
|
30
74
|
function createConnectionService({
|
|
31
75
|
configuration,
|
|
32
76
|
providers,
|
|
@@ -102,8 +146,8 @@ function createConnectionService({
|
|
|
102
146
|
if (registration.clientId.trim() === "MISSING") {
|
|
103
147
|
throw new ConnectorError("connector_binding_missing", "The provider client ID is not configured.");
|
|
104
148
|
}
|
|
105
|
-
const grantType = registration
|
|
106
|
-
const tokenEndpointAuthMethod = registration
|
|
149
|
+
const grantType = registrationGrantType(registration);
|
|
150
|
+
const tokenEndpointAuthMethod = registrationClientAuthentication(registration);
|
|
107
151
|
if (provider.oauthPkce === false && tokenEndpointAuthMethod === "none") {
|
|
108
152
|
throw new ConnectorError("connector_mode_unavailable", "This provider's non-PKCE flow requires a confidential client.");
|
|
109
153
|
}
|
|
@@ -166,6 +210,38 @@ function createConnectionService({
|
|
|
166
210
|
}
|
|
167
211
|
}
|
|
168
212
|
|
|
213
|
+
// An Env refresh token seeds the shared connection; the app then refreshes
|
|
214
|
+
// and stores rotated tokens itself, and reseeds when the value or the OAuth
|
|
215
|
+
// client changes.
|
|
216
|
+
async function envRefreshTokenFor(integration) {
|
|
217
|
+
try {
|
|
218
|
+
const value = await resolveReference(integration.authentication.refreshTokenRef);
|
|
219
|
+
const refreshToken = typeof value === "string" ? value.trim() : "";
|
|
220
|
+
if (!refreshToken || refreshToken === "MISSING" || /\s/u.test(refreshToken)) throw new Error("Invalid token.");
|
|
221
|
+
return { refreshToken, fingerprint: createHash("sha256").update(refreshToken).digest("hex") };
|
|
222
|
+
} catch {
|
|
223
|
+
throw new ConnectorError("connector_binding_missing", "The refresh token binding is missing or invalid.");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function envSeedIsCurrent(connection, seed, registration) {
|
|
228
|
+
return connection?.seedFingerprint === seed.fingerprint && connection.clientId === registration.client.client_id &&
|
|
229
|
+
connection.registrationRef === registration.registrationRef &&
|
|
230
|
+
connection.tokenEndpointAuthMethod === registration.tokenEndpointAuthMethod;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function envSeededConnection(integrationId, integration, provider, registration, seed) {
|
|
234
|
+
return {
|
|
235
|
+
integrationId, provider: provider.id, registrationRef: registration.registrationRef,
|
|
236
|
+
grantType: registration.grantType, clientId: registration.client.client_id,
|
|
237
|
+
tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod, status: "connected", source: "env",
|
|
238
|
+
seedFingerprint: seed.fingerprint, verifiedAt: now(), settings: structuredClone(integration.settings || {}),
|
|
239
|
+
...(provider.refreshRequiresRedirectUri ? { callbackUrl: registration.callbackUrl } : {}),
|
|
240
|
+
tokens: { accessToken: null, refreshToken: seed.refreshToken, expiresAt: 0 },
|
|
241
|
+
grantedScopes: [...integration.scopes]
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
169
245
|
async function serviceAccountGrant(integration, provider, binding, signal) {
|
|
170
246
|
if (typeof provider.serviceAccountGrant !== "function") {
|
|
171
247
|
throw new ConnectorError("connector_mode_unavailable", "This provider does not implement service-account authorization.");
|
|
@@ -200,11 +276,8 @@ function createConnectionService({
|
|
|
200
276
|
if (integration.authentication.method !== "service-account" || typeof provider.serviceAccountGrant !== "function") {
|
|
201
277
|
throw new ConnectorError("connector_mode_unavailable", "Select service-account credentials to verify this connection.");
|
|
202
278
|
}
|
|
203
|
-
provider.operations[provider.checkOperation]
|
|
204
|
-
|
|
205
|
-
if (required.length && !required.some((scope) => integration.scopes.includes(scope))) {
|
|
206
|
-
throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
|
|
207
|
-
}
|
|
279
|
+
operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
|
|
280
|
+
requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
|
|
208
281
|
return store.withConnection({ owner, integrationId }, async ({ save }) => {
|
|
209
282
|
try {
|
|
210
283
|
const binding = await serviceAccountCredentialFor(integration);
|
|
@@ -238,17 +311,11 @@ function createConnectionService({
|
|
|
238
311
|
if (!operation || !Object.hasOwn(provider.operations, operationId)) {
|
|
239
312
|
throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.");
|
|
240
313
|
}
|
|
241
|
-
if (["oauth2", "service-account"].includes(connection.method || "oauth2")
|
|
242
|
-
|
|
314
|
+
if (["oauth2", "service-account"].includes(connection.method || "oauth2")) {
|
|
315
|
+
requireOperationScope(operation, connection.grantedScopes, "Connect again with the permission required for this operation.");
|
|
243
316
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (!error.fieldErrors) throw error;
|
|
247
|
-
const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422 });
|
|
248
|
-
failure.fieldErrors = error.fieldErrors;
|
|
249
|
-
throw failure;
|
|
250
|
-
}
|
|
251
|
-
const url = new URL(request.url);
|
|
317
|
+
const descriptor = operationRequest(operation, input, connection.settings, connection.providerData);
|
|
318
|
+
const url = new URL(descriptor.url);
|
|
252
319
|
const origins = typeof provider.apiOrigins === "function" ? provider.apiOrigins(connection.settings || {}, connection.providerData) : provider.apiOrigins;
|
|
253
320
|
if (url.protocol !== "https:" || url.username || url.password || !origins.includes(url.origin)) {
|
|
254
321
|
throw new ConnectorError("connector_destination_invalid", "The provider operation has an invalid destination.");
|
|
@@ -265,17 +332,17 @@ function createConnectionService({
|
|
|
265
332
|
if (connection.method === "api-key" && provider.apiKey.queryParameter) {
|
|
266
333
|
url.searchParams.set(provider.apiKey.queryParameter, connection.apiKey);
|
|
267
334
|
}
|
|
268
|
-
let body =
|
|
335
|
+
let body = descriptor.body;
|
|
269
336
|
if (connection.method === "api-key" && provider.apiKey.bodyParameter) {
|
|
270
|
-
if (!body || typeof body !== "object" || Array.isArray(body) || ["GET", "HEAD"].includes(
|
|
337
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || ["GET", "HEAD"].includes(descriptor.method)) {
|
|
271
338
|
throw new ConnectorError("connector_request_invalid", "This provider requires a JSON request body.");
|
|
272
339
|
}
|
|
273
340
|
body = { ...body, [provider.apiKey.bodyParameter]: connection.apiKey };
|
|
274
341
|
}
|
|
275
342
|
const options = {
|
|
276
|
-
method:
|
|
343
|
+
method: descriptor.method,
|
|
277
344
|
body,
|
|
278
|
-
headers: { ...
|
|
345
|
+
headers: { ...descriptor.headers, Accept: "application/json", ...(connection.method === "api-key"
|
|
279
346
|
? provider.apiKey.headers?.(connection.apiKey, connection.settings || {})
|
|
280
347
|
: connection.method === "none" ? {} : {
|
|
281
348
|
...provider.oauthHeaders?.({ clientId: connection.clientId }), Authorization: `Bearer ${connection.tokens.accessToken}`
|
|
@@ -294,7 +361,8 @@ function createConnectionService({
|
|
|
294
361
|
options.signal.throwIfAborted();
|
|
295
362
|
throw error;
|
|
296
363
|
}
|
|
297
|
-
|
|
364
|
+
// The validator also receives the operation's own request, so it can check the provider answered what was asked.
|
|
365
|
+
if (typeof operation.validateResult === "function" && !operation.validateResult(result, descriptor)) {
|
|
298
366
|
throw new ConnectorError("connector_response_invalid", "The provider returned an unexpected response.", { statusCode: 502 });
|
|
299
367
|
}
|
|
300
368
|
return result;
|
|
@@ -329,10 +397,7 @@ function createConnectionService({
|
|
|
329
397
|
throw new ConnectorError("connector_response_invalid", "The provider returned an invalid permission grant.", { statusCode: 502 });
|
|
330
398
|
}
|
|
331
399
|
connection.grantedScopes = connection.grantedScopes.filter((scope) => requestedScopes.includes(scope) && scopes.includes(scope));
|
|
332
|
-
|
|
333
|
-
if (required.length && !required.some((scope) => connection.grantedScopes.includes(scope))) {
|
|
334
|
-
throw new ConnectorError("connector_scope_missing", "Connect again with the permission required for verification.", { statusCode: 403 });
|
|
335
|
-
}
|
|
400
|
+
requireOperationScope(provider.operations[provider.checkOperation], connection.grantedScopes, "Connect again with the permission required for verification.");
|
|
336
401
|
}
|
|
337
402
|
|
|
338
403
|
async function clientCredentialsGrant(registration, provider, settings, scopes, signal) {
|
|
@@ -357,11 +422,8 @@ function createConnectionService({
|
|
|
357
422
|
if (registration.grantType !== "client_credentials") {
|
|
358
423
|
throw new ConnectorError("connector_mode_unavailable", "Select client credentials to connect a service account.");
|
|
359
424
|
}
|
|
360
|
-
provider.operations[provider.checkOperation]
|
|
361
|
-
|
|
362
|
-
if (required.length && !required.some((scope) => integration.scopes.includes(scope))) {
|
|
363
|
-
throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
|
|
364
|
-
}
|
|
425
|
+
operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
|
|
426
|
+
requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
|
|
365
427
|
return store.withConnection({ owner, integrationId }, async ({ save }) => {
|
|
366
428
|
try {
|
|
367
429
|
const connection = {
|
|
@@ -380,8 +442,11 @@ function createConnectionService({
|
|
|
380
442
|
|
|
381
443
|
async function beginAuthorization({ context, integrationId, verificationInput = {}, signal }) {
|
|
382
444
|
const { owner, integration, provider } = await access(context, integrationId, "connect");
|
|
445
|
+
if (integration.authentication.refreshTokenRef) {
|
|
446
|
+
throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
|
|
447
|
+
}
|
|
383
448
|
// Resource-specific providers validate the first document ID before opening consent.
|
|
384
|
-
provider.operations[provider.checkOperation]
|
|
449
|
+
operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
|
|
385
450
|
const registration = await registrationFor(integration, provider);
|
|
386
451
|
if (registration.grantType !== "authorization_code") {
|
|
387
452
|
throw new ConnectorError("connector_mode_unavailable", "Service accounts connect without browser consent.");
|
|
@@ -456,8 +521,8 @@ function createConnectionService({
|
|
|
456
521
|
if (!attempt || attempt.expiresAt <= now() || attempt.provider !== provider.id ||
|
|
457
522
|
(attempt.codeVerifier === null) !== (provider.oauthPkce === false) ||
|
|
458
523
|
attempt.clientId !== registration.client.client_id || attempt.registrationRef !== registration.registrationRef ||
|
|
459
|
-
(attempt
|
|
460
|
-
(attempt
|
|
524
|
+
registrationGrantType(attempt) !== registration.grantType ||
|
|
525
|
+
registrationClientAuthentication(attempt) !== registration.tokenEndpointAuthMethod ||
|
|
461
526
|
attempt.callbackUrl !== registration.callbackUrl || JSON.stringify(attempt.scopes) !== JSON.stringify(integration.scopes) ||
|
|
462
527
|
!isDeepStrictEqual(attempt.settings || {}, integration.settings || {})) {
|
|
463
528
|
throw new ConnectorError("connector_attempt_invalid", "The authorization attempt is invalid or has expired.");
|
|
@@ -507,10 +572,7 @@ function createConnectionService({
|
|
|
507
572
|
!provider.apiKey?.bodyParameter && typeof provider.apiKey?.pathPrefix !== "function"))) {
|
|
508
573
|
throw new ConnectorError("connector_mode_unavailable", "Select the matching credential mode to verify this connection.");
|
|
509
574
|
}
|
|
510
|
-
|
|
511
|
-
if (requiredScopes.length && !requiredScopes.some((scope) => integration.scopes.includes(scope))) {
|
|
512
|
-
throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
|
|
513
|
-
}
|
|
575
|
+
requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
|
|
514
576
|
return store.withConnection({ owner, integrationId }, async ({ save }) => {
|
|
515
577
|
const connection = {
|
|
516
578
|
integrationId, provider: provider.id, method,
|
|
@@ -537,24 +599,20 @@ function createConnectionService({
|
|
|
537
599
|
if (!Object.hasOwn(provider.operations, operation)) {
|
|
538
600
|
throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.");
|
|
539
601
|
}
|
|
540
|
-
|
|
541
|
-
throw new ConnectorError("connector_scope_missing", "This operation requires a permission absent from the application's configuration.", { statusCode: 403 });
|
|
542
|
-
}
|
|
602
|
+
requireOperationScope(provider.operations[operation], integration.scopes, "This operation requires a permission absent from the application's configuration.");
|
|
543
603
|
const usesApiKey = integration.authentication.method === "api-key";
|
|
544
604
|
const usesServiceAccount = integration.authentication.method === "service-account";
|
|
545
605
|
const usesOAuth = integration.authentication.method === "oauth2";
|
|
546
606
|
const registration = usesOAuth ? await registrationFor(integration, provider) : null;
|
|
607
|
+
const seed = integration.authentication.refreshTokenRef ? await envRefreshTokenFor(integration) : null;
|
|
547
608
|
const outcome = await store.withConnection({ owner, integrationId }, async ({ connection, save }) => {
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
(
|
|
554
|
-
|
|
555
|
-
(registration.grantType === "client_credentials" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
|
|
556
|
-
(registration.grantType === "authorization_code" && provider.refreshRequiresRedirectUri && connection.callbackUrl !== registration.callbackUrl) ||
|
|
557
|
-
(connection.tokenEndpointAuthMethod || "client_secret_post") !== registration.tokenEndpointAuthMethod))) {
|
|
609
|
+
if (seed && !envSeedIsCurrent(connection, seed, registration)) {
|
|
610
|
+
connection = envSeededConnection(integrationId, integration, provider, registration, seed);
|
|
611
|
+
await save(connection);
|
|
612
|
+
}
|
|
613
|
+
if (!connection || connection.status !== "connected" ||
|
|
614
|
+
!connectionMatches(connection, integration, provider.id, usesOAuth ? { ...registration, clientId: registration.client.client_id } : null) ||
|
|
615
|
+
(usesOAuth && !callbackMatches(connection, provider, registration))) {
|
|
558
616
|
throw new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401 });
|
|
559
617
|
}
|
|
560
618
|
try {
|
|
@@ -597,8 +655,11 @@ function createConnectionService({
|
|
|
597
655
|
});
|
|
598
656
|
return { result };
|
|
599
657
|
} catch (error) {
|
|
600
|
-
|
|
601
|
-
if (failure.code === "connector_reconnect_required")
|
|
658
|
+
let failure = providerError(error);
|
|
659
|
+
if (failure.code === "connector_reconnect_required") {
|
|
660
|
+
await save({ ...connection, status: "reconnect-required" });
|
|
661
|
+
if (seed) failure = new ConnectorError(failure.code, "The refresh token in Env no longer works; replace that Env value.", { statusCode: 401 });
|
|
662
|
+
}
|
|
602
663
|
// Commit rotated tokens and reconnect state even when the API request fails.
|
|
603
664
|
return { failure };
|
|
604
665
|
}
|
|
@@ -609,17 +670,14 @@ function createConnectionService({
|
|
|
609
670
|
|
|
610
671
|
async function status({ context, integrationId }) {
|
|
611
672
|
const { owner, integration, provider } = await access(context, integrationId, "status");
|
|
673
|
+
// Compare against the configured registration without resolving its secrets.
|
|
612
674
|
const registration = config.registrations[integration.authentication.registrationRef];
|
|
675
|
+
const configured = {
|
|
676
|
+
registrationRef: integration.authentication.registrationRef, clientId: registration?.clientId,
|
|
677
|
+
grantType: registrationGrantType(registration), tokenEndpointAuthMethod: registrationClientAuthentication(registration)
|
|
678
|
+
};
|
|
613
679
|
return store.withConnection({ owner, integrationId }, async ({ connection }) => {
|
|
614
|
-
if (connection && (connection
|
|
615
|
-
(connection.method || "oauth2") !== integration.authentication.method ||
|
|
616
|
-
!isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
|
|
617
|
-
(["api-key", "service-account"].includes(integration.authentication.method) && connection.secretRef !== integration.authentication.secretRef) ||
|
|
618
|
-
(integration.authentication.method === "service-account" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
|
|
619
|
-
(integration.authentication.method === "oauth2" && (connection.registrationRef !== integration.authentication.registrationRef || connection.clientId !== registration?.clientId ||
|
|
620
|
-
(connection.grantType || "authorization_code") !== (registration?.grantType || "authorization_code") ||
|
|
621
|
-
(registration?.grantType === "client_credentials" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
|
|
622
|
-
(connection.tokenEndpointAuthMethod || "client_secret_post") !== (registration?.tokenEndpointAuthMethod || "client_secret_post"))))) {
|
|
680
|
+
if (connection && !integration.authentication.refreshTokenRef && !connectionMatches(connection, integration, integration.provider, configured)) {
|
|
623
681
|
return publicConnection({ ...connection, status: "reconnect-required" });
|
|
624
682
|
}
|
|
625
683
|
let callbackUrl;
|
|
@@ -627,8 +685,14 @@ function createConnectionService({
|
|
|
627
685
|
if (integration.authentication.method === "oauth2") {
|
|
628
686
|
const resolved = await registrationFor(integration, provider);
|
|
629
687
|
callbackUrl = resolved.callbackUrl;
|
|
630
|
-
if (
|
|
631
|
-
|
|
688
|
+
if (integration.authentication.refreshTokenRef) {
|
|
689
|
+
const seed = await envRefreshTokenFor(integration);
|
|
690
|
+
// A new value is used on the next call; until then it reads as connected from Env.
|
|
691
|
+
if (!envSeedIsCurrent(connection, seed, resolved)) {
|
|
692
|
+
return { status: "connected", source: "env", provider: provider.id, integrationId, grantedScopes: [...integration.scopes], callbackUrl };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (connection && !callbackMatches(connection, provider, resolved)) {
|
|
632
696
|
return publicConnection({ ...connection, status: "reconnect-required" });
|
|
633
697
|
}
|
|
634
698
|
} else if (integration.authentication.method === "api-key") {
|
|
@@ -647,7 +711,10 @@ function createConnectionService({
|
|
|
647
711
|
}
|
|
648
712
|
|
|
649
713
|
async function disconnect({ context, integrationId }) {
|
|
650
|
-
const { owner } = await access(context, integrationId, "disconnect");
|
|
714
|
+
const { owner, integration } = await access(context, integrationId, "disconnect");
|
|
715
|
+
if (integration.authentication.refreshTokenRef) {
|
|
716
|
+
throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
|
|
717
|
+
}
|
|
651
718
|
await store.withConnection({ owner, integrationId }, async ({ remove }) => remove());
|
|
652
719
|
return { status: "disconnected" };
|
|
653
720
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { createWithTransaction } from "@jskit-ai/database-runtime/shared";
|
|
3
2
|
|
|
4
3
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
5
4
|
|
|
@@ -10,12 +9,10 @@ function createKnexConnectionStore({ knex, protection }) {
|
|
|
10
9
|
if (typeof protection?.seal !== "function" || typeof protection?.open !== "function") {
|
|
11
10
|
throw new TypeError("Connector storage requires credential protection.");
|
|
12
11
|
}
|
|
13
|
-
const transaction = createWithTransaction(knex);
|
|
14
|
-
|
|
15
12
|
async function withConnection({ owner, integrationId }, work) {
|
|
16
13
|
const connectionKey = hash(JSON.stringify([owner.applicationId, owner.subjectId, integrationId]));
|
|
17
14
|
const connectionBinding = `connection:${connectionKey}`;
|
|
18
|
-
return transaction(async (trx) => {
|
|
15
|
+
return knex.transaction(async (trx) => {
|
|
19
16
|
// Retain this row on disconnect so other processes continue to lock the same identity.
|
|
20
17
|
await trx("connector_connections").insert({ connection_key: connectionKey })
|
|
21
18
|
.onConflict("connection_key").merge({ connection_key: connectionKey });
|
|
@@ -9,7 +9,8 @@ const secretReference = {
|
|
|
9
9
|
const authenticationSchema = createSchema({
|
|
10
10
|
method: { ...name, enum: ["oauth2", "api-key", "service-account", "none"] },
|
|
11
11
|
registrationRef: { ...name, required: false },
|
|
12
|
-
secretRef: { ...secretReference, required: false }
|
|
12
|
+
secretRef: { ...secretReference, required: false },
|
|
13
|
+
refreshTokenRef: { ...secretReference, required: false }
|
|
13
14
|
});
|
|
14
15
|
const assistantPermission = { type: "string", enum: ["ask", "always", "never"] };
|
|
15
16
|
const assistantPolicySchema = createSchema({
|
|
@@ -53,6 +54,22 @@ class IntegrationConfigurationError extends Error {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// Registrations, attempts and stored connections omit these when they use the defaults.
|
|
58
|
+
const DEFAULT_GRANT_TYPE = "authorization_code";
|
|
59
|
+
const DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_post";
|
|
60
|
+
|
|
61
|
+
function registrationGrantType(record) {
|
|
62
|
+
return record?.grantType || DEFAULT_GRANT_TYPE;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function registrationClientAuthentication(record) {
|
|
66
|
+
return record?.tokenEndpointAuthMethod || DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function providerGrantTypes(provider) {
|
|
70
|
+
return provider.oauthGrantTypes || [DEFAULT_GRANT_TYPE];
|
|
71
|
+
}
|
|
72
|
+
|
|
56
73
|
function getProviderSettingsSchema(provider, settings = {}) {
|
|
57
74
|
return typeof provider.settingsSchema === "function" ? provider.settingsSchema(settings) : provider.settingsSchema;
|
|
58
75
|
}
|
|
@@ -65,15 +82,28 @@ function getProviderAccountModes(provider, settings = {}) {
|
|
|
65
82
|
return provider.accountModesForSettings ? provider.accountModesForSettings(settings) : provider.accountModes;
|
|
66
83
|
}
|
|
67
84
|
|
|
68
|
-
function getProviderScopes(provider, settings = {}, grantType =
|
|
85
|
+
function getProviderScopes(provider, settings = {}, grantType = DEFAULT_GRANT_TYPE, authenticationMethod) {
|
|
69
86
|
const scopes = provider.scopesForGrantType ? provider.scopesForGrantType(grantType, settings)
|
|
70
87
|
: provider.scopesForSettings ? provider.scopesForSettings(settings) : provider.scopes;
|
|
71
88
|
return authenticationMethod ? scopes.filter(scope => !scope.authenticationMethods || scope.authenticationMethods.includes(authenticationMethod)) : scopes;
|
|
72
89
|
}
|
|
73
90
|
|
|
74
|
-
function getProviderClientAuthenticationMethods(provider, grantType =
|
|
91
|
+
function getProviderClientAuthenticationMethods(provider, grantType = DEFAULT_GRANT_TYPE, settings = {}) {
|
|
75
92
|
const methods = provider.oauthClientAuthenticationMethods;
|
|
76
|
-
return typeof methods === "function" ? methods(grantType, settings) : methods || [
|
|
93
|
+
return typeof methods === "function" ? methods(grantType, settings) : methods || [DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Setup text precedence: settings-dependent, then per-authentication-method, then static.
|
|
97
|
+
function getProviderSetup(provider, settings = {}, authenticationMethod) {
|
|
98
|
+
const setup = provider.setup || {};
|
|
99
|
+
return {
|
|
100
|
+
url: setup.urlForSettings?.(settings, authenticationMethod) || setup.urlByAuthentication?.[authenticationMethod] || setup.url,
|
|
101
|
+
steps: setup.stepsForSettings?.(settings, authenticationMethod) || setup.stepsByAuthentication?.[authenticationMethod] || setup.steps
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isProviderConfigurationOnly(provider, settings = {}) {
|
|
106
|
+
return Boolean(provider.configurationOnly || provider.configurationOnlyForSettings?.(settings));
|
|
77
107
|
}
|
|
78
108
|
|
|
79
109
|
function validateIntegrationConfiguration(input, { providers = [], allowUnknownProviders = false } = {}) {
|
|
@@ -116,12 +146,20 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
|
|
|
116
146
|
const auth = integration.authentication;
|
|
117
147
|
const provider = definitions.get(integration.provider);
|
|
118
148
|
const registration = config.registrations[auth.registrationRef];
|
|
119
|
-
const grantType = registration
|
|
149
|
+
const grantType = registrationGrantType(registration);
|
|
150
|
+
if (auth.refreshTokenRef && auth.method !== "oauth2") {
|
|
151
|
+
fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only an OAuth connection can start from a refresh token.";
|
|
152
|
+
}
|
|
120
153
|
if (auth.method === "oauth2") {
|
|
121
154
|
if (!auth.registrationRef || !Object.hasOwn(config.registrations, auth.registrationRef)) {
|
|
122
155
|
fieldErrors[`${path}.authentication.registrationRef`] = "Select a registration defined in this file.";
|
|
123
156
|
}
|
|
124
157
|
if (auth.secretRef) fieldErrors[`${path}.authentication.secretRef`] = "Use the registration's secret reference.";
|
|
158
|
+
if (auth.refreshTokenRef && integration.accountMode !== "shared") {
|
|
159
|
+
fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only a shared connection can start from a refresh token in Env.";
|
|
160
|
+
} else if (auth.refreshTokenRef && grantType !== "authorization_code") {
|
|
161
|
+
fieldErrors[`${path}.authentication.refreshTokenRef`] = "Client credentials do not use a refresh token.";
|
|
162
|
+
}
|
|
125
163
|
if (grantType === "client_credentials" && integration.accountMode === "per-user") {
|
|
126
164
|
fieldErrors[`${path}.accountMode`] = "A service account cannot connect as each app user.";
|
|
127
165
|
}
|
|
@@ -172,11 +210,11 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
|
|
|
172
210
|
}
|
|
173
211
|
if (auth.method === "oauth2" && registration?.source === "own") {
|
|
174
212
|
const registrationPath = `registrations.${auth.registrationRef}`;
|
|
175
|
-
if (!(provider
|
|
213
|
+
if (!providerGrantTypes(provider).includes(grantType)) {
|
|
176
214
|
fieldErrors[`${registrationPath}.grantType`] = "This provider does not support this OAuth flow.";
|
|
177
215
|
}
|
|
178
216
|
const methods = getProviderClientAuthenticationMethods(provider, grantType, integration.settings || {});
|
|
179
|
-
if (!methods.includes(registration
|
|
217
|
+
if (!methods.includes(registrationClientAuthentication(registration))) {
|
|
180
218
|
fieldErrors[`${registrationPath}.tokenEndpointAuthMethod`] = "This provider does not support this client authentication method.";
|
|
181
219
|
}
|
|
182
220
|
if (registration.clientId && provider.validateClientId) {
|
|
@@ -212,11 +250,18 @@ function parseIntegrationConfiguration(text, options) {
|
|
|
212
250
|
}
|
|
213
251
|
|
|
214
252
|
export {
|
|
253
|
+
DEFAULT_GRANT_TYPE,
|
|
254
|
+
DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD,
|
|
255
|
+
registrationGrantType,
|
|
256
|
+
registrationClientAuthentication,
|
|
257
|
+
providerGrantTypes,
|
|
215
258
|
getProviderAuthenticationMethods,
|
|
216
259
|
getProviderClientAuthenticationMethods,
|
|
217
260
|
getProviderAccountModes,
|
|
218
261
|
getProviderScopes,
|
|
219
262
|
getProviderSettingsSchema,
|
|
263
|
+
getProviderSetup,
|
|
264
|
+
isProviderConfigurationOnly,
|
|
220
265
|
secretReference,
|
|
221
266
|
integrationsSchema,
|
|
222
267
|
IntegrationConfigurationError,
|
|
@@ -79,6 +79,26 @@ test("credential-free grants require verification, authorize access and invalida
|
|
|
79
79
|
assert.equal((await restart.status(input)).status, "disconnected");
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
test("result validation sees the request its operation built and rejects a mismatched answer", async () => {
|
|
83
|
+
const check = clickhouseProvider.operations["connection.check"];
|
|
84
|
+
const seen = [];
|
|
85
|
+
let expectedUser = "default";
|
|
86
|
+
const provider = { ...clickhouseProvider, operations: { ...clickhouseProvider.operations, "connection.check": { ...check,
|
|
87
|
+
validateResult: (value, request) => { seen.push(request); return check.validateResult(value) && value.data[0].user === expectedUser; } } } };
|
|
88
|
+
const service = createConnectionService({
|
|
89
|
+
configuration: databaseConfiguration({ method: "none" }), providers: [provider], store: memoryStore(),
|
|
90
|
+
authorize: async (context) => context, resolveReference: async () => "secret",
|
|
91
|
+
fetchImpl: async () => Response.json({ meta: [], data: [{ ok: 1, user: "default" }], rows: 1 })
|
|
92
|
+
});
|
|
93
|
+
const input = { context: owner, integrationId: "database" };
|
|
94
|
+
await service.connectWithoutCredentials(input);
|
|
95
|
+
const built = check.request({}, { httpUrl: "https://database.example:8443/" });
|
|
96
|
+
assert.equal(seen[0].url, built.url);
|
|
97
|
+
assert.equal(seen[0].method, built.method);
|
|
98
|
+
expectedUser = "someone-else";
|
|
99
|
+
await assert.rejects(service.invoke({ ...input, operation: "connection.check" }), { code: "connector_response_invalid", statusCode: 502 });
|
|
100
|
+
});
|
|
101
|
+
|
|
82
102
|
test("the ordinary credential-free Feature action verifies without resolving references", async () => {
|
|
83
103
|
const contexts = [];
|
|
84
104
|
let actions;
|
|
@@ -735,6 +755,16 @@ test("invalid operation input remains a field error and never reaches the provid
|
|
|
735
755
|
return true;
|
|
736
756
|
});
|
|
737
757
|
assert.equal(requests.length, count);
|
|
758
|
+
// Preflight verification input reports the same field error before consent opens.
|
|
759
|
+
await assert.rejects(service.beginAuthorization({
|
|
760
|
+
context: owner, integrationId: "calendar", verificationInput: { maxResults: -1 }
|
|
761
|
+
}), (error) => {
|
|
762
|
+
assert.equal(error.code, "connector_input_invalid");
|
|
763
|
+
assert.equal(error.statusCode, 422);
|
|
764
|
+
assert.ok(error.fieldErrors.maxResults);
|
|
765
|
+
return true;
|
|
766
|
+
});
|
|
767
|
+
assert.equal(requests.length, count);
|
|
738
768
|
});
|
|
739
769
|
|
|
740
770
|
test("saved grants cannot override permissions removed from application source", async () => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { getProviderSetup, isProviderConfigurationOnly } from "../src/shared/configuration.js";
|
|
4
|
+
|
|
5
|
+
const provider = {
|
|
6
|
+
configurationOnlyForSettings: (settings) => settings.mode === "public",
|
|
7
|
+
setup: {
|
|
8
|
+
url: "https://example.test/token", steps: ["Create a token."],
|
|
9
|
+
urlByAuthentication: { oauth2: "https://example.test/oauth" }, stepsByAuthentication: { oauth2: ["Register a client."] },
|
|
10
|
+
urlForSettings: (settings) => settings.mode === "mcp" ? "https://example.test/mcp" : undefined,
|
|
11
|
+
stepsForSettings: (settings, method) => settings.mode === "mcp" ? [`Register an MCP client for ${method}.`] : undefined
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
test("provider setup resolves settings, then authentication method, then static text", () => {
|
|
16
|
+
assert.deepEqual(getProviderSetup(provider, {}, "api-key"), { url: "https://example.test/token", steps: ["Create a token."] });
|
|
17
|
+
assert.deepEqual(getProviderSetup(provider, {}, "oauth2"), { url: "https://example.test/oauth", steps: ["Register a client."] });
|
|
18
|
+
assert.deepEqual(getProviderSetup(provider, { mode: "mcp" }, "oauth2"),
|
|
19
|
+
{ url: "https://example.test/mcp", steps: ["Register an MCP client for oauth2."] });
|
|
20
|
+
assert.deepEqual(getProviderSetup({ setup: { url: "https://example.test", steps: [] } }),
|
|
21
|
+
{ url: "https://example.test", steps: [] });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("configuration-only providers resolve static and settings-dependent flags", () => {
|
|
25
|
+
assert.equal(isProviderConfigurationOnly({ configurationOnly: true }), true);
|
|
26
|
+
assert.equal(isProviderConfigurationOnly(provider, { mode: "public" }), true);
|
|
27
|
+
assert.equal(isProviderConfigurationOnly(provider, {}), false);
|
|
28
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { googleCalendarProvider } from "../../connector-google-calendar/src/server/provider.js";
|
|
4
|
+
import { createConnectionService, createEnvironmentReferenceResolver } from "../src/server/index.js";
|
|
5
|
+
import { validateIntegrationConfiguration } from "../src/shared/configuration.js";
|
|
6
|
+
|
|
7
|
+
const listScope = "https://www.googleapis.com/auth/calendar.calendarlist.readonly";
|
|
8
|
+
const owner = { applicationId: "app-1", subjectId: "app" };
|
|
9
|
+
const integrationId = "calendar";
|
|
10
|
+
|
|
11
|
+
function configuration({ accountMode = "shared", method = "oauth2", refreshTokenRef = "env:GOOGLE_REFRESH_TOKEN", grantType } = {}) {
|
|
12
|
+
return {
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
integrations: {
|
|
15
|
+
calendar: {
|
|
16
|
+
provider: "google-calendar", accountMode, scopes: [listScope],
|
|
17
|
+
authentication: method === "oauth2"
|
|
18
|
+
? { method, registrationRef: "google", ...(refreshTokenRef ? { refreshTokenRef } : {}) }
|
|
19
|
+
: { method, secretRef: "env:KEY", refreshTokenRef },
|
|
20
|
+
settings: {}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
registrations: {
|
|
24
|
+
google: {
|
|
25
|
+
source: "own", clientId: "client-1", clientSecretRef: "env:GOOGLE_SECRET",
|
|
26
|
+
...(grantType ? { grantType, tokenEndpointAuthMethod: "client_secret_basic" } : { callbackUrlRef: "env:CALLBACK" })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function memoryStore() {
|
|
33
|
+
const connections = new Map();
|
|
34
|
+
return {
|
|
35
|
+
connections,
|
|
36
|
+
async withConnection({ integrationId: id }, run) {
|
|
37
|
+
let pending = structuredClone(connections.get(id) || null);
|
|
38
|
+
const result = await run({
|
|
39
|
+
connection: pending,
|
|
40
|
+
save: async (value) => { pending = structuredClone(value); },
|
|
41
|
+
remove: async () => { pending = null; },
|
|
42
|
+
putAttempt: async () => {},
|
|
43
|
+
consumeAttempt: async () => null
|
|
44
|
+
});
|
|
45
|
+
if (pending) connections.set(id, pending);
|
|
46
|
+
else connections.delete(id);
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setup({ env = { GOOGLE_REFRESH_TOKEN: "seed-token-1" } } = {}) {
|
|
53
|
+
const variables = { GOOGLE_SECRET: "client-secret", CALLBACK: "http://127.0.0.1:8080/callback", ...env };
|
|
54
|
+
const store = memoryStore();
|
|
55
|
+
const refreshes = [];
|
|
56
|
+
const revoked = new Set();
|
|
57
|
+
let time = 1_000_000;
|
|
58
|
+
const service = createConnectionService({
|
|
59
|
+
configuration: configuration(),
|
|
60
|
+
providers: [googleCalendarProvider],
|
|
61
|
+
store,
|
|
62
|
+
authorize: async (context) => context,
|
|
63
|
+
resolveReference: createEnvironmentReferenceResolver(variables),
|
|
64
|
+
now: () => time,
|
|
65
|
+
async fetchImpl(url, init) {
|
|
66
|
+
if (String(url) === "https://oauth2.googleapis.com/token") {
|
|
67
|
+
const body = new URLSearchParams(init.body);
|
|
68
|
+
const refreshToken = body.get("refresh_token");
|
|
69
|
+
refreshes.push(refreshToken);
|
|
70
|
+
if (revoked.has(refreshToken)) return Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
71
|
+
return Response.json({
|
|
72
|
+
token_type: "Bearer", access_token: `access-${refreshes.length}`, refresh_token: `rotated-${refreshes.length}`,
|
|
73
|
+
expires_in: 3600, scope: listScope
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return Response.json({ kind: "calendar#calendarList", items: [] });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const call = () => service.invoke({ context: owner, integrationId, operation: googleCalendarProvider.checkOperation });
|
|
80
|
+
return { service, store, variables, refreshes, revoked, call, advance: (ms) => { time += ms; } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test("refreshTokenRef is accepted only on a shared OAuth integration with a browser registration", () => {
|
|
84
|
+
const validate = (config) => validateIntegrationConfiguration(config, { providers: [googleCalendarProvider] });
|
|
85
|
+
assert.equal(validate(configuration()).integrations.calendar.authentication.refreshTokenRef, "env:GOOGLE_REFRESH_TOKEN");
|
|
86
|
+
const refused = [
|
|
87
|
+
[configuration({ accountMode: "per-user" }), "Only a shared connection can start from a refresh token in Env."],
|
|
88
|
+
[configuration({ grantType: "client_credentials" }), "Client credentials do not use a refresh token."],
|
|
89
|
+
[configuration({ method: "api-key" }), "Only an OAuth connection can start from a refresh token."]
|
|
90
|
+
];
|
|
91
|
+
for (const [config, message] of refused) {
|
|
92
|
+
assert.throws(() => validate(config), (error) => error.fieldErrors["integrations.calendar.authentication.refreshTokenRef"] === message);
|
|
93
|
+
}
|
|
94
|
+
assert.throws(() => validate(configuration({ refreshTokenRef: "a-raw-token" })), { code: "integration_configuration_invalid" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("the Env refresh token seeds the shared connection, which then refreshes on its own", async () => {
|
|
98
|
+
const { service, store, refreshes, call, advance } = setup();
|
|
99
|
+
assert.deepEqual(await service.status({ context: owner, integrationId }), {
|
|
100
|
+
status: "connected", source: "env", provider: "google-calendar", integrationId, grantedScopes: [listScope],
|
|
101
|
+
callbackUrl: "http://127.0.0.1:8080/callback"
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await call();
|
|
105
|
+
assert.deepEqual(refreshes, ["seed-token-1"]);
|
|
106
|
+
const stored = store.connections.get(integrationId);
|
|
107
|
+
assert.equal(stored.source, "env");
|
|
108
|
+
assert.equal(stored.tokens.refreshToken, "rotated-1");
|
|
109
|
+
assert.equal(JSON.stringify(stored).includes("seed-token-1"), false, "the seed itself is not kept once rotated");
|
|
110
|
+
assert.equal((await service.status({ context: owner, integrationId })).source, "env");
|
|
111
|
+
|
|
112
|
+
await call();
|
|
113
|
+
assert.deepEqual(refreshes, ["seed-token-1"], "a valid access token is reused");
|
|
114
|
+
advance(3600 * 1000);
|
|
115
|
+
await call();
|
|
116
|
+
assert.deepEqual(refreshes, ["seed-token-1", "rotated-1"], "the rotated token renews, not the seed");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a replaced Env value reseeds on the next call", async () => {
|
|
120
|
+
const { variables, refreshes, call } = setup();
|
|
121
|
+
await call();
|
|
122
|
+
variables.GOOGLE_REFRESH_TOKEN = "seed-token-2";
|
|
123
|
+
await call();
|
|
124
|
+
assert.deepEqual(refreshes, ["seed-token-1", "seed-token-2"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a revoked Env token asks for a new value, and a new value recovers", async () => {
|
|
128
|
+
const { service, variables, revoked, call } = setup();
|
|
129
|
+
revoked.add("seed-token-1");
|
|
130
|
+
await assert.rejects(call(), {
|
|
131
|
+
code: "connector_reconnect_required",
|
|
132
|
+
message: "The refresh token in Env no longer works; replace that Env value."
|
|
133
|
+
});
|
|
134
|
+
assert.equal((await service.status({ context: owner, integrationId })).status, "reconnect-required");
|
|
135
|
+
variables.GOOGLE_REFRESH_TOKEN = "seed-token-2";
|
|
136
|
+
assert.equal((await service.status({ context: owner, integrationId })).status, "connected");
|
|
137
|
+
await call();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an Env-seeded connection is not disconnected or reconnected in the app", async () => {
|
|
141
|
+
const { service, call } = setup();
|
|
142
|
+
await call();
|
|
143
|
+
const envManaged = { code: "connector_env_managed", statusCode: 409, message: "This connection comes from an Env value; unlink or replace that value instead." };
|
|
144
|
+
await assert.rejects(service.disconnect({ context: owner, integrationId }), envManaged);
|
|
145
|
+
await assert.rejects(service.beginAuthorization({ context: owner, integrationId }), envManaged);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a missing Env value reads as unconfigured", async () => {
|
|
149
|
+
const { service, call } = setup({ env: {} });
|
|
150
|
+
assert.deepEqual(await service.status({ context: owner, integrationId }), { status: "unconfigured", configurationError: "connector_binding_missing" });
|
|
151
|
+
await assert.rejects(call(), { code: "connector_binding_missing" });
|
|
152
|
+
});
|