@jskit-ai/connectors-core 0.1.62 → 0.1.64
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
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.64",
|
|
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.245"
|
|
59
|
-
},
|
|
60
|
-
"peerDependenciesMeta": {
|
|
61
|
-
"@jskit-ai/database-runtime": {
|
|
62
|
-
"optional": true
|
|
63
|
-
}
|
|
56
|
+
"@jskit-ai/http-runtime": "0.1.245",
|
|
57
|
+
"@jskit-ai/kernel": "0.1.247"
|
|
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) {
|
|
@@ -19,6 +19,24 @@ function publicConnection(connection) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
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
|
+
}
|
|
22
40
|
|
|
23
41
|
function validateCallbackUrl(value) {
|
|
24
42
|
let url;
|
|
@@ -30,6 +48,29 @@ function validateCallbackUrl(value) {
|
|
|
30
48
|
return url.href;
|
|
31
49
|
}
|
|
32
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
|
+
|
|
33
74
|
function createConnectionService({
|
|
34
75
|
configuration,
|
|
35
76
|
providers,
|
|
@@ -105,8 +146,8 @@ function createConnectionService({
|
|
|
105
146
|
if (registration.clientId.trim() === "MISSING") {
|
|
106
147
|
throw new ConnectorError("connector_binding_missing", "The provider client ID is not configured.");
|
|
107
148
|
}
|
|
108
|
-
const grantType = registration
|
|
109
|
-
const tokenEndpointAuthMethod = registration
|
|
149
|
+
const grantType = registrationGrantType(registration);
|
|
150
|
+
const tokenEndpointAuthMethod = registrationClientAuthentication(registration);
|
|
110
151
|
if (provider.oauthPkce === false && tokenEndpointAuthMethod === "none") {
|
|
111
152
|
throw new ConnectorError("connector_mode_unavailable", "This provider's non-PKCE flow requires a confidential client.");
|
|
112
153
|
}
|
|
@@ -235,11 +276,8 @@ function createConnectionService({
|
|
|
235
276
|
if (integration.authentication.method !== "service-account" || typeof provider.serviceAccountGrant !== "function") {
|
|
236
277
|
throw new ConnectorError("connector_mode_unavailable", "Select service-account credentials to verify this connection.");
|
|
237
278
|
}
|
|
238
|
-
provider.operations[provider.checkOperation]
|
|
239
|
-
|
|
240
|
-
if (required.length && !required.some((scope) => integration.scopes.includes(scope))) {
|
|
241
|
-
throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
|
|
242
|
-
}
|
|
279
|
+
operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
|
|
280
|
+
requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
|
|
243
281
|
return store.withConnection({ owner, integrationId }, async ({ save }) => {
|
|
244
282
|
try {
|
|
245
283
|
const binding = await serviceAccountCredentialFor(integration);
|
|
@@ -273,17 +311,11 @@ function createConnectionService({
|
|
|
273
311
|
if (!operation || !Object.hasOwn(provider.operations, operationId)) {
|
|
274
312
|
throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.");
|
|
275
313
|
}
|
|
276
|
-
if (["oauth2", "service-account"].includes(connection.method || "oauth2")
|
|
277
|
-
|
|
314
|
+
if (["oauth2", "service-account"].includes(connection.method || "oauth2")) {
|
|
315
|
+
requireOperationScope(operation, connection.grantedScopes, "Connect again with the permission required for this operation.");
|
|
278
316
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (!error.fieldErrors) throw error;
|
|
282
|
-
const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422 });
|
|
283
|
-
failure.fieldErrors = error.fieldErrors;
|
|
284
|
-
throw failure;
|
|
285
|
-
}
|
|
286
|
-
const url = new URL(request.url);
|
|
317
|
+
const descriptor = operationRequest(operation, input, connection.settings, connection.providerData);
|
|
318
|
+
const url = new URL(descriptor.url);
|
|
287
319
|
const origins = typeof provider.apiOrigins === "function" ? provider.apiOrigins(connection.settings || {}, connection.providerData) : provider.apiOrigins;
|
|
288
320
|
if (url.protocol !== "https:" || url.username || url.password || !origins.includes(url.origin)) {
|
|
289
321
|
throw new ConnectorError("connector_destination_invalid", "The provider operation has an invalid destination.");
|
|
@@ -300,17 +332,17 @@ function createConnectionService({
|
|
|
300
332
|
if (connection.method === "api-key" && provider.apiKey.queryParameter) {
|
|
301
333
|
url.searchParams.set(provider.apiKey.queryParameter, connection.apiKey);
|
|
302
334
|
}
|
|
303
|
-
let body =
|
|
335
|
+
let body = descriptor.body;
|
|
304
336
|
if (connection.method === "api-key" && provider.apiKey.bodyParameter) {
|
|
305
|
-
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)) {
|
|
306
338
|
throw new ConnectorError("connector_request_invalid", "This provider requires a JSON request body.");
|
|
307
339
|
}
|
|
308
340
|
body = { ...body, [provider.apiKey.bodyParameter]: connection.apiKey };
|
|
309
341
|
}
|
|
310
342
|
const options = {
|
|
311
|
-
method:
|
|
343
|
+
method: descriptor.method,
|
|
312
344
|
body,
|
|
313
|
-
headers: { ...
|
|
345
|
+
headers: { ...descriptor.headers, Accept: "application/json", ...(connection.method === "api-key"
|
|
314
346
|
? provider.apiKey.headers?.(connection.apiKey, connection.settings || {})
|
|
315
347
|
: connection.method === "none" ? {} : {
|
|
316
348
|
...provider.oauthHeaders?.({ clientId: connection.clientId }), Authorization: `Bearer ${connection.tokens.accessToken}`
|
|
@@ -329,7 +361,8 @@ function createConnectionService({
|
|
|
329
361
|
options.signal.throwIfAborted();
|
|
330
362
|
throw error;
|
|
331
363
|
}
|
|
332
|
-
|
|
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)) {
|
|
333
366
|
throw new ConnectorError("connector_response_invalid", "The provider returned an unexpected response.", { statusCode: 502 });
|
|
334
367
|
}
|
|
335
368
|
return result;
|
|
@@ -364,10 +397,7 @@ function createConnectionService({
|
|
|
364
397
|
throw new ConnectorError("connector_response_invalid", "The provider returned an invalid permission grant.", { statusCode: 502 });
|
|
365
398
|
}
|
|
366
399
|
connection.grantedScopes = connection.grantedScopes.filter((scope) => requestedScopes.includes(scope) && scopes.includes(scope));
|
|
367
|
-
|
|
368
|
-
if (required.length && !required.some((scope) => connection.grantedScopes.includes(scope))) {
|
|
369
|
-
throw new ConnectorError("connector_scope_missing", "Connect again with the permission required for verification.", { statusCode: 403 });
|
|
370
|
-
}
|
|
400
|
+
requireOperationScope(provider.operations[provider.checkOperation], connection.grantedScopes, "Connect again with the permission required for verification.");
|
|
371
401
|
}
|
|
372
402
|
|
|
373
403
|
async function clientCredentialsGrant(registration, provider, settings, scopes, signal) {
|
|
@@ -392,11 +422,8 @@ function createConnectionService({
|
|
|
392
422
|
if (registration.grantType !== "client_credentials") {
|
|
393
423
|
throw new ConnectorError("connector_mode_unavailable", "Select client credentials to connect a service account.");
|
|
394
424
|
}
|
|
395
|
-
provider.operations[provider.checkOperation]
|
|
396
|
-
|
|
397
|
-
if (required.length && !required.some((scope) => integration.scopes.includes(scope))) {
|
|
398
|
-
throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
|
|
399
|
-
}
|
|
425
|
+
operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
|
|
426
|
+
requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
|
|
400
427
|
return store.withConnection({ owner, integrationId }, async ({ save }) => {
|
|
401
428
|
try {
|
|
402
429
|
const connection = {
|
|
@@ -419,7 +446,7 @@ function createConnectionService({
|
|
|
419
446
|
throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
|
|
420
447
|
}
|
|
421
448
|
// Resource-specific providers validate the first document ID before opening consent.
|
|
422
|
-
provider.operations[provider.checkOperation]
|
|
449
|
+
operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
|
|
423
450
|
const registration = await registrationFor(integration, provider);
|
|
424
451
|
if (registration.grantType !== "authorization_code") {
|
|
425
452
|
throw new ConnectorError("connector_mode_unavailable", "Service accounts connect without browser consent.");
|
|
@@ -494,8 +521,8 @@ function createConnectionService({
|
|
|
494
521
|
if (!attempt || attempt.expiresAt <= now() || attempt.provider !== provider.id ||
|
|
495
522
|
(attempt.codeVerifier === null) !== (provider.oauthPkce === false) ||
|
|
496
523
|
attempt.clientId !== registration.client.client_id || attempt.registrationRef !== registration.registrationRef ||
|
|
497
|
-
(attempt
|
|
498
|
-
(attempt
|
|
524
|
+
registrationGrantType(attempt) !== registration.grantType ||
|
|
525
|
+
registrationClientAuthentication(attempt) !== registration.tokenEndpointAuthMethod ||
|
|
499
526
|
attempt.callbackUrl !== registration.callbackUrl || JSON.stringify(attempt.scopes) !== JSON.stringify(integration.scopes) ||
|
|
500
527
|
!isDeepStrictEqual(attempt.settings || {}, integration.settings || {})) {
|
|
501
528
|
throw new ConnectorError("connector_attempt_invalid", "The authorization attempt is invalid or has expired.");
|
|
@@ -545,10 +572,7 @@ function createConnectionService({
|
|
|
545
572
|
!provider.apiKey?.bodyParameter && typeof provider.apiKey?.pathPrefix !== "function"))) {
|
|
546
573
|
throw new ConnectorError("connector_mode_unavailable", "Select the matching credential mode to verify this connection.");
|
|
547
574
|
}
|
|
548
|
-
|
|
549
|
-
if (requiredScopes.length && !requiredScopes.some((scope) => integration.scopes.includes(scope))) {
|
|
550
|
-
throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
|
|
551
|
-
}
|
|
575
|
+
requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
|
|
552
576
|
return store.withConnection({ owner, integrationId }, async ({ save }) => {
|
|
553
577
|
const connection = {
|
|
554
578
|
integrationId, provider: provider.id, method,
|
|
@@ -575,9 +599,7 @@ function createConnectionService({
|
|
|
575
599
|
if (!Object.hasOwn(provider.operations, operation)) {
|
|
576
600
|
throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.");
|
|
577
601
|
}
|
|
578
|
-
|
|
579
|
-
throw new ConnectorError("connector_scope_missing", "This operation requires a permission absent from the application's configuration.", { statusCode: 403 });
|
|
580
|
-
}
|
|
602
|
+
requireOperationScope(provider.operations[operation], integration.scopes, "This operation requires a permission absent from the application's configuration.");
|
|
581
603
|
const usesApiKey = integration.authentication.method === "api-key";
|
|
582
604
|
const usesServiceAccount = integration.authentication.method === "service-account";
|
|
583
605
|
const usesOAuth = integration.authentication.method === "oauth2";
|
|
@@ -588,16 +610,9 @@ function createConnectionService({
|
|
|
588
610
|
connection = envSeededConnection(integrationId, integration, provider, registration, seed);
|
|
589
611
|
await save(connection);
|
|
590
612
|
}
|
|
591
|
-
if (!connection || connection.status !== "connected" ||
|
|
592
|
-
(connection.
|
|
593
|
-
!
|
|
594
|
-
((usesApiKey || usesServiceAccount) && connection.secretRef !== integration.authentication.secretRef) ||
|
|
595
|
-
(usesServiceAccount && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
|
|
596
|
-
(usesOAuth && (connection.registrationRef !== registration.registrationRef || connection.clientId !== registration.client.client_id ||
|
|
597
|
-
(connection.grantType || "authorization_code") !== registration.grantType ||
|
|
598
|
-
(registration.grantType === "client_credentials" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
|
|
599
|
-
(registration.grantType === "authorization_code" && provider.refreshRequiresRedirectUri && connection.callbackUrl !== registration.callbackUrl) ||
|
|
600
|
-
(connection.tokenEndpointAuthMethod || "client_secret_post") !== registration.tokenEndpointAuthMethod))) {
|
|
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))) {
|
|
601
616
|
throw new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401 });
|
|
602
617
|
}
|
|
603
618
|
try {
|
|
@@ -655,17 +670,14 @@ function createConnectionService({
|
|
|
655
670
|
|
|
656
671
|
async function status({ context, integrationId }) {
|
|
657
672
|
const { owner, integration, provider } = await access(context, integrationId, "status");
|
|
673
|
+
// Compare against the configured registration without resolving its secrets.
|
|
658
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
|
+
};
|
|
659
679
|
return store.withConnection({ owner, integrationId }, async ({ connection }) => {
|
|
660
|
-
if (connection && !integration.authentication.refreshTokenRef && (connection
|
|
661
|
-
(connection.method || "oauth2") !== integration.authentication.method ||
|
|
662
|
-
!isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
|
|
663
|
-
(["api-key", "service-account"].includes(integration.authentication.method) && connection.secretRef !== integration.authentication.secretRef) ||
|
|
664
|
-
(integration.authentication.method === "service-account" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
|
|
665
|
-
(integration.authentication.method === "oauth2" && (connection.registrationRef !== integration.authentication.registrationRef || connection.clientId !== registration?.clientId ||
|
|
666
|
-
(connection.grantType || "authorization_code") !== (registration?.grantType || "authorization_code") ||
|
|
667
|
-
(registration?.grantType === "client_credentials" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
|
|
668
|
-
(connection.tokenEndpointAuthMethod || "client_secret_post") !== (registration?.tokenEndpointAuthMethod || "client_secret_post"))))) {
|
|
680
|
+
if (connection && !integration.authentication.refreshTokenRef && !connectionMatches(connection, integration, integration.provider, configured)) {
|
|
669
681
|
return publicConnection({ ...connection, status: "reconnect-required" });
|
|
670
682
|
}
|
|
671
683
|
let callbackUrl;
|
|
@@ -680,8 +692,7 @@ function createConnectionService({
|
|
|
680
692
|
return { status: "connected", source: "env", provider: provider.id, integrationId, grantedScopes: [...integration.scopes], callbackUrl };
|
|
681
693
|
}
|
|
682
694
|
}
|
|
683
|
-
if (connection && provider
|
|
684
|
-
connection.callbackUrl !== resolved.callbackUrl) {
|
|
695
|
+
if (connection && !callbackMatches(connection, provider, resolved)) {
|
|
685
696
|
return publicConnection({ ...connection, status: "reconnect-required" });
|
|
686
697
|
}
|
|
687
698
|
} else if (integration.authentication.method === "api-key") {
|
|
@@ -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 });
|
|
@@ -54,6 +54,22 @@ class IntegrationConfigurationError extends Error {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
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
|
+
|
|
57
73
|
function getProviderSettingsSchema(provider, settings = {}) {
|
|
58
74
|
return typeof provider.settingsSchema === "function" ? provider.settingsSchema(settings) : provider.settingsSchema;
|
|
59
75
|
}
|
|
@@ -66,15 +82,28 @@ function getProviderAccountModes(provider, settings = {}) {
|
|
|
66
82
|
return provider.accountModesForSettings ? provider.accountModesForSettings(settings) : provider.accountModes;
|
|
67
83
|
}
|
|
68
84
|
|
|
69
|
-
function getProviderScopes(provider, settings = {}, grantType =
|
|
85
|
+
function getProviderScopes(provider, settings = {}, grantType = DEFAULT_GRANT_TYPE, authenticationMethod) {
|
|
70
86
|
const scopes = provider.scopesForGrantType ? provider.scopesForGrantType(grantType, settings)
|
|
71
87
|
: provider.scopesForSettings ? provider.scopesForSettings(settings) : provider.scopes;
|
|
72
88
|
return authenticationMethod ? scopes.filter(scope => !scope.authenticationMethods || scope.authenticationMethods.includes(authenticationMethod)) : scopes;
|
|
73
89
|
}
|
|
74
90
|
|
|
75
|
-
function getProviderClientAuthenticationMethods(provider, grantType =
|
|
91
|
+
function getProviderClientAuthenticationMethods(provider, grantType = DEFAULT_GRANT_TYPE, settings = {}) {
|
|
76
92
|
const methods = provider.oauthClientAuthenticationMethods;
|
|
77
|
-
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));
|
|
78
107
|
}
|
|
79
108
|
|
|
80
109
|
function validateIntegrationConfiguration(input, { providers = [], allowUnknownProviders = false } = {}) {
|
|
@@ -117,7 +146,7 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
|
|
|
117
146
|
const auth = integration.authentication;
|
|
118
147
|
const provider = definitions.get(integration.provider);
|
|
119
148
|
const registration = config.registrations[auth.registrationRef];
|
|
120
|
-
const grantType = registration
|
|
149
|
+
const grantType = registrationGrantType(registration);
|
|
121
150
|
if (auth.refreshTokenRef && auth.method !== "oauth2") {
|
|
122
151
|
fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only an OAuth connection can start from a refresh token.";
|
|
123
152
|
}
|
|
@@ -181,11 +210,11 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
|
|
|
181
210
|
}
|
|
182
211
|
if (auth.method === "oauth2" && registration?.source === "own") {
|
|
183
212
|
const registrationPath = `registrations.${auth.registrationRef}`;
|
|
184
|
-
if (!(provider
|
|
213
|
+
if (!providerGrantTypes(provider).includes(grantType)) {
|
|
185
214
|
fieldErrors[`${registrationPath}.grantType`] = "This provider does not support this OAuth flow.";
|
|
186
215
|
}
|
|
187
216
|
const methods = getProviderClientAuthenticationMethods(provider, grantType, integration.settings || {});
|
|
188
|
-
if (!methods.includes(registration
|
|
217
|
+
if (!methods.includes(registrationClientAuthentication(registration))) {
|
|
189
218
|
fieldErrors[`${registrationPath}.tokenEndpointAuthMethod`] = "This provider does not support this client authentication method.";
|
|
190
219
|
}
|
|
191
220
|
if (registration.clientId && provider.validateClientId) {
|
|
@@ -221,11 +250,18 @@ function parseIntegrationConfiguration(text, options) {
|
|
|
221
250
|
}
|
|
222
251
|
|
|
223
252
|
export {
|
|
253
|
+
DEFAULT_GRANT_TYPE,
|
|
254
|
+
DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD,
|
|
255
|
+
registrationGrantType,
|
|
256
|
+
registrationClientAuthentication,
|
|
257
|
+
providerGrantTypes,
|
|
224
258
|
getProviderAuthenticationMethods,
|
|
225
259
|
getProviderClientAuthenticationMethods,
|
|
226
260
|
getProviderAccountModes,
|
|
227
261
|
getProviderScopes,
|
|
228
262
|
getProviderSettingsSchema,
|
|
263
|
+
getProviderSetup,
|
|
264
|
+
isProviderConfigurationOnly,
|
|
229
265
|
secretReference,
|
|
230
266
|
integrationsSchema,
|
|
231
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
|
+
});
|