@vellumai/credential-executor 0.6.3 → 0.6.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.
@@ -5,22 +5,24 @@
5
5
  "": {
6
6
  "name": "@vellumai/ces-contracts",
7
7
  "dependencies": {
8
- "zod": "^4.3.6",
8
+ "zod": "4.3.6",
9
9
  },
10
10
  "devDependencies": {
11
- "@types/bun": "^1.2.4",
12
- "typescript": "^5.7.3",
11
+ "@types/bun": "1.2.4",
12
+ "typescript": "5.7.3",
13
13
  },
14
14
  },
15
15
  },
16
16
  "packages": {
17
- "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
17
+ "@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
18
18
 
19
19
  "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
20
20
 
21
- "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
21
+ "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="],
22
22
 
23
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
23
+ "bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="],
24
+
25
+ "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
24
26
 
25
27
  "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
26
28
 
@@ -16,10 +16,10 @@
16
16
  "test": "bun test src/"
17
17
  },
18
18
  "dependencies": {
19
- "zod": "^4.3.6"
19
+ "zod": "4.3.6"
20
20
  },
21
21
  "devDependencies": {
22
- "@types/bun": "^1.2.4",
23
- "typescript": "^5.7.3"
22
+ "@types/bun": "1.2.4",
23
+ "typescript": "5.7.3"
24
24
  }
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Tests for CES local-token-refresh `refresh_url` support.
3
+ *
4
+ * Verifies that `createLocalTokenRefreshFn`:
5
+ * 1. Uses `refresh_url` when it is set on the provider.
6
+ * 2. Falls back to `token_url` when `refresh_url` is null or empty.
7
+ * 3. Preserves existing `token_exchange_body_format` and `token_endpoint_auth_method` behaviour.
8
+ */
9
+
10
+ import Database from "bun:sqlite";
11
+ import { mkdirSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
14
+
15
+ import type {
16
+ SecureKeyBackend,
17
+ SecureKeyDeleteResult,
18
+ } from "@vellumai/credential-storage";
19
+
20
+ import { createLocalTokenRefreshFn } from "../materializers/local-token-refresh.js";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Test helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function createMemoryBackend(
27
+ initial: Record<string, string> = {},
28
+ ): SecureKeyBackend {
29
+ const store = new Map<string, string>(Object.entries(initial));
30
+ return {
31
+ async get(key: string): Promise<string | undefined> {
32
+ return store.get(key);
33
+ },
34
+ async set(key: string, value: string): Promise<boolean> {
35
+ store.set(key, value);
36
+ return true;
37
+ },
38
+ async delete(key: string): Promise<SecureKeyDeleteResult> {
39
+ if (store.has(key)) {
40
+ store.delete(key);
41
+ return "deleted";
42
+ }
43
+ return "not-found";
44
+ },
45
+ async list(): Promise<string[]> {
46
+ return Array.from(store.keys());
47
+ },
48
+ };
49
+ }
50
+
51
+ /** Unique temp root for each test run. */
52
+ let tmpRoot: string;
53
+
54
+ function setupTestDb(opts: {
55
+ providerKey?: string;
56
+ tokenUrl?: string;
57
+ refreshUrl?: string | null;
58
+ tokenEndpointAuthMethod?: string | null;
59
+ tokenExchangeBodyFormat?: string | null;
60
+ }): string {
61
+ const providerKey = opts.providerKey ?? "test-provider";
62
+ const tokenUrl = opts.tokenUrl ?? "https://provider.example.com/token";
63
+ const refreshUrl = opts.refreshUrl ?? null;
64
+ const authMethod = opts.tokenEndpointAuthMethod ?? "client_secret_post";
65
+ const bodyFormat = opts.tokenExchangeBodyFormat ?? "form";
66
+
67
+ tmpRoot = join(
68
+ "/tmp",
69
+ `ces-token-refresh-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
70
+ );
71
+ const dbDir = join(tmpRoot, "workspace", "data", "db");
72
+ mkdirSync(dbDir, { recursive: true });
73
+
74
+ const dbPath = join(dbDir, "assistant.db");
75
+ const db = new Database(dbPath);
76
+
77
+ // Create minimal schema matching the assistant's tables
78
+ db.exec(/*sql*/ `
79
+ CREATE TABLE oauth_providers (
80
+ provider_key TEXT PRIMARY KEY,
81
+ auth_url TEXT NOT NULL,
82
+ token_url TEXT NOT NULL,
83
+ refresh_url TEXT,
84
+ token_endpoint_auth_method TEXT,
85
+ token_exchange_body_format TEXT,
86
+ created_at INTEGER NOT NULL,
87
+ updated_at INTEGER NOT NULL
88
+ )
89
+ `);
90
+
91
+ db.exec(/*sql*/ `
92
+ CREATE TABLE oauth_apps (
93
+ id TEXT PRIMARY KEY,
94
+ provider_key TEXT NOT NULL REFERENCES oauth_providers(provider_key),
95
+ client_id TEXT NOT NULL,
96
+ client_secret_credential_path TEXT NOT NULL,
97
+ created_at INTEGER NOT NULL,
98
+ updated_at INTEGER NOT NULL
99
+ )
100
+ `);
101
+
102
+ db.exec(/*sql*/ `
103
+ CREATE TABLE oauth_connections (
104
+ id TEXT PRIMARY KEY,
105
+ oauth_app_id TEXT NOT NULL REFERENCES oauth_apps(id),
106
+ provider_key TEXT NOT NULL,
107
+ status TEXT NOT NULL DEFAULT 'active',
108
+ created_at INTEGER NOT NULL,
109
+ updated_at INTEGER NOT NULL
110
+ )
111
+ `);
112
+
113
+ const now = Date.now();
114
+
115
+ db.exec(/*sql*/ `
116
+ INSERT INTO oauth_providers (provider_key, auth_url, token_url, refresh_url, token_endpoint_auth_method, token_exchange_body_format, created_at, updated_at)
117
+ VALUES ('${providerKey}', 'https://provider.example.com/authorize', '${tokenUrl}', ${refreshUrl === null ? "NULL" : `'${refreshUrl}'`}, ${authMethod === null ? "NULL" : `'${authMethod}'`}, ${bodyFormat === null ? "NULL" : `'${bodyFormat}'`}, ${now}, ${now})
118
+ `);
119
+
120
+ db.exec(/*sql*/ `
121
+ INSERT INTO oauth_apps (id, provider_key, client_id, client_secret_credential_path, created_at, updated_at)
122
+ VALUES ('app-1', '${providerKey}', 'test-client-id', 'oauth_app/app-1/client_secret', ${now}, ${now})
123
+ `);
124
+
125
+ db.exec(/*sql*/ `
126
+ INSERT INTO oauth_connections (id, oauth_app_id, provider_key, status, created_at, updated_at)
127
+ VALUES ('conn-1', 'app-1', '${providerKey}', 'active', ${now}, ${now})
128
+ `);
129
+
130
+ db.close();
131
+ return tmpRoot;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Mock fetch
136
+ // ---------------------------------------------------------------------------
137
+
138
+ const originalFetch = globalThis.fetch;
139
+
140
+ function mockFetch(capturedUrls: string[]): void {
141
+ globalThis.fetch = mock(async (input: string | URL | Request) => {
142
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
143
+ capturedUrls.push(url);
144
+ return new Response(
145
+ JSON.stringify({
146
+ access_token: "new-access-token",
147
+ refresh_token: "new-refresh-token",
148
+ expires_in: 3600,
149
+ }),
150
+ { status: 200, headers: { "Content-Type": "application/json" } },
151
+ );
152
+ }) as unknown as typeof globalThis.fetch;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Tests
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe("createLocalTokenRefreshFn – refresh_url support", () => {
160
+ const capturedUrls: string[] = [];
161
+
162
+ beforeEach(() => {
163
+ capturedUrls.length = 0;
164
+ mockFetch(capturedUrls);
165
+ });
166
+
167
+ afterEach(() => {
168
+ globalThis.fetch = originalFetch;
169
+ if (tmpRoot) {
170
+ rmSync(tmpRoot, { recursive: true, force: true });
171
+ }
172
+ });
173
+
174
+ test("uses refresh_url when set on the provider", async () => {
175
+ const root = setupTestDb({
176
+ tokenUrl: "https://provider.example.com/token",
177
+ refreshUrl: "https://provider.example.com/refresh",
178
+ });
179
+
180
+ const backend = createMemoryBackend({
181
+ "oauth_app/app-1/client_secret": "test-secret",
182
+ });
183
+
184
+ const refreshFn = createLocalTokenRefreshFn(root, backend);
185
+ const result = await refreshFn("conn-1", "old-refresh-token");
186
+
187
+ expect(result.success).toBe(true);
188
+ expect(capturedUrls).toHaveLength(1);
189
+ expect(capturedUrls[0]).toBe("https://provider.example.com/refresh");
190
+ });
191
+
192
+ test("falls back to token_url when refresh_url is null", async () => {
193
+ const root = setupTestDb({
194
+ tokenUrl: "https://provider.example.com/token",
195
+ refreshUrl: null,
196
+ });
197
+
198
+ const backend = createMemoryBackend({
199
+ "oauth_app/app-1/client_secret": "test-secret",
200
+ });
201
+
202
+ const refreshFn = createLocalTokenRefreshFn(root, backend);
203
+ const result = await refreshFn("conn-1", "old-refresh-token");
204
+
205
+ expect(result.success).toBe(true);
206
+ expect(capturedUrls).toHaveLength(1);
207
+ expect(capturedUrls[0]).toBe("https://provider.example.com/token");
208
+ });
209
+
210
+ test("falls back to token_url when refresh_url is an empty string", async () => {
211
+ const root = setupTestDb({
212
+ tokenUrl: "https://provider.example.com/token",
213
+ refreshUrl: "",
214
+ });
215
+
216
+ const backend = createMemoryBackend({
217
+ "oauth_app/app-1/client_secret": "test-secret",
218
+ });
219
+
220
+ const refreshFn = createLocalTokenRefreshFn(root, backend);
221
+ const result = await refreshFn("conn-1", "old-refresh-token");
222
+
223
+ expect(result.success).toBe(true);
224
+ expect(capturedUrls).toHaveLength(1);
225
+ expect(capturedUrls[0]).toBe("https://provider.example.com/token");
226
+ });
227
+
228
+ test("preserves token_endpoint_auth_method=client_secret_basic behaviour", async () => {
229
+ const root = setupTestDb({
230
+ refreshUrl: "https://provider.example.com/refresh",
231
+ tokenEndpointAuthMethod: "client_secret_basic",
232
+ });
233
+
234
+ const backend = createMemoryBackend({
235
+ "oauth_app/app-1/client_secret": "test-secret",
236
+ });
237
+
238
+ // Capture the fetch call to verify Authorization header
239
+ const capturedHeaders: Record<string, string>[] = [];
240
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
241
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
242
+ capturedUrls.push(url);
243
+ if (init?.headers) {
244
+ capturedHeaders.push(init.headers as Record<string, string>);
245
+ }
246
+ return new Response(
247
+ JSON.stringify({
248
+ access_token: "new-access-token",
249
+ refresh_token: "new-refresh-token",
250
+ expires_in: 3600,
251
+ }),
252
+ { status: 200, headers: { "Content-Type": "application/json" } },
253
+ );
254
+ }) as unknown as typeof globalThis.fetch;
255
+
256
+ const refreshFn = createLocalTokenRefreshFn(root, backend);
257
+ const result = await refreshFn("conn-1", "old-refresh-token");
258
+
259
+ expect(result.success).toBe(true);
260
+ expect(capturedUrls).toHaveLength(1);
261
+ expect(capturedUrls[0]).toBe("https://provider.example.com/refresh");
262
+
263
+ // Verify Basic auth header was sent
264
+ expect(capturedHeaders).toHaveLength(1);
265
+ const expectedCredentials = Buffer.from("test-client-id:test-secret").toString("base64");
266
+ expect(capturedHeaders[0]["Authorization"]).toBe(`Basic ${expectedCredentials}`);
267
+ });
268
+
269
+ test("preserves token_exchange_body_format=json behaviour", async () => {
270
+ const root = setupTestDb({
271
+ refreshUrl: "https://provider.example.com/refresh",
272
+ tokenExchangeBodyFormat: "json",
273
+ });
274
+
275
+ const backend = createMemoryBackend({
276
+ "oauth_app/app-1/client_secret": "test-secret",
277
+ });
278
+
279
+ const capturedContentTypes: string[] = [];
280
+ const capturedBodies: string[] = [];
281
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
282
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
283
+ capturedUrls.push(url);
284
+ if (init?.headers) {
285
+ const headers = init.headers as Record<string, string>;
286
+ capturedContentTypes.push(headers["Content-Type"] ?? "");
287
+ }
288
+ if (init?.body) {
289
+ capturedBodies.push(typeof init.body === "string" ? init.body : String(init.body));
290
+ }
291
+ return new Response(
292
+ JSON.stringify({
293
+ access_token: "new-access-token",
294
+ refresh_token: "new-refresh-token",
295
+ expires_in: 3600,
296
+ }),
297
+ { status: 200, headers: { "Content-Type": "application/json" } },
298
+ );
299
+ }) as unknown as typeof globalThis.fetch;
300
+
301
+ const refreshFn = createLocalTokenRefreshFn(root, backend);
302
+ const result = await refreshFn("conn-1", "old-refresh-token");
303
+
304
+ expect(result.success).toBe(true);
305
+ expect(capturedContentTypes).toHaveLength(1);
306
+ expect(capturedContentTypes[0]).toBe("application/json");
307
+
308
+ // Verify the body was sent as JSON
309
+ expect(capturedBodies).toHaveLength(1);
310
+ const parsed = JSON.parse(capturedBodies[0]);
311
+ expect(parsed.grant_type).toBe("refresh_token");
312
+ expect(parsed.refresh_token).toBe("old-refresh-token");
313
+ });
314
+
315
+ test("returns successful token refresh result", async () => {
316
+ const root = setupTestDb({
317
+ refreshUrl: "https://provider.example.com/refresh",
318
+ });
319
+
320
+ const backend = createMemoryBackend({
321
+ "oauth_app/app-1/client_secret": "test-secret",
322
+ });
323
+
324
+ const refreshFn = createLocalTokenRefreshFn(root, backend);
325
+ const result = await refreshFn("conn-1", "old-refresh-token");
326
+
327
+ expect(result.success).toBe(true);
328
+ if (result.success) {
329
+ expect(result.accessToken).toBe("new-access-token");
330
+ expect(result.refreshToken).toBe("new-refresh-token");
331
+ expect(result.expiresAt).toBeTypeOf("number");
332
+ }
333
+ });
334
+ });
@@ -46,7 +46,9 @@ interface OAuthAppRow {
46
46
  interface OAuthProviderRow {
47
47
  provider_key: string;
48
48
  token_url: string;
49
+ refresh_url: string | null;
49
50
  token_endpoint_auth_method: string | null;
51
+ token_exchange_body_format: string | null;
50
52
  }
51
53
 
52
54
  // ---------------------------------------------------------------------------
@@ -64,6 +66,7 @@ interface RefreshConfig {
64
66
  clientId: string;
65
67
  clientSecret?: string;
66
68
  authMethod: TokenEndpointAuthMethod;
69
+ bodyFormat: "form" | "json";
67
70
  }
68
71
 
69
72
  /**
@@ -108,7 +111,7 @@ async function resolveRefreshConfig(
108
111
  // 3. Look up the provider to get token_url and auth method
109
112
  const provider = db
110
113
  .query<OAuthProviderRow, [string]>(
111
- `SELECT provider_key, token_url, token_endpoint_auth_method FROM oauth_providers WHERE provider_key = ? LIMIT 1`,
114
+ `SELECT provider_key, token_url, refresh_url, token_endpoint_auth_method, token_exchange_body_format FROM oauth_providers WHERE provider_key = ? LIMIT 1`,
112
115
  )
113
116
  .get(conn.provider_key);
114
117
 
@@ -116,7 +119,10 @@ async function resolveRefreshConfig(
116
119
  return { error: `No OAuth provider found for "${conn.provider_key}"` };
117
120
  }
118
121
 
119
- if (!provider.token_url || !app.client_id) {
122
+ // Resolve the effective token URL: prefer refresh_url, fall back to token_url
123
+ const tokenUrl = provider.refresh_url || provider.token_url;
124
+
125
+ if (!tokenUrl || !app.client_id) {
120
126
  return { error: `Missing OAuth2 refresh config for "${conn.provider_key}"` };
121
127
  }
122
128
 
@@ -126,12 +132,14 @@ async function resolveRefreshConfig(
126
132
  );
127
133
 
128
134
  const authMethod = (provider.token_endpoint_auth_method as TokenEndpointAuthMethod | null) ?? "client_secret_post";
135
+ const bodyFormat = (provider.token_exchange_body_format as "form" | "json" | null) ?? "form";
129
136
 
130
137
  return {
131
- tokenUrl: provider.token_url,
138
+ tokenUrl,
132
139
  clientId: app.client_id,
133
140
  clientSecret,
134
141
  authMethod,
142
+ bodyFormat,
135
143
  };
136
144
  } catch (err) {
137
145
  const msg = err instanceof Error ? err.message : String(err);
@@ -161,7 +169,10 @@ async function performTokenRefresh(
161
169
  };
162
170
 
163
171
  const headers: Record<string, string> = {
164
- "Content-Type": "application/x-www-form-urlencoded",
172
+ "Content-Type":
173
+ config.bodyFormat === "json"
174
+ ? "application/json"
175
+ : "application/x-www-form-urlencoded",
165
176
  };
166
177
 
167
178
  if (config.clientSecret && config.authMethod === "client_secret_basic") {
@@ -179,7 +190,10 @@ async function performTokenRefresh(
179
190
  const resp = await fetch(config.tokenUrl, {
180
191
  method: "POST",
181
192
  headers,
182
- body: new URLSearchParams(body),
193
+ body:
194
+ config.bodyFormat === "json"
195
+ ? JSON.stringify(body)
196
+ : new URLSearchParams(body),
183
197
  });
184
198
 
185
199
  if (!resp.ok) {