veryfront 0.1.241 → 0.1.243
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/esm/cli/templates/manifest.js +77 -77
- package/esm/deno.js +1 -1
- package/esm/src/agent/conversation-root-run-context.d.ts.map +1 -1
- package/esm/src/agent/conversation-root-run-context.js +2 -0
- package/esm/src/agent/conversation-run-context.d.ts +2 -0
- package/esm/src/agent/conversation-run-context.d.ts.map +1 -1
- package/esm/src/agent/durable.d.ts +23 -0
- package/esm/src/agent/durable.d.ts.map +1 -1
- package/esm/src/agent/durable.js +39 -0
- package/esm/src/agent/index.d.ts +1 -1
- package/esm/src/agent/index.d.ts.map +1 -1
- package/esm/src/agent/index.js +1 -1
- package/esm/src/oauth/handlers/callback-handler.d.ts +2 -2
- package/esm/src/oauth/handlers/callback-handler.d.ts.map +1 -1
- package/esm/src/oauth/handlers/callback-handler.js +17 -5
- package/esm/src/oauth/handlers/init-handler.d.ts +24 -4
- package/esm/src/oauth/handlers/init-handler.d.ts.map +1 -1
- package/esm/src/oauth/handlers/init-handler.js +47 -10
- package/esm/src/oauth/providers/base.d.ts +9 -2
- package/esm/src/oauth/providers/base.d.ts.map +1 -1
- package/esm/src/oauth/providers/base.js +12 -5
- package/esm/src/oauth/token-store/index.d.ts +1 -1
- package/esm/src/oauth/token-store/index.d.ts.map +1 -1
- package/esm/src/oauth/token-store/memory.d.ts +21 -9
- package/esm/src/oauth/token-store/memory.d.ts.map +1 -1
- package/esm/src/oauth/token-store/memory.js +42 -28
- package/esm/src/oauth/types.d.ts +33 -7
- package/esm/src/oauth/types.d.ts.map +1 -1
- package/esm/src/platform/compat/framework-source-resolver.d.ts.map +1 -1
- package/esm/src/platform/compat/framework-source-resolver.js +34 -0
- package/esm/src/routing/api/module-loader/loader.d.ts +11 -0
- package/esm/src/routing/api/module-loader/loader.d.ts.map +1 -1
- package/esm/src/routing/api/module-loader/loader.js +18 -2
- package/esm/src/server/handlers/dev/dashboard/api.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/dashboard/api.js +34 -13
- package/esm/src/server/handlers/dev/files/esbuild-plugins.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/files/esbuild-plugins.js +45 -4
- package/esm/src/utils/version-constant.d.ts +1 -1
- package/esm/src/utils/version-constant.js +1 -1
- package/package.json +1 -1
- package/src/cli/templates/manifest.js +77 -77
- package/src/deno.js +1 -1
- package/src/src/agent/conversation-root-run-context.ts +2 -0
- package/src/src/agent/durable.ts +60 -0
- package/src/src/agent/index.ts +3 -0
- package/src/src/oauth/handlers/callback-handler.ts +25 -8
- package/src/src/oauth/handlers/init-handler.ts +83 -15
- package/src/src/oauth/providers/base.ts +12 -5
- package/src/src/oauth/token-store/index.ts +1 -1
- package/src/src/oauth/token-store/memory.ts +48 -35
- package/src/src/oauth/types.ts +34 -7
- package/src/src/platform/compat/framework-source-resolver.ts +32 -0
- package/src/src/routing/api/module-loader/loader.ts +18 -2
- package/src/src/server/handlers/dev/dashboard/api.ts +32 -10
- package/src/src/server/handlers/dev/files/esbuild-plugins.ts +54 -5
- package/src/src/utils/version-constant.ts +1 -1
package/src/deno.js
CHANGED
|
@@ -24,6 +24,8 @@ function normalizeProvidedRun(input: {
|
|
|
24
24
|
messageId: input.providedRun.messageId,
|
|
25
25
|
latestEventId: input.providedRun.latestEventId ?? 0,
|
|
26
26
|
latestExternalEventSequence: input.providedRun.latestExternalEventSequence ?? 0,
|
|
27
|
+
waitingToolCallId: null,
|
|
28
|
+
waitingToolName: null,
|
|
27
29
|
status: "running",
|
|
28
30
|
};
|
|
29
31
|
}
|
package/src/src/agent/durable.ts
CHANGED
|
@@ -86,6 +86,10 @@ export const ConversationRunProjectionSchema = z
|
|
|
86
86
|
latest_event_id: z.number().int().nonnegative().optional(),
|
|
87
87
|
latestExternalEventSequence: z.number().int().nonnegative().optional(),
|
|
88
88
|
latest_external_event_sequence: z.number().int().nonnegative().optional(),
|
|
89
|
+
waitingToolCallId: z.string().min(1).nullable().optional(),
|
|
90
|
+
waiting_tool_call_id: z.string().min(1).nullable().optional(),
|
|
91
|
+
waitingToolName: z.string().nullable().optional(),
|
|
92
|
+
waiting_tool_name: z.string().nullable().optional(),
|
|
89
93
|
status: ConversationRunStatusSchema,
|
|
90
94
|
})
|
|
91
95
|
.passthrough()
|
|
@@ -111,6 +115,8 @@ export const ConversationRunProjectionSchema = z
|
|
|
111
115
|
messageId,
|
|
112
116
|
latestEventId,
|
|
113
117
|
latestExternalEventSequence,
|
|
118
|
+
waitingToolCallId: data.waitingToolCallId ?? data.waiting_tool_call_id ?? null,
|
|
119
|
+
waitingToolName: data.waitingToolName ?? data.waiting_tool_name ?? null,
|
|
114
120
|
status: data.status,
|
|
115
121
|
};
|
|
116
122
|
});
|
|
@@ -124,6 +130,10 @@ export type TerminalConversationRunStatus = Extract<
|
|
|
124
130
|
ConversationRunProjection["status"],
|
|
125
131
|
"completed" | "failed" | "cancelled"
|
|
126
132
|
>;
|
|
133
|
+
export type ConversationRunAppendCursorResyncResult =
|
|
134
|
+
| "advanced"
|
|
135
|
+
| "non_appendable"
|
|
136
|
+
| "unchanged";
|
|
127
137
|
|
|
128
138
|
export const CreateConversationRunAcceptedSchema = z
|
|
129
139
|
.object({
|
|
@@ -322,6 +332,56 @@ export function isActiveConversationRunStatus(
|
|
|
322
332
|
return status === "pending" || status === "running" || status === "waiting_for_tool";
|
|
323
333
|
}
|
|
324
334
|
|
|
335
|
+
export function isAppendableConversationRunProjection(run: ConversationRunProjection): boolean {
|
|
336
|
+
return (
|
|
337
|
+
run.status !== "completed" &&
|
|
338
|
+
run.status !== "failed" &&
|
|
339
|
+
run.status !== "cancelled" &&
|
|
340
|
+
run.status !== "waiting_for_tool" &&
|
|
341
|
+
run.waitingToolCallId === null &&
|
|
342
|
+
run.waitingToolName === null
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function resyncConversationRunAppendCursor(input: {
|
|
347
|
+
authToken: string;
|
|
348
|
+
apiUrl: string;
|
|
349
|
+
conversationId: string;
|
|
350
|
+
runId: string;
|
|
351
|
+
previousLatestExternalEventSequence: number;
|
|
352
|
+
abortSignal?: AbortSignal;
|
|
353
|
+
}): Promise<{
|
|
354
|
+
result: ConversationRunAppendCursorResyncResult;
|
|
355
|
+
run: ConversationRunProjection;
|
|
356
|
+
}> {
|
|
357
|
+
const run = await getConversationRun({
|
|
358
|
+
authToken: input.authToken,
|
|
359
|
+
apiUrl: input.apiUrl,
|
|
360
|
+
conversationId: input.conversationId,
|
|
361
|
+
runId: input.runId,
|
|
362
|
+
abortSignal: input.abortSignal,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (run.latestExternalEventSequence > input.previousLatestExternalEventSequence) {
|
|
366
|
+
return {
|
|
367
|
+
result: "advanced",
|
|
368
|
+
run,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!isAppendableConversationRunProjection(run)) {
|
|
373
|
+
return {
|
|
374
|
+
result: "non_appendable",
|
|
375
|
+
run,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
result: "unchanged",
|
|
381
|
+
run,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
325
385
|
async function waitForConversationRunPoll(
|
|
326
386
|
ms: number,
|
|
327
387
|
abortSignal?: AbortSignal,
|
package/src/src/agent/index.ts
CHANGED
|
@@ -219,6 +219,7 @@ export {
|
|
|
219
219
|
AppendConversationRunEventsResponseSchema,
|
|
220
220
|
CompleteConversationRunResponseSchema,
|
|
221
221
|
type ConversationAgentRunUsage,
|
|
222
|
+
type ConversationRunAppendCursorResyncResult,
|
|
222
223
|
type ConversationRunProjection,
|
|
223
224
|
ConversationRunProjectionSchema,
|
|
224
225
|
ConversationRunStatusSchema,
|
|
@@ -229,11 +230,13 @@ export {
|
|
|
229
230
|
finalizeConversationAgentRun,
|
|
230
231
|
getConversationRun,
|
|
231
232
|
isActiveConversationRunStatus,
|
|
233
|
+
isAppendableConversationRunProjection,
|
|
232
234
|
isCursorMismatchConversationRunAppendError,
|
|
233
235
|
isIgnorableConversationRunAppendError,
|
|
234
236
|
monitorConversationRunStatus,
|
|
235
237
|
parseAppendConversationRunEventsErrorBody,
|
|
236
238
|
resolveConversationRunTargets,
|
|
239
|
+
resyncConversationRunAppendCursor,
|
|
237
240
|
type TerminalConversationRunStatus,
|
|
238
241
|
} from "./durable.js";
|
|
239
242
|
export {
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "../../config/environment-config.js";
|
|
7
7
|
import { type EnvReader, OAuthService } from "../providers/base.js";
|
|
8
8
|
import { memoryTokenStore } from "../token-store/memory.js";
|
|
9
|
-
import type { OAuthServiceConfig,
|
|
9
|
+
import type { OAuthServiceConfig, StoredOAuthState, TokenStore } from "../types.js";
|
|
10
10
|
|
|
11
11
|
const logger = baseLogger.component("o-auth");
|
|
12
12
|
|
|
@@ -23,8 +23,8 @@ export interface OAuthCallbackHandlerOptions {
|
|
|
23
23
|
/** Error redirect path */
|
|
24
24
|
errorRedirect?: string;
|
|
25
25
|
|
|
26
|
-
/** Custom success callback */
|
|
27
|
-
onSuccess?: (serviceId: string, tokens: unknown) => void | Promise<void>;
|
|
26
|
+
/** Custom success callback (called with the user id the tokens were stored under) */
|
|
27
|
+
onSuccess?: (serviceId: string, tokens: unknown, userId: string) => void | Promise<void>;
|
|
28
28
|
|
|
29
29
|
/** Custom error callback */
|
|
30
30
|
onError?: (serviceId: string, error: string) => void | Promise<void>;
|
|
@@ -102,7 +102,7 @@ export function createOAuthCallbackHandler(
|
|
|
102
102
|
|
|
103
103
|
if (!code) return handleError(appUrl, "no_code");
|
|
104
104
|
|
|
105
|
-
let storedState:
|
|
105
|
+
let storedState: StoredOAuthState | null = null;
|
|
106
106
|
|
|
107
107
|
if (!skipStateValidation && !state) {
|
|
108
108
|
return handleError(appUrl, "invalid_state", "Missing state parameter", {
|
|
@@ -111,12 +111,20 @@ export function createOAuthCallbackHandler(
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
if (state) {
|
|
114
|
-
|
|
114
|
+
// Atomic read+delete. Unknown/expired/forged state all return null.
|
|
115
|
+
storedState = await tokenStore.consumeState(state);
|
|
115
116
|
if (!skipStateValidation && !storedState) {
|
|
116
117
|
return handleError(appUrl, "invalid_state", "Invalid or expired state", {
|
|
117
118
|
serviceId: config.serviceId,
|
|
118
119
|
});
|
|
119
120
|
}
|
|
121
|
+
// A state record from a different service must never authorize this one.
|
|
122
|
+
if (storedState && storedState.serviceId !== config.serviceId) {
|
|
123
|
+
return handleError(appUrl, "invalid_state", "State serviceId mismatch", {
|
|
124
|
+
serviceId: config.serviceId,
|
|
125
|
+
stateServiceId: storedState.serviceId,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
const service = new OAuthService(config, tokenStore, envReader);
|
|
@@ -138,11 +146,20 @@ export function createOAuthCallbackHandler(
|
|
|
138
146
|
);
|
|
139
147
|
}
|
|
140
148
|
|
|
141
|
-
|
|
149
|
+
// Without state (skipStateValidation) we have no userId — refuse to
|
|
150
|
+
// store tokens under a shared slot. Callers who need this path must
|
|
151
|
+
// provide a store that handles it themselves (e.g. cookie-scoped).
|
|
152
|
+
if (!storedState) {
|
|
153
|
+
return handleError(
|
|
154
|
+
appUrl,
|
|
155
|
+
"invalid_state",
|
|
156
|
+
`Cannot store tokens for ${config.serviceId}: no state (and thus no userId) available`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
142
159
|
|
|
143
|
-
|
|
160
|
+
await tokenStore.setTokens(config.serviceId, storedState.userId, result.tokens);
|
|
144
161
|
|
|
145
|
-
await onSuccess?.(config.serviceId, result.tokens);
|
|
162
|
+
await onSuccess?.(config.serviceId, result.tokens, storedState.userId);
|
|
146
163
|
|
|
147
164
|
const successUrl = new URL(successRedirect, appUrl);
|
|
148
165
|
successUrl.searchParams.set("connected", config.serviceId);
|
|
@@ -22,6 +22,24 @@ async function isRequestUnauthorized(
|
|
|
22
22
|
return isAuthenticated ? !(await isAuthenticated(req)) : false;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the userId for a request, returning null when anonymous.
|
|
27
|
+
*
|
|
28
|
+
* `getUserId` is required at compile time (see handler option types). We
|
|
29
|
+
* still tolerate `undefined` at runtime (e.g. a JS caller) and treat it as
|
|
30
|
+
* unauthenticated — NEVER fall back to "anonymous": that preserves
|
|
31
|
+
* VULN-AUTH-2 where unrelated users share a single token slot.
|
|
32
|
+
*/
|
|
33
|
+
async function resolveUserId(
|
|
34
|
+
req: Request,
|
|
35
|
+
getUserId: GetUserIdFn | undefined,
|
|
36
|
+
): Promise<string | null> {
|
|
37
|
+
if (!getUserId) return null;
|
|
38
|
+
const result = await getUserId(req);
|
|
39
|
+
if (!result) return null; // null, undefined, or empty string all fail.
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
function resolveAppUrl(baseUrl: string | undefined, env: EnvironmentConfig): string {
|
|
26
44
|
return baseUrl ?? env.appUrl ?? DEFAULT_APP_URL;
|
|
27
45
|
}
|
|
@@ -46,6 +64,9 @@ function createInitErrorResponse(error: unknown): Response {
|
|
|
46
64
|
);
|
|
47
65
|
}
|
|
48
66
|
|
|
67
|
+
/** Signature for resolving the authenticated user's id from a request. */
|
|
68
|
+
export type GetUserIdFn = (req: Request) => string | null | Promise<string | null>;
|
|
69
|
+
|
|
49
70
|
export interface OAuthInitHandlerOptions {
|
|
50
71
|
/** Token store to use (defaults to memory store) */
|
|
51
72
|
tokenStore?: TokenStore;
|
|
@@ -61,21 +82,45 @@ export interface OAuthInitHandlerOptions {
|
|
|
61
82
|
|
|
62
83
|
/** EnvReader for dynamic env vars (defaults to getEnv) */
|
|
63
84
|
envReader?: EnvReader;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Optional authentication check. If supplied and returns false the request
|
|
88
|
+
* is rejected with 401. Independent from `getUserId` which always runs.
|
|
89
|
+
*/
|
|
90
|
+
isAuthenticated?: (req: Request) => boolean | Promise<boolean>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* REQUIRED. Resolve the authenticated user's id. The returned id is
|
|
94
|
+
* persisted with the OAuth `state` so the callback stores tokens in that
|
|
95
|
+
* user's slot. Return `null` (or an empty string) to reject unauthenticated
|
|
96
|
+
* requests with 401. NEVER return a shared constant like "anonymous" —
|
|
97
|
+
* that re-introduces VULN-AUTH-2.
|
|
98
|
+
*/
|
|
99
|
+
getUserId: GetUserIdFn;
|
|
64
100
|
}
|
|
65
101
|
|
|
66
102
|
export function createOAuthInitHandler(
|
|
67
103
|
config: OAuthServiceConfig,
|
|
68
|
-
options: OAuthInitHandlerOptions
|
|
69
|
-
): () => Promise<Response> {
|
|
104
|
+
options: OAuthInitHandlerOptions,
|
|
105
|
+
): (req: Request) => Promise<Response> {
|
|
70
106
|
const {
|
|
71
107
|
tokenStore = memoryTokenStore,
|
|
72
108
|
baseUrl,
|
|
73
109
|
authOptions = {},
|
|
74
110
|
env = getEnvironmentConfig(),
|
|
75
111
|
envReader = getEnv,
|
|
76
|
-
|
|
112
|
+
isAuthenticated,
|
|
113
|
+
getUserId,
|
|
114
|
+
} = options ?? ({} as OAuthInitHandlerOptions);
|
|
115
|
+
|
|
116
|
+
return async function handler(req: Request): Promise<Response> {
|
|
117
|
+
if (await isRequestUnauthorized(req, isAuthenticated)) {
|
|
118
|
+
return createUnauthorizedResponse();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const userId = await resolveUserId(req, getUserId);
|
|
122
|
+
if (!userId) return createUnauthorizedResponse();
|
|
77
123
|
|
|
78
|
-
return async function handler(): Promise<Response> {
|
|
79
124
|
const service = new OAuthService(config, tokenStore, envReader);
|
|
80
125
|
|
|
81
126
|
if (!service.isConfigured()) {
|
|
@@ -87,7 +132,15 @@ export function createOAuthInitHandler(
|
|
|
87
132
|
|
|
88
133
|
try {
|
|
89
134
|
const { url, state } = await service.createAuthorizationUrl({ ...authOptions, redirectUri });
|
|
90
|
-
await tokenStore.setState(state
|
|
135
|
+
await tokenStore.setState(state.state, {
|
|
136
|
+
userId,
|
|
137
|
+
serviceId: config.serviceId,
|
|
138
|
+
codeVerifier: state.codeVerifier,
|
|
139
|
+
redirectUri: state.redirectUri,
|
|
140
|
+
scopes: state.scopes,
|
|
141
|
+
createdAt: state.createdAt,
|
|
142
|
+
metadata: state.metadata,
|
|
143
|
+
});
|
|
91
144
|
return Response.redirect(url);
|
|
92
145
|
} catch (error) {
|
|
93
146
|
logger.error("Init error", { serviceId: config.serviceId }, error);
|
|
@@ -105,24 +158,31 @@ export interface OAuthStatusHandlerOptions {
|
|
|
105
158
|
|
|
106
159
|
/** Optional authentication check — return true if the request is authenticated */
|
|
107
160
|
isAuthenticated?: (req: Request) => boolean | Promise<boolean>;
|
|
161
|
+
|
|
162
|
+
/** REQUIRED. Resolve the authenticated user's id (see OAuthInitHandlerOptions). */
|
|
163
|
+
getUserId: GetUserIdFn;
|
|
108
164
|
}
|
|
109
165
|
|
|
110
166
|
export function createOAuthStatusHandler(
|
|
111
167
|
config: OAuthServiceConfig,
|
|
112
|
-
options: OAuthStatusHandlerOptions
|
|
168
|
+
options: OAuthStatusHandlerOptions,
|
|
113
169
|
): (req: Request) => Promise<Response> {
|
|
114
170
|
const {
|
|
115
171
|
tokenStore = memoryTokenStore,
|
|
116
172
|
envReader = getEnv,
|
|
117
173
|
isAuthenticated,
|
|
118
|
-
|
|
174
|
+
getUserId,
|
|
175
|
+
} = options ?? ({} as OAuthStatusHandlerOptions);
|
|
119
176
|
|
|
120
177
|
return async function handler(req: Request): Promise<Response> {
|
|
121
178
|
if (await isRequestUnauthorized(req, isAuthenticated)) {
|
|
122
179
|
return createUnauthorizedResponse();
|
|
123
180
|
}
|
|
124
181
|
|
|
125
|
-
const
|
|
182
|
+
const userId = await resolveUserId(req, getUserId);
|
|
183
|
+
if (!userId) return createUnauthorizedResponse();
|
|
184
|
+
|
|
185
|
+
const tokens = await tokenStore.getTokens(config.serviceId, userId);
|
|
126
186
|
|
|
127
187
|
const isConnected = !!tokens?.accessToken;
|
|
128
188
|
const isExpired = tokens?.expiresAt ? Date.now() > tokens.expiresAt : false;
|
|
@@ -139,22 +199,30 @@ export function createOAuthStatusHandler(
|
|
|
139
199
|
};
|
|
140
200
|
}
|
|
141
201
|
|
|
202
|
+
export interface OAuthDisconnectHandlerOptions {
|
|
203
|
+
tokenStore?: TokenStore;
|
|
204
|
+
/** Optional authentication check — return true if the request is authenticated */
|
|
205
|
+
isAuthenticated?: (req: Request) => boolean | Promise<boolean>;
|
|
206
|
+
/** REQUIRED. Resolve the authenticated user's id (see OAuthInitHandlerOptions). */
|
|
207
|
+
getUserId: GetUserIdFn;
|
|
208
|
+
}
|
|
209
|
+
|
|
142
210
|
export function createOAuthDisconnectHandler(
|
|
143
211
|
config: OAuthServiceConfig,
|
|
144
|
-
options:
|
|
145
|
-
tokenStore?: TokenStore;
|
|
146
|
-
/** Optional authentication check — return true if the request is authenticated */
|
|
147
|
-
isAuthenticated?: (req: Request) => boolean | Promise<boolean>;
|
|
148
|
-
} = {},
|
|
212
|
+
options: OAuthDisconnectHandlerOptions,
|
|
149
213
|
): (req: Request) => Promise<Response> {
|
|
150
|
-
const { tokenStore = memoryTokenStore, isAuthenticated } = options
|
|
214
|
+
const { tokenStore = memoryTokenStore, isAuthenticated, getUserId } = options ??
|
|
215
|
+
({} as OAuthDisconnectHandlerOptions);
|
|
151
216
|
|
|
152
217
|
return async function handler(req: Request): Promise<Response> {
|
|
153
218
|
if (await isRequestUnauthorized(req, isAuthenticated)) {
|
|
154
219
|
return createUnauthorizedResponse();
|
|
155
220
|
}
|
|
156
221
|
|
|
157
|
-
await
|
|
222
|
+
const userId = await resolveUserId(req, getUserId);
|
|
223
|
+
if (!userId) return createUnauthorizedResponse();
|
|
224
|
+
|
|
225
|
+
await tokenStore.clearTokens(config.serviceId, userId);
|
|
158
226
|
|
|
159
227
|
return Response.json({
|
|
160
228
|
success: true,
|
|
@@ -315,8 +315,15 @@ export class OAuthService extends OAuthProvider {
|
|
|
315
315
|
});
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Get a valid access token for the given user, refreshing if needed.
|
|
320
|
+
*
|
|
321
|
+
* `userId` is required — this store is keyed by `(serviceId, userId)` to
|
|
322
|
+
* prevent one user's OAuth completion from overwriting another user's
|
|
323
|
+
* tokens. See VULN-AUTH-2.
|
|
324
|
+
*/
|
|
325
|
+
async getAccessToken(userId: string): Promise<string | null> {
|
|
326
|
+
const tokens = await this.tokenStore?.getTokens(this.serviceId, userId);
|
|
320
327
|
if (!tokens) return null;
|
|
321
328
|
|
|
322
329
|
const isExpired = tokens.expiresAt && Date.now() > tokens.expiresAt - TOKEN_REFRESH_BUFFER_MS;
|
|
@@ -330,12 +337,12 @@ export class OAuthService extends OAuthProvider {
|
|
|
330
337
|
if (!this.tokenStore) {
|
|
331
338
|
throw new Error("TokenStore not configured");
|
|
332
339
|
}
|
|
333
|
-
await this.tokenStore.setTokens(this.serviceId, result.tokens);
|
|
340
|
+
await this.tokenStore.setTokens(this.serviceId, userId, result.tokens);
|
|
334
341
|
return result.tokens.accessToken;
|
|
335
342
|
}
|
|
336
343
|
|
|
337
|
-
async fetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
338
|
-
const token = await this.getAccessToken();
|
|
344
|
+
async fetch<T>(userId: string, endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
345
|
+
const token = await this.getAccessToken(userId);
|
|
339
346
|
if (!token) {
|
|
340
347
|
throw TOKEN_STORAGE_ERROR.create({
|
|
341
348
|
detail: `Not authenticated with ${this.serviceConfig.displayName}`,
|
|
@@ -1,69 +1,82 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
const
|
|
5
|
-
|
|
1
|
+
import type { OAuthTokens, StoredOAuthState, TokenStore } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/** State expiry window: reject any state older than this (10 minutes). */
|
|
4
|
+
const STATE_EXPIRY_MS = 10 * 60 * 1_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-memory TokenStore keyed by `(serviceId, userId)`.
|
|
8
|
+
*
|
|
9
|
+
* Suitable for development and tests. For production use a persistent store
|
|
10
|
+
* (Redis, Postgres, ...) keyed the same way. Never share a single slot per
|
|
11
|
+
* service across users — see VULN-AUTH-2.
|
|
12
|
+
*/
|
|
6
13
|
export class MemoryTokenStore implements TokenStore {
|
|
7
14
|
private tokens = new Map<string, OAuthTokens>();
|
|
8
|
-
private states = new Map<string,
|
|
15
|
+
private states = new Map<string, StoredOAuthState>();
|
|
9
16
|
private projectId: string;
|
|
10
17
|
|
|
11
18
|
constructor(projectId = "default") {
|
|
12
19
|
this.projectId = projectId;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
private scopedKey(serviceId: string): string {
|
|
16
|
-
return `${this.projectId}:${serviceId}`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async getTokens(serviceId: string): Promise<OAuthTokens | null> {
|
|
20
|
-
return this.tokens.get(this.scopedKey(serviceId)) ?? null;
|
|
22
|
+
private scopedKey(serviceId: string, userId: string): string {
|
|
23
|
+
return `${this.projectId}:${serviceId}:${userId}`;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
this.tokens.
|
|
26
|
+
getTokens(serviceId: string, userId: string): Promise<OAuthTokens | null> {
|
|
27
|
+
return Promise.resolve(this.tokens.get(this.scopedKey(serviceId, userId)) ?? null);
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
this.tokens.
|
|
30
|
+
setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise<void> {
|
|
31
|
+
this.tokens.set(this.scopedKey(serviceId, userId), tokens);
|
|
32
|
+
return Promise.resolve();
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (Date.now() - storedState.createdAt > STATE_EXPIRATION_MS) {
|
|
36
|
-
this.states.delete(state);
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return storedState;
|
|
35
|
+
clearTokens(serviceId: string, userId: string): Promise<void> {
|
|
36
|
+
this.tokens.delete(this.scopedKey(serviceId, userId));
|
|
37
|
+
return Promise.resolve();
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
this.states.set(
|
|
40
|
+
setState(state: string, meta: StoredOAuthState): Promise<void> {
|
|
41
|
+
this.states.set(state, meta);
|
|
45
42
|
this.cleanupExpiredStates();
|
|
43
|
+
return Promise.resolve();
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Atomically read and delete state (one-shot). Returns null for unknown or
|
|
48
|
+
* expired entries. Expired entries are removed on read.
|
|
49
|
+
*/
|
|
50
|
+
consumeState(state: string): Promise<StoredOAuthState | null> {
|
|
51
|
+
const meta = this.states.get(state);
|
|
52
|
+
if (!meta) return Promise.resolve(null);
|
|
49
53
|
this.states.delete(state);
|
|
54
|
+
if (Date.now() - meta.createdAt > STATE_EXPIRY_MS) {
|
|
55
|
+
return Promise.resolve(null);
|
|
56
|
+
}
|
|
57
|
+
return Promise.resolve(meta);
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
private cleanupExpiredStates(): void {
|
|
53
61
|
const now = Date.now();
|
|
54
|
-
for (const [state,
|
|
55
|
-
if (now -
|
|
62
|
+
for (const [state, meta] of this.states) {
|
|
63
|
+
if (now - meta.createdAt > STATE_EXPIRY_MS) {
|
|
56
64
|
this.states.delete(state);
|
|
57
65
|
}
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
/** List connected slots as `${serviceId}:${userId}` strings (test/debug aid). */
|
|
61
70
|
getConnectedServices(): string[] {
|
|
62
|
-
|
|
71
|
+
const prefix = `${this.projectId}:`;
|
|
72
|
+
return [...this.tokens.keys()].map((key) =>
|
|
73
|
+
key.startsWith(prefix) ? key.slice(prefix.length) : key
|
|
74
|
+
);
|
|
63
75
|
}
|
|
64
76
|
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
/** Whether a given user has usable tokens for a service. */
|
|
78
|
+
isConnected(serviceId: string, userId: string): boolean {
|
|
79
|
+
const tokens = this.tokens.get(this.scopedKey(serviceId, userId));
|
|
67
80
|
if (!tokens) return false;
|
|
68
81
|
|
|
69
82
|
const isExpired = tokens.expiresAt != null && Date.now() > tokens.expiresAt;
|
|
@@ -76,4 +89,4 @@ export class MemoryTokenStore implements TokenStore {
|
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
export const memoryTokenStore = new MemoryTokenStore();
|
|
92
|
+
export const memoryTokenStore: TokenStore = new MemoryTokenStore();
|
package/src/src/oauth/types.ts
CHANGED
|
@@ -10,13 +10,40 @@ export type {
|
|
|
10
10
|
} from "./schemas/index.js";
|
|
11
11
|
|
|
12
12
|
// Import types used locally in this file
|
|
13
|
-
import type {
|
|
13
|
+
import type { OAuthTokens } from "./schemas/index.js";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Persisted OAuth state row. Created when init handler starts a flow and
|
|
17
|
+
* consumed exactly once by the callback handler.
|
|
18
|
+
*
|
|
19
|
+
* `userId` binds the flow to the authenticated user who initiated it so the
|
|
20
|
+
* resulting tokens are stored in that user's slot (not a shared one).
|
|
21
|
+
*/
|
|
22
|
+
export interface StoredOAuthState {
|
|
23
|
+
userId: string;
|
|
24
|
+
serviceId: string;
|
|
25
|
+
codeVerifier?: string;
|
|
26
|
+
redirectUri?: string;
|
|
27
|
+
scopes?: string[];
|
|
28
|
+
createdAt: number;
|
|
29
|
+
metadata?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* TokenStore is keyed by `(serviceId, userId)` — tokens are per-user.
|
|
34
|
+
*
|
|
35
|
+
* Using a single-slot-per-service store is a vulnerability: the last OAuth
|
|
36
|
+
* completion overwrites all others, so an attacker who starts and finishes
|
|
37
|
+
* an OAuth flow with their own account can cause server-side code to act
|
|
38
|
+
* on the attacker's account. Callers MUST pass `userId` from authenticated
|
|
39
|
+
* session context.
|
|
40
|
+
*/
|
|
15
41
|
export interface TokenStore {
|
|
16
|
-
getTokens(serviceId: string): Promise<OAuthTokens | null>;
|
|
17
|
-
setTokens(serviceId: string, tokens: OAuthTokens): Promise<void>;
|
|
18
|
-
clearTokens(serviceId: string): Promise<void>;
|
|
19
|
-
|
|
20
|
-
setState(state:
|
|
21
|
-
|
|
42
|
+
getTokens(serviceId: string, userId: string): Promise<OAuthTokens | null>;
|
|
43
|
+
setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise<void>;
|
|
44
|
+
clearTokens(serviceId: string, userId: string): Promise<void>;
|
|
45
|
+
/** Persist a new OAuth state row for the initiating user. */
|
|
46
|
+
setState(state: string, meta: StoredOAuthState): Promise<void>;
|
|
47
|
+
/** Atomically read and delete state. Returns null if unknown/expired. */
|
|
48
|
+
consumeState(state: string): Promise<StoredOAuthState | null>;
|
|
22
49
|
}
|
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
import { join } from "./path/index.js";
|
|
2
2
|
import type { FileInfo } from "../adapters/base.js";
|
|
3
|
+
import { isWithinDirectory } from "../../security/path-validation.js";
|
|
3
4
|
import { createFileSystem } from "./fs.js";
|
|
4
5
|
import { getFrameworkRoot, getFrameworkRootFromMeta } from "./vfs-paths.js";
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Reject candidate paths that contain traversal indicators — plain `..`,
|
|
9
|
+
* NUL, or any percent-encoded variant (including multiply-encoded forms such
|
|
10
|
+
* as `%252e` or `%25252e`). The public `/_vf_modules/...` route reaches this
|
|
11
|
+
* resolver, so a malicious basePath like
|
|
12
|
+
* `_veryfront/%2e%2e%2fsecret.ts` would otherwise be joined with the
|
|
13
|
+
* framework lookupDir and escape it.
|
|
14
|
+
*/
|
|
15
|
+
function hasDangerousSegments(candidate: string): boolean {
|
|
16
|
+
if (candidate.includes("\0")) return true;
|
|
17
|
+
// Plain-text traversal (post URL-decode).
|
|
18
|
+
if (/(^|[/\\])\.\.([/\\]|$)/.test(candidate)) return true;
|
|
19
|
+
// Any occurrence of a percent sign is treated as suspicious: this resolver
|
|
20
|
+
// is called with inputs taken from URL path segments which have already
|
|
21
|
+
// been decoded once upstream. A lingering `%` means the attacker
|
|
22
|
+
// double-encoded the input, or that decoding missed a sequence — either
|
|
23
|
+
// way, refuse to probe the filesystem. Framework source paths never
|
|
24
|
+
// legitimately contain `%`.
|
|
25
|
+
if (candidate.includes("%")) return true;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
6
29
|
export const FRAMEWORK_ROOT = getFrameworkRootFromMeta(import.meta.url);
|
|
7
30
|
export const FRAMEWORK_SRC_DIR = join(FRAMEWORK_ROOT, "src");
|
|
8
31
|
export const FRAMEWORK_EMBEDDED_SRC_DIR = join(FRAMEWORK_ROOT, "dist", "framework-src");
|
|
@@ -105,6 +128,11 @@ export async function resolveFrameworkSourcePath(
|
|
|
105
128
|
relativePathWithoutExt: string,
|
|
106
129
|
options: ResolveFrameworkSourcePathOptions = {},
|
|
107
130
|
): Promise<FrameworkSourceLookupResult | null> {
|
|
131
|
+
// VULN-FS-3: Reject any candidate containing traversal indicators
|
|
132
|
+
// (plain or percent-encoded) before joining with the framework lookup dir.
|
|
133
|
+
// The public /_vf_modules/... route reaches this function with user input.
|
|
134
|
+
if (hasDangerousSegments(relativePathWithoutExt)) return null;
|
|
135
|
+
|
|
108
136
|
const fs = options.fileSystem ?? createFileSystem();
|
|
109
137
|
const lookupDirs = getFrameworkSourceLookupDirs(options.extraLookupDirs);
|
|
110
138
|
const extensions = options.extensions ?? DEFAULT_FRAMEWORK_SOURCE_EXTENSIONS;
|
|
@@ -119,6 +147,10 @@ export async function resolveFrameworkSourcePath(
|
|
|
119
147
|
for (const ext of extensions) {
|
|
120
148
|
const candidatePath = join(lookupDir, candidate + ext);
|
|
121
149
|
|
|
150
|
+
// Defence in depth: even if the candidate passed the textual gate
|
|
151
|
+
// above, confirm the joined path is physically within the lookup dir.
|
|
152
|
+
if (!isWithinDirectory(lookupDir, candidatePath)) continue;
|
|
153
|
+
|
|
122
154
|
try {
|
|
123
155
|
const stat = await fs.stat(candidatePath);
|
|
124
156
|
if (stat.isFile) {
|