runline 0.29.0 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/errors.d.ts +22 -0
- package/dist/auth/errors.js +25 -0
- package/dist/auth/oauth2.d.ts +21 -0
- package/dist/auth/oauth2.js +154 -0
- package/dist/auth/token.d.ts +11 -0
- package/dist/auth/token.js +231 -0
- package/dist/auth/types.d.ts +93 -0
- package/dist/auth/types.js +1 -0
- package/dist/commands/auth.js +26 -13
- package/dist/commands/connection.js +3 -6
- package/dist/commands/exec.js +7 -2
- package/dist/commands/init.js +9 -2
- package/dist/config/loader.d.ts +7 -13
- package/dist/config/loader.js +58 -88
- package/dist/config/store.d.ts +17 -0
- package/dist/config/store.js +85 -0
- package/dist/connections/file.d.ts +13 -0
- package/dist/connections/file.js +53 -0
- package/dist/connections/memory.d.ts +15 -0
- package/dist/connections/memory.js +70 -0
- package/dist/connections/types.d.ts +47 -0
- package/dist/connections/types.js +10 -0
- package/dist/core/engine.d.ts +5 -2
- package/dist/core/engine.js +48 -55
- package/dist/core/oauth.d.ts +20 -15
- package/dist/core/oauth.js +195 -98
- package/dist/credentials/http.d.ts +19 -0
- package/dist/credentials/http.js +109 -0
- package/dist/credentials/policy.d.ts +5 -0
- package/dist/credentials/policy.js +92 -0
- package/dist/credentials/registry.d.ts +31 -0
- package/dist/credentials/registry.js +171 -0
- package/dist/credentials/transport.d.ts +38 -0
- package/dist/credentials/transport.js +283 -0
- package/dist/credentials/types.d.ts +80 -0
- package/dist/credentials/types.js +1 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +9 -1
- package/dist/plugin/types.d.ts +30 -17
- package/dist/plugins/_shared/credentialAdapter.js +88 -0
- package/dist/plugins/_shared/googleAuth.js +89 -103
- package/dist/plugins/_shared/googleCredentials.js +160 -0
- package/dist/plugins/_shared/microsoftAuth.js +81 -74
- package/dist/plugins/_shared/microsoftCredentials.js +90 -0
- package/dist/plugins/_shared/tokenRefresh.js +39 -0
- package/dist/plugins/bitwarden/src/index.js +13 -31
- package/dist/plugins/gmail/src/index.js +8 -44
- package/dist/plugins/googleAppsScript/src/index.js +3 -25
- package/dist/plugins/googleCalendar/src/index.js +3 -41
- package/dist/plugins/googleContacts/src/index.js +3 -36
- package/dist/plugins/googleDocs/src/index.js +5 -0
- package/dist/plugins/googleDocs/src/shared.js +2 -30
- package/dist/plugins/googleDrive/src/index.js +52 -98
- package/dist/plugins/googleSheets/src/index.js +3 -40
- package/dist/plugins/googleSlides/src/index.js +5 -39
- package/dist/plugins/googleTasks/src/index.js +3 -30
- package/dist/plugins/halopsa/src/index.js +16 -31
- package/dist/plugins/microsoftCalendar/src/index.js +2 -1
- package/dist/plugins/microsoftFiles/src/index.js +10 -24
- package/dist/plugins/microsoftMail/src/index.js +2 -1
- package/dist/plugins/paypal/src/index.js +14 -29
- package/dist/plugins/plaud/src/index.js +210 -0
- package/dist/plugins/plaud/src/shared.js +221 -0
- package/dist/plugins/salesforce/src/shared.js +17 -32
- package/dist/sdk.d.ts +13 -5
- package/dist/sdk.js +35 -29
- package/dist/utils/observer.d.ts +2 -0
- package/dist/utils/observer.js +11 -0
- package/package.json +1 -1
package/dist/core/engine.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { Worker } from "node:worker_threads";
|
|
4
|
-
import {
|
|
4
|
+
import { MemoryConnectionProvider } from "../connections/memory.js";
|
|
5
5
|
import { formatValidationError, helpInputs, isTypedInputSchema, normalizeActionInput, validateTypedInput, } from "../plugin/schema.js";
|
|
6
|
+
import { notifyObserver } from "../utils/observer.js";
|
|
6
7
|
/**
|
|
7
8
|
* How many bodies one worker may run before it is retired.
|
|
8
9
|
*
|
|
@@ -89,12 +90,16 @@ export class ExecutionEngine {
|
|
|
89
90
|
registry;
|
|
90
91
|
config;
|
|
91
92
|
hooks;
|
|
93
|
+
connectionProvider;
|
|
92
94
|
pooled = null;
|
|
93
95
|
maxRunsPerWorker;
|
|
94
96
|
constructor(registry, config, hooks = {}) {
|
|
95
97
|
this.registry = registry;
|
|
96
98
|
this.config = config;
|
|
97
99
|
this.hooks = hooks;
|
|
100
|
+
this.connectionProvider =
|
|
101
|
+
hooks.connectionProvider ??
|
|
102
|
+
new MemoryConnectionProvider(config.connections);
|
|
98
103
|
this.maxRunsPerWorker =
|
|
99
104
|
config.maxRunsPerWorker ?? DEFAULT_MAX_RUNS_PER_WORKER;
|
|
100
105
|
}
|
|
@@ -338,7 +343,23 @@ export class ExecutionEngine {
|
|
|
338
343
|
throw new Error(`Unknown action: ${path}${this.selfPrefixHint(path)}`);
|
|
339
344
|
}
|
|
340
345
|
const { plugin, action } = resolved;
|
|
341
|
-
|
|
346
|
+
if (isTypedInputSchema(action.inputSchema)) {
|
|
347
|
+
const validation = validateTypedInput(action.inputSchema, args);
|
|
348
|
+
if (!validation.ok) {
|
|
349
|
+
throw new Error(formatValidationError(path, validation));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const handle = await this.connectionProvider.resolve({
|
|
353
|
+
plugin: plugin.name,
|
|
354
|
+
action: action.name,
|
|
355
|
+
schema: plugin.connectionConfigSchema,
|
|
356
|
+
context,
|
|
357
|
+
});
|
|
358
|
+
const connection = structuredClone(await handle.read());
|
|
359
|
+
if (connection.plugin !== plugin.name) {
|
|
360
|
+
throw new Error(`Connection provider returned the wrong plugin for ${path}`);
|
|
361
|
+
}
|
|
362
|
+
const connectionName = connection.name;
|
|
342
363
|
const ctx = {
|
|
343
364
|
connection,
|
|
344
365
|
context,
|
|
@@ -347,19 +368,15 @@ export class ExecutionEngine {
|
|
|
347
368
|
warn: (msg) => console.warn(`[${plugin.name}] ${msg}`),
|
|
348
369
|
error: (msg) => console.error(`[${plugin.name}] ${msg}`),
|
|
349
370
|
},
|
|
350
|
-
updateConnection: async (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
371
|
+
updateConnection: async (change) => {
|
|
372
|
+
const committed = await handle.update(change);
|
|
373
|
+
if (committed.plugin !== plugin.name ||
|
|
374
|
+
committed.name !== connectionName) {
|
|
375
|
+
throw new Error(`Connection provider changed identity during update for ${path}`);
|
|
376
|
+
}
|
|
377
|
+
connection.config = structuredClone(committed.config);
|
|
355
378
|
},
|
|
356
379
|
};
|
|
357
|
-
if (isTypedInputSchema(action.inputSchema)) {
|
|
358
|
-
const validation = validateTypedInput(action.inputSchema, args);
|
|
359
|
-
if (!validation.ok) {
|
|
360
|
-
throw new Error(formatValidationError(path, validation));
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
380
|
const started = performance.now();
|
|
364
381
|
try {
|
|
365
382
|
const value = await action.execute(args, ctx);
|
|
@@ -374,21 +391,13 @@ export class ExecutionEngine {
|
|
|
374
391
|
}
|
|
375
392
|
}
|
|
376
393
|
fireOnAction(pluginName, actionName, path, started, failed, error) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
path,
|
|
385
|
-
durationMs: performance.now() - started,
|
|
386
|
-
...(failed ? { error: error ?? new Error("Unknown error") } : {}),
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
catch {
|
|
390
|
-
// Observability never breaks a run.
|
|
391
|
-
}
|
|
394
|
+
notifyObserver(this.hooks.onAction, {
|
|
395
|
+
plugin: pluginName,
|
|
396
|
+
action: actionName,
|
|
397
|
+
path,
|
|
398
|
+
durationMs: performance.now() - started,
|
|
399
|
+
...(failed ? { error: error ?? new Error("Unknown error") } : {}),
|
|
400
|
+
});
|
|
392
401
|
}
|
|
393
402
|
/**
|
|
394
403
|
* A miss on "salesforce.status" is often a plugin that registered
|
|
@@ -409,15 +418,6 @@ export class ExecutionEngine {
|
|
|
409
418
|
`action name with the plugin prefix ("${path}"), so the full callable ` +
|
|
410
419
|
`path is "${doubled}".`);
|
|
411
420
|
}
|
|
412
|
-
resolveConnection(plugin) {
|
|
413
|
-
const conn = this.config.connections.find((c) => c.plugin === plugin.name);
|
|
414
|
-
const base = conn ?? {
|
|
415
|
-
name: "default",
|
|
416
|
-
plugin: plugin.name,
|
|
417
|
-
config: {},
|
|
418
|
-
};
|
|
419
|
-
return applyEnvOverrides(base, plugin.connectionConfigSchema);
|
|
420
|
-
}
|
|
421
421
|
}
|
|
422
422
|
// ── Helpers ──────────────────────────────
|
|
423
423
|
/**
|
|
@@ -433,9 +433,8 @@ function newRunId() {
|
|
|
433
433
|
return randomUUID();
|
|
434
434
|
}
|
|
435
435
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* result crossed a JSON boundary (Dates → ISO strings, no Maps, etc.).
|
|
436
|
+
* Action results cross a JSON boundary (Dates → ISO strings, no Maps).
|
|
437
|
+
* The round-trip also guarantees structured-clone compatibility.
|
|
439
438
|
*/
|
|
440
439
|
function toPlainJson(value) {
|
|
441
440
|
if (value === undefined)
|
|
@@ -453,8 +452,8 @@ function formatError(cause) {
|
|
|
453
452
|
}
|
|
454
453
|
return String(cause);
|
|
455
454
|
}
|
|
456
|
-
// ferrosearch is a Rust native addon
|
|
457
|
-
//
|
|
455
|
+
// ferrosearch is a Rust native addon and cannot be inlined as worker source.
|
|
456
|
+
// Resolve its entry point
|
|
458
457
|
// host-side and require it by absolute path inside the worker: eval'd worker
|
|
459
458
|
// code gets a cwd-relative `require`, so a bare specifier would resolve
|
|
460
459
|
// against the caller's cwd instead of runline's own node_modules.
|
|
@@ -495,10 +494,8 @@ export function buildRunBody(code) {
|
|
|
495
494
|
}
|
|
496
495
|
function buildWorkerSource(pluginNames = [], helpData = {}) {
|
|
497
496
|
// Injected into every body's scope. `require` is here because bodies are
|
|
498
|
-
// compiled with `new Function`, whose scope is the worker's
|
|
499
|
-
//
|
|
500
|
-
// pooling would silently revoke a documented capability ("agent code gets
|
|
501
|
-
// the full host JS runtime") as a side effect of a memory fix.
|
|
497
|
+
// compiled with `new Function`, whose scope is the worker's global object.
|
|
498
|
+
// Explicit injection gives bodies access to the host JS runtime.
|
|
502
499
|
const injectNames = [
|
|
503
500
|
...pluginNames,
|
|
504
501
|
"actions",
|
|
@@ -596,9 +593,8 @@ const __fmtErr = (e) => {
|
|
|
596
593
|
const __toJson = (v) => v === undefined ? undefined : JSON.parse(JSON.stringify(v));
|
|
597
594
|
|
|
598
595
|
// ferrosearch native addon, resolved to an absolute path by the host. Its
|
|
599
|
-
// index memory lives outside the V8 heap
|
|
600
|
-
//
|
|
601
|
-
// also means an index built by agent code is invisible to that limit.
|
|
596
|
+
// index memory lives outside the V8 heap. An index built by agent code is
|
|
597
|
+
// therefore invisible to maxOldGenerationSizeMb.
|
|
602
598
|
const { FerroSearch } = require(${JSON.stringify(ferrosearchPath)});
|
|
603
599
|
|
|
604
600
|
// The host's own validator, for actions.check on typed schemas.
|
|
@@ -936,12 +932,9 @@ const __scrubGlobals = () => {
|
|
|
936
932
|
|
|
937
933
|
// The host channel is the one capability a body must not hold.
|
|
938
934
|
//
|
|
939
|
-
// A
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
// body and can post a forged "done" for it, handing that run attacker-chosen
|
|
943
|
-
// output. Randomising the run id does not help — the listener is simply told
|
|
944
|
-
// what it is.
|
|
935
|
+
// A surviving parentPort listener sees the {t:"run", runId} of every later
|
|
936
|
+
// body and can forge its "done" message. Random run IDs alone cannot protect
|
|
937
|
+
// a channel that exposes those IDs to the body.
|
|
945
938
|
//
|
|
946
939
|
// Bun's MessagePort exposes no listenerCount/removeAllListeners, so listeners
|
|
947
940
|
// cannot be enumerated and stripped after the fact. Instead the route in is
|
package/dist/core/oauth.d.ts
CHANGED
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
* wants (redirect-URI paste, browser popup with postMessage,
|
|
23
23
|
* public HTTPS callback, …), calls exchangeAuthCode.
|
|
24
24
|
*/
|
|
25
|
+
import type { OAuthTokens } from "../auth/types.js";
|
|
25
26
|
import type { OAuthConfig } from "../plugin/types.js";
|
|
27
|
+
export type { OAuthTokens } from "../auth/types.js";
|
|
26
28
|
/**
|
|
27
29
|
* Fixed callback port for OAuth redirects. Pinned so users can
|
|
28
30
|
* register `http://127.0.0.1:<PORT>/callback` once with the
|
|
@@ -34,24 +36,26 @@ import type { OAuthConfig } from "../plugin/types.js";
|
|
|
34
36
|
export declare const OAUTH_CALLBACK_PORT: number;
|
|
35
37
|
/** Canonical localhost redirect URI for CLI-based flows. */
|
|
36
38
|
export declare const OAUTH_CALLBACK_URI: string;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
expiresAt: number;
|
|
42
|
-
scope?: string;
|
|
43
|
-
tokenType?: string;
|
|
39
|
+
interface Callback {
|
|
40
|
+
uri: string;
|
|
41
|
+
port: number;
|
|
42
|
+
path: string;
|
|
44
43
|
}
|
|
44
|
+
/** Only loopback HTTP callbacks are accepted; the flow never listens on other interfaces. */
|
|
45
|
+
export declare function oauthCallback(config: OAuthConfig): Callback;
|
|
45
46
|
export interface RunOAuthOptions {
|
|
46
47
|
clientId: string;
|
|
47
|
-
|
|
48
|
+
/** Omitted for public clients; PKCE protects the exchange instead. */
|
|
49
|
+
clientSecret?: string;
|
|
48
50
|
/**
|
|
49
51
|
* Called with the consent URL before the browser is opened.
|
|
50
52
|
* Lets the CLI print a clickable link in case auto-open fails.
|
|
51
53
|
*/
|
|
52
|
-
onAuthUrl?: (url: string) => void
|
|
54
|
+
onAuthUrl?: (url: string) => void | Promise<void>;
|
|
53
55
|
/** Override the browser launcher. Defaults to OS-appropriate command. */
|
|
54
|
-
openBrowser?: (url: string) => void
|
|
56
|
+
openBrowser?: (url: string) => void | Promise<void>;
|
|
57
|
+
/** Maximum wait for browser consent. Defaults to five minutes. */
|
|
58
|
+
callbackTimeoutMs?: number;
|
|
55
59
|
}
|
|
56
60
|
export interface BuildAuthUrlOptions {
|
|
57
61
|
clientId: string;
|
|
@@ -62,11 +66,13 @@ export interface BuildAuthUrlOptions {
|
|
|
62
66
|
}
|
|
63
67
|
export interface ExchangeCodeOptions {
|
|
64
68
|
clientId: string;
|
|
65
|
-
clientSecret
|
|
69
|
+
clientSecret?: string;
|
|
66
70
|
code: string;
|
|
67
71
|
redirectUri: string;
|
|
68
72
|
/** PKCE verifier matching the challenge sent on the auth URL. */
|
|
69
73
|
codeVerifier?: string;
|
|
74
|
+
/** Host-validated state; sent only when the provider protocol requires it. */
|
|
75
|
+
state?: string;
|
|
70
76
|
}
|
|
71
77
|
export interface PKCEPair {
|
|
72
78
|
verifier: string;
|
|
@@ -85,15 +91,14 @@ export declare function generatePKCE(): PKCEPair;
|
|
|
85
91
|
*/
|
|
86
92
|
export declare function buildAuthUrl(config: OAuthConfig, opts: BuildAuthUrlOptions): string;
|
|
87
93
|
/**
|
|
88
|
-
* Exchange an authorization code
|
|
89
|
-
*
|
|
90
|
-
* misconfiguration (e.g. missing `access_type=offline` for Google).
|
|
94
|
+
* Exchange an authorization code using the shared OAuth2 protocol runtime.
|
|
95
|
+
* Refresh tokens and expiry are optional provider capabilities.
|
|
91
96
|
*/
|
|
92
97
|
export declare function exchangeAuthCode(config: OAuthConfig, opts: ExchangeCodeOptions): Promise<OAuthTokens>;
|
|
93
98
|
/**
|
|
94
99
|
* Run the full OAuth2 authorization-code flow end-to-end.
|
|
95
100
|
* Resolves with the exchanged tokens once the user completes the
|
|
96
|
-
* browser consent and the token endpoint returns a
|
|
101
|
+
* browser consent and the token endpoint returns a valid access token.
|
|
97
102
|
*
|
|
98
103
|
* Uses the pinned localhost callback port. If you can't run a
|
|
99
104
|
* local callback server, drive the flow yourself with
|
package/dist/core/oauth.js
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
import { spawn } from "node:child_process";
|
|
26
26
|
import { createHash, randomBytes } from "node:crypto";
|
|
27
27
|
import { createServer, } from "node:http";
|
|
28
|
+
import { AuthError } from "../auth/errors.js";
|
|
29
|
+
import { buildOAuth2AuthorizationUrl, exchangeOAuth2Code, } from "../auth/oauth2.js";
|
|
28
30
|
/**
|
|
29
31
|
* Fixed callback port for OAuth redirects. Pinned so users can
|
|
30
32
|
* register `http://127.0.0.1:<PORT>/callback` once with the
|
|
@@ -44,6 +46,64 @@ export const OAUTH_CALLBACK_PORT = (() => {
|
|
|
44
46
|
})();
|
|
45
47
|
/** Canonical localhost redirect URI for CLI-based flows. */
|
|
46
48
|
export const OAUTH_CALLBACK_URI = `http://127.0.0.1:${OAUTH_CALLBACK_PORT}/callback`;
|
|
49
|
+
/** Browsers resolve `localhost` to either family; IPv6 may be unavailable. */
|
|
50
|
+
const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
|
|
51
|
+
const IPV6_UNAVAILABLE = new Set([
|
|
52
|
+
"EAFNOSUPPORT",
|
|
53
|
+
"EADDRNOTAVAIL",
|
|
54
|
+
"EINVAL",
|
|
55
|
+
"EPROTONOSUPPORT",
|
|
56
|
+
]);
|
|
57
|
+
/** Only loopback HTTP callbacks are accepted; the flow never listens on other interfaces. */
|
|
58
|
+
export function oauthCallback(config) {
|
|
59
|
+
if (config.redirectUri === undefined)
|
|
60
|
+
return {
|
|
61
|
+
uri: OAUTH_CALLBACK_URI,
|
|
62
|
+
port: OAUTH_CALLBACK_PORT,
|
|
63
|
+
path: "/callback",
|
|
64
|
+
};
|
|
65
|
+
let url;
|
|
66
|
+
try {
|
|
67
|
+
url = new URL(config.redirectUri);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new AuthError("invalid_definition");
|
|
71
|
+
}
|
|
72
|
+
const port = Number(url.port);
|
|
73
|
+
if (url.protocol !== "http:" ||
|
|
74
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
|
|
75
|
+
!Number.isInteger(port) ||
|
|
76
|
+
port <= 0 ||
|
|
77
|
+
url.username ||
|
|
78
|
+
url.password ||
|
|
79
|
+
url.search ||
|
|
80
|
+
url.hash ||
|
|
81
|
+
url.pathname === "/")
|
|
82
|
+
throw new AuthError("invalid_definition");
|
|
83
|
+
return { uri: url.toString(), port, path: url.pathname };
|
|
84
|
+
}
|
|
85
|
+
/** Plugin OAuth declarations retain their body-auth default at the compatibility boundary. */
|
|
86
|
+
function definition(config) {
|
|
87
|
+
if (config.protocol !== undefined) {
|
|
88
|
+
if (!config.protocol ||
|
|
89
|
+
config.authUrl !== undefined ||
|
|
90
|
+
config.tokenUrl !== undefined ||
|
|
91
|
+
config.authParams !== undefined)
|
|
92
|
+
throw new AuthError("invalid_definition");
|
|
93
|
+
return config.protocol;
|
|
94
|
+
}
|
|
95
|
+
if (config.publicClient)
|
|
96
|
+
throw new AuthError("invalid_definition");
|
|
97
|
+
return {
|
|
98
|
+
id: "plugin-oauth",
|
|
99
|
+
provider: "legacy",
|
|
100
|
+
authorization: { url: config.authUrl, parameters: config.authParams },
|
|
101
|
+
exchange: {
|
|
102
|
+
url: config.tokenUrl,
|
|
103
|
+
clientAuthentication: "client_secret_post",
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
47
107
|
// ─── Primitives ──────────────────────────────────────────────────
|
|
48
108
|
/**
|
|
49
109
|
* Generate a PKCE verifier and its SHA-256 challenge. The verifier
|
|
@@ -60,140 +120,178 @@ export function generatePKCE() {
|
|
|
60
120
|
* Pass `pkceChallenge` to include PKCE; omit for plain auth-code.
|
|
61
121
|
*/
|
|
62
122
|
export function buildAuthUrl(config, opts) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
url.searchParams.set("code_challenge", opts.pkceChallenge);
|
|
71
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
72
|
-
}
|
|
73
|
-
for (const [k, v] of Object.entries(config.authParams ?? {})) {
|
|
74
|
-
url.searchParams.set(k, v);
|
|
75
|
-
}
|
|
76
|
-
return url.toString();
|
|
123
|
+
return buildOAuth2AuthorizationUrl(definition(config), {
|
|
124
|
+
application: { clientId: opts.clientId },
|
|
125
|
+
redirectUri: opts.redirectUri,
|
|
126
|
+
state: opts.state,
|
|
127
|
+
scopes: config.scopes,
|
|
128
|
+
pkceChallenge: opts.pkceChallenge,
|
|
129
|
+
});
|
|
77
130
|
}
|
|
78
131
|
/**
|
|
79
|
-
* Exchange an authorization code
|
|
80
|
-
*
|
|
81
|
-
* misconfiguration (e.g. missing `access_type=offline` for Google).
|
|
132
|
+
* Exchange an authorization code using the shared OAuth2 protocol runtime.
|
|
133
|
+
* Refresh tokens and expiry are optional provider capabilities.
|
|
82
134
|
*/
|
|
83
135
|
export async function exchangeAuthCode(config, opts) {
|
|
84
|
-
const
|
|
136
|
+
const protocol = definition(config);
|
|
137
|
+
if (config.publicClient ? opts.clientSecret !== undefined : !opts.clientSecret)
|
|
138
|
+
throw new AuthError("invalid_credentials");
|
|
139
|
+
return exchangeOAuth2Code(protocol, {
|
|
140
|
+
application: { clientId: opts.clientId, clientSecret: opts.clientSecret },
|
|
85
141
|
code: opts.code,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
grant_type: "authorization_code",
|
|
90
|
-
});
|
|
91
|
-
if (opts.codeVerifier)
|
|
92
|
-
body.set("code_verifier", opts.codeVerifier);
|
|
93
|
-
const res = await fetch(config.tokenUrl, {
|
|
94
|
-
method: "POST",
|
|
95
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
96
|
-
body: body.toString(),
|
|
142
|
+
redirectUri: opts.redirectUri,
|
|
143
|
+
codeVerifier: opts.codeVerifier,
|
|
144
|
+
state: opts.state,
|
|
97
145
|
});
|
|
98
|
-
if (!res.ok) {
|
|
99
|
-
throw new Error(`OAuth: token exchange failed (${res.status}): ${await res.text()}`);
|
|
100
|
-
}
|
|
101
|
-
const data = (await res.json());
|
|
102
|
-
if (!data.refresh_token) {
|
|
103
|
-
throw new Error("OAuth: provider did not return a refresh token. This usually means a prior consent is cached — revoke access with the provider and retry. For Google, set authParams: { access_type: 'offline', prompt: 'consent' }.");
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
accessToken: data.access_token,
|
|
107
|
-
refreshToken: data.refresh_token,
|
|
108
|
-
expiresAt: Date.now() + data.expires_in * 1000,
|
|
109
|
-
scope: data.scope,
|
|
110
|
-
tokenType: data.token_type,
|
|
111
|
-
};
|
|
112
146
|
}
|
|
113
147
|
// ─── End-to-end CLI flow ─────────────────────────────────────────
|
|
114
148
|
/**
|
|
115
149
|
* Run the full OAuth2 authorization-code flow end-to-end.
|
|
116
150
|
* Resolves with the exchanged tokens once the user completes the
|
|
117
|
-
* browser consent and the token endpoint returns a
|
|
151
|
+
* browser consent and the token endpoint returns a valid access token.
|
|
118
152
|
*
|
|
119
153
|
* Uses the pinned localhost callback port. If you can't run a
|
|
120
154
|
* local callback server, drive the flow yourself with
|
|
121
155
|
* `buildAuthUrl` + `exchangeAuthCode`.
|
|
122
156
|
*/
|
|
123
157
|
export async function runOAuth(config, options) {
|
|
124
|
-
|
|
158
|
+
// Consent and exchange use the same detached setup snapshot.
|
|
159
|
+
let flowConfig;
|
|
160
|
+
try {
|
|
161
|
+
flowConfig = structuredClone(config);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
throw new AuthError("invalid_definition");
|
|
165
|
+
}
|
|
166
|
+
const { clientId, clientSecret, onAuthUrl, openBrowser = defaultOpenBrowser, callbackTimeoutMs = 300_000, } = options;
|
|
167
|
+
const callback = oauthCallback(flowConfig);
|
|
168
|
+
const redirectUri = callback.uri;
|
|
125
169
|
const state = randomState();
|
|
126
170
|
const { verifier, challenge } = generatePKCE();
|
|
127
|
-
const authUrl = buildAuthUrl(
|
|
128
|
-
clientId
|
|
171
|
+
const authUrl = buildAuthUrl(flowConfig, {
|
|
172
|
+
clientId,
|
|
129
173
|
redirectUri,
|
|
130
174
|
state,
|
|
131
175
|
pkceChallenge: challenge,
|
|
132
176
|
});
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const { code } = await captureCode(OAUTH_CALLBACK_PORT, state);
|
|
146
|
-
return exchangeAuthCode(config, {
|
|
147
|
-
clientId: options.clientId,
|
|
148
|
-
clientSecret: options.clientSecret,
|
|
177
|
+
const { code } = await captureCode(callback, state, async (signal) => {
|
|
178
|
+
// Publication may outlive the flow; never launch a finished consent request.
|
|
179
|
+
if (onAuthUrl)
|
|
180
|
+
await onAuthUrl(authUrl);
|
|
181
|
+
else
|
|
182
|
+
console.error(`Open this URL to authorize:\n ${authUrl}`);
|
|
183
|
+
if (!signal.aborted)
|
|
184
|
+
await openBrowser(authUrl);
|
|
185
|
+
}, callbackTimeoutMs);
|
|
186
|
+
return exchangeAuthCode(flowConfig, {
|
|
187
|
+
clientId,
|
|
188
|
+
clientSecret,
|
|
149
189
|
code,
|
|
150
190
|
redirectUri,
|
|
151
191
|
codeVerifier: verifier,
|
|
192
|
+
state,
|
|
152
193
|
});
|
|
153
194
|
}
|
|
154
195
|
// ─── Local callback server ───────────────────────────────────────
|
|
155
|
-
function
|
|
196
|
+
function listenLoopback(port, handler, onError) {
|
|
197
|
+
const servers = [];
|
|
198
|
+
const close = () => {
|
|
199
|
+
for (const server of servers)
|
|
200
|
+
server.close();
|
|
201
|
+
};
|
|
202
|
+
const listen = (host) => new Promise((resolve, reject) => {
|
|
203
|
+
const server = createServer(handler);
|
|
204
|
+
server.once("error", reject);
|
|
205
|
+
server.listen(port, host, () => {
|
|
206
|
+
server.removeListener("error", reject);
|
|
207
|
+
server.on("error", onError);
|
|
208
|
+
servers.push(server);
|
|
209
|
+
resolve();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
return (async () => {
|
|
213
|
+
for (const host of LOOPBACK_HOSTS) {
|
|
214
|
+
try {
|
|
215
|
+
await listen(host);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
const code = error.code ?? "";
|
|
219
|
+
if (host !== LOOPBACK_HOSTS[0] && IPV6_UNAVAILABLE.has(code))
|
|
220
|
+
continue;
|
|
221
|
+
close();
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return servers;
|
|
226
|
+
})();
|
|
227
|
+
}
|
|
228
|
+
function captureCode(callback, expectedState, onReady, timeoutMs) {
|
|
229
|
+
if (!Number.isInteger(timeoutMs) ||
|
|
230
|
+
timeoutMs <= 0 ||
|
|
231
|
+
timeoutMs > 2 ** 31 - 1) {
|
|
232
|
+
return Promise.reject(new Error("OAuth: invalid callback timeout"));
|
|
233
|
+
}
|
|
156
234
|
return new Promise((resolve, reject) => {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
res.end("Not found");
|
|
235
|
+
const lifecycle = new AbortController();
|
|
236
|
+
let servers = [];
|
|
237
|
+
const finish = (result) => {
|
|
238
|
+
if (lifecycle.signal.aborted)
|
|
162
239
|
return;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const error = url.searchParams.get("error");
|
|
167
|
-
if (error) {
|
|
168
|
-
res.statusCode = 400;
|
|
169
|
-
res.end(`Authorization failed: ${error}. You can close this tab.`);
|
|
240
|
+
lifecycle.abort();
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
for (const server of servers)
|
|
170
243
|
server.close();
|
|
171
|
-
|
|
244
|
+
if (result instanceof Error)
|
|
245
|
+
reject(result);
|
|
246
|
+
else
|
|
247
|
+
resolve(result);
|
|
248
|
+
};
|
|
249
|
+
const handler = (req, res) => {
|
|
250
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
251
|
+
res.setHeader("Cache-Control", "no-store");
|
|
252
|
+
let url;
|
|
253
|
+
try {
|
|
254
|
+
url = new URL(req.url ?? "/", `http://127.0.0.1:${callback.port}`);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
res.writeHead(400).end("Invalid callback");
|
|
172
258
|
return;
|
|
173
259
|
}
|
|
174
|
-
if (
|
|
175
|
-
res.
|
|
176
|
-
res.end("Missing code");
|
|
260
|
+
if (req.method !== "GET" || url.pathname !== callback.path) {
|
|
261
|
+
res.writeHead(404).end("Not found");
|
|
177
262
|
return;
|
|
178
263
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
264
|
+
// Unauthenticated callbacks cannot cancel or complete a pending flow.
|
|
265
|
+
if (lifecycle.signal.aborted ||
|
|
266
|
+
url.searchParams.getAll("state").length !== 1 ||
|
|
267
|
+
url.searchParams.get("state") !== expectedState) {
|
|
268
|
+
res.writeHead(400).end("State mismatch");
|
|
184
269
|
return;
|
|
185
270
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
271
|
+
if (url.searchParams.has("error")) {
|
|
272
|
+
res.writeHead(400).end("Authorization denied. You can close this tab.");
|
|
273
|
+
finish(new Error("OAuth: authorization denied"));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const code = url.searchParams.get("code");
|
|
277
|
+
if (!code || url.searchParams.getAll("code").length !== 1) {
|
|
278
|
+
res.writeHead(400).end("Missing or ambiguous code");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
res.end("Authorization received. Return to your terminal for the connection result.");
|
|
282
|
+
finish({ code });
|
|
283
|
+
};
|
|
284
|
+
const timer = setTimeout(() => finish(new Error("OAuth: browser consent timed out")), timeoutMs);
|
|
285
|
+
const unavailable = () => finish(new Error("OAuth: callback listener unavailable"));
|
|
286
|
+
listenLoopback(callback.port, handler, unavailable).then((bound) => {
|
|
287
|
+
servers = bound;
|
|
288
|
+
if (lifecycle.signal.aborted) {
|
|
289
|
+
for (const server of servers)
|
|
290
|
+
server.close();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
void onReady(lifecycle.signal).catch(() => finish(new Error("OAuth: browser launch failed")));
|
|
294
|
+
}, unavailable);
|
|
197
295
|
});
|
|
198
296
|
}
|
|
199
297
|
// ─── Helpers ─────────────────────────────────────────────────────
|
|
@@ -218,8 +316,7 @@ function defaultOpenBrowser(url) {
|
|
|
218
316
|
detached: true,
|
|
219
317
|
});
|
|
220
318
|
proc.on("error", () => {
|
|
221
|
-
//
|
|
222
|
-
// so the user can paste it manually.
|
|
319
|
+
// The displayed consent URL remains available for manual browser navigation.
|
|
223
320
|
});
|
|
224
321
|
proc.unref();
|
|
225
322
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HttpMethod } from "./types.js";
|
|
2
|
+
export interface ResourceOptions {
|
|
3
|
+
fetch: typeof globalThis.fetch;
|
|
4
|
+
timeoutMs: number;
|
|
5
|
+
maxResponseBytes: number;
|
|
6
|
+
resumableUpload?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Internal send primitive. Callers validate destination policy before reaching it. */
|
|
9
|
+
export declare function sendResource(url: string, method: HttpMethod, headers: Headers, body: Buffer | undefined, options: ResourceOptions): Promise<Response>;
|
|
10
|
+
/**
|
|
11
|
+
* Fetch a provider-issued preauthenticated URL without bearer tokens or cookies.
|
|
12
|
+
* Hosts approve exact origins and enforce DNS/IP egress through their fetch hook.
|
|
13
|
+
* The signed URL is itself secret: errors never include it. No redirects or retries.
|
|
14
|
+
*/
|
|
15
|
+
export declare function downloadResource(value: string, options: {
|
|
16
|
+
allowedOrigins: string[];
|
|
17
|
+
fetch: typeof globalThis.fetch;
|
|
18
|
+
maxResponseBytes?: number;
|
|
19
|
+
}): Promise<Response>;
|