runline 0.3.0 → 0.3.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.
@@ -1,13 +1,26 @@
1
1
  /**
2
2
  * Generic OAuth2 authorization-code flow.
3
3
  *
4
- * Runs the browser login dance for any plugin that declares
5
- * `setOAuth(config)`: opens the provider's consent URL, catches
6
- * the redirect on a local port, exchanges the code for tokens,
7
- * and returns `{accessToken, refreshToken, expiresAt, scope}`.
4
+ * Two surfaces:
8
5
  *
9
- * Provider-agnostic the caller (the `auth` CLI command) supplies
10
- * the plugin's `OAuthConfig` along with the client credentials.
6
+ * 1. `runOAuth(config, {clientId, clientSecret})` end-to-end CLI
7
+ * flow. Opens the browser, captures the redirect on a pinned
8
+ * localhost port, exchanges the code, returns tokens. Used by
9
+ * `runline auth <plugin>`.
10
+ *
11
+ * 2. Primitives for callers that can't run a local callback server
12
+ * (hosted apps, GUIs, anywhere the user's browser doesn't talk
13
+ * back to the process driving the flow):
14
+ *
15
+ * - `generatePKCE()` — S256 verifier + challenge
16
+ * - `buildAuthUrl(config, opts)` — assemble the consent URL
17
+ * - `exchangeAuthCode(config, opts)` — POST to the token
18
+ * endpoint, get back tokens
19
+ *
20
+ * The caller orchestrates: generates state + PKCE, builds the
21
+ * URL, shows it to the user, receives the code back however it
22
+ * wants (redirect-URI paste, browser popup with postMessage,
23
+ * public HTTPS callback, …), calls exchangeAuthCode.
11
24
  */
12
25
  import type { OAuthConfig } from "../plugin/types.js";
13
26
  /**
@@ -19,6 +32,8 @@ import type { OAuthConfig } from "../plugin/types.js";
19
32
  * URI with the provider after changing it).
20
33
  */
21
34
  export declare const OAUTH_CALLBACK_PORT: number;
