@jskit-ai/connectors-core 0.2.3 → 0.2.4
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 +6 -1
- package/docs/setup-command.md +25 -25
- package/locales/en.json +0 -1
- package/package.json +4 -3
- package/src/server/ConnectorsFeature.js +65 -18
- package/src/server/connectionCredentials.js +129 -0
- package/src/server/connectionService.js +104 -319
- package/src/server/credentialProtection.js +31 -20
- package/src/server/environmentReferences.js +10 -1
- package/src/server/errors.js +27 -7
- package/src/server/fileConnectionStore.js +10 -0
- package/src/server/index.js +1 -1
- package/src/server/knexConnectionStore.js +13 -2
- package/src/server/providerRequests.js +291 -0
- package/src/server/types.js +150 -0
- package/src/shared/configuration.js +23 -12
- package/test/connectionService.test.js +10 -8
- package/test/serviceAccount.test.js +4 -2
- package/test/setupCommand.test.js +9 -8
- /package/docs/{online-setup.md → application-setup.md} +0 -0
|
@@ -1,33 +1,44 @@
|
|
|
1
1
|
import { CompactEncrypt, compactDecrypt } from "jose";
|
|
2
2
|
import { ConnectorError } from "./errors.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Seals values with the active key and opens them with any named key, each bound to its record.
|
|
6
|
+
* @param {object} options
|
|
7
|
+
* @param {Record<string, Uint8Array>} options.keys Named 32-byte keys.
|
|
8
|
+
* @param {string} options.activeKeyId The key that seals new values.
|
|
9
|
+
*/
|
|
4
10
|
function createCredentialProtection({ keys, activeKeyId }) {
|
|
5
|
-
const keyring = new Map(Object.entries(keys
|
|
11
|
+
const keyring = new Map(Object.entries(keys));
|
|
6
12
|
if (!keyring.has(activeKeyId) || [...keyring.values()].some((key) => !(key instanceof Uint8Array) || key.length !== 32)) {
|
|
7
13
|
throw new TypeError("Credential protection requires named 32-byte keys and an active key ID.");
|
|
8
14
|
}
|
|
9
15
|
const encoder = new TextEncoder();
|
|
10
16
|
const decoder = new TextDecoder();
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
17
|
+
|
|
18
|
+
async function seal(value, binding) {
|
|
19
|
+
return new CompactEncrypt(encoder.encode(JSON.stringify({ binding, value })))
|
|
20
|
+
.setProtectedHeader({ alg: "dir", enc: "A256GCM", kid: activeKeyId })
|
|
21
|
+
.encrypt(keyring.get(activeKeyId));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function open(ciphertext, binding) {
|
|
25
|
+
try {
|
|
26
|
+
const { plaintext } = await compactDecrypt(ciphertext, (header) => {
|
|
27
|
+
const key = keyring.get(header.kid);
|
|
28
|
+
if (!key) throw new Error("Unknown key.");
|
|
29
|
+
return key;
|
|
30
|
+
}, { keyManagementAlgorithms: ["dir"], contentEncryptionAlgorithms: ["A256GCM"] });
|
|
31
|
+
const envelope = JSON.parse(decoder.decode(plaintext));
|
|
32
|
+
if (envelope.binding !== binding) throw new Error("Incorrect record binding.");
|
|
33
|
+
return envelope.value;
|
|
34
|
+
} catch {
|
|
35
|
+
throw new ConnectorError("connector_credentials_unavailable", "Stored credentials could not be opened.", { statusCode: 500 });
|
|
30
36
|
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
seal,
|
|
41
|
+
open
|
|
31
42
|
});
|
|
32
43
|
}
|
|
33
44
|
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { ConnectorError } from "./errors.js";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Resolves `env:NAME` references from the application's environment; an
|
|
5
|
+
* absent, blank or MISSING value is a missing binding.
|
|
6
|
+
* @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} env The application's environment, such as `process.env`.
|
|
7
|
+
* @returns {import("./types.js").ResolveReference}
|
|
8
|
+
*/
|
|
9
|
+
function createEnvironmentReferenceResolver(env) {
|
|
10
|
+
if (!env) {
|
|
11
|
+
throw new TypeError("createEnvironmentReferenceResolver requires the application's environment, such as process.env.");
|
|
12
|
+
}
|
|
4
13
|
return async (reference) => {
|
|
5
14
|
const match = /^env:([A-Z_][A-Z0-9_]*)$/u.exec(reference);
|
|
6
15
|
if (!match || typeof env[match[1]] !== "string" || (!env[match[1]].trim() || env[match[1]].trim() === "MISSING")) {
|
package/src/server/errors.js
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
// so each sentence also has a messageCode (with messageParams) that renders it from
|
|
3
3
|
// the "jskit.<messageCode>" catalog entry; message stays that English text.
|
|
4
4
|
class ConnectorError extends Error {
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} code
|
|
7
|
+
* @param {string} message
|
|
8
|
+
* @param {object} [details]
|
|
9
|
+
* @param {number} [details.statusCode]
|
|
10
|
+
* @param {number} [details.retryAfterSeconds]
|
|
11
|
+
* @param {string} [details.messageCode]
|
|
12
|
+
* @param {Record<string, unknown>} [details.messageParams]
|
|
13
|
+
*/
|
|
5
14
|
constructor(code, message, { statusCode = 400, retryAfterSeconds, messageCode, messageParams } = {}) {
|
|
6
15
|
super(message);
|
|
7
16
|
this.name = "ConnectorError";
|
|
@@ -15,20 +24,31 @@ class ConnectorError extends Error {
|
|
|
15
24
|
}
|
|
16
25
|
}
|
|
17
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The refusal for a provider's failed HTTP response, by its status, without the
|
|
29
|
+
* provider's own text: what a provider's exchange() throws for a response it
|
|
30
|
+
* does not map itself.
|
|
31
|
+
* @param {number} status
|
|
32
|
+
*/
|
|
33
|
+
function providerStatusError(status) {
|
|
34
|
+
if (status === 401) {
|
|
35
|
+
return new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401, messageCode: "connectors.connection.connectAccountAgain" });
|
|
36
|
+
}
|
|
37
|
+
if (status === 403) return new ConnectorError("connector_permission_denied", "The provider denied this operation.", { statusCode: 403, messageCode: "connectors.connection.providerDeniedOperation" });
|
|
38
|
+
if (status === 429) return new ConnectorError("connector_rate_limited", "The provider's request limit was reached.", { statusCode: 429, messageCode: "connectors.connection.providerRequestLimitReached" });
|
|
39
|
+
return new ConnectorError("connector_provider_failed", "The provider request failed. Try again.", { statusCode: 502, messageCode: "connectors.connection.providerRequestFailedTryAgain" });
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
function providerError(error) {
|
|
19
43
|
if (error instanceof ConnectorError) return error;
|
|
20
44
|
const status = Number(error?.status || error?.statusCode || 0);
|
|
21
45
|
if (error?.name === "AbortError") return new ConnectorError("connector_cancelled", "The operation was cancelled.", { messageCode: "connectors.connection.operationCancelled" });
|
|
22
46
|
if (error?.name === "TimeoutError") return new ConnectorError("connector_provider_timeout", "The provider request timed out. Try again.", { statusCode: 504 });
|
|
23
|
-
if (["invalid_grant", "invalid_client"].includes(error?.error) || status === 401)
|
|
24
|
-
return new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401, messageCode: "connectors.connection.connectAccountAgain" });
|
|
25
|
-
}
|
|
47
|
+
if (["invalid_grant", "invalid_client"].includes(error?.error) || status === 401) return providerStatusError(401);
|
|
26
48
|
if (["access_denied", "user_cancelled_login", "user_cancelled_authorize"].includes(error?.error)) {
|
|
27
49
|
return new ConnectorError("connector_consent_denied", "Account access was declined.");
|
|
28
50
|
}
|
|
29
|
-
|
|
30
|
-
if (status === 429) return new ConnectorError("connector_rate_limited", "The provider's request limit was reached.", { statusCode: 429, messageCode: "connectors.connection.providerRequestLimitReached" });
|
|
31
|
-
return new ConnectorError("connector_provider_failed", "The provider request failed. Try again.", { statusCode: 502, messageCode: "connectors.connection.providerRequestFailedTryAgain" });
|
|
51
|
+
return providerStatusError(status);
|
|
32
52
|
}
|
|
33
53
|
|
|
34
|
-
export { ConnectorError, providerError };
|
|
54
|
+
export { ConnectorError, providerError, providerStatusError };
|
|
@@ -5,8 +5,18 @@ import path from "node:path";
|
|
|
5
5
|
import lockfile from "proper-lockfile";
|
|
6
6
|
import { ConnectorError } from "./errors.js";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Connections kept as encrypted JSON files in a private directory, one locked file per connection.
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* @param {string} options.directory An absolute private runtime directory.
|
|
12
|
+
* @param {import("./types.js").CredentialProtection} options.protection
|
|
13
|
+
* @param {number} [options.directoryMode]
|
|
14
|
+
* @param {number} [options.fileMode]
|
|
15
|
+
* @param {() => number} [options.now]
|
|
16
|
+
*/
|
|
8
17
|
function createFileConnectionStore({ directory, protection, directoryMode = 0o700, fileMode = 0o600, now = Date.now }) {
|
|
9
18
|
if (typeof directory !== "string" || !path.isAbsolute(directory)) throw new TypeError("Use an absolute private runtime directory.");
|
|
19
|
+
// readable-check: allow method-probe - input validation: the application passes its credential protection
|
|
10
20
|
if (typeof protection?.seal !== "function" || typeof protection?.open !== "function") throw new TypeError("Credential protection is required.");
|
|
11
21
|
const root = path.resolve(directory);
|
|
12
22
|
const storageError = () => new ConnectorError("connector_storage_invalid", "Connection state could not be read safely.", { statusCode: 500 });
|
package/src/server/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { createConnectionService } from "./connectionService.js";
|
|
2
2
|
export { createEnvironmentReferenceResolver } from "./environmentReferences.js";
|
|
3
|
-
export { ConnectorError } from "./errors.js";
|
|
3
|
+
export { ConnectorError, providerStatusError } from "./errors.js";
|
|
4
4
|
export { createConnectorsFeature } from "./ConnectorsFeature.js";
|
|
@@ -2,10 +2,18 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Connections kept encrypted in the connector tables, each locked in a transaction while it is used.
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} options.knex The application's transactional Knex client.
|
|
9
|
+
* @param {import("./types.js").CredentialProtection} options.protection
|
|
10
|
+
*/
|
|
5
11
|
function createKnexConnectionStore({ knex, protection }) {
|
|
12
|
+
// readable-check: allow method-probe - input validation: the application passes its own Knex instance
|
|
6
13
|
if (typeof knex !== "function" || typeof knex.transaction !== "function") {
|
|
7
|
-
throw new TypeError("
|
|
14
|
+
throw new TypeError("createKnexConnectionStore requires the application's Knex instance.");
|
|
8
15
|
}
|
|
16
|
+
// readable-check: allow method-probe - input validation: the application passes its credential protection
|
|
9
17
|
if (typeof protection?.seal !== "function" || typeof protection?.open !== "function") {
|
|
10
18
|
throw new TypeError("Connector storage requires credential protection.");
|
|
11
19
|
}
|
|
@@ -56,7 +64,10 @@ function createKnexConnectionStore({ knex, protection }) {
|
|
|
56
64
|
return knex("connector_authorization_attempts").where("expires_at", "<=", before).delete();
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
return Object.freeze({
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
withConnection,
|
|
69
|
+
pruneExpiredAttempts
|
|
70
|
+
});
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
export { createKnexConnectionStore };
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import * as oauth from "oauth4webapi";
|
|
2
|
+
import { createHttpClient } from "@jskit-ai/http-runtime/client";
|
|
3
|
+
import { ConnectorError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_SCOPE_SEPARATOR = " ";
|
|
6
|
+
// Every OAuth token request takes this timeout; a provider's operations default to it.
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
8
|
+
const DEFAULT_TOKEN_REFRESH_LEEWAY_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A provider as an integration's requests use it: with its runtime for the
|
|
12
|
+
* integration's settings, its checkOperation for the credential mode and its
|
|
13
|
+
* defaults applied.
|
|
14
|
+
* @typedef {import("./types.js").ConnectorProvider & { checkOperation: string, scopeSeparator: string, requestTimeoutMs: number, tokenRefreshLeewayMs: number }} ConnectionProvider
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {import("./types.js").ConnectorProvider} definition
|
|
19
|
+
* @param {{ settings: Record<string, any>, authentication: { method: string } }} integration
|
|
20
|
+
* @returns {ConnectionProvider}
|
|
21
|
+
*/
|
|
22
|
+
function connectionProvider(definition, integration) {
|
|
23
|
+
// readable-check: allow method-probe - optional operation: runtimeForSettings is an optional hook of the provider definition
|
|
24
|
+
const runtime = typeof definition.runtimeForSettings === "function" ? { ...definition, ...definition.runtimeForSettings(integration.settings) } : definition;
|
|
25
|
+
return {
|
|
26
|
+
...runtime,
|
|
27
|
+
// readable-check: allow method-probe - data: a provider definition gives checkOperation as an operation id or as a function of the credential mode
|
|
28
|
+
checkOperation: typeof runtime.checkOperation === "function" ? runtime.checkOperation(integration.authentication.method) : runtime.checkOperation,
|
|
29
|
+
scopeSeparator: runtime.scopeSeparator || DEFAULT_SCOPE_SEPARATOR,
|
|
30
|
+
requestTimeoutMs: runtime.requestTimeoutMs ?? REQUEST_TIMEOUT_MS,
|
|
31
|
+
tokenRefreshLeewayMs: runtime.tokenRefreshLeewayMs ?? DEFAULT_TOKEN_REFRESH_LEEWAY_MS
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// An operation without scopes always runs; otherwise one of its scopes must be present.
|
|
36
|
+
function requireOperationScope(operation, scopes, message, { messageCode }) {
|
|
37
|
+
if (operation.scopes.length && !operation.scopes.some((scope) => scopes.includes(scope))) {
|
|
38
|
+
throw new ConnectorError("connector_scope_missing", message, { statusCode: 403, messageCode });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The token response oauth4webapi processes, rewritten by the provider's normalizeTokenResponse() when it has one.
|
|
43
|
+
function tokenResponse(provider, response, details) {
|
|
44
|
+
// readable-check: allow method-probe - optional operation: normalizeTokenResponse is an optional hook of the provider definition
|
|
45
|
+
return typeof provider.normalizeTokenResponse === "function" ? provider.normalizeTokenResponse(response, details) : response;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The headers that carry a connection's credential: the provider's API-key headers, or its OAuth headers and bearer token.
|
|
49
|
+
function credentialHeaders(provider, connection, settings) {
|
|
50
|
+
if (connection.method === "none") return {};
|
|
51
|
+
if (connection.method === "api-key") {
|
|
52
|
+
// readable-check: allow method-probe - optional operation: apiKey.headers is an optional hook of the provider definition
|
|
53
|
+
return typeof provider.apiKey.headers === "function" ? provider.apiKey.headers(connection.apiKey, settings) : {};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
// readable-check: allow method-probe - optional operation: oauthHeaders is an optional hook of the provider definition
|
|
57
|
+
...(typeof provider.oauthHeaders === "function" ? provider.oauthHeaders({ clientId: connection.clientId }) : {}),
|
|
58
|
+
Authorization: `Bearer ${connection.tokens.accessToken}`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A signal that aborts at the timeout or with the caller's signal.
|
|
63
|
+
function requestSignal(signal, timeoutMs) {
|
|
64
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
65
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Builds the provider request, reporting schema input errors as connector_input_invalid.
|
|
69
|
+
function operationRequest(operation, input, settings, providerData) {
|
|
70
|
+
try { return operation.request(input, settings, providerData); } catch (error) {
|
|
71
|
+
if (!error.fieldErrors) throw error;
|
|
72
|
+
const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422, messageCode: "connectors.connection.checkOperationInputValues" });
|
|
73
|
+
failure.fieldErrors = error.fieldErrors;
|
|
74
|
+
if (error.fieldErrorCodes) failure.fieldErrorCodes = error.fieldErrorCodes;
|
|
75
|
+
throw failure;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The requests a connection sends its provider: operations, and the token
|
|
81
|
+
* grants that keep the connection's access current.
|
|
82
|
+
* @param {object} dependencies
|
|
83
|
+
* @param {typeof globalThis.fetch} dependencies.fetchImpl
|
|
84
|
+
* @param {() => number} dependencies.now
|
|
85
|
+
* @param {import("./types.js").ResolveReference} dependencies.resolveReference
|
|
86
|
+
*/
|
|
87
|
+
function createProviderRequests({ fetchImpl, now, resolveReference }) {
|
|
88
|
+
const http = createHttpClient({ fetchImpl, csrf: { enabled: false }, credentials: "omit" });
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The oauth4webapi options of an OAuth token request: the fetch that sends
|
|
92
|
+
* it in the provider's token request encoding, and its timeout joined with
|
|
93
|
+
* the caller's signal.
|
|
94
|
+
* @param {AbortSignal | undefined} signal
|
|
95
|
+
* @param {ConnectionProvider} provider
|
|
96
|
+
*/
|
|
97
|
+
function tokenRequestOptions(signal, provider) {
|
|
98
|
+
return {
|
|
99
|
+
[oauth.customFetch]: provider.tokenRequestEncoding === "json" ? (address, options) => {
|
|
100
|
+
const headers = new Headers(options.headers);
|
|
101
|
+
headers.set("Content-Type", "application/json");
|
|
102
|
+
return fetchImpl(address, { ...options, headers,
|
|
103
|
+
body: JSON.stringify(Object.fromEntries(new URLSearchParams(options.body))) });
|
|
104
|
+
} : fetchImpl,
|
|
105
|
+
signal: requestSignal(signal, REQUEST_TIMEOUT_MS)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {ConnectionProvider} provider
|
|
111
|
+
* @param {string} operationId
|
|
112
|
+
* @param {any} input
|
|
113
|
+
* @param {Record<string, any>} connection
|
|
114
|
+
* @param {AbortSignal} [signal]
|
|
115
|
+
*/
|
|
116
|
+
async function request(provider, operationId, input, connection, signal) {
|
|
117
|
+
const operation = provider.operations[operationId];
|
|
118
|
+
if (!operation || !Object.hasOwn(provider.operations, operationId)) {
|
|
119
|
+
throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.", { messageCode: "connectors.connection.providerOperationNotAvailable" });
|
|
120
|
+
}
|
|
121
|
+
if (["oauth2", "service-account"].includes(connection.method || "oauth2")) {
|
|
122
|
+
requireOperationScope(operation, connection.grantedScopes, "Connect again with the permission required for this operation.", { messageCode: "connectors.connection.connectAgainPermissionRequiredOperation" });
|
|
123
|
+
}
|
|
124
|
+
const settings = connection.settings || {};
|
|
125
|
+
const descriptor = operationRequest(operation, input, settings, connection.providerData);
|
|
126
|
+
const url = new URL(descriptor.url);
|
|
127
|
+
// readable-check: allow method-probe - data: a provider definition gives apiOrigins as a list or as a function of the settings
|
|
128
|
+
const origins = typeof provider.apiOrigins === "function" ? provider.apiOrigins(settings, connection.providerData) : provider.apiOrigins;
|
|
129
|
+
if (url.protocol !== "https:" || url.username || url.password || !origins.includes(url.origin)) {
|
|
130
|
+
throw new ConnectorError("connector_destination_invalid", "The provider operation has an invalid destination.", { messageCode: "connectors.connection.providerOperationInvalidDestination" });
|
|
131
|
+
}
|
|
132
|
+
// readable-check: allow method-probe - optional operation: apiKey.pathPrefix is an optional hook of the provider definition
|
|
133
|
+
if (connection.method === "api-key" && typeof provider.apiKey.pathPrefix === "function") {
|
|
134
|
+
const prefix = provider.apiKey.pathPrefix(connection.apiKey);
|
|
135
|
+
if (typeof prefix !== "string" || !prefix.startsWith("/") || prefix.startsWith("//") ||
|
|
136
|
+
/[?#\\]/.test(prefix) || prefix.split("/").some((part) => /^(?:\.|%2e){1,2}$/i.test(part))) {
|
|
137
|
+
throw new ConnectorError("connector_request_invalid", "This provider has an invalid credential path.", { messageCode: "connectors.connection.providerInvalidCredentialPath" });
|
|
138
|
+
}
|
|
139
|
+
// A pathname assignment cannot replace the already checked origin.
|
|
140
|
+
url.pathname = prefix + url.pathname;
|
|
141
|
+
}
|
|
142
|
+
if (connection.method === "api-key" && provider.apiKey.queryParameter) {
|
|
143
|
+
url.searchParams.set(provider.apiKey.queryParameter, connection.apiKey);
|
|
144
|
+
}
|
|
145
|
+
let body = descriptor.body;
|
|
146
|
+
if (connection.method === "api-key" && provider.apiKey.bodyParameter) {
|
|
147
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || ["GET", "HEAD"].includes(descriptor.method)) {
|
|
148
|
+
throw new ConnectorError("connector_request_invalid", "This provider requires a JSON request body.", { messageCode: "connectors.connection.providerRequiresJsonRequestBody" });
|
|
149
|
+
}
|
|
150
|
+
body = { ...body, [provider.apiKey.bodyParameter]: connection.apiKey };
|
|
151
|
+
}
|
|
152
|
+
const options = {
|
|
153
|
+
method: descriptor.method,
|
|
154
|
+
body,
|
|
155
|
+
headers: { ...descriptor.headers, Accept: "application/json", ...credentialHeaders(provider, connection, settings) },
|
|
156
|
+
redirect: "error",
|
|
157
|
+
signal: requestSignal(signal, provider.requestTimeoutMs)
|
|
158
|
+
};
|
|
159
|
+
let result;
|
|
160
|
+
try {
|
|
161
|
+
// readable-check: allow method-probe - optional operation: exchange is an optional hook of the provider definition
|
|
162
|
+
result = typeof provider.exchange === "function"
|
|
163
|
+
? await provider.exchange(url.href, options, { fetchImpl, request: http.request, resolveReference,
|
|
164
|
+
settings, apiKey: connection.method === "api-key" ? connection.apiKey : undefined })
|
|
165
|
+
: await http.request(url.href, options);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// Transports can wrap cancellation in a network or protocol error.
|
|
168
|
+
options.signal.throwIfAborted();
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
// The validator also receives the operation's own request, so it can check the provider answered what was asked.
|
|
172
|
+
// readable-check: allow method-probe - optional operation: validateResult is an optional hook of the provider definition
|
|
173
|
+
if (typeof operation.validateResult === "function" && !operation.validateResult(result, descriptor)) {
|
|
174
|
+
throw new ConnectorError("connector_response_invalid", "The provider returned an unexpected response.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedUnexpectedResponse" });
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {any} response
|
|
181
|
+
* @param {Record<string, any> | null} previous
|
|
182
|
+
* @param {string[]} requestedScopes
|
|
183
|
+
* @param {ConnectionProvider} provider
|
|
184
|
+
*/
|
|
185
|
+
function tokensFrom(response, previous, requestedScopes, provider) {
|
|
186
|
+
return {
|
|
187
|
+
// readable-check: allow method-probe - optional operation: dataFromTokenResponse is an optional hook of the provider definition
|
|
188
|
+
...(typeof provider.dataFromTokenResponse === "function" ? { providerData: provider.dataFromTokenResponse(response, previous?.providerData) } : {}),
|
|
189
|
+
tokens: {
|
|
190
|
+
accessToken: response.access_token,
|
|
191
|
+
refreshToken: response.refresh_token || previous?.tokens.refreshToken || null,
|
|
192
|
+
expiresAt: response.expires_in === undefined ? null : now() + response.expires_in * 1000
|
|
193
|
+
},
|
|
194
|
+
grantedScopes: response.scope === undefined
|
|
195
|
+
? [...(previous?.grantedScopes || requestedScopes)]
|
|
196
|
+
: response.scope.split(provider.scopeSeparator).map((scope) => scope.trim()).filter(Boolean)
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {Record<string, any>} integration
|
|
202
|
+
* @param {ConnectionProvider} provider
|
|
203
|
+
* @param {{ credential: string, fingerprint: string }} binding
|
|
204
|
+
* @param {AbortSignal} [signal]
|
|
205
|
+
*/
|
|
206
|
+
async function serviceAccountGrant(integration, provider, binding, signal) {
|
|
207
|
+
const grantSignal = requestSignal(signal, provider.requestTimeoutMs);
|
|
208
|
+
grantSignal.throwIfAborted();
|
|
209
|
+
let response;
|
|
210
|
+
try {
|
|
211
|
+
response = await provider.serviceAccountGrant({
|
|
212
|
+
credential: binding.credential, settings: structuredClone(integration.settings),
|
|
213
|
+
scopes: [...integration.scopes], fetchImpl, signal: grantSignal, now: now()
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
grantSignal.throwIfAborted();
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
grantSignal.throwIfAborted();
|
|
220
|
+
if (typeof response?.access_token !== "string" || !response.access_token || /\s/u.test(response.access_token) ||
|
|
221
|
+
// readable-check: allow optional-call - data: the provider's token response
|
|
222
|
+
response.token_type?.toLowerCase?.() !== "bearer" ||
|
|
223
|
+
!Number.isFinite(response.expires_in) || response.expires_in <= 0 || response.expires_in > 86_400 ||
|
|
224
|
+
response.refresh_token !== undefined || (response.scope !== undefined && typeof response.scope !== "string")) {
|
|
225
|
+
throw new ConnectorError("connector_response_invalid", "The provider returned an invalid service-account token.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidServiceAccount" });
|
|
226
|
+
}
|
|
227
|
+
const grant = tokensFrom(response, null, integration.scopes, provider);
|
|
228
|
+
grant.grantedScopes = grant.grantedScopes.filter((scope) => integration.scopes.includes(scope));
|
|
229
|
+
return { ...grant, credentialFingerprint: binding.fingerprint };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {ConnectionProvider} provider
|
|
234
|
+
* @param {Record<string, any>} connection
|
|
235
|
+
* @param {any} verificationInput
|
|
236
|
+
* @param {string[]} requestedScopes
|
|
237
|
+
* @param {AbortSignal} [signal]
|
|
238
|
+
*/
|
|
239
|
+
async function verifyOAuthConnection(provider, connection, verificationInput, requestedScopes, signal) {
|
|
240
|
+
const verification = await request(provider, provider.checkOperation, verificationInput, connection, signal);
|
|
241
|
+
// readable-check: allow method-probe - optional operation: accountLabelFromVerification is an optional hook of the provider definition
|
|
242
|
+
if (typeof provider.accountLabelFromVerification === "function") {
|
|
243
|
+
const label = provider.accountLabelFromVerification(verification);
|
|
244
|
+
if (typeof label !== "string" || !label.trim() || label.length > 256 || /[\p{Cc}\p{Cf}]/u.test(label)) {
|
|
245
|
+
throw new ConnectorError("connector_response_invalid", "The provider returned an invalid account label.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidAccountLabel" });
|
|
246
|
+
}
|
|
247
|
+
connection.accountLabel = label;
|
|
248
|
+
}
|
|
249
|
+
// readable-check: allow method-probe - optional operation: grantedScopesFromVerification is an optional hook of the provider definition
|
|
250
|
+
if (typeof provider.grantedScopesFromVerification !== "function") return;
|
|
251
|
+
const scopes = provider.grantedScopesFromVerification(verification, { clientId: connection.clientId });
|
|
252
|
+
if (!Array.isArray(scopes) || scopes.some((scope) => typeof scope !== "string" || !scope)) {
|
|
253
|
+
throw new ConnectorError("connector_response_invalid", "The provider returned an invalid permission grant.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidPermissionGrant" });
|
|
254
|
+
}
|
|
255
|
+
connection.grantedScopes = connection.grantedScopes.filter((scope) => requestedScopes.includes(scope) && scopes.includes(scope));
|
|
256
|
+
requireOperationScope(provider.operations[provider.checkOperation], connection.grantedScopes, "Connect again with the permission required for verification.", { messageCode: "connectors.connection.connectAgainPermissionRequiredVerification" });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @param {Record<string, any>} registration
|
|
261
|
+
* @param {ConnectionProvider} provider
|
|
262
|
+
* @param {Record<string, any>} settings
|
|
263
|
+
* @param {string[]} scopes
|
|
264
|
+
* @param {AbortSignal} [signal]
|
|
265
|
+
*/
|
|
266
|
+
async function clientCredentialsGrant(registration, provider, settings, scopes, signal) {
|
|
267
|
+
const response = await oauth.clientCredentialsGrantRequest(
|
|
268
|
+
registration.oauth, registration.client, registration.clientAuth,
|
|
269
|
+
{ scope: scopes.join(provider.scopeSeparator), ...(registration.resource ? { resource: registration.resource } : {}) },
|
|
270
|
+
tokenRequestOptions(signal, provider)
|
|
271
|
+
);
|
|
272
|
+
const result = await oauth.processClientCredentialsResponse(registration.oauth, registration.client,
|
|
273
|
+
await tokenResponse(provider, response, { settings, grantType: "client_credentials" }));
|
|
274
|
+
const grant = tokensFrom(result, null, scopes, provider);
|
|
275
|
+
// Client credentials renew with the application's secret, never a user refresh token.
|
|
276
|
+
grant.tokens.refreshToken = null;
|
|
277
|
+
grant.grantedScopes = grant.grantedScopes.filter((scope) => scopes.includes(scope));
|
|
278
|
+
return grant;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
tokenRequestOptions,
|
|
283
|
+
request,
|
|
284
|
+
tokensFrom,
|
|
285
|
+
serviceAccountGrant,
|
|
286
|
+
verifyOAuthConnection,
|
|
287
|
+
clientCredentialsGrant
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export { connectionProvider, createProviderRequests, operationRequest, requireOperationScope, tokenResponse };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The type of the `connectors.core` capability, for features that require it,
|
|
2
|
+
// and the contracts of what an application passes createConnectionService().
|
|
3
|
+
|
|
4
|
+
/** @typedef {ReturnType<typeof import("./connectionService.js").createConnectionService>} ConnectorsCore */
|
|
5
|
+
/** @typedef {Parameters<typeof import("./connectionService.js").createConnectionService>[0]} ConnectionServiceOptions */
|
|
6
|
+
/** @typedef {ReturnType<typeof import("./credentialProtection.js").createCredentialProtection>} CredentialProtection */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The application identity a connection belongs to: one application and one
|
|
10
|
+
* user or shared subject in it.
|
|
11
|
+
* @typedef {object} ConnectionOwner
|
|
12
|
+
* @property {string} applicationId
|
|
13
|
+
* @property {string} subjectId
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a connector asks the application's access policy to decide.
|
|
18
|
+
* @typedef {object} ConnectorAuthorizationRequest
|
|
19
|
+
* @property {string} integrationId
|
|
20
|
+
* @property {string} operation The provider operation, or a connection step such as "connect", "status" or "disconnect".
|
|
21
|
+
* @property {string} accountMode The integration's account mode: "shared", "per-user" or "assistant".
|
|
22
|
+
* @property {{ action: string, decision: string }} [assistantPermission]
|
|
23
|
+
* In assistant execution, the assistant action and its configured decision ("ask" or "always").
|
|
24
|
+
* @property {unknown} [input] A copy of the operation's input, for an operation that has one.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The application's access policy: the owner whose connection the request may
|
|
29
|
+
* use, or null to deny it. An assistant action whose decision is "ask" also
|
|
30
|
+
* needs `approved: true`.
|
|
31
|
+
* @typedef {(context: any, request: ConnectorAuthorizationRequest) => Promise<(ConnectionOwner & { approved?: boolean }) | null>} ConnectorAuthorize
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolves a binding reference such as `env:NAME` to its value. Resolvers of
|
|
36
|
+
* per-user accounts also receive the authorized owner and the integration slot.
|
|
37
|
+
* @typedef {(reference: string, owner?: ConnectionOwner, slot?: { integrationId: string, accountMode: string, providerId: string }) => Promise<string>} ResolveReference
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A caller's request about one integration: the application context that
|
|
42
|
+
* authorize() receives and the integration's id.
|
|
43
|
+
* @typedef {object} IntegrationRequest
|
|
44
|
+
* @property {any} context
|
|
45
|
+
* @property {string} integrationId
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A request that may reach the provider, which `signal` cancels.
|
|
50
|
+
* @typedef {IntegrationRequest & { signal?: AbortSignal }} ProviderRequest
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A request that connects an account, with the input its verification takes.
|
|
55
|
+
* @typedef {ProviderRequest & { verificationInput?: Record<string, any> }} ConnectRequest
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One connection's stored state, held under the store's lock while work runs.
|
|
60
|
+
* @typedef {object} ConnectionRecord
|
|
61
|
+
* @property {Record<string, any> | null} connection The stored connection, or null.
|
|
62
|
+
* @property {(connection: object) => Promise<void>} save
|
|
63
|
+
* @property {() => Promise<void>} remove Removes the connection and its authorization attempts.
|
|
64
|
+
* @property {(attempt: Record<string, any> & { state: string, expiresAt: number }) => Promise<void>} putAttempt Stores an authorization attempt under its state.
|
|
65
|
+
* @property {(query: { after: number }) => Promise<Record<string, any> | null>} latestAttempt The newest attempt that expires after `after`.
|
|
66
|
+
* @property {(state: string) => Promise<Record<string, any> | null>} consumeAttempt Removes and answers the attempt with this state.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where connections are kept: createKnexConnectionStore(),
|
|
71
|
+
* createFileConnectionStore() or the application's own store.
|
|
72
|
+
* @typedef {object} ConnectionStore
|
|
73
|
+
* @property {<T>(slot: { owner: ConnectionOwner, integrationId: string }, work: (record: ConnectionRecord) => Promise<T>) => Promise<T>} withConnection
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The request a provider operation sends.
|
|
78
|
+
* @typedef {object} ConnectorRequest
|
|
79
|
+
* @property {string} method
|
|
80
|
+
* @property {string} url
|
|
81
|
+
* @property {Record<string, string>} [headers]
|
|
82
|
+
* @property {any} [body]
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One operation a provider offers.
|
|
87
|
+
* @typedef {object} ConnectorOperation
|
|
88
|
+
* @property {string[]} scopes The permissions of which one must be granted; an empty list always runs.
|
|
89
|
+
* @property {(input: any, settings: Record<string, any>, providerData?: any) => ConnectorRequest} request
|
|
90
|
+
* Builds the request for an input; throws an error with `fieldErrors` for an invalid one.
|
|
91
|
+
* @property {(result: any, request: ConnectorRequest) => boolean} [validateResult] Answers whether the response is what was asked.
|
|
92
|
+
* @property {string} [assistantAction] The assistant action whose permission covers it; its operation id otherwise.
|
|
93
|
+
* @property {string[]} [authenticationMethods] The credential modes it runs with; every mode otherwise.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What a provider's exchange() receives besides the URL and the request options.
|
|
98
|
+
* @typedef {object} ConnectorExchangeTransport
|
|
99
|
+
* @property {typeof globalThis.fetch} fetchImpl
|
|
100
|
+
* @property {ReturnType<typeof import("@jskit-ai/http-runtime/client").createHttpClient>["request"]} request The parsed-JSON HTTP client.
|
|
101
|
+
* @property {ResolveReference} resolveReference
|
|
102
|
+
* @property {Record<string, any>} settings
|
|
103
|
+
* @property {string | undefined} apiKey The API key of an API-key connection; undefined otherwise.
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A connector provider: its shared definition (id, scopes, credential modes,
|
|
108
|
+
* account modes and settings, which validateIntegrationConfiguration() reads)
|
|
109
|
+
* and the server runtime createConnectionService() calls. A field given as a
|
|
110
|
+
* value or as a function of the settings may take either form.
|
|
111
|
+
* @typedef {object} ConnectorProvider
|
|
112
|
+
* @property {string} id
|
|
113
|
+
* @property {Record<string, ConnectorOperation>} operations
|
|
114
|
+
* @property {string | ((method: string) => string)} checkOperation The operation that verifies a connection.
|
|
115
|
+
* @property {string[] | ((settings: Record<string, any>, providerData?: any) => string[])} apiOrigins The HTTPS origins its requests may reach.
|
|
116
|
+
* @property {(settings: Record<string, any>) => string[]} [authenticationMethodsForSettings] The credential modes for these settings, in place of the definition's list.
|
|
117
|
+
* @property {(settings: Record<string, any>) => string[]} [accountModesForSettings] The account modes for these settings, in place of the definition's list.
|
|
118
|
+
* @property {(grantType: string, settings: Record<string, any>) => Array<{ value: string }>} [scopesForGrantType] The scopes an OAuth grant type offers.
|
|
119
|
+
* @property {(settings: Record<string, any>) => Array<{ value: string }>} [scopesForSettings] The scopes for these settings, in place of the definition's list.
|
|
120
|
+
* @property {(clientId: string) => unknown} [validateClientId] True for a client ID the provider accepts; otherwise its field error.
|
|
121
|
+
* @property {(settings: Record<string, any>) => object} [runtimeForSettings] Runtime fields that replace these for the integration's settings.
|
|
122
|
+
* @property {Array<{ value: string }>} [assistantActions]
|
|
123
|
+
* @property {number} [requestTimeoutMs] The timeout of its operations and service-account grants. Defaults to 15 seconds, the timeout of every OAuth token request.
|
|
124
|
+
* @property {string} [scopeSeparator] Defaults to a space.
|
|
125
|
+
* @property {object | ((settings: Record<string, any>) => object)} [oauth] The OAuth authorization server metadata.
|
|
126
|
+
* @property {string | ((settings: Record<string, any>) => string)} [oauthResource]
|
|
127
|
+
* @property {boolean} [oauthPkce] False for a provider without PKCE.
|
|
128
|
+
* @property {string} [oauthBasicEncoding] "raw" sends Basic client credentials without form encoding.
|
|
129
|
+
* @property {string} [oauthClientIdParameter] The parameter that carries the client ID, when it is not `client_id`.
|
|
130
|
+
* @property {(client: { clientId: string }) => Record<string, string>} [oauthHeaders]
|
|
131
|
+
* @property {Record<string, string>} [authorizationParameters]
|
|
132
|
+
* @property {(settings: Record<string, any>) => string | undefined} [authorizationScopeParameter]
|
|
133
|
+
* @property {boolean} [scopesInAuthorizationResponse]
|
|
134
|
+
* @property {string} [tokenRequestEncoding] "json" sends every token request as JSON instead of a form.
|
|
135
|
+
* @property {number} [tokenRefreshLeewayMs] Defaults to 30 seconds.
|
|
136
|
+
* @property {boolean} [refreshRequiresRedirectUri]
|
|
137
|
+
* @property {(callbackUrl: string) => boolean} [validateCallbackUrl]
|
|
138
|
+
* @property {(response: Response, details: { settings: Record<string, any>, grantType: string }) => Response | Promise<Response>} [normalizeTokenResponse]
|
|
139
|
+
* @property {(response: any, previous: any) => any} [dataFromTokenResponse]
|
|
140
|
+
* @property {(verification: any) => string} [accountLabelFromVerification]
|
|
141
|
+
* @property {(verification: any, client: { clientId: string }) => string[]} [grantedScopesFromVerification]
|
|
142
|
+
* @property {(request: { credential: string, settings: Record<string, any>, scopes: string[], fetchImpl: typeof globalThis.fetch, signal: AbortSignal, now: number }) => Promise<any>} [serviceAccountGrant]
|
|
143
|
+
* @property {{ headers?: (apiKey: string, settings: Record<string, any>) => Record<string, string>, queryParameter?: string, bodyParameter?: string, pathPrefix?: (apiKey: string) => string }} [apiKey]
|
|
144
|
+
* Where an API-key connection places its key.
|
|
145
|
+
* @property {boolean} [apiKeySecretOptional]
|
|
146
|
+
* @property {(url: string, options: object, transport: ConnectorExchangeTransport) => Promise<any>} [exchange]
|
|
147
|
+
* Sends the request itself, for a protocol such as MCP or AWS Signature Version 4.
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
export {};
|