@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,165 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { mkdtemp, readdir, readFile, writeFile, rm, stat, symlink } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createFileConnectionStore, createCredentialProtection } from "../src/server/fileStorage.js";
|
|
9
|
+
|
|
10
|
+
const identity = { owner: { applicationId: "app", subjectId: "user" }, integrationId: "calendar" };
|
|
11
|
+
const key = new Uint8Array(32).fill(7);
|
|
12
|
+
const protection = () => createCredentialProtection({ keys: { current: key }, activeKeyId: "current" });
|
|
13
|
+
async function fixture(t) {
|
|
14
|
+
const root = await mkdtemp(path.join(tmpdir(), "connector-files-"));
|
|
15
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
16
|
+
const directory = path.join(root, "connections");
|
|
17
|
+
return { root, directory, store: createFileConnectionStore({ directory, protection: protection() }) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("JSON connection records survive restart with encrypted credentials and single-use attempts", async (t) => {
|
|
21
|
+
const { directory, store } = await fixture(t);
|
|
22
|
+
const connection = { status: "connected", tokens: { accessToken: "private-access", refreshToken: "private-refresh" } };
|
|
23
|
+
await store.withConnection(identity, async ({ save, putAttempt }) => {
|
|
24
|
+
await save(connection);
|
|
25
|
+
await putAttempt({ state: "one-time-state", codeVerifier: "private-verifier", expiresAt: Date.now() + 60_000 });
|
|
26
|
+
});
|
|
27
|
+
const [name] = await readdir(directory);
|
|
28
|
+
assert.match(name, /^[a-f0-9]{64}\.json$/u);
|
|
29
|
+
const text = await readFile(path.join(directory, name), "utf8");
|
|
30
|
+
assert.equal(JSON.parse(text).schemaVersion, 1);
|
|
31
|
+
assert.equal(/private-|one-time-state/u.test(text), false);
|
|
32
|
+
assert.equal((await stat(path.join(directory, name))).mode & 0o077, 0);
|
|
33
|
+
const restarted = createFileConnectionStore({ directory, protection: protection() });
|
|
34
|
+
await restarted.withConnection(identity, async ({ connection: actual, consumeAttempt }) => {
|
|
35
|
+
assert.deepEqual(actual, connection);
|
|
36
|
+
assert.equal((await consumeAttempt("one-time-state")).codeVerifier, "private-verifier");
|
|
37
|
+
});
|
|
38
|
+
await store.withConnection(identity, async ({ consumeAttempt }) => assert.equal(await consumeAttempt("one-time-state"), null));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("failed callbacks and failed writes preserve the prior complete JSON record", async (t) => {
|
|
42
|
+
const { directory, store } = await fixture(t);
|
|
43
|
+
await store.withConnection(identity, async ({ save }) => save({ version: 1 }));
|
|
44
|
+
await assert.rejects(store.withConnection(identity, async ({ save, putAttempt }) => {
|
|
45
|
+
await save({ version: 2 });
|
|
46
|
+
await putAttempt({ state: "uncommitted", expiresAt: Date.now() + 60_000 });
|
|
47
|
+
throw new Error("interrupted operation");
|
|
48
|
+
}), /interrupted operation/u);
|
|
49
|
+
const brokenWriter = createFileConnectionStore({ directory, protection: { ...protection(), seal: async () => { throw new Error("vault unavailable"); } } });
|
|
50
|
+
await assert.rejects(brokenWriter.withConnection(identity, async ({ save }) => save({ version: 3 })), /vault unavailable/u);
|
|
51
|
+
await store.withConnection(identity, async ({ connection, consumeAttempt }) => {
|
|
52
|
+
assert.deepEqual(connection, { version: 1 });
|
|
53
|
+
assert.equal(await consumeAttempt("uncommitted"), null);
|
|
54
|
+
});
|
|
55
|
+
assert.equal((await readdir(directory)).length, 1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("owner bindings reject copied records and malformed JSON is never replaced", async (t) => {
|
|
59
|
+
const { directory, store } = await fixture(t);
|
|
60
|
+
await store.withConnection(identity, async ({ save }) => save({ secret: "first-account" }));
|
|
61
|
+
const first = (await readdir(directory))[0];
|
|
62
|
+
const other = { ...identity, owner: { ...identity.owner, subjectId: "other-user" } };
|
|
63
|
+
await store.withConnection(other, async ({ connection, save }) => {
|
|
64
|
+
assert.equal(connection, null);
|
|
65
|
+
await save({ secret: "second-account" });
|
|
66
|
+
});
|
|
67
|
+
const second = (await readdir(directory)).find((name) => name !== first);
|
|
68
|
+
await writeFile(path.join(directory, second), await readFile(path.join(directory, first)));
|
|
69
|
+
await assert.rejects(store.withConnection(other, () => assert.fail("must not expose copied credentials")), { code: "connector_storage_invalid" });
|
|
70
|
+
await writeFile(path.join(directory, first), "broken-json");
|
|
71
|
+
await assert.rejects(store.withConnection(identity, async ({ save }) => save({ replaced: true })), { code: "connector_storage_invalid" });
|
|
72
|
+
assert.equal(await readFile(path.join(directory, first), "utf8"), "broken-json");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("disconnect removes pending attempts and expired attempts cannot resume", async (t) => {
|
|
76
|
+
const { directory } = await fixture(t);
|
|
77
|
+
let now = 1000;
|
|
78
|
+
const store = createFileConnectionStore({ directory, protection: protection(), now: () => now });
|
|
79
|
+
await store.withConnection(identity, async ({ save, putAttempt }) => {
|
|
80
|
+
await save({ status: "connected" });
|
|
81
|
+
await putAttempt({ state: "expired", expiresAt: 2000 });
|
|
82
|
+
await putAttempt({ state: "pending", expiresAt: 9000 });
|
|
83
|
+
});
|
|
84
|
+
now = 3000;
|
|
85
|
+
await store.withConnection(identity, async ({ consumeAttempt, remove }) => {
|
|
86
|
+
assert.equal(await consumeAttempt("expired"), null);
|
|
87
|
+
await remove();
|
|
88
|
+
});
|
|
89
|
+
await store.withConnection(identity, async ({ connection, consumeAttempt }) => {
|
|
90
|
+
assert.equal(connection, null);
|
|
91
|
+
assert.equal(await consumeAttempt("pending"), null);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("symlinked files and directories are rejected without modifying their targets", async (t) => {
|
|
96
|
+
const { root, directory, store } = await fixture(t);
|
|
97
|
+
await store.withConnection(identity, async ({ save }) => save({ first: true }));
|
|
98
|
+
const file = path.join(directory, (await readdir(directory))[0]);
|
|
99
|
+
const external = path.join(root, "outside.json");
|
|
100
|
+
await writeFile(external, "keep-me");
|
|
101
|
+
await rm(file);
|
|
102
|
+
await symlink(external, file);
|
|
103
|
+
await assert.rejects(store.withConnection(identity, async ({ save }) => save({ overwritten: true })), { code: "connector_storage_invalid" });
|
|
104
|
+
assert.equal(await readFile(external, "utf8"), "keep-me");
|
|
105
|
+
const alias = path.join(root, "alias");
|
|
106
|
+
await symlink(directory, alias);
|
|
107
|
+
const aliased = createFileConnectionStore({ directory: alias, protection: protection() });
|
|
108
|
+
await assert.rejects(aliased.withConnection(identity, () => {}), { code: "connector_storage_invalid" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("independent processes serialize updates to the same connection", async (t) => {
|
|
112
|
+
const { directory, store } = await fixture(t);
|
|
113
|
+
await store.withConnection(identity, async ({ save }) => save({ count: 0 }));
|
|
114
|
+
const moduleUrl = new URL("../src/server/fileStorage.js", import.meta.url).href;
|
|
115
|
+
const source = `
|
|
116
|
+
import { createFileConnectionStore, createCredentialProtection } from ${JSON.stringify(moduleUrl)};
|
|
117
|
+
import { setTimeout } from 'node:timers/promises';
|
|
118
|
+
const store = createFileConnectionStore({ directory: process.argv[1], protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(7) }, activeKeyId: 'current' }) });
|
|
119
|
+
for (let i = 0; i < 3; i++) await store.withConnection(${JSON.stringify(identity)}, async ({ connection, save }) => {
|
|
120
|
+
await setTimeout(15);
|
|
121
|
+
await save({ count: connection.count + 1 });
|
|
122
|
+
});
|
|
123
|
+
`;
|
|
124
|
+
await Promise.all([1, 2, 3].map(() => promisify(execFile)(process.execPath, ["--input-type=module", "-e", source, directory])));
|
|
125
|
+
await store.withConnection(identity, ({ connection }) => assert.equal(connection.count, 9));
|
|
126
|
+
assert.equal((await readdir(directory)).filter((file) => file.endsWith(".tmp") || file.endsWith(".lock")).length, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
test("pending OAuth resumes after reopening the app store without exposing private attempt fields", async (t) => {
|
|
131
|
+
const { directory } = await fixture(t);
|
|
132
|
+
const { createConnectionService } = await import("../src/server/index.js");
|
|
133
|
+
const { googleCalendarProvider } = await import("../../connector-google-calendar/src/server/provider.js");
|
|
134
|
+
let clock = 1000;
|
|
135
|
+
const configuration = {
|
|
136
|
+
schemaVersion: 1,
|
|
137
|
+
integrations: { calendar: { provider: "google-calendar", accountMode: "shared",
|
|
138
|
+
scopes: ["https://www.googleapis.com/auth/calendar.calendarlist.readonly"],
|
|
139
|
+
authentication: { method: "oauth2", registrationRef: "google" } } },
|
|
140
|
+
registrations: { google: { source: "own", clientId: "fixture-client", clientSecretRef: "env:SECRET", callbackUrlRef: "env:CALLBACK" } }
|
|
141
|
+
};
|
|
142
|
+
const makeService = (config = configuration) => createConnectionService({
|
|
143
|
+
configuration: config, providers: [googleCalendarProvider],
|
|
144
|
+
store: createFileConnectionStore({ directory, protection: protection(), now: () => clock }),
|
|
145
|
+
authorize: async (context) => context,
|
|
146
|
+
resolveReference: async (ref) => ref === "env:CALLBACK" ? "https://app.example/integrations/google/callback" : "fixture-private-secret",
|
|
147
|
+
now: () => clock, fetchImpl: async () => { throw new Error("Resume must not call provider"); }
|
|
148
|
+
});
|
|
149
|
+
const input = { context: identity.owner, integrationId: "calendar" };
|
|
150
|
+
const first = await makeService().beginAuthorization(input);
|
|
151
|
+
const restarted = makeService();
|
|
152
|
+
assert.deepEqual(await restarted.resumeAuthorization(input), first);
|
|
153
|
+
assert.deepEqual(Object.keys(first).sort(), ["authorizationUrl", "callbackUrl", "expiresAt"]);
|
|
154
|
+
assert.equal(await restarted.resumeAuthorization({ ...input, context: { ...identity.owner, applicationId: "other-app" } }), null);
|
|
155
|
+
assert.equal(await restarted.resumeAuthorization({ ...input, context: { ...identity.owner, subjectId: "other-user" } }), null);
|
|
156
|
+
const changed = structuredClone(configuration);
|
|
157
|
+
changed.registrations.google.clientId = "different-client";
|
|
158
|
+
assert.equal(await makeService(changed).resumeAuthorization(input), null);
|
|
159
|
+
const state = new URL(first.authorizationUrl).searchParams.get("state");
|
|
160
|
+
await restarted.cancelAuthorization({ ...input, state });
|
|
161
|
+
assert.equal(await makeService().resumeAuthorization(input), null);
|
|
162
|
+
await restarted.beginAuthorization(input);
|
|
163
|
+
clock += 600001;
|
|
164
|
+
assert.equal(await makeService().resumeAuthorization(input), null);
|
|
165
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createServer } from "node:net";
|
|
8
|
+
import { once } from "node:events";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import createKnex from "knex";
|
|
12
|
+
import migration from "../migrations/connectors_core_initial.cjs";
|
|
13
|
+
import { createCredentialProtection, createKnexConnectionStore } from "../src/server/storage.js";
|
|
14
|
+
|
|
15
|
+
test("credential protection binds ciphertext to its record and supports key rotation", async () => {
|
|
16
|
+
const oldKey = randomBytes(32);
|
|
17
|
+
const nextKey = randomBytes(32);
|
|
18
|
+
const old = createCredentialProtection({ keys: { old: oldKey }, activeKeyId: "old" });
|
|
19
|
+
const current = createCredentialProtection({ keys: { old: oldKey, current: nextKey }, activeKeyId: "current" });
|
|
20
|
+
const value = { accessToken: "a-secret-access-token", refreshToken: "a-secret-refresh-token" };
|
|
21
|
+
const ciphertext = await old.seal(value, "account-1");
|
|
22
|
+
assert.equal(ciphertext.includes(value.accessToken), false);
|
|
23
|
+
assert.deepEqual(await current.open(ciphertext, "account-1"), value);
|
|
24
|
+
await assert.rejects(current.open(ciphertext, "account-2"), { code: "connector_credentials_unavailable" });
|
|
25
|
+
const parts = ciphertext.split(".");
|
|
26
|
+
parts[3] = `${parts[3][0] === "A" ? "B" : "A"}${parts[3].slice(1)}`;
|
|
27
|
+
await assert.rejects(current.open(parts.join("."), "account-1"), { code: "connector_credentials_unavailable" });
|
|
28
|
+
const rotated = await current.seal(value, "account-1");
|
|
29
|
+
await assert.rejects(old.open(rotated, "account-1"), { code: "connector_credentials_unavailable" });
|
|
30
|
+
assert.throws(() => createCredentialProtection({ keys: { short: randomBytes(16) }, activeKeyId: "short" }));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const databaseUrl = process.env.CONNECTORS_TEST_DATABASE_URL;
|
|
34
|
+
test("durable connector storage on an isolated SQL database", { skip: !databaseUrl && "Set CONNECTORS_TEST_DATABASE_URL to a disposable connector test database." }, async (t) => {
|
|
35
|
+
const url = new URL(databaseUrl);
|
|
36
|
+
assert.match(url.pathname, /^\/jskit_connector_test_[a-z0-9_]+$/u, "Refuse to migrate a non-test database.");
|
|
37
|
+
const knex = createKnex({ client: url.protocol === "postgres:" ? "pg" : "mysql2", connection: databaseUrl, pool: { min: 0, max: 4 } });
|
|
38
|
+
const protection = createCredentialProtection({ keys: { test: randomBytes(32) }, activeKeyId: "test" });
|
|
39
|
+
const store = createKnexConnectionStore({ knex, protection });
|
|
40
|
+
const owner = { applicationId: "test-application", subjectId: "test-user" };
|
|
41
|
+
const scope = { owner, integrationId: "calendar" };
|
|
42
|
+
const connection = { tokens: { accessToken: "private-access", refreshToken: "private-refresh" }, version: 0 };
|
|
43
|
+
await migration.up(knex);
|
|
44
|
+
try {
|
|
45
|
+
await t.test("persists encrypted records and reopens them through another database pool", async () => {
|
|
46
|
+
await store.withConnection(scope, async ({ save }) => save(connection));
|
|
47
|
+
const rows = await knex("connector_connections").select();
|
|
48
|
+
assert.equal(JSON.stringify(rows).includes("private-access"), false);
|
|
49
|
+
const otherKnex = createKnex({ client: knex.client.config.client, connection: databaseUrl, pool: { min: 0, max: 2 } });
|
|
50
|
+
try {
|
|
51
|
+
const reopened = createKnexConnectionStore({ knex: otherKnex, protection });
|
|
52
|
+
assert.deepEqual(await reopened.withConnection(scope, async ({ connection }) => connection), connection);
|
|
53
|
+
await Promise.all([store, reopened].map((instance) => instance.withConnection(scope, async ({ connection, save }) => {
|
|
54
|
+
await save({ ...connection, version: connection.version + 1 });
|
|
55
|
+
})));
|
|
56
|
+
assert.equal(await reopened.withConnection(scope, async ({ connection }) => connection.version), 2);
|
|
57
|
+
} finally { await otherKnex.destroy(); }
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await t.test("rollback preserves the previous record and owners cannot read each other's credentials", async () => {
|
|
61
|
+
await assert.rejects(store.withConnection(scope, async ({ save }) => {
|
|
62
|
+
await save({ version: 999 });
|
|
63
|
+
throw new Error("rollback");
|
|
64
|
+
}), /rollback/u);
|
|
65
|
+
assert.equal(await store.withConnection(scope, async ({ connection }) => connection.version), 2);
|
|
66
|
+
const otherScope = { ...scope, owner: { ...owner, subjectId: "different-user" } };
|
|
67
|
+
assert.equal(await store.withConnection(otherScope, async ({ connection }) => connection), null);
|
|
68
|
+
const rows = await knex("connector_connections").select();
|
|
69
|
+
const saved = rows.find((row) => row.payload);
|
|
70
|
+
const empty = rows.find((row) => !row.payload);
|
|
71
|
+
await knex("connector_connections").where({ connection_key: empty.connection_key }).update({ payload: saved.payload });
|
|
72
|
+
await assert.rejects(store.withConnection(otherScope, async ({ connection }) => connection), { code: "connector_credentials_unavailable" });
|
|
73
|
+
await knex("connector_connections").where({ connection_key: empty.connection_key }).update({ payload: null });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await t.test("consent is encrypted, scoped, consumed once and invalidated by disconnect", async () => {
|
|
77
|
+
const attempt = { state: "random-authorization-state", codeVerifier: "private-pkce-verifier", owner, integrationId: "calendar", expiresAt: Date.now() + 60_000 };
|
|
78
|
+
await store.withConnection(scope, async ({ putAttempt }) => putAttempt(attempt));
|
|
79
|
+
const rows = await knex("connector_authorization_attempts").select();
|
|
80
|
+
assert.equal(JSON.stringify(rows).includes(attempt.state), false);
|
|
81
|
+
assert.equal(JSON.stringify(rows).includes(attempt.codeVerifier), false);
|
|
82
|
+
const otherScope = { ...scope, owner: { ...owner, applicationId: "different-app" } };
|
|
83
|
+
assert.equal(await store.withConnection(otherScope, async ({ latestAttempt }) => latestAttempt({ after: Date.now() })), null);
|
|
84
|
+
assert.deepEqual(await store.withConnection(scope, async ({ latestAttempt }) => latestAttempt({ after: Date.now() })), attempt);
|
|
85
|
+
assert.equal(await store.withConnection(scope, async ({ latestAttempt }) => latestAttempt({ after: attempt.expiresAt })), null);
|
|
86
|
+
assert.equal(await store.withConnection(otherScope, async ({ consumeAttempt }) => consumeAttempt(attempt.state)), null);
|
|
87
|
+
assert.deepEqual(await store.withConnection(scope, async ({ consumeAttempt }) => consumeAttempt(attempt.state)), attempt);
|
|
88
|
+
assert.equal(await store.withConnection(scope, async ({ consumeAttempt }) => consumeAttempt(attempt.state)), null);
|
|
89
|
+
await store.withConnection(scope, async ({ putAttempt, remove }) => {
|
|
90
|
+
await putAttempt(attempt);
|
|
91
|
+
await remove();
|
|
92
|
+
});
|
|
93
|
+
assert.equal(await store.withConnection(scope, async ({ consumeAttempt }) => consumeAttempt(attempt.state)), null);
|
|
94
|
+
assert.equal(await store.withConnection(scope, async ({ connection }) => connection), null);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await t.test("expired attempts can be pruned without deleting a live consent attempt", async () => {
|
|
98
|
+
const before = Date.now();
|
|
99
|
+
await store.withConnection(scope, async ({ putAttempt }) => {
|
|
100
|
+
await putAttempt({ state: "expired", expiresAt: before - 1 });
|
|
101
|
+
await putAttempt({ state: "live", expiresAt: before + 60_000 });
|
|
102
|
+
});
|
|
103
|
+
assert.equal(await store.pruneExpiredAttempts({ before }), 1);
|
|
104
|
+
assert.equal(await store.withConnection(scope, async ({ consumeAttempt }) => consumeAttempt("expired")), null);
|
|
105
|
+
assert.equal((await store.withConnection(scope, async ({ consumeAttempt }) => consumeAttempt("live"))).state, "live");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await t.test("the actual CLI source connects, survives restart, reads data and disconnects", { timeout: 20_000 }, async () => {
|
|
109
|
+
const example = new URL("../../connector-google-calendar/patterns/calendar-cli/example/", import.meta.url);
|
|
110
|
+
const directory = await mkdtemp(join(tmpdir(), "connector-cli-"));
|
|
111
|
+
const listener = createServer();
|
|
112
|
+
listener.listen(0, "127.0.0.1");
|
|
113
|
+
await once(listener, "listening");
|
|
114
|
+
const port = listener.address().port;
|
|
115
|
+
await new Promise((resolve) => listener.close(resolve));
|
|
116
|
+
const env = { ...process.env,
|
|
117
|
+
DATABASE_URL: databaseUrl,
|
|
118
|
+
GOOGLE_CLIENT_SECRET: "test-only-client-secret",
|
|
119
|
+
GOOGLE_CALLBACK_URL: `http://127.0.0.1:${port}/connections/google/callback`,
|
|
120
|
+
CONNECTOR_STORAGE_KEY: randomBytes(32).toString("base64"),
|
|
121
|
+
CONNECTOR_APPLICATION_ID: "cli-example", CONNECTOR_SUBJECT_ID: "local-operator"
|
|
122
|
+
};
|
|
123
|
+
const mock = join(directory, "provider-response.mjs");
|
|
124
|
+
await writeFile(mock, `globalThis.fetch = async (url) => {
|
|
125
|
+
if (String(url) === "https://oauth2.googleapis.com/token") return Response.json({ token_type: "Bearer", access_token: "test-access", refresh_token: "test-refresh", expires_in: 3600 });
|
|
126
|
+
if (new URL(url).origin !== "https://www.googleapis.com") throw new Error("Unexpected test network request");
|
|
127
|
+
return Response.json({ kind: String(url).includes("/events") ? "calendar#events" : "calendar#calendarList", items: [{ id: "calendar-item" }] });
|
|
128
|
+
};`, { mode: 0o600 });
|
|
129
|
+
await writeFile(join(directory, "integrations.json"), await readFile(new URL("integrations.json", example)));
|
|
130
|
+
const children = [];
|
|
131
|
+
function start(command) {
|
|
132
|
+
const child = spawn(process.execPath, ["--import", mock, fileURLToPath(new URL("scripts/calendar.js", example)), command], { cwd: directory, env });
|
|
133
|
+
children.push(child);
|
|
134
|
+
let stdout = "";
|
|
135
|
+
let stderr = "";
|
|
136
|
+
child.stdout.on("data", (data) => { stdout += data; });
|
|
137
|
+
child.stderr.on("data", (data) => { stderr += data; });
|
|
138
|
+
const closed = once(child, "close").then(([code]) => {
|
|
139
|
+
assert.equal(code, 0, stderr);
|
|
140
|
+
return stdout;
|
|
141
|
+
});
|
|
142
|
+
return { child, closed };
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
assert.match(await start("validate").closed, /configuration is valid/u);
|
|
146
|
+
const connecting = start("connect");
|
|
147
|
+
const authorization = new Promise((resolve, reject) => {
|
|
148
|
+
let output = "";
|
|
149
|
+
connecting.child.stdout.on("data", (data) => {
|
|
150
|
+
output += data;
|
|
151
|
+
const match = output.match(/https:\/\/accounts\.google\.com\/[^\s]+/u);
|
|
152
|
+
if (match) resolve(new URL(match[0]));
|
|
153
|
+
});
|
|
154
|
+
connecting.child.once("error", reject);
|
|
155
|
+
connecting.child.once("close", () => reject(new Error("CLI exited before authorization started.")));
|
|
156
|
+
});
|
|
157
|
+
const url = await authorization;
|
|
158
|
+
const callback = new URL(env.GOOGLE_CALLBACK_URL);
|
|
159
|
+
callback.searchParams.set("code", "test-code");
|
|
160
|
+
callback.searchParams.set("state", url.searchParams.get("state"));
|
|
161
|
+
assert.equal((await fetch(callback)).status, 200);
|
|
162
|
+
assert.match(await connecting.closed, /"status": "connected"/u);
|
|
163
|
+
assert.match(await start("status").closed, /"status": "connected"/u);
|
|
164
|
+
const events = await start("events").closed;
|
|
165
|
+
assert.match(events, /calendar-item/u);
|
|
166
|
+
assert.equal(events.includes("test-access"), false);
|
|
167
|
+
assert.match(await start("disconnect").closed, /"status": "disconnected"/u);
|
|
168
|
+
assert.match(await start("status").closed, /"status": "disconnected"/u);
|
|
169
|
+
} finally {
|
|
170
|
+
for (const child of children) if (child.exitCode === null) child.kill();
|
|
171
|
+
await rm(directory, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
} finally {
|
|
175
|
+
await migration.down(knex);
|
|
176
|
+
await knex.destroy();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createSchema } from "json-rest-schema";
|
|
7
|
+
import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
8
|
+
import { createActionProvider } from "@jskit-ai/kernel/server/actions";
|
|
9
|
+
import { createConnectionService, createConnectorsFeature } from "../src/server/index.js";
|
|
10
|
+
import { ConnectorError } from "../src/server/errors.js";
|
|
11
|
+
import { createFileConnectionStore, createCredentialProtection } from "../src/server/fileStorage.js";
|
|
12
|
+
import { validateIntegrationConfiguration } from "../src/shared/configuration.js";
|
|
13
|
+
|
|
14
|
+
const context = { applicationId: "application", subjectId: "team" };
|
|
15
|
+
const input = { context, integrationId: "notifications" };
|
|
16
|
+
async function fixture(t) {
|
|
17
|
+
const directory = await mkdtemp(path.join(tmpdir(), "service-account-connector-"));
|
|
18
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
19
|
+
const state = { now: 1_800_000_000_000, credential: '{\n "type": "service_account", "private_key": "fixture-key"\n}',
|
|
20
|
+
grants: [], requests: [], failGrant: false, status: 200, response: { ok: true }, resolutions: 0 };
|
|
21
|
+
const options = {
|
|
22
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: { notifications: {
|
|
23
|
+
provider: "service-fixture", accountMode: "shared", scopes: ["send"],
|
|
24
|
+
authentication: { method: "service-account", secretRef: "env:SERVICE_ACCOUNT" }, settings: { projectId: "project-1" }
|
|
25
|
+
} } },
|
|
26
|
+
store: createFileConnectionStore({ directory, protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(5) }, activeKeyId: "current" }) }),
|
|
27
|
+
now: () => state.now, authorize: async (owner) => owner,
|
|
28
|
+
resolveReference: async () => { state.resolutions++; return state.credential; },
|
|
29
|
+
fetchImpl: async (url, init) => {
|
|
30
|
+
init.signal.throwIfAborted();
|
|
31
|
+
state.requests.push({ url, init });
|
|
32
|
+
return Response.json(state.response, { status: state.status });
|
|
33
|
+
},
|
|
34
|
+
providers: [{ id: "service-fixture", accountModes: ["shared", "assistant"], authenticationMethods: ["service-account"], scopes: [{ value: "send" }, { value: "read" }],
|
|
35
|
+
settingsSchema: createSchema({ projectId: { type: "string", required: true } }),
|
|
36
|
+
apiOrigins: ["https://messages.example"], checkOperation: "check", requestTimeoutMs: 50,
|
|
37
|
+
operations: { check: { scopes: ["send"], request(values, settings) { return { url: `https://messages.example/${settings.projectId}`, method: "POST", body: values }; }, validateResult: (value) => value?.ok === true } },
|
|
38
|
+
async serviceAccountGrant(request) {
|
|
39
|
+
state.grants.push(request);
|
|
40
|
+
if (state.hang) {
|
|
41
|
+
state.started?.();
|
|
42
|
+
await new Promise((_, reject) => request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true }));
|
|
43
|
+
}
|
|
44
|
+
if (state.failGrant) throw new ConnectorError("connector_reconnect_required", "Reconnect this account.", { statusCode: 401 });
|
|
45
|
+
return state.tokenResponse || { access_token: `fixture-token-${state.grants.length}`, token_type: "Bearer", expires_in: 3600 };
|
|
46
|
+
}
|
|
47
|
+
}]
|
|
48
|
+
};
|
|
49
|
+
return { state, options, directory, service: createConnectionService(options) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test("service-account configuration requires an indirect credential, declared mode, scopes and shared ownership", async (t) => {
|
|
53
|
+
const f = await fixture(t);
|
|
54
|
+
const validate = (config, providers = f.options.providers) => validateIntegrationConfiguration(config, { providers });
|
|
55
|
+
assert.deepEqual(validate(f.options.configuration), f.options.configuration);
|
|
56
|
+
for (const change of [
|
|
57
|
+
{ authentication: { method: "service-account" } },
|
|
58
|
+
{ authentication: { method: "service-account", secretRef: f.state.credential } },
|
|
59
|
+
{ authentication: { method: "service-account", secretRef: "env:KEY", registrationRef: "unused" } },
|
|
60
|
+
{ accountMode: "per-user" }, { scopes: [] }, { scopes: ["admin"] }, { scopes: ["send", "send"] }
|
|
61
|
+
]) {
|
|
62
|
+
const config = structuredClone(f.options.configuration); Object.assign(config.integrations.notifications, change);
|
|
63
|
+
assert.throws(() => validate(config), { code: "integration_configuration_invalid" });
|
|
64
|
+
}
|
|
65
|
+
assert.throws(() => validate(f.options.configuration, [{ ...f.options.providers[0], authenticationMethods: ["api-key"] }]), { code: "integration_configuration_invalid" });
|
|
66
|
+
// Even a mistaken provider declaration cannot turn service credentials into end-user identity.
|
|
67
|
+
const personal = structuredClone(f.options.configuration); personal.integrations.notifications.accountMode = "per-user";
|
|
68
|
+
assert.throws(() => validate(personal, [{ ...f.options.providers[0], accountModes: ["per-user"] }]), { code: "integration_configuration_invalid" });
|
|
69
|
+
assert.equal(f.state.resolutions, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("service credentials obtain a verified grant once, persist encrypted tokens and reuse them after restart", async (t) => {
|
|
73
|
+
const f = await fixture(t);
|
|
74
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "check" }), { code: "connector_reconnect_required" });
|
|
75
|
+
const connected = await f.service.connectServiceAccount(input);
|
|
76
|
+
assert.deepEqual(connected, { provider: "service-fixture", integrationId: "notifications", status: "connected", grantedScopes: ["send"], verifiedAt: f.state.now });
|
|
77
|
+
assert.equal(f.state.grants.length, 1);
|
|
78
|
+
const grant = f.state.grants[0];
|
|
79
|
+
assert.equal(grant.credential, f.state.credential); assert.equal(grant.fetchImpl, f.options.fetchImpl);
|
|
80
|
+
assert.deepEqual(grant.settings, { projectId: "project-1" }); assert.deepEqual(grant.scopes, ["send"]); assert.equal(grant.now, f.state.now);
|
|
81
|
+
const restarted = createConnectionService(f.options);
|
|
82
|
+
assert.deepEqual(await restarted.status(input), connected);
|
|
83
|
+
await restarted.invoke({ ...input, operation: "check", input: { check: "again" } });
|
|
84
|
+
assert.equal(f.state.grants.length, 1);
|
|
85
|
+
assert.equal(new Headers(f.state.requests.at(-1).init.headers).get("authorization"), "Bearer fixture-token-1");
|
|
86
|
+
for (const file of await readdir(f.directory)) {
|
|
87
|
+
const text = await readFile(path.join(f.directory, file), "utf8");
|
|
88
|
+
for (const secret of ["fixture-key", "fixture-token-1", "credentialFingerprint"]) assert(!text.includes(secret));
|
|
89
|
+
}
|
|
90
|
+
await f.options.store.withConnection({ owner: context, integrationId: input.integrationId }, async ({ connection }) => {
|
|
91
|
+
assert.equal(connection.tokens.refreshToken, null); assert.match(connection.credentialFingerprint, /^[a-f0-9]{64}$/u);
|
|
92
|
+
assert(!JSON.stringify(connection).includes("fixture-key"));
|
|
93
|
+
});
|
|
94
|
+
await restarted.disconnect(input); assert.equal((await f.service.status(input)).status, "disconnected");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("expiry renews once under the file lock across independent service instances", async (t) => {
|
|
98
|
+
const f = await fixture(t); await f.service.connectServiceAccount(input);
|
|
99
|
+
f.state.now += 3_571_000;
|
|
100
|
+
const second = createConnectionService({ ...f.options, store: createFileConnectionStore({ directory: f.directory,
|
|
101
|
+
protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(5) }, activeKeyId: "current" }) }) });
|
|
102
|
+
await Promise.all([f.service, second, f.service].map((service) => service.invoke({ ...input, operation: "check" })));
|
|
103
|
+
assert.equal(f.state.grants.length, 2);
|
|
104
|
+
for (const request of f.state.requests.slice(1)) assert.equal(new Headers(request.init.headers).get("authorization"), "Bearer fixture-token-2");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("rotating the bound credential renews before the next API request and never persists the credential", async (t) => {
|
|
108
|
+
const f = await fixture(t); await f.service.connectServiceAccount(input);
|
|
109
|
+
f.state.credential = '{"private_key":"rotated-fixture-key"}';
|
|
110
|
+
await f.service.invoke({ ...input, operation: "check" });
|
|
111
|
+
assert.equal(f.state.grants.length, 2); assert.equal(f.state.grants[1].credential, f.state.credential);
|
|
112
|
+
await f.service.invoke({ ...input, operation: "check" }); assert.equal(f.state.grants.length, 2);
|
|
113
|
+
await f.options.store.withConnection({ owner: context, integrationId: input.integrationId }, async ({ connection }) => {
|
|
114
|
+
assert(!JSON.stringify(connection).includes("rotated-fixture-key"));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("ownership and changed reference, project or requested scopes cannot reuse another grant", async (t) => {
|
|
119
|
+
const f = await fixture(t); await f.service.connectServiceAccount(input);
|
|
120
|
+
for (const owner of [{ ...context, applicationId: "other" }, { ...context, subjectId: "other" }]) {
|
|
121
|
+
await assert.rejects(f.service.invoke({ ...input, context: owner, operation: "check" }), { code: "connector_reconnect_required" });
|
|
122
|
+
}
|
|
123
|
+
for (const change of [{ settings: { projectId: "project-2" } }, { scopes: ["send", "read"] }, { authentication: { method: "service-account", secretRef: "env:OTHER" } }]) {
|
|
124
|
+
const configuration = structuredClone(f.options.configuration); Object.assign(configuration.integrations.notifications, change);
|
|
125
|
+
const changed = createConnectionService({ ...f.options, configuration });
|
|
126
|
+
assert.equal((await changed.status(input)).status, "reconnect-required");
|
|
127
|
+
await assert.rejects(changed.invoke({ ...input, operation: "check" }), { code: "connector_reconnect_required" });
|
|
128
|
+
}
|
|
129
|
+
assert.equal(f.state.requests.length, 1); assert.equal(f.state.grants.length, 1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("failed verification and reduced provider grants never create a connected record", async (t) => {
|
|
133
|
+
const f = await fixture(t);
|
|
134
|
+
f.state.response = { ok: false };
|
|
135
|
+
await assert.rejects(f.service.connectServiceAccount(input), { code: "connector_response_invalid" });
|
|
136
|
+
assert.equal((await f.service.status(input)).status, "disconnected");
|
|
137
|
+
f.state.tokenResponse = { access_token: "token", token_type: "Bearer", expires_in: 3600, scope: "read admin" };
|
|
138
|
+
await assert.rejects(f.service.connectServiceAccount(input), { code: "connector_scope_missing" });
|
|
139
|
+
assert.equal(f.state.requests.length, 1); assert.equal((await f.service.status(input)).status, "disconnected");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("renewed tokens survive a subsequent provider failure and authorization failures require reconnect", async (t) => {
|
|
143
|
+
const f = await fixture(t); await f.service.connectServiceAccount(input); f.state.now += 3_600_000;
|
|
144
|
+
f.state.status = 429;
|
|
145
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "check" }), { code: "connector_rate_limited" });
|
|
146
|
+
f.state.status = 200;
|
|
147
|
+
await createConnectionService(f.options).invoke({ ...input, operation: "check" }); assert.equal(f.state.grants.length, 2);
|
|
148
|
+
f.state.credential = "rotated"; f.state.failGrant = true; const before = f.state.requests.length;
|
|
149
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "check" }), { code: "connector_reconnect_required" });
|
|
150
|
+
assert.equal(f.state.requests.length, before); assert.equal((await f.service.status(input)).status, "reconnect-required");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("malformed token responses and missing bindings do not expose provider values or create connections", async (t) => {
|
|
154
|
+
const f = await fixture(t);
|
|
155
|
+
for (const credential of [undefined, {}, "", "\0bad", "x".repeat(65_537)]) {
|
|
156
|
+
f.state.credential = credential;
|
|
157
|
+
await assert.rejects(f.service.connectServiceAccount(input), { code: "connector_binding_missing" });
|
|
158
|
+
}
|
|
159
|
+
assert.equal(f.state.grants.length, 0); f.state.credential = "fixture-key";
|
|
160
|
+
const valid = { access_token: "private-token", token_type: "Bearer", expires_in: 3600 };
|
|
161
|
+
for (const change of [{ access_token: "" }, { access_token: "token\nvalue" }, { token_type: "Basic" }, { token_type: null },
|
|
162
|
+
{ expires_in: "3600" }, { expires_in: 0 }, { expires_in: NaN }, { expires_in: 86_401 }, { refresh_token: "private-refresh" }, { scope: [] }]) {
|
|
163
|
+
f.state.tokenResponse = { ...valid, ...change };
|
|
164
|
+
await assert.rejects(f.service.connectServiceAccount(input), (error) => error.code === "connector_response_invalid" && !JSON.stringify(error).includes("private-"));
|
|
165
|
+
}
|
|
166
|
+
assert.equal(f.state.requests.length, 0); assert.equal((await f.service.status(input)).status, "disconnected");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("service grants honor cancellation and deadlines without replay or partial connection state", async (t) => {
|
|
170
|
+
const f = await fixture(t); f.state.hang = true;
|
|
171
|
+
const cancelled = new AbortController(); cancelled.abort();
|
|
172
|
+
await assert.rejects(f.service.connectServiceAccount({ ...input, signal: cancelled.signal }), { code: "connector_cancelled" });
|
|
173
|
+
assert.equal(f.state.grants.length, 0);
|
|
174
|
+
const active = new AbortController(); const started = new Promise((resolve) => { f.state.started = resolve; });
|
|
175
|
+
const pending = f.service.connectServiceAccount({ ...input, signal: active.signal }); await started; active.abort();
|
|
176
|
+
await assert.rejects(pending, { code: "connector_cancelled" });
|
|
177
|
+
const keepAlive = setTimeout(() => {}, 1000);
|
|
178
|
+
try { await assert.rejects(f.service.connectServiceAccount(input), { code: "connector_provider_timeout" }); } finally { clearTimeout(keepAlive); }
|
|
179
|
+
assert.equal(f.state.grants.length, 2); assert.equal(f.state.requests.length, 0);
|
|
180
|
+
assert.equal((await f.service.status(input)).status, "disconnected");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("the service-account Feature action uses the same application policy and verified runtime", async (t) => {
|
|
184
|
+
const f = await fixture(t); let actions;
|
|
185
|
+
const runtime = createCapabilityRuntime({ providers: [createActionProvider(), createConnectorsFeature(f.options),
|
|
186
|
+
defineProvider({ id: "test.service-observer", requires: { catalogue: "runtime.actions" }, setup({ catalogue }) { actions = catalogue; } })] });
|
|
187
|
+
await runtime.start();
|
|
188
|
+
try {
|
|
189
|
+
const result = await actions.execute({ actionId: "connectors.verifyServiceAccount", input: { integrationId: input.integrationId }, context: { ...context, channel: "api", surface: "app" } });
|
|
190
|
+
assert.equal(result.status, "connected"); assert.equal((await f.service.status(input)).status, "connected");
|
|
191
|
+
} finally { await runtime.shutdown(); }
|
|
192
|
+
});
|