@jskit-ai/connectors-core 0.2.2 → 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,9 +1,10 @@
|
|
|
1
1
|
import * as oauth from "oauth4webapi";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { createHttpClient } from "@jskit-ai/http-runtime/client";
|
|
5
4
|
import { registrationClientAuthentication, registrationGrantType, validateIntegrationConfiguration } from "../shared/configuration.js";
|
|
5
|
+
import { createConnectionCredentials } from "./connectionCredentials.js";
|
|
6
6
|
import { ConnectorError, providerError } from "./errors.js";
|
|
7
|
+
import { connectionProvider, createProviderRequests, operationRequest, requireOperationScope, tokenResponse } from "./providerRequests.js";
|
|
7
8
|
|
|
8
9
|
function publicConnection(connection) {
|
|
9
10
|
if (!connection) return { status: "disconnected" };
|
|
@@ -23,34 +24,6 @@ const ENV_SEEDED_CODE = { messageCode: "connectors.connection.envSeeded" };
|
|
|
23
24
|
const VERIFICATION_SCOPE_MESSAGE = "Connection verification requires a permission absent from the application's configuration.";
|
|
24
25
|
const VERIFICATION_SCOPE_CODE = { messageCode: "connectors.connection.verificationScopeAbsent" };
|
|
25
26
|
|
|
26
|
-
// An operation without scopes always runs; otherwise one of its scopes must be present.
|
|
27
|
-
function requireOperationScope(operation, scopes, message, { messageCode }) {
|
|
28
|
-
if (operation.scopes.length && !operation.scopes.some((scope) => scopes.includes(scope))) {
|
|
29
|
-
throw new ConnectorError("connector_scope_missing", message, { statusCode: 403, messageCode });
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Builds the provider request, reporting schema input errors as connector_input_invalid.
|
|
34
|
-
function operationRequest(operation, input, settings, providerData) {
|
|
35
|
-
try { return operation.request(input, settings || {}, providerData); } catch (error) {
|
|
36
|
-
if (!error.fieldErrors) throw error;
|
|
37
|
-
const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422, messageCode: "connectors.connection.checkOperationInputValues" });
|
|
38
|
-
failure.fieldErrors = error.fieldErrors;
|
|
39
|
-
if (error.fieldErrorCodes) failure.fieldErrorCodes = error.fieldErrorCodes;
|
|
40
|
-
throw failure;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function validateCallbackUrl(value) {
|
|
45
|
-
let url;
|
|
46
|
-
try { url = new URL(value); } catch { /* reported below */ }
|
|
47
|
-
const localHttp = url?.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
48
|
-
if (!url || (!localHttp && url.protocol !== "https:") || url.username || url.password || url.hash || url.search) {
|
|
49
|
-
throw new ConnectorError("connector_callback_invalid", "Configure an HTTPS callback URL, or a local development callback.", { messageCode: "connectors.connection.configureHttpsCallbackUrlLocal" });
|
|
50
|
-
}
|
|
51
|
-
return url.href;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
27
|
// Whether a stored connection still belongs to the configured integration and
|
|
55
28
|
// OAuth client. `registration` is { registrationRef, clientId, grantType,
|
|
56
29
|
// tokenEndpointAuthMethod } with defaults applied; only OAuth reads it.
|
|
@@ -58,7 +31,7 @@ function connectionMatches(connection, integration, providerId, registration) {
|
|
|
58
31
|
const method = integration.authentication.method;
|
|
59
32
|
return connection.provider === providerId &&
|
|
60
33
|
(connection.method || "oauth2") === method &&
|
|
61
|
-
isDeepStrictEqual(connection.settings || {}, integration.settings
|
|
34
|
+
isDeepStrictEqual(connection.settings || {}, integration.settings) &&
|
|
62
35
|
(!["api-key", "service-account"].includes(method) || connection.secretRef === integration.authentication.secretRef) &&
|
|
63
36
|
(method !== "service-account" || isDeepStrictEqual(connection.requestedScopes, integration.scopes)) &&
|
|
64
37
|
(method !== "oauth2" || (connection.registrationRef === registration.registrationRef &&
|
|
@@ -74,6 +47,19 @@ function callbackMatches(connection, provider, registration) {
|
|
|
74
47
|
connection.callbackUrl === registration.callbackUrl;
|
|
75
48
|
}
|
|
76
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The connections of one application's configured integrations: authorization,
|
|
52
|
+
* connection, provider operations, status and disconnection.
|
|
53
|
+
* @param {object} options
|
|
54
|
+
* @param {Record<string, any>} options.configuration The integration configuration, validated here against the providers.
|
|
55
|
+
* @param {import("./types.js").ConnectorProvider[]} options.providers
|
|
56
|
+
* @param {import("./types.js").ResolveReference} options.resolveReference
|
|
57
|
+
* @param {import("./types.js").ConnectionStore} options.store
|
|
58
|
+
* @param {import("./types.js").ConnectorAuthorize} options.authorize
|
|
59
|
+
* @param {"application" | "assistant"} [options.executionMode]
|
|
60
|
+
* @param {typeof globalThis.fetch} [options.fetchImpl]
|
|
61
|
+
* @param {() => number} [options.now]
|
|
62
|
+
*/
|
|
77
63
|
function createConnectionService({
|
|
78
64
|
configuration,
|
|
79
65
|
providers,
|
|
@@ -86,27 +72,29 @@ function createConnectionService({
|
|
|
86
72
|
}) {
|
|
87
73
|
if (!["application", "assistant"].includes(executionMode)) throw new TypeError("Select application or assistant execution.");
|
|
88
74
|
const config = validateIntegrationConfiguration(configuration, { providers });
|
|
75
|
+
// Each integration with its settings resolved, so the service reads them as one object.
|
|
76
|
+
const integrations = Object.fromEntries(Object.entries(config.integrations)
|
|
77
|
+
.map(([id, integration]) => [id, { ...integration, settings: integration.settings || {} }]));
|
|
89
78
|
const definitions = new Map(providers.map((provider) => [provider.id, provider]));
|
|
90
79
|
if (!providers.length || definitions.size !== providers.length) throw new TypeError("Register unique connector providers.");
|
|
80
|
+
// readable-check: allow method-probe - input validation: the application passes its access policy and reference resolver
|
|
91
81
|
if (typeof authorize !== "function" || typeof resolveReference !== "function") {
|
|
92
82
|
throw new TypeError("Connections require application authorization and reference resolution.");
|
|
93
83
|
}
|
|
84
|
+
// readable-check: allow method-probe - input validation: the application passes its own connection store
|
|
94
85
|
if (typeof store?.withConnection !== "function") throw new TypeError("Connection storage requires withConnection().");
|
|
95
|
-
const
|
|
86
|
+
const connectionCredentials = createConnectionCredentials({ config, resolveReference });
|
|
87
|
+
const providerRequests = createProviderRequests({ fetchImpl, now, resolveReference });
|
|
96
88
|
|
|
97
89
|
async function access(context, integrationId, operation, input, checkAssistantPermission = true) {
|
|
98
|
-
const integration =
|
|
99
|
-
if (!Object.hasOwn(
|
|
90
|
+
const integration = integrations[integrationId];
|
|
91
|
+
if (!Object.hasOwn(integrations, integrationId)) {
|
|
100
92
|
throw new ConnectorError("connector_not_found", "This integration is not configured.", { statusCode: 404, messageCode: "connectors.connection.integrationNotConfigured" });
|
|
101
93
|
}
|
|
102
|
-
const
|
|
103
|
-
const definition = baseDefinition.runtimeForSettings
|
|
104
|
-
? { ...baseDefinition, ...baseDefinition.runtimeForSettings(integration.settings || {}) } : baseDefinition;
|
|
105
|
-
const provider = typeof definition.checkOperation === "function"
|
|
106
|
-
? { ...definition, checkOperation: definition.checkOperation(integration.authentication.method) }
|
|
107
|
-
: definition;
|
|
94
|
+
const provider = connectionProvider(definitions.get(integration.provider), integration);
|
|
108
95
|
let assistantPermission;
|
|
109
96
|
if (executionMode === "assistant" && checkAssistantPermission && !["status", "disconnect"].includes(operation)) {
|
|
97
|
+
// A configuration-only definition has no operations; its connection steps refuse it.
|
|
110
98
|
const action = provider.operations?.[operation]?.assistantAction || operation;
|
|
111
99
|
const policy = integration.assistantPolicy;
|
|
112
100
|
const decision = policy?.enabled === false ? "never" : policy?.actions?.[action] || policy?.defaultPermission || "ask";
|
|
@@ -132,8 +120,9 @@ function createConnectionService({
|
|
|
132
120
|
return { owner: { applicationId: owner.applicationId, subjectId: owner.subjectId }, integration, provider };
|
|
133
121
|
}
|
|
134
122
|
|
|
123
|
+
/** @param {import("./types.js").IntegrationRequest & { action: string, input?: any }} request */
|
|
135
124
|
async function authorizeAssistantAction({ context, integrationId, action, input = {} }) {
|
|
136
|
-
const provider = definitions.get(
|
|
125
|
+
const provider = definitions.get(integrations[integrationId]?.provider);
|
|
137
126
|
if (executionMode !== "assistant" || !provider?.assistantActions?.some((item) => item.value === action)) {
|
|
138
127
|
throw new ConnectorError("connector_operation_unknown", "This provider does not declare this assistant action.", { messageCode: "connectors.connection.providerDoesNotDeclareAssistant" });
|
|
139
128
|
}
|
|
@@ -141,92 +130,6 @@ function createConnectionService({
|
|
|
141
130
|
// The host performs its own lifecycle operation after authorization; no provider action is executed here.
|
|
142
131
|
}
|
|
143
132
|
|
|
144
|
-
async function registrationFor(integration, provider) {
|
|
145
|
-
const registration = config.registrations[integration.authentication.registrationRef];
|
|
146
|
-
if (registration?.source !== "own" || integration.authentication.method !== "oauth2" || !provider.oauth) {
|
|
147
|
-
throw new ConnectorError("connector_mode_unavailable", "This runtime does not yet support the selected credential mode.", { messageCode: "connectors.connection.runtimeDoesNotYetSupport" });
|
|
148
|
-
}
|
|
149
|
-
if (registration.clientId.trim() === "MISSING") {
|
|
150
|
-
throw new ConnectorError("connector_binding_missing", "The provider client ID is not configured.", { messageCode: "connectors.connection.providerClientIdNotConfigured" });
|
|
151
|
-
}
|
|
152
|
-
const grantType = registrationGrantType(registration);
|
|
153
|
-
const tokenEndpointAuthMethod = registrationClientAuthentication(registration);
|
|
154
|
-
if (provider.oauthPkce === false && tokenEndpointAuthMethod === "none") {
|
|
155
|
-
throw new ConnectorError("connector_mode_unavailable", "This provider's non-PKCE flow requires a confidential client.", { messageCode: "connectors.connection.providerNonPkceFlowRequires" });
|
|
156
|
-
}
|
|
157
|
-
let clientSecret;
|
|
158
|
-
let callback;
|
|
159
|
-
try {
|
|
160
|
-
if (tokenEndpointAuthMethod !== "none") {
|
|
161
|
-
clientSecret = await resolveReference(registration.clientSecretRef);
|
|
162
|
-
if (typeof clientSecret !== "string" || !clientSecret.trim() || clientSecret.trim() === "MISSING") throw new Error("Invalid client secret.");
|
|
163
|
-
}
|
|
164
|
-
if (grantType === "authorization_code") callback = await resolveReference(registration.callbackUrlRef);
|
|
165
|
-
} catch {
|
|
166
|
-
throw new ConnectorError("connector_binding_missing", "A required registration binding is missing.", { messageCode: "connectors.connection.requiredRegistrationBindingMissing" });
|
|
167
|
-
}
|
|
168
|
-
const callbackUrl = grantType === "authorization_code" ? validateCallbackUrl(callback) : undefined;
|
|
169
|
-
if (callbackUrl && provider.validateCallbackUrl && provider.validateCallbackUrl(callbackUrl) !== true) {
|
|
170
|
-
throw new ConnectorError("connector_callback_invalid", "The callback URL does not meet this provider's requirements.", { messageCode: "connectors.connection.callbackUrlDoesNotMeet" });
|
|
171
|
-
}
|
|
172
|
-
let clientAuth = tokenEndpointAuthMethod === "none" ? oauth.None()
|
|
173
|
-
: tokenEndpointAuthMethod === "client_secret_basic" ? oauth.ClientSecretBasic(clientSecret) : oauth.ClientSecretPost(clientSecret);
|
|
174
|
-
if (tokenEndpointAuthMethod === "client_secret_basic" && provider.oauthBasicEncoding === "raw") {
|
|
175
|
-
clientAuth = (_as, client, _body, headers) => {
|
|
176
|
-
headers.set("authorization", `Basic ${Buffer.from(`${client.client_id}:${clientSecret}`).toString("base64")}`);
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
return {
|
|
180
|
-
oauth: typeof provider.oauth === "function" ? provider.oauth(integration.settings || {}) : provider.oauth,
|
|
181
|
-
resource: typeof provider.oauthResource === "function" ? provider.oauthResource(integration.settings || {}) : provider.oauthResource,
|
|
182
|
-
client: { client_id: registration.clientId },
|
|
183
|
-
clientAuth: provider.oauthClientIdParameter ? (as, client, body, headers) => {
|
|
184
|
-
clientAuth(as, client, body, headers);
|
|
185
|
-
body.set(provider.oauthClientIdParameter, client.client_id);
|
|
186
|
-
body.delete("client_id");
|
|
187
|
-
} : clientAuth,
|
|
188
|
-
tokenEndpointAuthMethod,
|
|
189
|
-
grantType,
|
|
190
|
-
callbackUrl,
|
|
191
|
-
registrationRef: integration.authentication.registrationRef
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async function apiKeyFor(integration, provider) {
|
|
196
|
-
if (!integration.authentication.secretRef && provider.apiKeySecretOptional) return "";
|
|
197
|
-
try {
|
|
198
|
-
const value = await resolveReference(integration.authentication.secretRef);
|
|
199
|
-
if (typeof value !== "string" || (!provider.apiKeySecretOptional && !value.trim()) || value.trim() === "MISSING" || /[\r\n]/u.test(value)) throw new Error("Invalid key.");
|
|
200
|
-
return value;
|
|
201
|
-
} catch {
|
|
202
|
-
throw new ConnectorError("connector_binding_missing", "The API key binding is missing or invalid.", { messageCode: "connectors.connection.apiKeyBindingMissingInvalid" });
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function serviceAccountCredentialFor(integration) {
|
|
207
|
-
try {
|
|
208
|
-
const credential = await resolveReference(integration.authentication.secretRef);
|
|
209
|
-
if (typeof credential !== "string" || !credential.trim() || credential.trim() === "MISSING" || credential.length > 65_536 || credential.includes("\0")) throw new Error("Invalid credential.");
|
|
210
|
-
return { credential, fingerprint: createHash("sha256").update(credential).digest("hex") };
|
|
211
|
-
} catch {
|
|
212
|
-
throw new ConnectorError("connector_binding_missing", "The service-account credential binding is missing or invalid.", { messageCode: "connectors.connection.serviceAccountCredentialBindingMissing" });
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// An Env refresh token seeds the shared connection; the app then refreshes
|
|
217
|
-
// and stores rotated tokens itself, and reseeds when the value or the OAuth
|
|
218
|
-
// client changes.
|
|
219
|
-
async function envRefreshTokenFor(integration) {
|
|
220
|
-
try {
|
|
221
|
-
const value = await resolveReference(integration.authentication.refreshTokenRef);
|
|
222
|
-
const refreshToken = typeof value === "string" ? value.trim() : "";
|
|
223
|
-
if (!refreshToken || refreshToken === "MISSING" || /\s/u.test(refreshToken)) throw new Error("Invalid token.");
|
|
224
|
-
return { refreshToken, fingerprint: createHash("sha256").update(refreshToken).digest("hex") };
|
|
225
|
-
} catch {
|
|
226
|
-
throw new ConnectorError("connector_binding_missing", "The refresh token binding is missing or invalid.", { messageCode: "connectors.connection.refreshTokenBindingMissingInvalid" });
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
133
|
function envSeedIsCurrent(connection, seed, registration) {
|
|
231
134
|
return connection?.seedFingerprint === seed.fingerprint && connection.clientId === registration.client.client_id &&
|
|
232
135
|
connection.registrationRef === registration.registrationRef &&
|
|
@@ -238,44 +141,18 @@ function createConnectionService({
|
|
|
238
141
|
integrationId, provider: provider.id, registrationRef: registration.registrationRef,
|
|
239
142
|
grantType: registration.grantType, clientId: registration.client.client_id,
|
|
240
143
|
tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod, status: "connected", source: "env",
|
|
241
|
-
seedFingerprint: seed.fingerprint, verifiedAt: now(), settings: structuredClone(integration.settings
|
|
144
|
+
seedFingerprint: seed.fingerprint, verifiedAt: now(), settings: structuredClone(integration.settings),
|
|
242
145
|
...(provider.refreshRequiresRedirectUri ? { callbackUrl: registration.callbackUrl } : {}),
|
|
243
146
|
tokens: { accessToken: null, refreshToken: seed.refreshToken, expiresAt: 0 },
|
|
244
147
|
grantedScopes: [...integration.scopes]
|
|
245
148
|
};
|
|
246
149
|
}
|
|
247
150
|
|
|
248
|
-
|
|
249
|
-
if (typeof provider.serviceAccountGrant !== "function") {
|
|
250
|
-
throw new ConnectorError("connector_mode_unavailable", "This provider does not implement service-account authorization.", { messageCode: "connectors.connection.providerDoesNotImplementService" });
|
|
251
|
-
}
|
|
252
|
-
const options = requestOptions(signal, provider.requestTimeoutMs);
|
|
253
|
-
options.signal.throwIfAborted();
|
|
254
|
-
let response;
|
|
255
|
-
try {
|
|
256
|
-
response = await provider.serviceAccountGrant({
|
|
257
|
-
credential: binding.credential, settings: structuredClone(integration.settings || {}),
|
|
258
|
-
scopes: [...integration.scopes], fetchImpl, signal: options.signal, now: now()
|
|
259
|
-
});
|
|
260
|
-
} catch (error) {
|
|
261
|
-
options.signal.throwIfAborted();
|
|
262
|
-
throw error;
|
|
263
|
-
}
|
|
264
|
-
options.signal.throwIfAborted();
|
|
265
|
-
if (typeof response?.access_token !== "string" || !response.access_token || /\s/u.test(response.access_token) ||
|
|
266
|
-
response.token_type?.toLowerCase?.() !== "bearer" ||
|
|
267
|
-
!Number.isFinite(response.expires_in) || response.expires_in <= 0 || response.expires_in > 86_400 ||
|
|
268
|
-
response.refresh_token !== undefined || (response.scope !== undefined && typeof response.scope !== "string")) {
|
|
269
|
-
throw new ConnectorError("connector_response_invalid", "The provider returned an invalid service-account token.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidServiceAccount" });
|
|
270
|
-
}
|
|
271
|
-
const grant = tokensFrom(response, null, integration.scopes, provider.scopeSeparator, provider);
|
|
272
|
-
grant.grantedScopes = grant.grantedScopes.filter((scope) => integration.scopes.includes(scope));
|
|
273
|
-
return { ...grant, credentialFingerprint: binding.fingerprint };
|
|
274
|
-
}
|
|
275
|
-
|
|
151
|
+
/** @param {import("./types.js").ConnectRequest} request */
|
|
276
152
|
async function connectServiceAccount({ context, integrationId, verificationInput = {}, signal }) {
|
|
277
153
|
verificationInput = structuredClone(verificationInput);
|
|
278
154
|
const { owner, integration, provider } = await access(context, integrationId, "connect");
|
|
155
|
+
// readable-check: allow method-probe - optional operation: serviceAccountGrant is an optional hook of the provider definition
|
|
279
156
|
if (integration.authentication.method !== "service-account" || typeof provider.serviceAccountGrant !== "function") {
|
|
280
157
|
throw new ConnectorError("connector_mode_unavailable", "Select service-account credentials to verify this connection.", { messageCode: "connectors.connection.selectServiceAccountCredentialsVerify" });
|
|
281
158
|
}
|
|
@@ -283,145 +160,24 @@ function createConnectionService({
|
|
|
283
160
|
requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE, VERIFICATION_SCOPE_CODE);
|
|
284
161
|
return store.withConnection({ owner, integrationId }, async ({ save }) => {
|
|
285
162
|
try {
|
|
286
|
-
const binding = await serviceAccountCredentialFor(integration);
|
|
163
|
+
const binding = await connectionCredentials.serviceAccountCredentialFor(integration);
|
|
287
164
|
const connection = {
|
|
288
165
|
integrationId, provider: provider.id, method: "service-account", secretRef: integration.authentication.secretRef,
|
|
289
|
-
requestedScopes: [...integration.scopes], settings: structuredClone(integration.settings
|
|
290
|
-
status: "connected", verifiedAt: now(), ...await serviceAccountGrant(integration, provider, binding, signal)
|
|
166
|
+
requestedScopes: [...integration.scopes], settings: structuredClone(integration.settings),
|
|
167
|
+
status: "connected", verifiedAt: now(), ...await providerRequests.serviceAccountGrant(integration, provider, binding, signal)
|
|
291
168
|
};
|
|
292
|
-
await request(provider, provider.checkOperation, verificationInput, connection, signal);
|
|
169
|
+
await providerRequests.request(provider, provider.checkOperation, verificationInput, connection, signal);
|
|
293
170
|
await save(connection);
|
|
294
171
|
return publicConnection(connection);
|
|
295
172
|
} catch (error) { throw providerError(error); }
|
|
296
173
|
});
|
|
297
174
|
}
|
|
298
175
|
|
|
299
|
-
|
|
300
|
-
const timeout = AbortSignal.timeout(timeoutMs);
|
|
301
|
-
return {
|
|
302
|
-
[oauth.customFetch]: tokenRequestEncoding === "json" ? (address, options) => {
|
|
303
|
-
const headers = new Headers(options.headers);
|
|
304
|
-
headers.set("Content-Type", "application/json");
|
|
305
|
-
return fetchImpl(address, { ...options, headers,
|
|
306
|
-
body: JSON.stringify(Object.fromEntries(new URLSearchParams(options.body))) });
|
|
307
|
-
} : fetchImpl,
|
|
308
|
-
signal: signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
async function request(provider, operationId, input, connection, signal) {
|
|
313
|
-
const operation = provider.operations[operationId];
|
|
314
|
-
if (!operation || !Object.hasOwn(provider.operations, operationId)) {
|
|
315
|
-
throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.", { messageCode: "connectors.connection.providerOperationNotAvailable" });
|
|
316
|
-
}
|
|
317
|
-
if (["oauth2", "service-account"].includes(connection.method || "oauth2")) {
|
|
318
|
-
requireOperationScope(operation, connection.grantedScopes, "Connect again with the permission required for this operation.", { messageCode: "connectors.connection.connectAgainPermissionRequiredOperation" });
|
|
319
|
-
}
|
|
320
|
-
const descriptor = operationRequest(operation, input, connection.settings, connection.providerData);
|
|
321
|
-
const url = new URL(descriptor.url);
|
|
322
|
-
const origins = typeof provider.apiOrigins === "function" ? provider.apiOrigins(connection.settings || {}, connection.providerData) : provider.apiOrigins;
|
|
323
|
-
if (url.protocol !== "https:" || url.username || url.password || !origins.includes(url.origin)) {
|
|
324
|
-
throw new ConnectorError("connector_destination_invalid", "The provider operation has an invalid destination.", { messageCode: "connectors.connection.providerOperationInvalidDestination" });
|
|
325
|
-
}
|
|
326
|
-
if (connection.method === "api-key" && provider.apiKey.pathPrefix) {
|
|
327
|
-
const prefix = provider.apiKey.pathPrefix(connection.apiKey);
|
|
328
|
-
if (typeof prefix !== "string" || !prefix.startsWith("/") || prefix.startsWith("//") ||
|
|
329
|
-
/[?#\\]/.test(prefix) || prefix.split("/").some((part) => /^(?:\.|%2e){1,2}$/i.test(part))) {
|
|
330
|
-
throw new ConnectorError("connector_request_invalid", "This provider has an invalid credential path.", { messageCode: "connectors.connection.providerInvalidCredentialPath" });
|
|
331
|
-
}
|
|
332
|
-
// A pathname assignment cannot replace the already checked origin.
|
|
333
|
-
url.pathname = prefix + url.pathname;
|
|
334
|
-
}
|
|
335
|
-
if (connection.method === "api-key" && provider.apiKey.queryParameter) {
|
|
336
|
-
url.searchParams.set(provider.apiKey.queryParameter, connection.apiKey);
|
|
337
|
-
}
|
|
338
|
-
let body = descriptor.body;
|
|
339
|
-
if (connection.method === "api-key" && provider.apiKey.bodyParameter) {
|
|
340
|
-
if (!body || typeof body !== "object" || Array.isArray(body) || ["GET", "HEAD"].includes(descriptor.method)) {
|
|
341
|
-
throw new ConnectorError("connector_request_invalid", "This provider requires a JSON request body.", { messageCode: "connectors.connection.providerRequiresJsonRequestBody" });
|
|
342
|
-
}
|
|
343
|
-
body = { ...body, [provider.apiKey.bodyParameter]: connection.apiKey };
|
|
344
|
-
}
|
|
345
|
-
const options = {
|
|
346
|
-
method: descriptor.method,
|
|
347
|
-
body,
|
|
348
|
-
headers: { ...descriptor.headers, Accept: "application/json", ...(connection.method === "api-key"
|
|
349
|
-
? provider.apiKey.headers?.(connection.apiKey, connection.settings || {})
|
|
350
|
-
: connection.method === "none" ? {} : {
|
|
351
|
-
...provider.oauthHeaders?.({ clientId: connection.clientId }), Authorization: `Bearer ${connection.tokens.accessToken}`
|
|
352
|
-
}) },
|
|
353
|
-
redirect: "error",
|
|
354
|
-
signal: requestOptions(signal, provider.requestTimeoutMs).signal
|
|
355
|
-
};
|
|
356
|
-
let result;
|
|
357
|
-
try {
|
|
358
|
-
result = provider.exchange
|
|
359
|
-
? await provider.exchange(url.href, options, { fetchImpl, request: http.request, resolveReference,
|
|
360
|
-
settings: connection.settings || {}, apiKey: connection.method === "api-key" ? connection.apiKey : undefined })
|
|
361
|
-
: await http.request(url.href, options);
|
|
362
|
-
} catch (error) {
|
|
363
|
-
// Transports can wrap cancellation in a network or protocol error.
|
|
364
|
-
options.signal.throwIfAborted();
|
|
365
|
-
throw error;
|
|
366
|
-
}
|
|
367
|
-
// The validator also receives the operation's own request, so it can check the provider answered what was asked.
|
|
368
|
-
if (typeof operation.validateResult === "function" && !operation.validateResult(result, descriptor)) {
|
|
369
|
-
throw new ConnectorError("connector_response_invalid", "The provider returned an unexpected response.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedUnexpectedResponse" });
|
|
370
|
-
}
|
|
371
|
-
return result;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function tokensFrom(response, previous, requestedScopes, scopeSeparator = " ", provider) {
|
|
375
|
-
return {
|
|
376
|
-
...(provider?.dataFromTokenResponse ? { providerData: provider.dataFromTokenResponse(response, previous?.providerData) } : {}),
|
|
377
|
-
tokens: {
|
|
378
|
-
accessToken: response.access_token,
|
|
379
|
-
refreshToken: response.refresh_token || previous?.tokens.refreshToken || null,
|
|
380
|
-
expiresAt: response.expires_in === undefined ? null : now() + response.expires_in * 1000
|
|
381
|
-
},
|
|
382
|
-
grantedScopes: response.scope === undefined
|
|
383
|
-
? [...(previous?.grantedScopes || requestedScopes)]
|
|
384
|
-
: response.scope.split(scopeSeparator).map((scope) => scope.trim()).filter(Boolean)
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function verifyOAuthConnection(provider, connection, verificationInput, requestedScopes, signal) {
|
|
389
|
-
const verification = await request(provider, provider.checkOperation, verificationInput, connection, signal);
|
|
390
|
-
if (provider.accountLabelFromVerification) {
|
|
391
|
-
const label = provider.accountLabelFromVerification(verification);
|
|
392
|
-
if (typeof label !== "string" || !label.trim() || label.length > 256 || /[\p{Cc}\p{Cf}]/u.test(label)) {
|
|
393
|
-
throw new ConnectorError("connector_response_invalid", "The provider returned an invalid account label.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidAccountLabel" });
|
|
394
|
-
}
|
|
395
|
-
connection.accountLabel = label;
|
|
396
|
-
}
|
|
397
|
-
if (!provider.grantedScopesFromVerification) return;
|
|
398
|
-
const scopes = provider.grantedScopesFromVerification(verification, { clientId: connection.clientId });
|
|
399
|
-
if (!Array.isArray(scopes) || scopes.some((scope) => typeof scope !== "string" || !scope)) {
|
|
400
|
-
throw new ConnectorError("connector_response_invalid", "The provider returned an invalid permission grant.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidPermissionGrant" });
|
|
401
|
-
}
|
|
402
|
-
connection.grantedScopes = connection.grantedScopes.filter((scope) => requestedScopes.includes(scope) && scopes.includes(scope));
|
|
403
|
-
requireOperationScope(provider.operations[provider.checkOperation], connection.grantedScopes, "Connect again with the permission required for verification.", { messageCode: "connectors.connection.connectAgainPermissionRequiredVerification" });
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
async function clientCredentialsGrant(registration, provider, settings, scopes, signal) {
|
|
407
|
-
const response = await oauth.clientCredentialsGrantRequest(
|
|
408
|
-
registration.oauth, registration.client, registration.clientAuth,
|
|
409
|
-
{ scope: scopes.join(provider.scopeSeparator || " "), ...(registration.resource ? { resource: registration.resource } : {}) },
|
|
410
|
-
requestOptions(signal)
|
|
411
|
-
);
|
|
412
|
-
const result = await oauth.processClientCredentialsResponse(registration.oauth, registration.client,
|
|
413
|
-
provider.normalizeTokenResponse ? await provider.normalizeTokenResponse(response, { settings, grantType: "client_credentials" }) : response);
|
|
414
|
-
const grant = tokensFrom(result, null, scopes, provider.scopeSeparator, provider);
|
|
415
|
-
// Client credentials renew with the application's secret, never a user refresh token.
|
|
416
|
-
grant.tokens.refreshToken = null;
|
|
417
|
-
grant.grantedScopes = grant.grantedScopes.filter((scope) => scopes.includes(scope));
|
|
418
|
-
return grant;
|
|
419
|
-
}
|
|
420
|
-
|
|
176
|
+
/** @param {import("./types.js").ConnectRequest} request */
|
|
421
177
|
async function connectClientCredentials({ context, integrationId, verificationInput = {}, signal }) {
|
|
422
178
|
verificationInput = structuredClone(verificationInput);
|
|
423
179
|
const { owner, integration, provider } = await access(context, integrationId, "connect");
|
|
424
|
-
const registration = await registrationFor(integration, provider);
|
|
180
|
+
const registration = await connectionCredentials.registrationFor(integration, provider);
|
|
425
181
|
if (registration.grantType !== "client_credentials") {
|
|
426
182
|
throw new ConnectorError("connector_mode_unavailable", "Select client credentials to connect a service account.", { messageCode: "connectors.connection.selectClientCredentialsConnectService" });
|
|
427
183
|
}
|
|
@@ -433,16 +189,17 @@ function createConnectionService({
|
|
|
433
189
|
integrationId, provider: provider.id, registrationRef: registration.registrationRef,
|
|
434
190
|
clientId: registration.client.client_id, tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
|
|
435
191
|
grantType: registration.grantType, requestedScopes: [...integration.scopes],
|
|
436
|
-
settings: structuredClone(integration.settings
|
|
437
|
-
...await clientCredentialsGrant(registration, provider, integration.settings
|
|
192
|
+
settings: structuredClone(integration.settings), status: "connected", verifiedAt: now(),
|
|
193
|
+
...await providerRequests.clientCredentialsGrant(registration, provider, integration.settings, integration.scopes, signal)
|
|
438
194
|
};
|
|
439
|
-
await verifyOAuthConnection(provider, connection, verificationInput, integration.scopes, signal);
|
|
195
|
+
await providerRequests.verifyOAuthConnection(provider, connection, verificationInput, integration.scopes, signal);
|
|
440
196
|
await save(connection);
|
|
441
197
|
return publicConnection(connection);
|
|
442
198
|
} catch (error) { throw providerError(error); }
|
|
443
199
|
});
|
|
444
200
|
}
|
|
445
201
|
|
|
202
|
+
/** @param {import("./types.js").ConnectRequest} request */
|
|
446
203
|
async function beginAuthorization({ context, integrationId, verificationInput = {}, signal }) {
|
|
447
204
|
const { owner, integration, provider } = await access(context, integrationId, "connect");
|
|
448
205
|
if (integration.authentication.refreshTokenRef) {
|
|
@@ -450,7 +207,7 @@ function createConnectionService({
|
|
|
450
207
|
}
|
|
451
208
|
// Resource-specific providers validate the first document ID before opening consent.
|
|
452
209
|
operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
|
|
453
|
-
const registration = await registrationFor(integration, provider);
|
|
210
|
+
const registration = await connectionCredentials.registrationFor(integration, provider);
|
|
454
211
|
if (registration.grantType !== "authorization_code") {
|
|
455
212
|
throw new ConnectorError("connector_mode_unavailable", "Service accounts connect without browser consent.", { messageCode: "connectors.connection.serviceAccountsConnectWithoutBrowser" });
|
|
456
213
|
}
|
|
@@ -463,8 +220,9 @@ function createConnectionService({
|
|
|
463
220
|
url.searchParams.set(provider.oauthClientIdParameter || "client_id", registration.client.client_id);
|
|
464
221
|
url.searchParams.set("redirect_uri", registration.callbackUrl);
|
|
465
222
|
url.searchParams.set("response_type", "code");
|
|
466
|
-
|
|
467
|
-
|
|
223
|
+
// readable-check: allow method-probe - optional operation: authorizationScopeParameter is an optional hook of the provider definition
|
|
224
|
+
const scopeParameter = (typeof provider.authorizationScopeParameter === "function" && provider.authorizationScopeParameter(integration.settings)) || "scope";
|
|
225
|
+
if (integration.scopes.length) url.searchParams.set(scopeParameter, integration.scopes.join(provider.scopeSeparator));
|
|
468
226
|
url.searchParams.set("state", state);
|
|
469
227
|
if (codeVerifier !== null) {
|
|
470
228
|
url.searchParams.set("code_challenge", await oauth.calculatePKCECodeChallenge(codeVerifier));
|
|
@@ -477,16 +235,17 @@ function createConnectionService({
|
|
|
477
235
|
registrationRef: registration.registrationRef,
|
|
478
236
|
grantType: registration.grantType,
|
|
479
237
|
callbackUrl: registration.callbackUrl,
|
|
480
|
-
scopes: [...integration.scopes], settings: structuredClone(integration.settings
|
|
238
|
+
scopes: [...integration.scopes], settings: structuredClone(integration.settings),
|
|
481
239
|
verificationInput: structuredClone(verificationInput), authorizationUrl: url.href, codeVerifier, expiresAt
|
|
482
240
|
}));
|
|
483
241
|
return { authorizationUrl: url.href, expiresAt, callbackUrl: registration.callbackUrl };
|
|
484
242
|
}
|
|
485
243
|
|
|
244
|
+
/** @param {import("./types.js").IntegrationRequest} request */
|
|
486
245
|
async function resumeAuthorization({ context, integrationId }) {
|
|
487
246
|
const { owner, integration, provider } = await access(context, integrationId, "connect");
|
|
488
247
|
if (integration.authentication.method !== "oauth2") return null;
|
|
489
|
-
const registration = await registrationFor(integration, provider);
|
|
248
|
+
const registration = await connectionCredentials.registrationFor(integration, provider);
|
|
490
249
|
if (registration.grantType !== "authorization_code") return null;
|
|
491
250
|
return store.withConnection({ owner, integrationId }, async ({ latestAttempt }) => {
|
|
492
251
|
const attempt = await latestAttempt({ after: now() });
|
|
@@ -496,14 +255,15 @@ function createConnectionService({
|
|
|
496
255
|
attempt.callbackUrl !== registration.callbackUrl ||
|
|
497
256
|
attempt.tokenEndpointAuthMethod !== registration.tokenEndpointAuthMethod ||
|
|
498
257
|
!isDeepStrictEqual(attempt.scopes, integration.scopes) ||
|
|
499
|
-
!isDeepStrictEqual(attempt.settings, integration.settings
|
|
258
|
+
!isDeepStrictEqual(attempt.settings, integration.settings)) return null;
|
|
500
259
|
return { authorizationUrl: attempt.authorizationUrl, expiresAt: attempt.expiresAt, callbackUrl: registration.callbackUrl };
|
|
501
260
|
});
|
|
502
261
|
}
|
|
503
262
|
|
|
263
|
+
/** @param {import("./types.js").ProviderRequest & { callbackUrl: string }} request */
|
|
504
264
|
async function completeAuthorization({ context, integrationId, callbackUrl, signal }) {
|
|
505
265
|
const { owner, integration, provider } = await access(context, integrationId, "connect");
|
|
506
|
-
const registration = await registrationFor(integration, provider);
|
|
266
|
+
const registration = await connectionCredentials.registrationFor(integration, provider);
|
|
507
267
|
if (registration.grantType !== "authorization_code") {
|
|
508
268
|
throw new ConnectorError("connector_mode_unavailable", "Service accounts do not use an authorization callback.", { messageCode: "connectors.connection.serviceAccountsDoNotUse" });
|
|
509
269
|
}
|
|
@@ -527,7 +287,7 @@ function createConnectionService({
|
|
|
527
287
|
registrationGrantType(attempt) !== registration.grantType ||
|
|
528
288
|
registrationClientAuthentication(attempt) !== registration.tokenEndpointAuthMethod ||
|
|
529
289
|
attempt.callbackUrl !== registration.callbackUrl || JSON.stringify(attempt.scopes) !== JSON.stringify(integration.scopes) ||
|
|
530
|
-
!isDeepStrictEqual(attempt.settings || {}, integration.settings
|
|
290
|
+
!isDeepStrictEqual(attempt.settings || {}, integration.settings)) {
|
|
531
291
|
throw new ConnectorError("connector_attempt_invalid", "The authorization attempt is invalid or has expired.", { messageCode: "connectors.connection.authorizationAttemptInvalidExpired" });
|
|
532
292
|
}
|
|
533
293
|
const parameters = oauth.validateAuthResponse(registration.oauth, registration.client, url, state);
|
|
@@ -537,26 +297,26 @@ function createConnectionService({
|
|
|
537
297
|
if (values.length !== 1) {
|
|
538
298
|
throw new ConnectorError("connector_response_invalid", "The provider did not return an unambiguous permission grant.", { statusCode: 502, messageCode: "connectors.connection.ambiguousPermissionGrant" });
|
|
539
299
|
}
|
|
540
|
-
const granted = new Set(values[0].split(provider.scopeSeparator
|
|
300
|
+
const granted = new Set(values[0].split(provider.scopeSeparator).map((scope) => scope.trim()).filter(Boolean));
|
|
541
301
|
fallbackScopes = attempt.scopes.filter((scope) => granted.has(scope));
|
|
542
302
|
}
|
|
543
303
|
const response = await oauth.authorizationCodeGrantRequest(
|
|
544
304
|
registration.oauth, registration.client, registration.clientAuth,
|
|
545
|
-
parameters, registration.callbackUrl, attempt.codeVerifier === null ? oauth.nopkce : attempt.codeVerifier, { ...
|
|
305
|
+
parameters, registration.callbackUrl, attempt.codeVerifier === null ? oauth.nopkce : attempt.codeVerifier, { ...providerRequests.tokenRequestOptions(signal, provider),
|
|
546
306
|
...(registration.resource ? { additionalParameters: { resource: registration.resource } } : {}) }
|
|
547
307
|
);
|
|
548
308
|
const result = await oauth.processAuthorizationCodeResponse(registration.oauth, registration.client,
|
|
549
|
-
|
|
309
|
+
await tokenResponse(provider, response, { settings: integration.settings, grantType: "authorization_code" }));
|
|
550
310
|
const connection = {
|
|
551
311
|
integrationId, provider: provider.id, registrationRef: registration.registrationRef,
|
|
552
312
|
grantType: registration.grantType,
|
|
553
313
|
clientId: registration.client.client_id, tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod, status: "connected", verifiedAt: now(),
|
|
554
|
-
settings: structuredClone(integration.settings
|
|
314
|
+
settings: structuredClone(integration.settings),
|
|
555
315
|
...(provider.refreshRequiresRedirectUri ? { callbackUrl: registration.callbackUrl } : {}),
|
|
556
|
-
...tokensFrom(result, null, fallbackScopes, provider
|
|
316
|
+
...providerRequests.tokensFrom(result, null, fallbackScopes, provider)
|
|
557
317
|
};
|
|
558
318
|
connection.grantedScopes = connection.grantedScopes.filter((scope) => integration.scopes.includes(scope));
|
|
559
|
-
await verifyOAuthConnection(provider, connection, attempt.verificationInput || {}, attempt.scopes, signal);
|
|
319
|
+
await providerRequests.verifyOAuthConnection(provider, connection, attempt.verificationInput || {}, attempt.scopes, signal);
|
|
560
320
|
await save(connection);
|
|
561
321
|
return { result: publicConnection(connection) };
|
|
562
322
|
} catch (error) {
|
|
@@ -568,10 +328,16 @@ function createConnectionService({
|
|
|
568
328
|
return outcome.result;
|
|
569
329
|
}
|
|
570
330
|
|
|
331
|
+
/**
|
|
332
|
+
* @param {import("./types.js").ConnectRequest} request
|
|
333
|
+
* @param {"api-key" | "none"} method
|
|
334
|
+
*/
|
|
571
335
|
async function verifyDirectConnection({ context, integrationId, verificationInput = {}, signal }, method) {
|
|
572
336
|
const { owner, integration, provider } = await access(context, integrationId, "connect");
|
|
573
337
|
if (integration.authentication.method !== method || (method === "api-key" &&
|
|
338
|
+
// readable-check: allow method-probe - input validation: an API-key provider definition places its key with headers(), a parameter or pathPrefix()
|
|
574
339
|
(typeof provider.apiKey?.headers !== "function" && !provider.apiKey?.queryParameter &&
|
|
340
|
+
// readable-check: allow method-probe - input validation: an API-key provider definition places its key with headers(), a parameter or pathPrefix()
|
|
575
341
|
!provider.apiKey?.bodyParameter && typeof provider.apiKey?.pathPrefix !== "function"))) {
|
|
576
342
|
throw new ConnectorError("connector_mode_unavailable", "Select the matching credential mode to verify this connection.", { messageCode: "connectors.connection.selectMatchingCredentialModeVerify" });
|
|
577
343
|
}
|
|
@@ -580,11 +346,11 @@ function createConnectionService({
|
|
|
580
346
|
const connection = {
|
|
581
347
|
integrationId, provider: provider.id, method,
|
|
582
348
|
...(method === "api-key" && integration.authentication.secretRef ? { secretRef: integration.authentication.secretRef } : {}),
|
|
583
|
-
grantedScopes: [], status: "connected", verifiedAt: now(), settings: structuredClone(integration.settings
|
|
349
|
+
grantedScopes: [], status: "connected", verifiedAt: now(), settings: structuredClone(integration.settings)
|
|
584
350
|
};
|
|
585
351
|
try {
|
|
586
|
-
const credentials = method === "api-key" ? { ...connection, apiKey: await apiKeyFor(integration, provider) } : connection;
|
|
587
|
-
await request(provider, provider.checkOperation, verificationInput, credentials, signal);
|
|
352
|
+
const credentials = method === "api-key" ? { ...connection, apiKey: await connectionCredentials.apiKeyFor(integration, provider) } : connection;
|
|
353
|
+
await providerRequests.request(provider, provider.checkOperation, verificationInput, credentials, signal);
|
|
588
354
|
if (method === "api-key") connection.credentialFingerprint = createHash("sha256").update(credentials.apiKey).digest("hex");
|
|
589
355
|
await save(connection);
|
|
590
356
|
return publicConnection(connection);
|
|
@@ -592,9 +358,12 @@ function createConnectionService({
|
|
|
592
358
|
});
|
|
593
359
|
}
|
|
594
360
|
|
|
361
|
+
/** @param {import("./types.js").ConnectRequest} input */
|
|
595
362
|
const connectApiKey = (input) => verifyDirectConnection(input, "api-key");
|
|
363
|
+
/** @param {import("./types.js").ConnectRequest} input */
|
|
596
364
|
const connectWithoutCredentials = (input) => verifyDirectConnection(input, "none");
|
|
597
365
|
|
|
366
|
+
/** @param {import("./types.js").ProviderRequest & { operation: string, input?: any }} request */
|
|
598
367
|
async function invoke({ context, integrationId, operation, input = {}, signal }) {
|
|
599
368
|
// Authorize and execute the same input even if the caller changes its object while awaiting policy.
|
|
600
369
|
input = structuredClone(input);
|
|
@@ -606,8 +375,8 @@ function createConnectionService({
|
|
|
606
375
|
const usesApiKey = integration.authentication.method === "api-key";
|
|
607
376
|
const usesServiceAccount = integration.authentication.method === "service-account";
|
|
608
377
|
const usesOAuth = integration.authentication.method === "oauth2";
|
|
609
|
-
const registration = usesOAuth ? await registrationFor(integration, provider) : null;
|
|
610
|
-
const seed = integration.authentication.refreshTokenRef ? await envRefreshTokenFor(integration) : null;
|
|
378
|
+
const registration = usesOAuth ? await connectionCredentials.registrationFor(integration, provider) : null;
|
|
379
|
+
const seed = integration.authentication.refreshTokenRef ? await connectionCredentials.envRefreshTokenFor(integration) : null;
|
|
611
380
|
const outcome = await store.withConnection({ owner, integrationId }, async ({ connection, save }) => {
|
|
612
381
|
if (seed && !envSeedIsCurrent(connection, seed, registration)) {
|
|
613
382
|
connection = envSeededConnection(integrationId, integration, provider, registration, seed);
|
|
@@ -620,39 +389,39 @@ function createConnectionService({
|
|
|
620
389
|
}
|
|
621
390
|
try {
|
|
622
391
|
if (usesServiceAccount) {
|
|
623
|
-
const binding = await serviceAccountCredentialFor(integration);
|
|
624
|
-
if (binding.fingerprint !== connection.credentialFingerprint || connection.tokens.expiresAt <= now() +
|
|
625
|
-
connection = { ...connection, ...await serviceAccountGrant(integration, provider, binding, signal) };
|
|
392
|
+
const binding = await connectionCredentials.serviceAccountCredentialFor(integration);
|
|
393
|
+
if (binding.fingerprint !== connection.credentialFingerprint || connection.tokens.expiresAt <= now() + provider.tokenRefreshLeewayMs) {
|
|
394
|
+
connection = { ...connection, ...await providerRequests.serviceAccountGrant(integration, provider, binding, signal) };
|
|
626
395
|
await save(connection);
|
|
627
396
|
}
|
|
628
397
|
}
|
|
629
|
-
if (usesOAuth && connection.tokens.expiresAt !== null && connection.tokens.expiresAt <= now() +
|
|
398
|
+
if (usesOAuth && connection.tokens.expiresAt !== null && connection.tokens.expiresAt <= now() + provider.tokenRefreshLeewayMs) {
|
|
630
399
|
let grant;
|
|
631
400
|
if (registration.grantType === "client_credentials") {
|
|
632
|
-
grant = await clientCredentialsGrant(registration, provider, integration.settings
|
|
401
|
+
grant = await providerRequests.clientCredentialsGrant(registration, provider, integration.settings, connection.grantedScopes, signal);
|
|
633
402
|
} else {
|
|
634
403
|
if (!connection.tokens.refreshToken) {
|
|
635
404
|
throw new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401, messageCode: "connectors.connection.connectAccountAgain" });
|
|
636
405
|
}
|
|
637
406
|
const response = await oauth.refreshTokenGrantRequest(
|
|
638
407
|
registration.oauth, registration.client, registration.clientAuth,
|
|
639
|
-
connection.tokens.refreshToken, { ...
|
|
408
|
+
connection.tokens.refreshToken, { ...providerRequests.tokenRequestOptions(signal, provider),
|
|
640
409
|
additionalParameters: {
|
|
641
410
|
...(registration.resource ? { resource: registration.resource } : {}),
|
|
642
411
|
...(provider.refreshRequiresRedirectUri ? { redirect_uri: connection.callbackUrl } : {})
|
|
643
412
|
} }
|
|
644
413
|
);
|
|
645
414
|
const refreshed = await oauth.processRefreshTokenResponse(registration.oauth, registration.client,
|
|
646
|
-
|
|
647
|
-
grant = tokensFrom(refreshed, connection, integration.scopes, provider
|
|
415
|
+
await tokenResponse(provider, response, { settings: integration.settings, grantType: "refresh_token" }));
|
|
416
|
+
grant = providerRequests.tokensFrom(refreshed, connection, integration.scopes, provider);
|
|
648
417
|
}
|
|
649
418
|
grant.grantedScopes = grant.grantedScopes.filter((scope) =>
|
|
650
419
|
connection.grantedScopes.includes(scope) && integration.scopes.includes(scope));
|
|
651
420
|
connection = { ...connection, ...grant };
|
|
652
421
|
await save(connection);
|
|
653
422
|
}
|
|
654
|
-
const credentials = usesApiKey ? { ...connection, apiKey: await apiKeyFor(integration, provider) } : connection;
|
|
655
|
-
const result = await request(provider, operation, input, credentials, signal);
|
|
423
|
+
const credentials = usesApiKey ? { ...connection, apiKey: await connectionCredentials.apiKeyFor(integration, provider) } : connection;
|
|
424
|
+
const result = await providerRequests.request(provider, operation, input, credentials, signal);
|
|
656
425
|
await save({ ...connection, verifiedAt: now(),
|
|
657
426
|
...(usesApiKey ? { credentialFingerprint: createHash("sha256").update(credentials.apiKey).digest("hex") } : {})
|
|
658
427
|
});
|
|
@@ -671,6 +440,7 @@ function createConnectionService({
|
|
|
671
440
|
return outcome.result;
|
|
672
441
|
}
|
|
673
442
|
|
|
443
|
+
/** @param {import("./types.js").IntegrationRequest} request */
|
|
674
444
|
async function status({ context, integrationId }) {
|
|
675
445
|
const { owner, integration, provider } = await access(context, integrationId, "status");
|
|
676
446
|
// Compare against the configured registration without resolving its secrets.
|
|
@@ -686,10 +456,10 @@ function createConnectionService({
|
|
|
686
456
|
let callbackUrl;
|
|
687
457
|
try {
|
|
688
458
|
if (integration.authentication.method === "oauth2") {
|
|
689
|
-
const resolved = await registrationFor(integration, provider);
|
|
459
|
+
const resolved = await connectionCredentials.registrationFor(integration, provider);
|
|
690
460
|
callbackUrl = resolved.callbackUrl;
|
|
691
461
|
if (integration.authentication.refreshTokenRef) {
|
|
692
|
-
const seed = await envRefreshTokenFor(integration);
|
|
462
|
+
const seed = await connectionCredentials.envRefreshTokenFor(integration);
|
|
693
463
|
// A new value is used on the next call; until then it reads as connected from Env.
|
|
694
464
|
if (!envSeedIsCurrent(connection, seed, resolved)) {
|
|
695
465
|
return { status: "connected", source: "env", provider: provider.id, integrationId, grantedScopes: [...integration.scopes], callbackUrl };
|
|
@@ -699,11 +469,11 @@ function createConnectionService({
|
|
|
699
469
|
return publicConnection({ ...connection, status: "reconnect-required" });
|
|
700
470
|
}
|
|
701
471
|
} else if (integration.authentication.method === "api-key") {
|
|
702
|
-
const key = await apiKeyFor(integration, provider);
|
|
472
|
+
const key = await connectionCredentials.apiKeyFor(integration, provider);
|
|
703
473
|
if (connection && connection.credentialFingerprint !== createHash("sha256").update(key).digest("hex")) {
|
|
704
474
|
return publicConnection({ ...connection, status: "reconnect-required" });
|
|
705
475
|
}
|
|
706
|
-
} else if (integration.authentication.method === "service-account") await serviceAccountCredentialFor(integration);
|
|
476
|
+
} else if (integration.authentication.method === "service-account") await connectionCredentials.serviceAccountCredentialFor(integration);
|
|
707
477
|
} catch (error) {
|
|
708
478
|
if (!["connector_binding_missing", "connector_callback_invalid"].includes(error.code)) throw error;
|
|
709
479
|
if (!connection) return { status: "unconfigured", configurationError: error.code };
|
|
@@ -713,6 +483,7 @@ function createConnectionService({
|
|
|
713
483
|
});
|
|
714
484
|
}
|
|
715
485
|
|
|
486
|
+
/** @param {import("./types.js").IntegrationRequest} request */
|
|
716
487
|
async function disconnect({ context, integrationId }) {
|
|
717
488
|
const { owner, integration } = await access(context, integrationId, "disconnect");
|
|
718
489
|
if (integration.authentication.refreshTokenRef) {
|
|
@@ -722,13 +493,27 @@ function createConnectionService({
|
|
|
722
493
|
return { status: "disconnected" };
|
|
723
494
|
}
|
|
724
495
|
|
|
496
|
+
/** @param {import("./types.js").IntegrationRequest & { state: string }} request */
|
|
725
497
|
async function cancelAuthorization({ context, integrationId, state }) {
|
|
726
498
|
const { owner } = await access(context, integrationId, "connect", undefined, false);
|
|
727
499
|
await store.withConnection({ owner, integrationId }, async ({ consumeAttempt }) => consumeAttempt(state));
|
|
728
500
|
return { status: "cancelled" };
|
|
729
501
|
}
|
|
730
502
|
|
|
731
|
-
return Object.freeze({
|
|
503
|
+
return Object.freeze({
|
|
504
|
+
beginAuthorization,
|
|
505
|
+
resumeAuthorization,
|
|
506
|
+
completeAuthorization,
|
|
507
|
+
cancelAuthorization,
|
|
508
|
+
connectClientCredentials,
|
|
509
|
+
connectServiceAccount,
|
|
510
|
+
connectApiKey,
|
|
511
|
+
connectWithoutCredentials,
|
|
512
|
+
invoke,
|
|
513
|
+
status,
|
|
514
|
+
disconnect,
|
|
515
|
+
authorizeAssistantAction
|
|
516
|
+
});
|
|
732
517
|
}
|
|
733
518
|
|
|
734
519
|
export { createConnectionService };
|