35
+ /** Canonical localhost redirect URI for CLI-based flows. */
36
+ export declare const OAUTH_CALLBACK_URI: string;
22
37
  export interface OAuthTokens {
23
38
  accessToken: string;
24
39
  refreshToken: string;
@@ -38,9 +53,50 @@ export interface RunOAuthOptions {
38
53
  /** Override the browser launcher. Defaults to OS-appropriate command. */
39
54
  openBrowser?: (url: string) => void;
40
55
  }
56
+ export interface BuildAuthUrlOptions {
57
+ clientId: string;
58
+ redirectUri: string;
59
+ state: string;
60
+ /** S256 code challenge. Pass alongside a verifier kept by the driver. */
61
+ pkceChallenge?: string;
62
+ }
63
+ export interface ExchangeCodeOptions {
64
+ clientId: string;
65
+ clientSecret: string;
66
+ code: string;
67
+ redirectUri: string;
68
+ /** PKCE verifier matching the challenge sent on the auth URL. */
69
+ codeVerifier?: string;
70
+ }
71
+ export interface PKCEPair {
72
+ verifier: string;
73
+ /** Base64url SHA-256 of the verifier, for `code_challenge_method=S256`. */
74
+ challenge: string;
75
+ }
76
+ /**
77
+ * Generate a PKCE verifier and its SHA-256 challenge. The verifier
78
+ * stays with the driver until the token exchange; the challenge
79
+ * goes on the auth URL.
80
+ */
81
+ export declare function generatePKCE(): PKCEPair;
82
+ /**
83
+ * Assemble the authorization URL for a plugin's OAuth config.
84
+ * Pass `pkceChallenge` to include PKCE; omit for plain auth-code.
85
+ */
86
+ export declare function buildAuthUrl(config: OAuthConfig, opts: BuildAuthUrlOptions): string;
87
+ /**
88
+ * Exchange an authorization code for tokens. Throws if the
89
+ * provider doesn't return a refresh_token — that's almost always a
90
+ * misconfiguration (e.g. missing `access_type=offline` for Google).
91
+ */
92
+ export declare function exchangeAuthCode(config: OAuthConfig, opts: ExchangeCodeOptions): Promise<OAuthTokens>;
41
93
  /**
42
94
  * Run the full OAuth2 authorization-code flow end-to-end.
43
95
  * Resolves with the exchanged tokens once the user completes the
44
96
  * browser consent and the token endpoint returns a refresh token.
97
+ *
98
+ * Uses the pinned localhost callback port. If you can't run a
99
+ * local callback server, drive the flow yourself with
100
+ * `buildAuthUrl` + `exchangeAuthCode`.
45
101
  */
46
102
  export declare function runOAuth(config: OAuthConfig, options: RunOAuthOptions): Promise<OAuthTokens>;
@@ -1,15 +1,29 @@
1
1
  /**
2
2
  * Generic OAuth2 authorization-code flow.
3
3
  *
4
- * Runs the browser login dance for any plugin that declares
5
- * `setOAuth(config)`: opens the provider's consent URL, catches
6
- * the redirect on a local port, exchanges the code for tokens,
7
- * and returns `{accessToken, refreshToken, expiresAt, scope}`.
4
+ * Two surfaces:
8
5
  *
9
- * Provider-agnostic the caller (the `auth` CLI command) supplies
10
- * the plugin's `OAuthConfig` along with the client credentials.
6
+ * 1. `runOAuth(config, {clientId, clientSecret})` end-to-end CLI
7
+ * flow. Opens the browser, captures the redirect on a pinned
8
+ * localhost port, exchanges the code, returns tokens. Used by
9
+ * `runline auth <plugin>`.
10
+ *
11
+ * 2. Primitives for callers that can't run a local callback server
12
+ * (hosted apps, GUIs, anywhere the user's browser doesn't talk
13
+ * back to the process driving the flow):
14
+ *
15
+ * - `generatePKCE()` — S256 verifier + challenge
16
+ * - `buildAuthUrl(config, opts)` — assemble the consent URL
17
+ * - `exchangeAuthCode(config, opts)` — POST to the token
18
+ * endpoint, get back tokens
19
+ *
20
+ * The caller orchestrates: generates state + PKCE, builds the
21
+ * URL, shows it to the user, receives the code back however it
22
+ * wants (redirect-URI paste, browser popup with postMessage,
23
+ * public HTTPS callback, …), calls exchangeAuthCode.
11
24
  */
12
25
  import { spawn } from "node:child_process";
26
+ import { createHash, randomBytes } from "node:crypto";
13
27
  import { createServer, } from "node:http";
14
28
  /**
15
29
  * Fixed callback port for OAuth redirects. Pinned so users can
@@ -28,40 +42,116 @@ export const OAUTH_CALLBACK_PORT = (() => {
28
42
  }
29
43
  return 47823;
30
44
  })();
45
+ /** Canonical localhost redirect URI for CLI-based flows. */
46
+ export const OAUTH_CALLBACK_URI = `http://127.0.0.1:${OAUTH_CALLBACK_PORT}/callback`;
47
+ // ─── Primitives ──────────────────────────────────────────────────
48
+ /**
49
+ * Generate a PKCE verifier and its SHA-256 challenge. The verifier
50
+ * stays with the driver until the token exchange; the challenge
51
+ * goes on the auth URL.
52
+ */
53
+ export function generatePKCE() {
54
+ const verifier = base64urlEncode(randomBytes(32));
55
+ const challenge = base64urlEncode(createHash("sha256").update(verifier).digest());
56
+ return { verifier, challenge };
57
+ }
58
+ /**
59
+ * Assemble the authorization URL for a plugin's OAuth config.
60
+ * Pass `pkceChallenge` to include PKCE; omit for plain auth-code.
61
+ */
62
+ export function buildAuthUrl(config, opts) {
63
+ const url = new URL(config.authUrl);
64
+ url.searchParams.set("client_id", opts.clientId);
65
+ url.searchParams.set("redirect_uri", opts.redirectUri);
66
+ url.searchParams.set("response_type", "code");
67
+ url.searchParams.set("scope", config.scopes.join(" "));
68
+ url.searchParams.set("state", opts.state);
69
+ if (opts.pkceChallenge) {
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();
77
+ }
78
+ /**
79
+ * Exchange an authorization code for tokens. Throws if the
80
+ * provider doesn't return a refresh_token — that's almost always a
81
+ * misconfiguration (e.g. missing `access_type=offline` for Google).
82
+ */
83
+ export async function exchangeAuthCode(config, opts) {
84
+ const body = new URLSearchParams({
85
+ code: opts.code,
86
+ client_id: opts.clientId,
87
+ client_secret: opts.clientSecret,
88
+ redirect_uri: opts.redirectUri,
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(),
97
+ });
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
+ }
113
+ // ─── End-to-end CLI flow ─────────────────────────────────────────
31
114
  /**
32
115
  * Run the full OAuth2 authorization-code flow end-to-end.
33
116
  * Resolves with the exchanged tokens once the user completes the
34
117
  * browser consent and the token endpoint returns a refresh token.
118
+ *
119
+ * Uses the pinned localhost callback port. If you can't run a
120
+ * local callback server, drive the flow yourself with
121
+ * `buildAuthUrl` + `exchangeAuthCode`.
35
122
  */
36
123
  export async function runOAuth(config, options) {
37
- const port = OAUTH_CALLBACK_PORT;
38
- const redirectUri = `http://127.0.0.1:${port}/callback`;
124
+ const redirectUri = OAUTH_CALLBACK_URI;
39
125
  const state = randomState();
40
- const authUrl = new URL(config.authUrl);
41
- authUrl.searchParams.set("client_id", options.clientId);
42
- authUrl.searchParams.set("redirect_uri", redirectUri);
43
- authUrl.searchParams.set("response_type", "code");
44
- authUrl.searchParams.set("scope", config.scopes.join(" "));
45
- authUrl.searchParams.set("state", state);
46
- for (const [k, v] of Object.entries(config.authParams ?? {})) {
47
- authUrl.searchParams.set(k, v);
126
+ const { verifier, challenge } = generatePKCE();
127
+ const authUrl = buildAuthUrl(config, {
128
+ clientId: options.clientId,
129
+ redirectUri,
130
+ state,
131
+ pkceChallenge: challenge,
132
+ });
133
+ // Always make the URL visible. `onAuthUrl` is the preferred
134
+ // channel (CLI formats it inline); if absent, fall back to
135
+ // stderr so headless invocations (SSH, CI) aren't left waiting
136
+ // on a browser that never opens.
137
+ if (options.onAuthUrl) {
138
+ options.onAuthUrl(authUrl);
48
139
  }
49
- options.onAuthUrl?.(authUrl.toString());
50
- const open = options.openBrowser ?? defaultOpenBrowser;
51
- open(authUrl.toString());
52
- const { code } = await captureCode(port, state);
53
- const tokens = await exchangeCode(config.tokenUrl, code, options.clientId, options.clientSecret, redirectUri);
54
- if (!tokens.refresh_token) {
55
- 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' }.");
140
+ else {
141
+ console.error(`Open this URL to authorize:\n ${authUrl}`);
56
142
  }
57
- return {
58
- accessToken: tokens.access_token,
59
- refreshToken: tokens.refresh_token,
60
- expiresAt: Date.now() + tokens.expires_in * 1000,
61
- scope: tokens.scope,
62
- tokenType: tokens.token_type,
63
- };
143
+ const open = options.openBrowser ?? defaultOpenBrowser;
144
+ open(authUrl);
145
+ const { code } = await captureCode(OAUTH_CALLBACK_PORT, state);
146
+ return exchangeAuthCode(config, {
147
+ clientId: options.clientId,
148
+ clientSecret: options.clientSecret,
149
+ code,
150
+ redirectUri,
151
+ codeVerifier: verifier,
152
+ });
64
153
  }
154
+ // ─── Local callback server ───────────────────────────────────────
65
155
  function captureCode(port, expectedState) {
66
156
  return new Promise((resolve, reject) => {
67
157
  const server = createServer((req, res) => {
@@ -106,26 +196,16 @@ function captureCode(port, expectedState) {
106
196
  server.listen(port, "127.0.0.1");
107
197
  });
108
198
  }
109
- async function exchangeCode(tokenUrl, code, clientId, clientSecret, redirectUri) {
110
- const body = new URLSearchParams({
111
- code,
112
- client_id: clientId,
113
- client_secret: clientSecret,
114
- redirect_uri: redirectUri,
115
- grant_type: "authorization_code",
116
- });
117
- const res = await fetch(tokenUrl, {
118
- method: "POST",
119
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
120
- body: body.toString(),
121
- });
122
- if (!res.ok) {
123
- throw new Error(`OAuth: token exchange failed (${res.status}): ${await res.text()}`);
124
- }
125
- return (await res.json());
126
- }
199
+ // ─── Helpers ─────────────────────────────────────────────────────
127
200
  function randomState() {
128
- return (Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
201
+ return base64urlEncode(randomBytes(16));
202
+ }
203
+ function base64urlEncode(buf) {
204
+ return buf
205
+ .toString("base64")
206
+ .replace(/\+/g, "-")
207
+ .replace(/\//g, "_")
208
+ .replace(/=+$/, "");
129
209
  }
130
210
  function defaultOpenBrowser(url) {
131
211
  const cmd = process.platform === "darwin"
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ export type { RunlineConfig } from "./config/types.js";
3
3
  export { DEFAULT_CONFIG } from "./config/types.js";
4
4
  export type { EngineOptions, ExecuteResult } from "./core/engine.js";
5
5
  export { ExecutionEngine } from "./core/engine.js";
6
- export type { OAuthTokens, RunOAuthOptions } from "./core/oauth.js";
7
- export { OAUTH_CALLBACK_PORT, runOAuth } from "./core/oauth.js";
6
+ export type { BuildAuthUrlOptions, ExchangeCodeOptions, OAuthTokens, PKCEPair, RunOAuthOptions, } from "./core/oauth.js";
7
+ export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, runOAuth, } from "./core/oauth.js";
8
8
  export type { ActionDefinition, PluginFunction, RunlinePluginAPI, SchemaField, } from "./plugin/api.js";
9
9
  export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
10
10
  export type { InstalledPlugin, PluginSource } from "./plugin/installer.js";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { addConnection, findConfigDir, getConnection, loadConfig, removeConnection, saveConfig, updateConnectionConfig, } from "./config/loader.js";
2
2
  export { DEFAULT_CONFIG } from "./config/types.js";
3
3
  export { ExecutionEngine } from "./core/engine.js";
4
- export { OAUTH_CALLBACK_PORT, runOAuth } from "./core/oauth.js";
4
+ export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, runOAuth, } from "./core/oauth.js";
5
5
  export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
6
6
  export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
7
7
  export { discoverPlugins, loadAllPlugins, loadPluginFromPath, loadPluginsFromConfig, } from "./plugin/loader.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Code mode for agents — turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",