@jskit-ai/connectors-core 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +551 -0
- package/docs/oauth-callbacks.md +65 -0
- package/docs/online-setup.md +56 -0
- package/docs/setup-command.md +178 -0
- package/migrations/connectors_core_initial.cjs +17 -0
- package/package.json +65 -0
- package/src/server/ConnectorsFeature.js +36 -0
- package/src/server/connectionService.js +664 -0
- package/src/server/credentialProtection.js +34 -0
- package/src/server/environmentReferences.js +13 -0
- package/src/server/errors.js +27 -0
- package/src/server/fileConnectionStore.js +86 -0
- package/src/server/fileStorage.js +2 -0
- package/src/server/index.js +4 -0
- package/src/server/knexConnectionStore.js +65 -0
- package/src/server/storage.js +2 -0
- package/src/shared/configuration.js +225 -0
- package/test/connectionService.test.js +1231 -0
- package/test/fileConnectionStore.test.js +165 -0
- package/test/knexConnectionStore.test.js +178 -0
- package/test/serviceAccount.test.js +192 -0
- package/test/setupCommand.test.js +364 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { CompactEncrypt, compactDecrypt } from "jose";
|
|
2
|
+
import { ConnectorError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
function createCredentialProtection({ keys, activeKeyId }) {
|
|
5
|
+
const keyring = new Map(Object.entries(keys || {}));
|
|
6
|
+
if (!keyring.has(activeKeyId) || [...keyring.values()].some((key) => !(key instanceof Uint8Array) || key.length !== 32)) {
|
|
7
|
+
throw new TypeError("Credential protection requires named 32-byte keys and an active key ID.");
|
|
8
|
+
}
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
async seal(value, binding) {
|
|
13
|
+
return new CompactEncrypt(encoder.encode(JSON.stringify({ binding, value })))
|
|
14
|
+
.setProtectedHeader({ alg: "dir", enc: "A256GCM", kid: activeKeyId })
|
|
15
|
+
.encrypt(keyring.get(activeKeyId));
|
|
16
|
+
},
|
|
17
|
+
async open(ciphertext, binding) {
|
|
18
|
+
try {
|
|
19
|
+
const { plaintext } = await compactDecrypt(ciphertext, (header) => {
|
|
20
|
+
const key = keyring.get(header.kid);
|
|
21
|
+
if (!key) throw new Error("Unknown key.");
|
|
22
|
+
return key;
|
|
23
|
+
}, { keyManagementAlgorithms: ["dir"], contentEncryptionAlgorithms: ["A256GCM"] });
|
|
24
|
+
const envelope = JSON.parse(decoder.decode(plaintext));
|
|
25
|
+
if (envelope.binding !== binding) throw new Error("Incorrect record binding.");
|
|
26
|
+
return envelope.value;
|
|
27
|
+
} catch {
|
|
28
|
+
throw new ConnectorError("connector_credentials_unavailable", "Stored credentials could not be opened.", { statusCode: 500 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { createCredentialProtection };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ConnectorError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
function createEnvironmentReferenceResolver(env = process.env) {
|
|
4
|
+
return async (reference) => {
|
|
5
|
+
const match = /^env:([A-Z_][A-Z0-9_]*)$/u.exec(reference);
|
|
6
|
+
if (!match || typeof env[match[1]] !== "string" || (!env[match[1]].trim() || env[match[1]].trim() === "MISSING")) {
|
|
7
|
+
throw new ConnectorError("connector_binding_missing", "A required environment binding is missing.");
|
|
8
|
+
}
|
|
9
|
+
return env[match[1]];
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { createEnvironmentReferenceResolver };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class ConnectorError extends Error {
|
|
2
|
+
constructor(code, message, { statusCode = 400, retryAfterSeconds } = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "ConnectorError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.statusCode = statusCode;
|
|
7
|
+
if (retryAfterSeconds !== undefined) this.retryAfterSeconds = retryAfterSeconds;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function providerError(error) {
|
|
12
|
+
if (error instanceof ConnectorError) return error;
|
|
13
|
+
const status = Number(error?.status || error?.statusCode || 0);
|
|
14
|
+
if (error?.name === "AbortError") return new ConnectorError("connector_cancelled", "The operation was cancelled.");
|
|
15
|
+
if (error?.name === "TimeoutError") return new ConnectorError("connector_provider_timeout", "The provider request timed out. Try again.", { statusCode: 504 });
|
|
16
|
+
if (["invalid_grant", "invalid_client"].includes(error?.error) || status === 401) {
|
|
17
|
+
return new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401 });
|
|
18
|
+
}
|
|
19
|
+
if (["access_denied", "user_cancelled_login", "user_cancelled_authorize"].includes(error?.error)) {
|
|
20
|
+
return new ConnectorError("connector_consent_denied", "Account access was declined.");
|
|
21
|
+
}
|
|
22
|
+
if (status === 403) return new ConnectorError("connector_permission_denied", "The provider denied this operation.", { statusCode: 403 });
|
|
23
|
+
if (status === 429) return new ConnectorError("connector_rate_limited", "The provider's request limit was reached.", { statusCode: 429 });
|
|
24
|
+
return new ConnectorError("connector_provider_failed", "The provider request failed. Try again.", { statusCode: 502 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { ConnectorError, providerError };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import lockfile from "proper-lockfile";
|
|
6
|
+
import { ConnectorError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
function createFileConnectionStore({ directory, protection, directoryMode = 0o700, fileMode = 0o600, now = Date.now }) {
|
|
9
|
+
if (typeof directory !== "string" || !path.isAbsolute(directory)) throw new TypeError("Use an absolute private runtime directory.");
|
|
10
|
+
if (typeof protection?.seal !== "function" || typeof protection?.open !== "function") throw new TypeError("Credential protection is required.");
|
|
11
|
+
const root = path.resolve(directory);
|
|
12
|
+
const storageError = () => new ConnectorError("connector_storage_invalid", "Connection state could not be read safely.", { statusCode: 500 });
|
|
13
|
+
|
|
14
|
+
async function withConnection({ owner, integrationId }, work) {
|
|
15
|
+
await mkdir(root, { recursive: true, mode: directoryMode });
|
|
16
|
+
if (!(await lstat(root)).isDirectory() || await realpath(root) !== root) throw storageError();
|
|
17
|
+
const identity = [owner.applicationId, owner.subjectId, integrationId];
|
|
18
|
+
if (identity.some((value) => typeof value !== "string" || !value)) throw new TypeError("Connection storage requires a complete owner and slot.");
|
|
19
|
+
const key = createHash("sha256").update(JSON.stringify(identity)).digest("hex");
|
|
20
|
+
const file = path.join(root, `${key}.json`);
|
|
21
|
+
const binding = `file-connection:${key}`;
|
|
22
|
+
let compromised = false;
|
|
23
|
+
const release = await lockfile.lock(file, {
|
|
24
|
+
realpath: false, stale: 60_000, update: 10_000,
|
|
25
|
+
retries: { retries: 80, minTimeout: 100, maxTimeout: 500 },
|
|
26
|
+
onCompromised() { compromised = true; }
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
let record = { connection: null, attempts: {} };
|
|
30
|
+
let handle;
|
|
31
|
+
try {
|
|
32
|
+
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
33
|
+
const info = await handle.stat();
|
|
34
|
+
if (!info.isFile() || info.size > 4 * 1024 * 1024) throw storageError();
|
|
35
|
+
const envelope = JSON.parse(await handle.readFile("utf8"));
|
|
36
|
+
if (envelope.schemaVersion !== 1 || typeof envelope.payload !== "string") throw storageError();
|
|
37
|
+
record = await protection.open(envelope.payload, binding);
|
|
38
|
+
if (!record || !Object.hasOwn(record, "connection") || !record.attempts || typeof record.attempts !== "object" || Array.isArray(record.attempts)) throw storageError();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code !== "ENOENT") throw storageError();
|
|
41
|
+
} finally { await handle?.close(); }
|
|
42
|
+
let changed = false;
|
|
43
|
+
for (const [state, attempt] of Object.entries(record.attempts)) {
|
|
44
|
+
if (attempt.expiresAt <= now()) { delete record.attempts[state]; changed = true; }
|
|
45
|
+
}
|
|
46
|
+
const attemptKey = (state) => createHash("sha256").update(state).digest("hex");
|
|
47
|
+
const result = await work({
|
|
48
|
+
connection: structuredClone(record.connection),
|
|
49
|
+
save: async (connection) => { record.connection = structuredClone(connection); changed = true; },
|
|
50
|
+
remove: async () => { record = { connection: null, attempts: {} }; changed = true; },
|
|
51
|
+
putAttempt: async (attempt) => { record.attempts[attemptKey(attempt.state)] = structuredClone(attempt); changed = true; },
|
|
52
|
+
latestAttempt: async ({ after }) => structuredClone(Object.values(record.attempts)
|
|
53
|
+
.filter((attempt) => attempt.expiresAt > after)
|
|
54
|
+
.sort((a, b) => b.expiresAt - a.expiresAt)[0] || null),
|
|
55
|
+
consumeAttempt: async (state) => {
|
|
56
|
+
const id = attemptKey(state);
|
|
57
|
+
const attempt = record.attempts[id] || null;
|
|
58
|
+
if (attempt) { delete record.attempts[id]; changed = true; }
|
|
59
|
+
return attempt;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
if (compromised) throw storageError();
|
|
63
|
+
if (changed) {
|
|
64
|
+
const payload = await protection.seal(record, binding);
|
|
65
|
+
const temporary = path.join(root, `.${key}.${randomUUID()}.tmp`);
|
|
66
|
+
let output;
|
|
67
|
+
try {
|
|
68
|
+
output = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, fileMode);
|
|
69
|
+
await output.writeFile(`${JSON.stringify({ schemaVersion: 1, payload })}\n`, "utf8");
|
|
70
|
+
await output.sync();
|
|
71
|
+
await output.close();
|
|
72
|
+
output = null;
|
|
73
|
+
if (compromised) throw storageError();
|
|
74
|
+
await rename(temporary, file);
|
|
75
|
+
} finally {
|
|
76
|
+
await output?.close();
|
|
77
|
+
await rm(temporary, { force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
} finally { if (!compromised) await release(); }
|
|
82
|
+
}
|
|
83
|
+
return Object.freeze({ withConnection });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { createFileConnectionStore };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createWithTransaction } from "@jskit-ai/database-runtime/shared";
|
|
3
|
+
|
|
4
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
5
|
+
|
|
6
|
+
function createKnexConnectionStore({ knex, protection }) {
|
|
7
|
+
if (typeof knex !== "function" || typeof knex.transaction !== "function") {
|
|
8
|
+
throw new TypeError("Connector storage requires the application's transactional Knex client.");
|
|
9
|
+
}
|
|
10
|
+
if (typeof protection?.seal !== "function" || typeof protection?.open !== "function") {
|
|
11
|
+
throw new TypeError("Connector storage requires credential protection.");
|
|
12
|
+
}
|
|
13
|
+
const transaction = createWithTransaction(knex);
|
|
14
|
+
|
|
15
|
+
async function withConnection({ owner, integrationId }, work) {
|
|
16
|
+
const connectionKey = hash(JSON.stringify([owner.applicationId, owner.subjectId, integrationId]));
|
|
17
|
+
const connectionBinding = `connection:${connectionKey}`;
|
|
18
|
+
return transaction(async (trx) => {
|
|
19
|
+
// Retain this row on disconnect so other processes continue to lock the same identity.
|
|
20
|
+
await trx("connector_connections").insert({ connection_key: connectionKey })
|
|
21
|
+
.onConflict("connection_key").merge({ connection_key: connectionKey });
|
|
22
|
+
const row = await trx("connector_connections").where({ connection_key: connectionKey }).forUpdate().first();
|
|
23
|
+
return work({
|
|
24
|
+
connection: row.payload ? await protection.open(row.payload, connectionBinding) : null,
|
|
25
|
+
save: async (connection) => {
|
|
26
|
+
const payload = await protection.seal(connection, connectionBinding);
|
|
27
|
+
await trx("connector_connections").where({ connection_key: connectionKey }).update({ payload });
|
|
28
|
+
},
|
|
29
|
+
remove: async () => {
|
|
30
|
+
await trx("connector_connections").where({ connection_key: connectionKey }).update({ payload: null });
|
|
31
|
+
await trx("connector_authorization_attempts").where({ connection_key: connectionKey }).delete();
|
|
32
|
+
},
|
|
33
|
+
putAttempt: async (attempt) => {
|
|
34
|
+
const attemptKey = hash(attempt.state);
|
|
35
|
+
await trx("connector_authorization_attempts").insert({
|
|
36
|
+
attempt_key: attemptKey, connection_key: connectionKey, expires_at: attempt.expiresAt,
|
|
37
|
+
payload: await protection.seal(attempt, `attempt:${connectionKey}:${attemptKey}`)
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
latestAttempt: async ({ after }) => {
|
|
41
|
+
const attempt = await trx("connector_authorization_attempts")
|
|
42
|
+
.where({ connection_key: connectionKey }).where("expires_at", ">", after)
|
|
43
|
+
.orderBy("expires_at", "desc").first();
|
|
44
|
+
return attempt ? protection.open(attempt.payload, `attempt:${connectionKey}:${attempt.attempt_key}`) : null;
|
|
45
|
+
},
|
|
46
|
+
consumeAttempt: async (state) => {
|
|
47
|
+
const attemptKey = hash(state);
|
|
48
|
+
const where = { attempt_key: attemptKey, connection_key: connectionKey };
|
|
49
|
+
const attempt = await trx("connector_authorization_attempts").where(where).first();
|
|
50
|
+
if (!attempt) return null;
|
|
51
|
+
await trx("connector_authorization_attempts").where(where).delete();
|
|
52
|
+
return protection.open(attempt.payload, `attempt:${connectionKey}:${attemptKey}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function pruneExpiredAttempts({ before = Date.now() } = {}) {
|
|
59
|
+
return knex("connector_authorization_attempts").where("expires_at", "<=", before).delete();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Object.freeze({ withConnection, pruneExpiredAttempts });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { createKnexConnectionStore };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createSchema } from "json-rest-schema";
|
|
2
|
+
import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
|
|
3
|
+
|
|
4
|
+
const name = { type: "string", required: true, minLength: 1, maxLength: 200 };
|
|
5
|
+
const secretReference = {
|
|
6
|
+
...name,
|
|
7
|
+
validator: (value) => (/^[a-z][a-z0-9-]*:[^\s]+$/u.test(value) && !/^https?:/u.test(value)) || "Use a reference such as env:VARIABLE_NAME."
|
|
8
|
+
};
|
|
9
|
+
const authenticationSchema = createSchema({
|
|
10
|
+
method: { ...name, enum: ["oauth2", "api-key", "service-account", "none"] },
|
|
11
|
+
registrationRef: { ...name, required: false },
|
|
12
|
+
secretRef: { ...secretReference, required: false }
|
|
13
|
+
});
|
|
14
|
+
const assistantPermission = { type: "string", enum: ["ask", "always", "never"] };
|
|
15
|
+
const assistantPolicySchema = createSchema({
|
|
16
|
+
enabled: { type: "boolean", strictBoolean: true, defaultTo: true },
|
|
17
|
+
defaultPermission: { ...assistantPermission, defaultTo: "ask" },
|
|
18
|
+
actions: { type: "object", values: assistantPermission, defaultTo: {} }
|
|
19
|
+
});
|
|
20
|
+
const integrationSchema = createSchema({
|
|
21
|
+
provider: name,
|
|
22
|
+
displayName: { ...name, required: false },
|
|
23
|
+
accountMode: { ...name, enum: ["shared", "per-user", "assistant"] },
|
|
24
|
+
scopes: { type: "array", items: { ...name, maxLength: 2048 }, required: true },
|
|
25
|
+
authentication: { type: "object", schema: authenticationSchema, required: true },
|
|
26
|
+
settings: { type: "object", additionalProperties: true, required: false },
|
|
27
|
+
assistantPolicy: { type: "object", schema: assistantPolicySchema, required: false },
|
|
28
|
+
extensions: { type: "object", additionalProperties: true, required: false }
|
|
29
|
+
});
|
|
30
|
+
const registrationSchema = createSchema({
|
|
31
|
+
source: { ...name, enum: ["own"] },
|
|
32
|
+
grantType: { ...name, enum: ["authorization_code", "client_credentials"], required: false },
|
|
33
|
+
clientId: { ...name, maxLength: 2048, required: false },
|
|
34
|
+
clientSecretRef: { ...secretReference, required: false },
|
|
35
|
+
tokenEndpointAuthMethod: { ...name, enum: ["client_secret_post", "client_secret_basic", "none"], required: false },
|
|
36
|
+
callbackUrlRef: { ...secretReference, required: false }
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const integrationsSchema = createSchema({
|
|
40
|
+
schemaVersion: { type: "integer", enum: [1], required: true },
|
|
41
|
+
integrations: { type: "object", values: integrationSchema, required: true },
|
|
42
|
+
registrations: { type: "object", values: registrationSchema, required: true },
|
|
43
|
+
extensions: { type: "object", additionalProperties: true, required: false }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
class IntegrationConfigurationError extends Error {
|
|
47
|
+
constructor(fieldErrors) {
|
|
48
|
+
super("Integration configuration is invalid.");
|
|
49
|
+
this.name = "IntegrationConfigurationError";
|
|
50
|
+
this.code = "integration_configuration_invalid";
|
|
51
|
+
this.statusCode = 422;
|
|
52
|
+
this.fieldErrors = fieldErrors;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getProviderSettingsSchema(provider, settings = {}) {
|
|
57
|
+
return typeof provider.settingsSchema === "function" ? provider.settingsSchema(settings) : provider.settingsSchema;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getProviderAuthenticationMethods(provider, settings = {}) {
|
|
61
|
+
return provider.authenticationMethodsForSettings ? provider.authenticationMethodsForSettings(settings) : provider.authenticationMethods;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getProviderAccountModes(provider, settings = {}) {
|
|
65
|
+
return provider.accountModesForSettings ? provider.accountModesForSettings(settings) : provider.accountModes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getProviderScopes(provider, settings = {}, grantType = "authorization_code", authenticationMethod) {
|
|
69
|
+
const scopes = provider.scopesForGrantType ? provider.scopesForGrantType(grantType, settings)
|
|
70
|
+
: provider.scopesForSettings ? provider.scopesForSettings(settings) : provider.scopes;
|
|
71
|
+
return authenticationMethod ? scopes.filter(scope => !scope.authenticationMethods || scope.authenticationMethods.includes(authenticationMethod)) : scopes;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getProviderClientAuthenticationMethods(provider, grantType = "authorization_code", settings = {}) {
|
|
75
|
+
const methods = provider.oauthClientAuthenticationMethods;
|
|
76
|
+
return typeof methods === "function" ? methods(grantType, settings) : methods || ["client_secret_post"];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateIntegrationConfiguration(input, { providers = [], allowUnknownProviders = false } = {}) {
|
|
80
|
+
if (input?.schemaVersion !== 1) {
|
|
81
|
+
throw new IntegrationConfigurationError({ schemaVersion: "Use numeric schemaVersion 1; other versions require an explicit migration." });
|
|
82
|
+
}
|
|
83
|
+
let config;
|
|
84
|
+
try {
|
|
85
|
+
config = validateSchemaPayload({ schema: integrationsSchema, mode: "replace" }, input);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new IntegrationConfigurationError(error.fieldErrors || { configuration: "Invalid configuration." });
|
|
88
|
+
}
|
|
89
|
+
const fieldErrors = {};
|
|
90
|
+
const definitions = new Map(providers.map((provider) => [provider.id, provider]));
|
|
91
|
+
const requireValue = (record, key, path) => {
|
|
92
|
+
if (!record[key]) fieldErrors[`${path}.${key}`] = "This value is required.";
|
|
93
|
+
};
|
|
94
|
+
for (const field of ["integrations", "registrations"]) {
|
|
95
|
+
for (const id of Object.keys(config[field])) {
|
|
96
|
+
if (!/^[a-z][a-z0-9-]*$/u.test(id) || ["constructor", "prototype"].includes(id)) {
|
|
97
|
+
fieldErrors[`${field}.${id}`] = "Use a stable lowercase name containing letters, digits and hyphens.";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const [id, registration] of Object.entries(config.registrations)) {
|
|
102
|
+
const path = `registrations.${id}`;
|
|
103
|
+
const clientCredentials = registration.grantType === "client_credentials";
|
|
104
|
+
const required = ["clientId", ...(clientCredentials ? [] : ["callbackUrlRef"]), ...(registration.tokenEndpointAuthMethod === "none" ? [] : ["clientSecretRef"])];
|
|
105
|
+
for (const key of required) requireValue(registration, key, path);
|
|
106
|
+
const forbidden = [...(clientCredentials ? ["callbackUrlRef"] : []), ...(registration.tokenEndpointAuthMethod === "none" ? ["clientSecretRef"] : [])];
|
|
107
|
+
for (const key of forbidden) {
|
|
108
|
+
if (Object.hasOwn(registration, key)) fieldErrors[`${path}.${key}`] = "This field belongs to another credential mode.";
|
|
109
|
+
}
|
|
110
|
+
if (clientCredentials && registration.tokenEndpointAuthMethod === "none") {
|
|
111
|
+
fieldErrors[`${path}.tokenEndpointAuthMethod`] = "Client credentials require a confidential client.";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const [id, integration] of Object.entries(config.integrations)) {
|
|
115
|
+
const path = `integrations.${id}`;
|
|
116
|
+
const auth = integration.authentication;
|
|
117
|
+
const provider = definitions.get(integration.provider);
|
|
118
|
+
const registration = config.registrations[auth.registrationRef];
|
|
119
|
+
const grantType = registration?.grantType || "authorization_code";
|
|
120
|
+
if (auth.method === "oauth2") {
|
|
121
|
+
if (!auth.registrationRef || !Object.hasOwn(config.registrations, auth.registrationRef)) {
|
|
122
|
+
fieldErrors[`${path}.authentication.registrationRef`] = "Select a registration defined in this file.";
|
|
123
|
+
}
|
|
124
|
+
if (auth.secretRef) fieldErrors[`${path}.authentication.secretRef`] = "Use the registration's secret reference.";
|
|
125
|
+
if (grantType === "client_credentials" && integration.accountMode === "per-user") {
|
|
126
|
+
fieldErrors[`${path}.accountMode`] = "A service account cannot connect as each app user.";
|
|
127
|
+
}
|
|
128
|
+
} else if (auth.method === "api-key") {
|
|
129
|
+
if (!provider?.apiKeySecretOptional) requireValue(auth, "secretRef", `${path}.authentication`);
|
|
130
|
+
if (auth.registrationRef) fieldErrors[`${path}.authentication.registrationRef`] = "API keys do not use an OAuth registration.";
|
|
131
|
+
} else if (auth.method === "service-account") {
|
|
132
|
+
requireValue(auth, "secretRef", `${path}.authentication`);
|
|
133
|
+
if (auth.registrationRef) fieldErrors[`${path}.authentication.registrationRef`] = "Service-account credentials do not use a browser OAuth registration.";
|
|
134
|
+
if (integration.accountMode === "per-user") fieldErrors[`${path}.accountMode`] = "A service account cannot connect as each app user.";
|
|
135
|
+
} else {
|
|
136
|
+
for (const field of ["secretRef", "registrationRef"]) {
|
|
137
|
+
if (Object.hasOwn(auth, field)) fieldErrors[`${path}.authentication.${field}`] = "This mode does not use credentials.";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (new Set(integration.scopes).size !== integration.scopes.length) {
|
|
141
|
+
fieldErrors[`${path}.scopes`] = "Select each permission only once.";
|
|
142
|
+
}
|
|
143
|
+
if (providers.length) {
|
|
144
|
+
if (!provider) {
|
|
145
|
+
if (!allowUnknownProviders) fieldErrors[`${path}.provider`] = "Install and register this provider before using it.";
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const action of Object.keys(integration.assistantPolicy?.actions || {})) {
|
|
149
|
+
if (!provider.assistantActions?.some((item) => item.value === action)) {
|
|
150
|
+
fieldErrors[`${path}.assistantPolicy.actions.${action}`] = "This provider does not declare this assistant action.";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (provider.settingsSchema) {
|
|
154
|
+
try {
|
|
155
|
+
integration.settings = validateSchemaPayload({ schema: getProviderSettingsSchema(provider, integration.settings), mode: "replace" }, integration.settings || {});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
for (const [field, message] of Object.entries(error.fieldErrors || { settings: "Check this provider's settings." })) {
|
|
158
|
+
fieldErrors[`${path}.settings.${field}`] = message;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const field of provider.settingsFields || []) {
|
|
163
|
+
if (field.authenticationMethods && !field.authenticationMethods.includes(auth.method) && Object.hasOwn(integration.settings || {}, field.name)) {
|
|
164
|
+
fieldErrors[`${path}.settings.${field.name}`] = "This setting belongs to another credential mode.";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!getProviderAccountModes(provider, integration.settings || {}).includes(integration.accountMode)) {
|
|
168
|
+
fieldErrors[`${path}.accountMode`] = "This provider does not support this account mode.";
|
|
169
|
+
}
|
|
170
|
+
if (!getProviderAuthenticationMethods(provider, integration.settings).includes(auth.method)) {
|
|
171
|
+
fieldErrors[`${path}.authentication.method`] = "This provider configuration does not support this credential mode.";
|
|
172
|
+
}
|
|
173
|
+
if (auth.method === "oauth2" && registration?.source === "own") {
|
|
174
|
+
const registrationPath = `registrations.${auth.registrationRef}`;
|
|
175
|
+
if (!(provider.oauthGrantTypes || ["authorization_code"]).includes(grantType)) {
|
|
176
|
+
fieldErrors[`${registrationPath}.grantType`] = "This provider does not support this OAuth flow.";
|
|
177
|
+
}
|
|
178
|
+
const methods = getProviderClientAuthenticationMethods(provider, grantType, integration.settings || {});
|
|
179
|
+
if (!methods.includes(registration.tokenEndpointAuthMethod || "client_secret_post")) {
|
|
180
|
+
fieldErrors[`${registrationPath}.tokenEndpointAuthMethod`] = "This provider does not support this client authentication method.";
|
|
181
|
+
}
|
|
182
|
+
if (registration.clientId && provider.validateClientId) {
|
|
183
|
+
const result = provider.validateClientId(registration.clientId);
|
|
184
|
+
if (result !== true) fieldErrors[`${registrationPath}.clientId`] = result || "Check this provider's client identifier.";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const scopes = getProviderScopes(provider, integration.settings || {}, grantType, auth.method);
|
|
188
|
+
const allowedScopes = new Set(scopes.map((scope) => scope.value));
|
|
189
|
+
if (scopes.some((scope) => scope.required && !integration.scopes.includes(scope.value))) {
|
|
190
|
+
fieldErrors[`${path}.scopes`] = "Include this provider's required permissions.";
|
|
191
|
+
}
|
|
192
|
+
if (integration.scopes.some((scope) => !allowedScopes.has(scope))) {
|
|
193
|
+
fieldErrors[`${path}.scopes`] = "A selected permission is not supported by this provider configuration.";
|
|
194
|
+
}
|
|
195
|
+
if (["oauth2", "service-account"].includes(auth.method) && scopes.length && !integration.scopes.length) {
|
|
196
|
+
fieldErrors[`${path}.scopes`] = "Select at least one permission.";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(fieldErrors).length) throw new IntegrationConfigurationError(fieldErrors);
|
|
201
|
+
return config;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseIntegrationConfiguration(text, options) {
|
|
205
|
+
let value;
|
|
206
|
+
try {
|
|
207
|
+
value = JSON.parse(text);
|
|
208
|
+
} catch {
|
|
209
|
+
throw new IntegrationConfigurationError({ configuration: "Enter valid JSON." });
|
|
210
|
+
}
|
|
211
|
+
return validateIntegrationConfiguration(value, options);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export {
|
|
215
|
+
getProviderAuthenticationMethods,
|
|
216
|
+
getProviderClientAuthenticationMethods,
|
|
217
|
+
getProviderAccountModes,
|
|
218
|
+
getProviderScopes,
|
|
219
|
+
getProviderSettingsSchema,
|
|
220
|
+
secretReference,
|
|
221
|
+
integrationsSchema,
|
|
222
|
+
IntegrationConfigurationError,
|
|
223
|
+
parseIntegrationConfiguration,
|
|
224
|
+
validateIntegrationConfiguration
|
|
225
|
+
};
|