@jskit-ai/connectors-core 0.1.60 → 0.1.62
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/connectors-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.62",
|
|
4
4
|
"description": "Portable integration configuration and server-side account connection runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
57
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
58
|
-
"@jskit-ai/kernel": "0.1.
|
|
56
|
+
"@jskit-ai/database-runtime": "0.1.245",
|
|
57
|
+
"@jskit-ai/http-runtime": "0.1.243",
|
|
58
|
+
"@jskit-ai/kernel": "0.1.245"
|
|
59
59
|
},
|
|
60
60
|
"peerDependenciesMeta": {
|
|
61
61
|
"@jskit-ai/database-runtime": {
|
|
@@ -13,10 +13,13 @@ function publicConnection(connection) {
|
|
|
13
13
|
integrationId: connection.integrationId,
|
|
14
14
|
grantedScopes: [...connection.grantedScopes],
|
|
15
15
|
verifiedAt: connection.verifiedAt,
|
|
16
|
-
...(connection.accountLabel ? { accountLabel: connection.accountLabel } : {})
|
|
16
|
+
...(connection.accountLabel ? { accountLabel: connection.accountLabel } : {}),
|
|
17
|
+
...(connection.source ? { source: connection.source } : {})
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
const ENV_SEEDED_MESSAGE = "This connection comes from an Env value; unlink or replace that value instead.";
|
|
22
|
+
|
|
20
23
|
function validateCallbackUrl(value) {
|
|
21
24
|
let url;
|
|
22
25
|
try { url = new URL(value); } catch { /* reported below */ }
|
|
@@ -166,6 +169,38 @@ function createConnectionService({
|
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
// An Env refresh token seeds the shared connection; the app then refreshes
|
|
173
|
+
// and stores rotated tokens itself, and reseeds when the value or the OAuth
|
|
174
|
+
// client changes.
|
|
175
|
+
async function envRefreshTokenFor(integration) {
|
|
176
|
+
try {
|
|
177
|
+
const value = await resolveReference(integration.authentication.refreshTokenRef);
|
|
178
|
+
const refreshToken = typeof value === "string" ? value.trim() : "";
|
|
179
|
+
if (!refreshToken || refreshToken === "MISSING" || /\s/u.test(refreshToken)) throw new Error("Invalid token.");
|
|
180
|
+
return { refreshToken, fingerprint: createHash("sha256").update(refreshToken).digest("hex") };
|
|
181
|
+
} catch {
|
|
182
|
+
throw new ConnectorError("connector_binding_missing", "The refresh token binding is missing or invalid.");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function envSeedIsCurrent(connection, seed, registration) {
|
|
187
|
+
return connection?.seedFingerprint === seed.fingerprint && connection.clientId === registration.client.client_id &&
|
|
188
|
+
connection.registrationRef === registration.registrationRef &&
|
|
189
|
+
connection.tokenEndpointAuthMethod === registration.tokenEndpointAuthMethod;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function envSeededConnection(integrationId, integration, provider, registration, seed) {
|
|
193
|
+
return {
|
|
194
|
+
integrationId, provider: provider.id, registrationRef: registration.registrationRef,
|
|
195
|
+
grantType: registration.grantType, clientId: registration.client.client_id,
|
|
196
|
+
tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod, status: "connected", source: "env",
|
|
197
|
+
seedFingerprint: seed.fingerprint, verifiedAt: now(), settings: structuredClone(integration.settings || {}),
|
|
198
|
+
...(provider.refreshRequiresRedirectUri ? { callbackUrl: registration.callbackUrl } : {}),
|
|
199
|
+
tokens: { accessToken: null, refreshToken: seed.refreshToken, expiresAt: 0 },
|
|
200
|
+
grantedScopes: [...integration.scopes]
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
169
204
|
async function serviceAccountGrant(integration, provider, binding, signal) {
|
|
170
205
|
if (typeof provider.serviceAccountGrant !== "function") {
|
|
171
206
|
throw new ConnectorError("connector_mode_unavailable", "This provider does not implement service-account authorization.");
|
|
@@ -380,6 +415,9 @@ function createConnectionService({
|
|
|
380
415
|
|
|
381
416
|
async function beginAuthorization({ context, integrationId, verificationInput = {}, signal }) {
|
|
382
417
|
const { owner, integration, provider } = await access(context, integrationId, "connect");
|
|
418
|
+
if (integration.authentication.refreshTokenRef) {
|
|
419
|
+
throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
|
|
420
|
+
}
|
|
383
421
|
// Resource-specific providers validate the first document ID before opening consent.
|
|
384
422
|
provider.operations[provider.checkOperation].request(verificationInput, integration.settings || {});
|
|
385
423
|
const registration = await registrationFor(integration, provider);
|
|
@@ -544,7 +582,12 @@ function createConnectionService({
|
|
|
544
582
|
const usesServiceAccount = integration.authentication.method === "service-account";
|
|
545
583
|
const usesOAuth = integration.authentication.method === "oauth2";
|
|
546
584
|
const registration = usesOAuth ? await registrationFor(integration, provider) : null;
|
|
585
|
+
const seed = integration.authentication.refreshTokenRef ? await envRefreshTokenFor(integration) : null;
|
|
547
586
|
const outcome = await store.withConnection({ owner, integrationId }, async ({ connection, save }) => {
|
|
587
|
+
if (seed && !envSeedIsCurrent(connection, seed, registration)) {
|
|
588
|
+
connection = envSeededConnection(integrationId, integration, provider, registration, seed);
|
|
589
|
+
await save(connection);
|
|
590
|
+
}
|
|
548
591
|
if (!connection || connection.status !== "connected" || connection.provider !== provider.id ||
|
|
549
592
|
(connection.method || "oauth2") !== integration.authentication.method ||
|
|
550
593
|
!isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
|
|
@@ -597,8 +640,11 @@ function createConnectionService({
|
|
|
597
640
|
});
|
|
598
641
|
return { result };
|
|
599
642
|
} catch (error) {
|
|
600
|
-
|
|
601
|
-
if (failure.code === "connector_reconnect_required")
|
|
643
|
+
let failure = providerError(error);
|
|
644
|
+
if (failure.code === "connector_reconnect_required") {
|
|
645
|
+
await save({ ...connection, status: "reconnect-required" });
|
|
646
|
+
if (seed) failure = new ConnectorError(failure.code, "The refresh token in Env no longer works; replace that Env value.", { statusCode: 401 });
|
|
647
|
+
}
|
|
602
648
|
// Commit rotated tokens and reconnect state even when the API request fails.
|
|
603
649
|
return { failure };
|
|
604
650
|
}
|
|
@@ -611,7 +657,7 @@ function createConnectionService({
|
|
|
611
657
|
const { owner, integration, provider } = await access(context, integrationId, "status");
|
|
612
658
|
const registration = config.registrations[integration.authentication.registrationRef];
|
|
613
659
|
return store.withConnection({ owner, integrationId }, async ({ connection }) => {
|
|
614
|
-
if (connection && (connection.provider !== integration.provider ||
|
|
660
|
+
if (connection && !integration.authentication.refreshTokenRef && (connection.provider !== integration.provider ||
|
|
615
661
|
(connection.method || "oauth2") !== integration.authentication.method ||
|
|
616
662
|
!isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
|
|
617
663
|
(["api-key", "service-account"].includes(integration.authentication.method) && connection.secretRef !== integration.authentication.secretRef) ||
|
|
@@ -627,6 +673,13 @@ function createConnectionService({
|
|
|
627
673
|
if (integration.authentication.method === "oauth2") {
|
|
628
674
|
const resolved = await registrationFor(integration, provider);
|
|
629
675
|
callbackUrl = resolved.callbackUrl;
|
|
676
|
+
if (integration.authentication.refreshTokenRef) {
|
|
677
|
+
const seed = await envRefreshTokenFor(integration);
|
|
678
|
+
// A new value is used on the next call; until then it reads as connected from Env.
|
|
679
|
+
if (!envSeedIsCurrent(connection, seed, resolved)) {
|
|
680
|
+
return { status: "connected", source: "env", provider: provider.id, integrationId, grantedScopes: [...integration.scopes], callbackUrl };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
630
683
|
if (connection && provider.refreshRequiresRedirectUri && resolved.grantType === "authorization_code" &&
|
|
631
684
|
connection.callbackUrl !== resolved.callbackUrl) {
|
|
632
685
|
return publicConnection({ ...connection, status: "reconnect-required" });
|
|
@@ -647,7 +700,10 @@ function createConnectionService({
|
|
|
647
700
|
}
|
|
648
701
|
|
|
649
702
|
async function disconnect({ context, integrationId }) {
|
|
650
|
-
const { owner } = await access(context, integrationId, "disconnect");
|
|
703
|
+
const { owner, integration } = await access(context, integrationId, "disconnect");
|
|
704
|
+
if (integration.authentication.refreshTokenRef) {
|
|
705
|
+
throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
|
|
706
|
+
}
|
|
651
707
|
await store.withConnection({ owner, integrationId }, async ({ remove }) => remove());
|
|
652
708
|
return { status: "disconnected" };
|
|
653
709
|
}
|
|
@@ -9,7 +9,8 @@ const secretReference = {
|
|
|
9
9
|
const authenticationSchema = createSchema({
|
|
10
10
|
method: { ...name, enum: ["oauth2", "api-key", "service-account", "none"] },
|
|
11
11
|
registrationRef: { ...name, required: false },
|
|
12
|
-
secretRef: { ...secretReference, required: false }
|
|
12
|
+
secretRef: { ...secretReference, required: false },
|
|
13
|
+
refreshTokenRef: { ...secretReference, required: false }
|
|
13
14
|
});
|
|
14
15
|
const assistantPermission = { type: "string", enum: ["ask", "always", "never"] };
|
|
15
16
|
const assistantPolicySchema = createSchema({
|
|
@@ -117,11 +118,19 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
|
|
|
117
118
|
const provider = definitions.get(integration.provider);
|
|
118
119
|
const registration = config.registrations[auth.registrationRef];
|
|
119
120
|
const grantType = registration?.grantType || "authorization_code";
|
|
121
|
+
if (auth.refreshTokenRef && auth.method !== "oauth2") {
|
|
122
|
+
fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only an OAuth connection can start from a refresh token.";
|
|
123
|
+
}
|
|
120
124
|
if (auth.method === "oauth2") {
|
|
121
125
|
if (!auth.registrationRef || !Object.hasOwn(config.registrations, auth.registrationRef)) {
|
|
122
126
|
fieldErrors[`${path}.authentication.registrationRef`] = "Select a registration defined in this file.";
|
|
123
127
|
}
|
|
124
128
|
if (auth.secretRef) fieldErrors[`${path}.authentication.secretRef`] = "Use the registration's secret reference.";
|
|
129
|
+
if (auth.refreshTokenRef && integration.accountMode !== "shared") {
|
|
130
|
+
fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only a shared connection can start from a refresh token in Env.";
|
|
131
|
+
} else if (auth.refreshTokenRef && grantType !== "authorization_code") {
|
|
132
|
+
fieldErrors[`${path}.authentication.refreshTokenRef`] = "Client credentials do not use a refresh token.";
|
|
133
|
+
}
|
|
125
134
|
if (grantType === "client_credentials" && integration.accountMode === "per-user") {
|
|
126
135
|
fieldErrors[`${path}.accountMode`] = "A service account cannot connect as each app user.";
|
|
127
136
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { googleCalendarProvider } from "../../connector-google-calendar/src/server/provider.js";
|
|
4
|
+
import { createConnectionService, createEnvironmentReferenceResolver } from "../src/server/index.js";
|
|
5
|
+
import { validateIntegrationConfiguration } from "../src/shared/configuration.js";
|
|
6
|
+
|
|
7
|
+
const listScope = "https://www.googleapis.com/auth/calendar.calendarlist.readonly";
|
|
8
|
+
const owner = { applicationId: "app-1", subjectId: "app" };
|
|
9
|
+
const integrationId = "calendar";
|
|
10
|
+
|
|
11
|
+
function configuration({ accountMode = "shared", method = "oauth2", refreshTokenRef = "env:GOOGLE_REFRESH_TOKEN", grantType } = {}) {
|
|
12
|
+
return {
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
integrations: {
|
|
15
|
+
calendar: {
|
|
16
|
+
provider: "google-calendar", accountMode, scopes: [listScope],
|
|
17
|
+
authentication: method === "oauth2"
|
|
18
|
+
? { method, registrationRef: "google", ...(refreshTokenRef ? { refreshTokenRef } : {}) }
|
|
19
|
+
: { method, secretRef: "env:KEY", refreshTokenRef },
|
|
20
|
+
settings: {}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
registrations: {
|
|
24
|
+
google: {
|
|
25
|
+
source: "own", clientId: "client-1", clientSecretRef: "env:GOOGLE_SECRET",
|
|
26
|
+
...(grantType ? { grantType, tokenEndpointAuthMethod: "client_secret_basic" } : { callbackUrlRef: "env:CALLBACK" })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function memoryStore() {
|
|
33
|
+
const connections = new Map();
|
|
34
|
+
return {
|
|
35
|
+
connections,
|
|
36
|
+
async withConnection({ integrationId: id }, run) {
|
|
37
|
+
let pending = structuredClone(connections.get(id) || null);
|
|
38
|
+
const result = await run({
|
|
39
|
+
connection: pending,
|
|
40
|
+
save: async (value) => { pending = structuredClone(value); },
|
|
41
|
+
remove: async () => { pending = null; },
|
|
42
|
+
putAttempt: async () => {},
|
|
43
|
+
consumeAttempt: async () => null
|
|
44
|
+
});
|
|
45
|
+
if (pending) connections.set(id, pending);
|
|
46
|
+
else connections.delete(id);
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setup({ env = { GOOGLE_REFRESH_TOKEN: "seed-token-1" } } = {}) {
|
|
53
|
+
const variables = { GOOGLE_SECRET: "client-secret", CALLBACK: "http://127.0.0.1:8080/callback", ...env };
|
|
54
|
+
const store = memoryStore();
|
|
55
|
+
const refreshes = [];
|
|
56
|
+
const revoked = new Set();
|
|
57
|
+
let time = 1_000_000;
|
|
58
|
+
const service = createConnectionService({
|
|
59
|
+
configuration: configuration(),
|
|
60
|
+
providers: [googleCalendarProvider],
|
|
61
|
+
store,
|
|
62
|
+
authorize: async (context) => context,
|
|
63
|
+
resolveReference: createEnvironmentReferenceResolver(variables),
|
|
64
|
+
now: () => time,
|
|
65
|
+
async fetchImpl(url, init) {
|
|
66
|
+
if (String(url) === "https://oauth2.googleapis.com/token") {
|
|
67
|
+
const body = new URLSearchParams(init.body);
|
|
68
|
+
const refreshToken = body.get("refresh_token");
|
|
69
|
+
refreshes.push(refreshToken);
|
|
70
|
+
if (revoked.has(refreshToken)) return Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
71
|
+
return Response.json({
|
|
72
|
+
token_type: "Bearer", access_token: `access-${refreshes.length}`, refresh_token: `rotated-${refreshes.length}`,
|
|
73
|
+
expires_in: 3600, scope: listScope
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return Response.json({ kind: "calendar#calendarList", items: [] });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const call = () => service.invoke({ context: owner, integrationId, operation: googleCalendarProvider.checkOperation });
|
|
80
|
+
return { service, store, variables, refreshes, revoked, call, advance: (ms) => { time += ms; } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test("refreshTokenRef is accepted only on a shared OAuth integration with a browser registration", () => {
|
|
84
|
+
const validate = (config) => validateIntegrationConfiguration(config, { providers: [googleCalendarProvider] });
|
|
85
|
+
assert.equal(validate(configuration()).integrations.calendar.authentication.refreshTokenRef, "env:GOOGLE_REFRESH_TOKEN");
|
|
86
|
+
const refused = [
|
|
87
|
+
[configuration({ accountMode: "per-user" }), "Only a shared connection can start from a refresh token in Env."],
|
|
88
|
+
[configuration({ grantType: "client_credentials" }), "Client credentials do not use a refresh token."],
|
|
89
|
+
[configuration({ method: "api-key" }), "Only an OAuth connection can start from a refresh token."]
|
|
90
|
+
];
|
|
91
|
+
for (const [config, message] of refused) {
|
|
92
|
+
assert.throws(() => validate(config), (error) => error.fieldErrors["integrations.calendar.authentication.refreshTokenRef"] === message);
|
|
93
|
+
}
|
|
94
|
+
assert.throws(() => validate(configuration({ refreshTokenRef: "a-raw-token" })), { code: "integration_configuration_invalid" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("the Env refresh token seeds the shared connection, which then refreshes on its own", async () => {
|
|
98
|
+
const { service, store, refreshes, call, advance } = setup();
|
|
99
|
+
assert.deepEqual(await service.status({ context: owner, integrationId }), {
|
|
100
|
+
status: "connected", source: "env", provider: "google-calendar", integrationId, grantedScopes: [listScope],
|
|
101
|
+
callbackUrl: "http://127.0.0.1:8080/callback"
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await call();
|
|
105
|
+
assert.deepEqual(refreshes, ["seed-token-1"]);
|
|
106
|
+
const stored = store.connections.get(integrationId);
|
|
107
|
+
assert.equal(stored.source, "env");
|
|
108
|
+
assert.equal(stored.tokens.refreshToken, "rotated-1");
|
|
109
|
+
assert.equal(JSON.stringify(stored).includes("seed-token-1"), false, "the seed itself is not kept once rotated");
|
|
110
|
+
assert.equal((await service.status({ context: owner, integrationId })).source, "env");
|
|
111
|
+
|
|
112
|
+
await call();
|
|
113
|
+
assert.deepEqual(refreshes, ["seed-token-1"], "a valid access token is reused");
|
|
114
|
+
advance(3600 * 1000);
|
|
115
|
+
await call();
|
|
116
|
+
assert.deepEqual(refreshes, ["seed-token-1", "rotated-1"], "the rotated token renews, not the seed");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a replaced Env value reseeds on the next call", async () => {
|
|
120
|
+
const { variables, refreshes, call } = setup();
|
|
121
|
+
await call();
|
|
122
|
+
variables.GOOGLE_REFRESH_TOKEN = "seed-token-2";
|
|
123
|
+
await call();
|
|
124
|
+
assert.deepEqual(refreshes, ["seed-token-1", "seed-token-2"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a revoked Env token asks for a new value, and a new value recovers", async () => {
|
|
128
|
+
const { service, variables, revoked, call } = setup();
|
|
129
|
+
revoked.add("seed-token-1");
|
|
130
|
+
await assert.rejects(call(), {
|
|
131
|
+
code: "connector_reconnect_required",
|
|
132
|
+
message: "The refresh token in Env no longer works; replace that Env value."
|
|
133
|
+
});
|
|
134
|
+
assert.equal((await service.status({ context: owner, integrationId })).status, "reconnect-required");
|
|
135
|
+
variables.GOOGLE_REFRESH_TOKEN = "seed-token-2";
|
|
136
|
+
assert.equal((await service.status({ context: owner, integrationId })).status, "connected");
|
|
137
|
+
await call();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an Env-seeded connection is not disconnected or reconnected in the app", async () => {
|
|
141
|
+
const { service, call } = setup();
|
|
142
|
+
await call();
|
|
143
|
+
const envManaged = { code: "connector_env_managed", statusCode: 409, message: "This connection comes from an Env value; unlink or replace that value instead." };
|
|
144
|
+
await assert.rejects(service.disconnect({ context: owner, integrationId }), envManaged);
|
|
145
|
+
await assert.rejects(service.beginAuthorization({ context: owner, integrationId }), envManaged);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a missing Env value reads as unconfigured", async () => {
|
|
149
|
+
const { service, call } = setup({ env: {} });
|
|
150
|
+
assert.deepEqual(await service.status({ context: owner, integrationId }), { status: "unconfigured", configurationError: "connector_binding_missing" });
|
|
151
|
+
await assert.rejects(call(), { code: "connector_binding_missing" });
|
|
152
|
+
});
|