@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895

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.
Files changed (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +1 -2
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
  5. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
  6. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
  8. package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
  9. package/package.json +2 -3
  10. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  11. package/src/__tests__/local-standalone.test.ts +5 -36
  12. package/src/__tests__/managed-integration.test.ts +112 -91
  13. package/src/__tests__/managed-reconnect.test.ts +2 -2
  14. package/src/__tests__/transport.test.ts +23 -27
  15. package/src/cli.ts +1 -1
  16. package/src/index.ts +8 -88
  17. package/src/main.ts +228 -340
  18. package/src/paths.ts +4 -20
  19. package/src/server.ts +52 -469
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
  21. package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
  22. package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
  23. package/src/__tests__/command-executor.test.ts +0 -1879
  24. package/src/__tests__/command-validator.test.ts +0 -1405
  25. package/src/__tests__/command-workspace.test.ts +0 -1050
  26. package/src/__tests__/grant-store.test.ts +0 -689
  27. package/src/__tests__/http-executor.test.ts +0 -1336
  28. package/src/__tests__/http-policy.test.ts +0 -1069
  29. package/src/__tests__/local-materializers.test.ts +0 -860
  30. package/src/__tests__/local-token-refresh.test.ts +0 -361
  31. package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
  32. package/src/__tests__/managed-lazy-getters.test.ts +0 -359
  33. package/src/__tests__/managed-materializers.test.ts +0 -1028
  34. package/src/__tests__/managed-rejection.test.ts +0 -43
  35. package/src/__tests__/toolstore.test.ts +0 -773
  36. package/src/audit/store.ts +0 -188
  37. package/src/commands/auth-adapters.ts +0 -169
  38. package/src/commands/egress-hooks.ts +0 -203
  39. package/src/commands/executor.ts +0 -1155
  40. package/src/commands/output-scan.ts +0 -157
  41. package/src/commands/profiles.ts +0 -286
  42. package/src/commands/validator.ts +0 -702
  43. package/src/commands/workspace.ts +0 -550
  44. package/src/grants/index.ts +0 -17
  45. package/src/grants/persistent-store.ts +0 -309
  46. package/src/grants/rpc-handlers.ts +0 -293
  47. package/src/grants/temporary-store.ts +0 -289
  48. package/src/http/audit.ts +0 -84
  49. package/src/http/executor.ts +0 -684
  50. package/src/http/path-template.ts +0 -245
  51. package/src/http/policy.ts +0 -238
  52. package/src/http/response-filter.ts +0 -233
  53. package/src/managed-errors.ts +0 -9
  54. package/src/managed-lazy-getters.ts +0 -106
  55. package/src/managed-main.ts +0 -822
  56. package/src/materializers/local-oauth-lookup.ts +0 -98
  57. package/src/materializers/local-token-refresh.ts +0 -287
  58. package/src/materializers/local.ts +0 -316
  59. package/src/materializers/managed-platform.ts +0 -295
  60. package/src/subjects/local.ts +0 -177
  61. package/src/subjects/managed.ts +0 -311
  62. package/src/subjects/policy.ts +0 -79
  63. package/src/toolstore/integrity.ts +0 -94
  64. package/src/toolstore/manifest.ts +0 -154
  65. package/src/toolstore/publish.ts +0 -571
@@ -1,98 +0,0 @@
1
- /**
2
- * CES-native read-only OAuth connection lookup for local mode.
3
- *
4
- * In local mode, CES runs as a child process of the assistant on the same
5
- * machine and can read the assistant's SQLite database to look up OAuth
6
- * connections.
7
- *
8
- * This implementation opens the database in read-only mode and queries the
9
- * `oauth_connections` table directly using raw SQLite queries. It does not
10
- * use Drizzle ORM to avoid importing assistant-internal schema modules.
11
- *
12
- * The lookup is read-only — CES never modifies OAuth connection records.
13
- */
14
-
15
- import Database from "bun:sqlite";
16
- import { existsSync } from "node:fs";
17
- import { join } from "node:path";
18
-
19
- import type { OAuthConnectionRecord } from "@vellumai/credential-storage";
20
- import { oauthConnectionAccessTokenPath } from "@vellumai/credential-storage";
21
- import type { OAuthConnectionLookup } from "../subjects/local.js";
22
-
23
- // ---------------------------------------------------------------------------
24
- // Raw SQLite row shape (matches oauth_connections table)
25
- // ---------------------------------------------------------------------------
26
-
27
- interface OAuthConnectionRow {
28
- id: string;
29
- oauth_app_id: string;
30
- provider_key: string;
31
- account_info: string | null;
32
- granted_scopes: string;
33
- expires_at: number | null;
34
- has_refresh_token: number;
35
- status: string;
36
- label: string | null;
37
- metadata: string | null;
38
- created_at: number;
39
- updated_at: number;
40
- }
41
-
42
- // ---------------------------------------------------------------------------
43
- // Row → OAuthConnectionRecord mapping
44
- // ---------------------------------------------------------------------------
45
-
46
- function rowToRecord(row: OAuthConnectionRow): OAuthConnectionRecord {
47
- return {
48
- id: row.id,
49
- providerKey: row.provider_key,
50
- accountInfo: row.account_info,
51
- grantedScopes: JSON.parse(row.granted_scopes || "[]"),
52
- accessTokenPath: oauthConnectionAccessTokenPath(row.id),
53
- hasRefreshToken: row.has_refresh_token === 1,
54
- expiresAt: row.expires_at,
55
- createdAt: row.created_at,
56
- updatedAt: row.updated_at,
57
- };
58
- }
59
-
60
- // ---------------------------------------------------------------------------
61
- // Lookup implementation
62
- // ---------------------------------------------------------------------------
63
-
64
- /**
65
- * Create a read-only OAuth connection lookup backed by the assistant's
66
- * SQLite database.
67
- *
68
- * @param workspaceDir - The workspace directory (e.g. `$VELLUM_WORKSPACE_DIR`).
69
- */
70
- export function createLocalOAuthLookup(
71
- workspaceDir: string,
72
- ): OAuthConnectionLookup {
73
- const dbPath = join(workspaceDir, "data", "db", "assistant.db");
74
-
75
- return {
76
- getById(connectionId: string): OAuthConnectionRecord | undefined {
77
- if (!existsSync(dbPath)) return undefined;
78
-
79
- let db: Database | undefined;
80
- try {
81
- db = new Database(dbPath, { readonly: true });
82
- const row = db
83
- .query<
84
- OAuthConnectionRow,
85
- [string, string]
86
- >(`SELECT * FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`)
87
- .get(connectionId, "active");
88
-
89
- if (!row) return undefined;
90
- return rowToRecord(row);
91
- } catch {
92
- return undefined;
93
- } finally {
94
- db?.close();
95
- }
96
- },
97
- };
98
- }
@@ -1,287 +0,0 @@
1
- /**
2
- * OAuth token refresh implementation for CES local mode.
3
- *
4
- * Performs the actual OAuth2 token refresh by:
5
- * 1. Looking up the connection's provider and app configuration from the
6
- * assistant's SQLite database (read-only).
7
- * 2. Retrieving the client secret from the secure-key backend.
8
- * 3. Calling the provider's token endpoint with the refresh token.
9
- * 4. Returning a `TokenRefreshResult` for the `LocalMaterialiser` to persist.
10
- *
11
- * This module does NOT import any assistant-internal modules. It queries
12
- * the SQLite database directly (like `local-oauth-lookup.ts`) and performs
13
- * the HTTP refresh call inline (replicating the logic from the assistant's
14
- * `security/oauth2.ts:refreshOAuth2Token`).
15
- */
16
-
17
- import Database from "bun:sqlite";
18
- import { existsSync } from "node:fs";
19
- import { join } from "node:path";
20
-
21
- import {
22
- computeExpiresAt,
23
- type SecureKeyBackend,
24
- type TokenRefreshResult,
25
- } from "@vellumai/credential-storage";
26
-
27
- import type { TokenRefreshFn } from "./local.js";
28
-
29
- // ---------------------------------------------------------------------------
30
- // SQLite row shapes (match assistant schema without importing Drizzle)
31
- // ---------------------------------------------------------------------------
32
-
33
- interface OAuthConnectionRow {
34
- id: string;
35
- oauth_app_id: string;
36
- provider_key: string;
37
- }
38
-
39
- interface OAuthAppRow {
40
- id: string;
41
- provider_key: string;
42
- client_id: string;
43
- client_secret_credential_path: string;
44
- }
45
-
46
- interface OAuthProviderRow {
47
- provider_key: string;
48
- token_url: string;
49
- refresh_url: string | null;
50
- token_endpoint_auth_method: string | null;
51
- token_exchange_body_format: string | null;
52
- }
53
-
54
- // ---------------------------------------------------------------------------
55
- // Token endpoint auth method (matches assistant/src/security/oauth2.ts)
56
- // ---------------------------------------------------------------------------
57
-
58
- type TokenEndpointAuthMethod = "client_secret_basic" | "client_secret_post";
59
-
60
- // ---------------------------------------------------------------------------
61
- // Refresh config resolution
62
- // ---------------------------------------------------------------------------
63
-
64
- interface RefreshConfig {
65
- tokenUrl: string;
66
- clientId: string;
67
- clientSecret?: string;
68
- authMethod: TokenEndpointAuthMethod;
69
- bodyFormat: "form" | "json";
70
- }
71
-
72
- /**
73
- * Resolve the OAuth refresh configuration for a connection by querying
74
- * the assistant's SQLite database (read-only) and the secure-key backend.
75
- */
76
- async function resolveRefreshConfig(
77
- dbPath: string,
78
- connectionId: string,
79
- secureKeyBackend: SecureKeyBackend,
80
- ): Promise<RefreshConfig | { error: string }> {
81
- if (!existsSync(dbPath)) {
82
- return { error: `Database not found at ${dbPath}` };
83
- }
84
-
85
- let db: Database | undefined;
86
- try {
87
- db = new Database(dbPath, { readonly: true });
88
-
89
- // 1. Look up the connection to get oauth_app_id and provider_key
90
- const conn = db
91
- .query<
92
- OAuthConnectionRow,
93
- [string, string]
94
- >(`SELECT id, oauth_app_id, provider_key FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`)
95
- .get(connectionId, "active");
96
-
97
- if (!conn) {
98
- return {
99
- error: `No active OAuth connection found for "${connectionId}"`,
100
- };
101
- }
102
-
103
- // 2. Look up the app to get client_id and client_secret_credential_path
104
- const app = db
105
- .query<
106
- OAuthAppRow,
107
- [string]
108
- >(`SELECT id, provider_key, client_id, client_secret_credential_path FROM oauth_apps WHERE id = ? LIMIT 1`)
109
- .get(conn.oauth_app_id);
110
-
111
- if (!app) {
112
- return { error: `No OAuth app found for connection "${connectionId}"` };
113
- }
114
-
115
- // 3. Look up the provider to get token_url and auth method
116
- const provider = db
117
- .query<
118
- OAuthProviderRow,
119
- [string]
120
- >(`SELECT provider_key, token_url, refresh_url, token_endpoint_auth_method, token_exchange_body_format FROM oauth_providers WHERE provider_key = ? LIMIT 1`)
121
- .get(conn.provider_key);
122
-
123
- if (!provider) {
124
- return { error: `No OAuth provider found for "${conn.provider_key}"` };
125
- }
126
-
127
- // Resolve the effective token URL: prefer refresh_url, fall back to token_url
128
- const tokenUrl = provider.refresh_url || provider.token_url;
129
-
130
- if (!tokenUrl || !app.client_id) {
131
- return {
132
- error: `Missing OAuth2 refresh config for "${conn.provider_key}"`,
133
- };
134
- }
135
-
136
- // 4. Retrieve the client secret from secure storage
137
- const clientSecret = await secureKeyBackend.get(
138
- app.client_secret_credential_path,
139
- );
140
-
141
- const authMethod =
142
- (provider.token_endpoint_auth_method as TokenEndpointAuthMethod | null) ??
143
- "client_secret_post";
144
- const bodyFormat =
145
- (provider.token_exchange_body_format as "form" | "json" | null) ?? "form";
146
-
147
- return {
148
- tokenUrl,
149
- clientId: app.client_id,
150
- clientSecret,
151
- authMethod,
152
- bodyFormat,
153
- };
154
- } catch (err) {
155
- const msg = err instanceof Error ? err.message : String(err);
156
- return { error: `Failed to resolve refresh config: ${msg}` };
157
- } finally {
158
- db?.close();
159
- }
160
- }
161
-
162
- // ---------------------------------------------------------------------------
163
- // HTTP token refresh (replicates assistant/src/security/oauth2.ts logic)
164
- // ---------------------------------------------------------------------------
165
-
166
- interface RefreshTokenResponse {
167
- accessToken: string;
168
- refreshToken?: string;
169
- expiresIn?: number;
170
- }
171
-
172
- async function performTokenRefresh(
173
- config: RefreshConfig,
174
- refreshToken: string,
175
- ): Promise<RefreshTokenResponse> {
176
- const body: Record<string, string> = {
177
- grant_type: "refresh_token",
178
- refresh_token: refreshToken,
179
- };
180
-
181
- const headers: Record<string, string> = {
182
- "Content-Type":
183
- config.bodyFormat === "json"
184
- ? "application/json"
185
- : "application/x-www-form-urlencoded",
186
- };
187
-
188
- if (config.clientSecret && config.authMethod === "client_secret_basic") {
189
- const credentials = Buffer.from(
190
- `${config.clientId}:${config.clientSecret}`,
191
- ).toString("base64");
192
- headers["Authorization"] = `Basic ${credentials}`;
193
- } else {
194
- body.client_id = config.clientId;
195
- if (config.clientSecret) {
196
- body.client_secret = config.clientSecret;
197
- }
198
- }
199
-
200
- const resp = await fetch(config.tokenUrl, {
201
- method: "POST",
202
- headers,
203
- body:
204
- config.bodyFormat === "json"
205
- ? JSON.stringify(body)
206
- : new URLSearchParams(body),
207
- });
208
-
209
- if (!resp.ok) {
210
- const rawBody = await resp.text().catch(() => "");
211
- let errorCode = "";
212
- try {
213
- const parsed = JSON.parse(rawBody) as Record<string, unknown>;
214
- if (parsed.error) {
215
- errorCode = String(parsed.error);
216
- }
217
- } catch {
218
- // non-JSON response
219
- }
220
- const detail = errorCode
221
- ? `HTTP ${resp.status}: ${errorCode}`
222
- : `HTTP ${resp.status}`;
223
- throw new Error(`OAuth2 token refresh failed (${detail})`);
224
- }
225
-
226
- const data = (await resp.json()) as Record<string, unknown>;
227
-
228
- return {
229
- accessToken: data.access_token as string,
230
- refreshToken: (data.refresh_token as string | undefined) ?? refreshToken,
231
- expiresIn: data.expires_in as number | undefined,
232
- };
233
- }
234
-
235
- // ---------------------------------------------------------------------------
236
- // Public factory
237
- // ---------------------------------------------------------------------------
238
-
239
- /**
240
- * Create a `TokenRefreshFn` for CES local mode.
241
- *
242
- * The returned function looks up OAuth configuration from the assistant's
243
- * SQLite database (read-only) and performs the HTTP token refresh call.
244
- * Token persistence is handled by the `LocalMaterialiser` after this
245
- * function returns.
246
- *
247
- * @param vellumRoot - The Vellum root directory (e.g. `~/.vellum`).
248
- * @param secureKeyBackend - Backend for retrieving the OAuth client secret.
249
- */
250
- export function createLocalTokenRefreshFn(
251
- workspaceDir: string,
252
- secureKeyBackend: SecureKeyBackend,
253
- ): TokenRefreshFn {
254
- const dbPath = join(workspaceDir, "data", "db", "assistant.db");
255
-
256
- return async (
257
- connectionId: string,
258
- refreshToken: string,
259
- ): Promise<TokenRefreshResult> => {
260
- // 1. Resolve the refresh config from SQLite + secure storage
261
- const config = await resolveRefreshConfig(
262
- dbPath,
263
- connectionId,
264
- secureKeyBackend,
265
- );
266
-
267
- if ("error" in config) {
268
- return { success: false, error: config.error };
269
- }
270
-
271
- // 2. Perform the HTTP token refresh
272
- try {
273
- const result = await performTokenRefresh(config, refreshToken);
274
- const expiresAt = computeExpiresAt(result.expiresIn ?? null);
275
-
276
- return {
277
- success: true,
278
- accessToken: result.accessToken,
279
- expiresAt,
280
- refreshToken: result.refreshToken,
281
- };
282
- } catch (err) {
283
- const message = err instanceof Error ? err.message : String(err);
284
- return { success: false, error: message };
285
- }
286
- };
287
- }
@@ -1,316 +0,0 @@
1
- /**
2
- * CES local credential materialisation.
3
- *
4
- * Materialises credential values from local storage into per-operation
5
- * results that the CES execution layer can inject into authenticated
6
- * requests or commands. Materialised values never persist to assistant-
7
- * visible state — they exist only for the duration of the execution.
8
- *
9
- * Supports two credential types:
10
- *
11
- * - **Static secrets** — Retrieved from the secure-key backend using the
12
- * storage key from the resolved subject. Fails if the key is missing.
13
- *
14
- * - **OAuth tokens** — Retrieved from the secure-key backend using the
15
- * connection's access token path. Automatically refreshes expired tokens
16
- * using the shared `@vellumai/credential-storage` refresh primitives.
17
- * Fails if no access token exists (disconnected connection) or if
18
- * refresh fails.
19
- *
20
- * Materialisation is fail-closed: missing keys, disconnected connections,
21
- * and refresh failures all return errors before any outbound work starts.
22
- */
23
-
24
- import {
25
- type InjectionTemplate,
26
- type SecureKeyBackend,
27
- type TokenRefreshResult,
28
- getStoredAccessToken,
29
- getStoredRefreshToken,
30
- isTokenExpired,
31
- RefreshCircuitBreaker,
32
- RefreshDeduplicator,
33
- persistRefreshedTokens,
34
- } from "@vellumai/credential-storage";
35
- import { HandleType } from "@vellumai/service-contracts/credential-rpc";
36
-
37
- import type {
38
- ResolvedLocalSubject,
39
- ResolvedOAuthSubject,
40
- ResolvedStaticSubject,
41
- } from "../subjects/local.js";
42
-
43
- // ---------------------------------------------------------------------------
44
- // Materialisation result
45
- // ---------------------------------------------------------------------------
46
-
47
- /**
48
- * A materialised credential value ready for injection into an execution
49
- * environment. The value is ephemeral and must not be persisted to any
50
- * assistant-visible store.
51
- */
52
- export interface MaterialisedCredential {
53
- /** The credential value (secret, token, etc.). */
54
- value: string;
55
- /** The handle type that produced this value. */
56
- handleType: HandleType;
57
- /** For OAuth: the token expiry timestamp (null if unknown). */
58
- expiresAt?: number | null;
59
- /** Injection templates from the credential metadata (local_static only). */
60
- injectionTemplates?: InjectionTemplate[];
61
- }
62
-
63
- export type MaterialisationResult =
64
- | { ok: true; credential: MaterialisedCredential }
65
- | { ok: false; error: string };
66
-
67
- // ---------------------------------------------------------------------------
68
- // Token refresh callback
69
- // ---------------------------------------------------------------------------
70
-
71
- /**
72
- * Callback for performing the actual OAuth token refresh network call.
73
- *
74
- * CES delegates the refresh network call to callers so it remains
75
- * transport-agnostic. The callback receives the connection ID and
76
- * refresh token, and returns a `TokenRefreshResult` from the shared
77
- * credential-storage primitives.
78
- */
79
- export type TokenRefreshFn = (
80
- connectionId: string,
81
- refreshToken: string,
82
- ) => Promise<TokenRefreshResult>;
83
-
84
- // ---------------------------------------------------------------------------
85
- // Local materialiser
86
- // ---------------------------------------------------------------------------
87
-
88
- export interface LocalMaterialiserDeps {
89
- /** Secure-key backend for retrieving secret values. */
90
- secureKeyBackend: SecureKeyBackend;
91
- /** Optional token refresh callback for OAuth tokens. */
92
- tokenRefreshFn?: TokenRefreshFn;
93
- }
94
-
95
- /**
96
- * Local credential materialiser.
97
- *
98
- * Stateful: maintains a per-connection circuit breaker and refresh
99
- * deduplicator for OAuth token refresh. Create one instance per CES
100
- * process lifetime.
101
- */
102
- export class LocalMaterialiser {
103
- private readonly backend: SecureKeyBackend;
104
- private readonly tokenRefreshFn?: TokenRefreshFn;
105
- private readonly circuitBreaker = new RefreshCircuitBreaker();
106
- private readonly deduplicator = new RefreshDeduplicator();
107
-
108
- constructor(deps: LocalMaterialiserDeps) {
109
- this.backend = deps.secureKeyBackend;
110
- this.tokenRefreshFn = deps.tokenRefreshFn;
111
- }
112
-
113
- /**
114
- * Materialise a resolved local subject into a credential value.
115
- *
116
- * Dispatches to the appropriate handler based on the subject type.
117
- * Returns a discriminated result — never throws for expected failure
118
- * modes (missing keys, disconnected connections, expired tokens).
119
- */
120
- async materialise(
121
- subject: ResolvedLocalSubject,
122
- ): Promise<MaterialisationResult> {
123
- switch (subject.type) {
124
- case HandleType.LocalStatic:
125
- return this.materialiseStatic(subject);
126
- case HandleType.LocalOAuth:
127
- return this.materialiseOAuth(subject);
128
- default:
129
- return {
130
- ok: false,
131
- error: `Unsupported subject type for local materialisation`,
132
- };
133
- }
134
- }
135
-
136
- // -----------------------------------------------------------------------
137
- // Static secret materialisation
138
- // -----------------------------------------------------------------------
139
-
140
- private async materialiseStatic(
141
- subject: ResolvedStaticSubject,
142
- ): Promise<MaterialisationResult> {
143
- const secretValue = await this.backend.get(subject.storageKey);
144
- if (secretValue === undefined) {
145
- return {
146
- ok: false,
147
- error: `Secure key "${subject.storageKey}" not found in local credential store. ` +
148
- `The credential for service="${subject.metadata.service}", field="${subject.metadata.field}" ` +
149
- `has metadata but no secret value stored.`,
150
- };
151
- }
152
-
153
- return {
154
- ok: true,
155
- credential: {
156
- value: secretValue,
157
- handleType: HandleType.LocalStatic,
158
- injectionTemplates: subject.metadata.injectionTemplates,
159
- },
160
- };
161
- }
162
-
163
- // -----------------------------------------------------------------------
164
- // OAuth token materialisation
165
- // -----------------------------------------------------------------------
166
-
167
- private async materialiseOAuth(
168
- subject: ResolvedOAuthSubject,
169
- ): Promise<MaterialisationResult> {
170
- const { connection } = subject;
171
- const connectionId = connection.id;
172
-
173
- // 1. Get the stored access token
174
- const accessToken = await getStoredAccessToken(
175
- this.backend,
176
- connectionId,
177
- );
178
-
179
- if (!accessToken) {
180
- return {
181
- ok: false,
182
- error: `No access token found for OAuth connection "${connectionId}" ` +
183
- `(provider="${connection.providerKey}"). The connection is disconnected.`,
184
- };
185
- }
186
-
187
- // 2. Check if the token is expired and needs refresh
188
- if (connection.hasRefreshToken) {
189
- // For refreshable tokens, use the proactive buffer so we can refresh
190
- // before the token actually expires.
191
- if (isTokenExpired(connection.expiresAt)) {
192
- return this.refreshAndMaterialise(subject, connectionId);
193
- }
194
- } else {
195
- // For non-refreshable tokens, check against the hard expiry — use
196
- // every valid second rather than the 5-minute proactive buffer.
197
- if (connection.expiresAt && Date.now() >= connection.expiresAt) {
198
- return {
199
- ok: false,
200
- error: `Token for OAuth connection "${connectionId}" is expired and no refresh ` +
201
- `token is available. Re-authorization required.`,
202
- };
203
- }
204
- }
205
-
206
- // 3. Token is valid — return it
207
- return {
208
- ok: true,
209
- credential: {
210
- value: accessToken,
211
- handleType: HandleType.LocalOAuth,
212
- expiresAt: connection.expiresAt,
213
- },
214
- };
215
- }
216
-
217
- /**
218
- * Refresh an expired OAuth token and return the materialised result.
219
- *
220
- * Uses the circuit breaker to prevent retry storms and the deduplicator
221
- * to coalesce concurrent refresh attempts for the same connection.
222
- */
223
- private async refreshAndMaterialise(
224
- subject: ResolvedOAuthSubject,
225
- connectionId: string,
226
- ): Promise<MaterialisationResult> {
227
- // Check circuit breaker
228
- if (this.circuitBreaker.isOpen(connectionId)) {
229
- return {
230
- ok: false,
231
- error: `Token refresh circuit breaker is open for connection "${connectionId}". ` +
232
- `Too many consecutive refresh failures. Re-authorization may be required.`,
233
- };
234
- }
235
-
236
- if (!this.tokenRefreshFn) {
237
- return {
238
- ok: false,
239
- error: `Token for OAuth connection "${connectionId}" is expired but no refresh ` +
240
- `function is configured. Re-authorization required.`,
241
- };
242
- }
243
-
244
- // Get the refresh token
245
- const refreshToken = await getStoredRefreshToken(
246
- this.backend,
247
- connectionId,
248
- );
249
- if (!refreshToken) {
250
- return {
251
- ok: false,
252
- error: `Token for OAuth connection "${connectionId}" is expired and no refresh ` +
253
- `token is available. Re-authorization required.`,
254
- };
255
- }
256
-
257
- try {
258
- // Use deduplicator to prevent concurrent refresh attempts
259
- const tokenRefreshFn = this.tokenRefreshFn;
260
- const backend = this.backend;
261
- const circuitBreaker = this.circuitBreaker;
262
-
263
- const newAccessToken = await this.deduplicator.deduplicate(
264
- connectionId,
265
- async () => {
266
- const result = await tokenRefreshFn(connectionId, refreshToken);
267
- if (!result.success) {
268
- circuitBreaker.recordFailure(connectionId);
269
- throw new Error(result.error);
270
- }
271
-
272
- circuitBreaker.recordSuccess(connectionId);
273
-
274
- // Persist the refreshed tokens to the secure-key backend
275
- // (but NOT to any assistant-visible state)
276
- const persisted = await persistRefreshedTokens(
277
- backend,
278
- connectionId,
279
- {
280
- accessToken: result.accessToken,
281
- refreshToken: result.refreshToken,
282
- expiresIn: result.expiresAt
283
- ? Math.floor((result.expiresAt - Date.now()) / 1000)
284
- : null,
285
- },
286
- );
287
-
288
- return persisted.accessToken;
289
- },
290
- );
291
-
292
- return {
293
- ok: true,
294
- credential: {
295
- value: newAccessToken,
296
- handleType: HandleType.LocalOAuth,
297
- expiresAt: null, // Refresh result expiry is tracked internally
298
- },
299
- };
300
- } catch (err) {
301
- const message = err instanceof Error ? err.message : String(err);
302
- return {
303
- ok: false,
304
- error: `Failed to refresh token for OAuth connection "${connectionId}": ${message}`,
305
- };
306
- }
307
- }
308
-
309
- /**
310
- * Reset circuit breaker and deduplicator state (primarily for testing).
311
- */
312
- reset(): void {
313
- this.circuitBreaker.clear();
314
- this.deduplicator.clear();
315
- }
316
- }