@lingerai/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +100 -0
- package/bin/linger.js +11 -0
- package/package.json +38 -0
- package/src/api.js +220 -0
- package/src/cli-parse.js +200 -0
- package/src/config.js +17 -0
- package/src/credentials.js +57 -0
- package/src/format.js +219 -0
- package/src/index.js +429 -0
- package/src/oauth.js +280 -0
- package/src/pkce.js +47 -0
package/src/oauth.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// OAuth 授权码 + PKCE + 本机回环登录流程(M3 实装 · M4/M5 加固)。
|
|
2
|
+
//
|
|
3
|
+
// 整条登录链(RFC 7636 PKCE + RFC 8252 本机回环):
|
|
4
|
+
// 1. 本机起一个临时 HTTP server(端口由系统随机分配)接授权回调
|
|
5
|
+
// 2. 生成 PKCE 暗号 + state 防伪标记
|
|
6
|
+
// 3. 打开默认浏览器到 Linger 授权确认页(/oauth/authorize)
|
|
7
|
+
// 4. 用户在浏览器点「允许」→ 平台把浏览器跳回本机临时地址、带回授权码
|
|
8
|
+
// 5. 本机 server 收到授权码 → 关掉自己
|
|
9
|
+
// 6. 拿授权码 + 暗号原文向平台换 token(POST /oauth/token)
|
|
10
|
+
// 7. token 存到本机 ~/.linger/credentials
|
|
11
|
+
//
|
|
12
|
+
// 设计原则:纯函数(buildAuthorizeUrl / exchangeToken)单独可测;
|
|
13
|
+
// 带副作用的部分(起 server / 开浏览器)做成可注入,便于将来端到端测试。
|
|
14
|
+
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
import { generateVerifier, challengeFromVerifier, generateState } from './pkce.js';
|
|
19
|
+
import { saveCredentials } from './credentials.js';
|
|
20
|
+
|
|
21
|
+
// M5 预注册 client_id(已在平台 oauth_clients 表预注册,无需动态注册)。
|
|
22
|
+
export const POC_CLIENT_ID = 'linger-cli';
|
|
23
|
+
|
|
24
|
+
// 本机回环回调路径(与平台白名单登记的 path 必须一致 · 只端口可变)。
|
|
25
|
+
const CALLBACK_PATH = '/callback';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 拼装 Linger 授权确认页地址(/oauth/authorize)。纯字符串,无副作用。
|
|
29
|
+
*
|
|
30
|
+
* @param {string} baseUrl 平台基础地址(已去尾斜杠)
|
|
31
|
+
* @param {object} p { clientId, redirectUri, codeChallenge, state, scope? }
|
|
32
|
+
* @returns {string} 完整授权页 URL
|
|
33
|
+
*/
|
|
34
|
+
export function buildAuthorizeUrl(baseUrl, p) {
|
|
35
|
+
const url = new URL(`${baseUrl}/oauth/authorize`);
|
|
36
|
+
url.searchParams.set('response_type', 'code');
|
|
37
|
+
url.searchParams.set('client_id', p.clientId);
|
|
38
|
+
url.searchParams.set('redirect_uri', p.redirectUri);
|
|
39
|
+
url.searchParams.set('code_challenge', p.codeChallenge);
|
|
40
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
41
|
+
url.searchParams.set('state', p.state);
|
|
42
|
+
url.searchParams.set('scope', p.scope || 'platform');
|
|
43
|
+
return url.toString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 用授权码 + PKCE 暗号原文向平台换 token(POST /oauth/token)。
|
|
48
|
+
*
|
|
49
|
+
* 按 OAuth 2.0 §4.1.3 发 application/x-www-form-urlencoded(后端真实客户端口径)。
|
|
50
|
+
*
|
|
51
|
+
* @returns {Promise<object>} 平台返回的 token 对象 { access_token, refresh_token, ... }
|
|
52
|
+
* @throws {Error} 换码失败(HTTP 非 2xx 或网络错误)
|
|
53
|
+
*/
|
|
54
|
+
export async function exchangeToken(baseUrl, { code, codeVerifier, redirectUri, clientId }) {
|
|
55
|
+
const body = new URLSearchParams({
|
|
56
|
+
grant_type: 'authorization_code',
|
|
57
|
+
code,
|
|
58
|
+
redirect_uri: redirectUri,
|
|
59
|
+
client_id: clientId,
|
|
60
|
+
code_verifier: codeVerifier,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const resp = await fetch(`${baseUrl}/oauth/token`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
66
|
+
body: body.toString(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const text = await resp.text();
|
|
70
|
+
if (!resp.ok) {
|
|
71
|
+
let msg = text;
|
|
72
|
+
try {
|
|
73
|
+
const j = JSON.parse(text);
|
|
74
|
+
msg = (j.detail && (j.detail.message || j.detail.error)) || j.message || j.error || text;
|
|
75
|
+
} catch {
|
|
76
|
+
/* 非 JSON 错误体,原样用 */
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`换取登录凭证失败(${resp.status}):${msg}`);
|
|
79
|
+
}
|
|
80
|
+
return JSON.parse(text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 在本机起一个临时 HTTP server 接授权回调。端口由系统随机分配(port 0)。
|
|
85
|
+
*
|
|
86
|
+
* @param {object} [opts] { host?, port? } —— 仅测试用:覆盖监听 host/端口以强制 bind 失败回归;
|
|
87
|
+
* 生产默认 127.0.0.1:0(OS 随机分配空闲端口·天然无冲突)
|
|
88
|
+
* @returns {Promise<{ port, redirectUri, waitForCode, close }>}
|
|
89
|
+
* - port: 系统分配的随机端口
|
|
90
|
+
* - redirectUri: http://127.0.0.1:{port}/callback(拼授权请求用)
|
|
91
|
+
* - waitForCode(): Promise<string> —— 等浏览器回调拿到授权码(或 state/error)
|
|
92
|
+
* - close(): 关 server
|
|
93
|
+
*/
|
|
94
|
+
export function startLoopbackServer({ host = '127.0.0.1', port = 0 } = {}) {
|
|
95
|
+
let resolveCode, rejectCode;
|
|
96
|
+
const codePromise = new Promise((res, rej) => {
|
|
97
|
+
resolveCode = res;
|
|
98
|
+
rejectCode = rej;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// 外层 startup Promise 的 reject + 启动状态标记(bind 失败投递用·见 server.on('error'))
|
|
102
|
+
let rejectStartup;
|
|
103
|
+
let started = false;
|
|
104
|
+
|
|
105
|
+
const server = http.createServer((req, res) => {
|
|
106
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
107
|
+
if (u.pathname !== CALLBACK_PATH) {
|
|
108
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
109
|
+
res.end('Not Found');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const code = u.searchParams.get('code');
|
|
113
|
+
const error = u.searchParams.get('error');
|
|
114
|
+
const state = u.searchParams.get('state');
|
|
115
|
+
|
|
116
|
+
// 给浏览器一个「可以关掉这个标签页了」的极简页面
|
|
117
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
118
|
+
if (error) {
|
|
119
|
+
res.end('<p>授权未完成,可以关闭此页面,回到命令行查看。</p>');
|
|
120
|
+
rejectCode(new Error(`用户未授权或授权被拒绝:${error}`));
|
|
121
|
+
} else if (code) {
|
|
122
|
+
res.end('<p>登录成功,可以关闭此页面,回到命令行。</p>');
|
|
123
|
+
resolveCode({ code, state });
|
|
124
|
+
} else {
|
|
125
|
+
res.end('<p>回调缺少授权码,可以关闭此页面。</p>');
|
|
126
|
+
rejectCode(new Error('回调地址未带授权码'));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// B 组加固(bug-m5-cli-001 修正):bind 失败(EADDRINUSE 等)必须让
|
|
131
|
+
// `await startLoopbackServer()` 立即失败、不静默挂起。按启动阶段区分投递目标:
|
|
132
|
+
// - 启动阶段出错(started=false)→ reject 外层 startup Promise(await 处抛错)
|
|
133
|
+
// - 启动后运行期出错(started=true)→ reject 内层 codePromise(waitForCode 处抛错)
|
|
134
|
+
// 两路互斥,避免「reject 了 codePromise 却无人 await」导致 unhandledRejection 逃逸。
|
|
135
|
+
server.on('error', (err) => {
|
|
136
|
+
const e = new Error(
|
|
137
|
+
`本机临时服务启动失败(${err.message})。端口可能被占用,请重试 linger auth login,`
|
|
138
|
+
+ `或加 --no-wait 在能打开浏览器的机器上完成授权。`
|
|
139
|
+
);
|
|
140
|
+
if (!started && rejectStartup) {
|
|
141
|
+
rejectStartup(e);
|
|
142
|
+
} else {
|
|
143
|
+
rejectCode(e);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
rejectStartup = reject;
|
|
149
|
+
// 监听 host:port(默认 127.0.0.1:0 系统随机分配端口·本机回环)
|
|
150
|
+
server.listen(port, host, () => {
|
|
151
|
+
started = true;
|
|
152
|
+
const port = server.address().port;
|
|
153
|
+
resolve({
|
|
154
|
+
port,
|
|
155
|
+
redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
|
|
156
|
+
waitForCode: () => codePromise,
|
|
157
|
+
close: () => new Promise((r) => server.close(() => r())),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 尽力打开系统默认浏览器到指定 URL(跨平台·零依赖)。
|
|
165
|
+
* 打不开不致命:调用方会把 URL 打印出来让用户手动复制。
|
|
166
|
+
*/
|
|
167
|
+
export function openBrowser(url) {
|
|
168
|
+
let cmd, args;
|
|
169
|
+
if (process.platform === 'darwin') {
|
|
170
|
+
cmd = 'open';
|
|
171
|
+
args = [url];
|
|
172
|
+
} else if (process.platform === 'win32') {
|
|
173
|
+
cmd = 'cmd';
|
|
174
|
+
args = ['/c', 'start', '', url];
|
|
175
|
+
} else {
|
|
176
|
+
cmd = 'xdg-open';
|
|
177
|
+
args = [url];
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
181
|
+
child.on('error', () => {}); // 命令不存在等 → 忽略(调用方已打印 URL)
|
|
182
|
+
child.unref();
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 执行 `linger auth login` 全流程。
|
|
191
|
+
*
|
|
192
|
+
* @param {object} opts
|
|
193
|
+
* - baseUrl: 平台地址
|
|
194
|
+
* - noWait: true → 只准备好回环 + 打印授权 URL 就返回(对齐飞书 --no-wait,给 agent 用),
|
|
195
|
+
* 不阻塞等浏览器;调用方负责后续。返回 { url, redirectUri, server, verifier, state }。
|
|
196
|
+
* - credDir: 凭证目录覆盖(测试用)
|
|
197
|
+
* - log: 输出函数(默认 console.log,便于测试静默)
|
|
198
|
+
* - timeoutMs: 等浏览器回调的超时(默认 5 分钟)
|
|
199
|
+
* @returns {Promise<object>} noWait=false → 存好的 credentials;noWait=true → 上述准备态对象
|
|
200
|
+
*/
|
|
201
|
+
export async function runAuthLogin(opts = {}) {
|
|
202
|
+
const {
|
|
203
|
+
baseUrl,
|
|
204
|
+
noWait = false,
|
|
205
|
+
credDir,
|
|
206
|
+
log = console.log,
|
|
207
|
+
timeoutMs = 5 * 60 * 1000,
|
|
208
|
+
clientId = POC_CLIENT_ID,
|
|
209
|
+
} = opts;
|
|
210
|
+
|
|
211
|
+
const verifier = generateVerifier();
|
|
212
|
+
const challenge = challengeFromVerifier(verifier);
|
|
213
|
+
const state = generateState();
|
|
214
|
+
|
|
215
|
+
const server = await startLoopbackServer();
|
|
216
|
+
const authUrl = buildAuthorizeUrl(baseUrl, {
|
|
217
|
+
clientId,
|
|
218
|
+
redirectUri: server.redirectUri,
|
|
219
|
+
codeChallenge: challenge,
|
|
220
|
+
state,
|
|
221
|
+
scope: 'platform',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (noWait) {
|
|
225
|
+
// --no-wait:给 agent 用 —— 不开浏览器、不阻塞,只把授权 URL 交出去。
|
|
226
|
+
// 注意:此模式下回环 server 仍在监听,调用方需自行驱动 waitForCode + 换码 + close。
|
|
227
|
+
log(authUrl);
|
|
228
|
+
return { url: authUrl, redirectUri: server.redirectUri, server, verifier, state, clientId, baseUrl };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 交互模式:打开浏览器 + 阻塞等回调
|
|
232
|
+
log('正在打开浏览器完成 Linger 授权…');
|
|
233
|
+
log(`如果浏览器没有自动打开,请手动访问:\n${authUrl}\n`);
|
|
234
|
+
openBrowser(authUrl);
|
|
235
|
+
|
|
236
|
+
// 超时保护:用户关了浏览器没授权 → 不静默挂起。
|
|
237
|
+
// B 组加固:
|
|
238
|
+
// 1. timer.unref():让计时器不阻止进程退出(即使 timer 未 clear,进程也能正常退)。
|
|
239
|
+
// 2. clearTimeout(timerId):成功/失败后主动清掉计时器(双重保险)。
|
|
240
|
+
let timerId;
|
|
241
|
+
const timeout = new Promise((_, rej) => {
|
|
242
|
+
timerId = setTimeout(
|
|
243
|
+
() => rej(new Error(
|
|
244
|
+
'等待授权超时(5 分钟内未完成)。\n'
|
|
245
|
+
+ '• 如果当前环境无法打开浏览器,请加 --no-wait 参数(打印授权 URL 让你在别处完成)。\n'
|
|
246
|
+
+ '• 或换一台能打开浏览器的机器重试 linger auth login。'
|
|
247
|
+
)),
|
|
248
|
+
timeoutMs
|
|
249
|
+
);
|
|
250
|
+
// unref:计时器不阻止进程退出(Node 进程事件循环里不会因为这个 timer 还在跑就不退出)
|
|
251
|
+
timerId.unref();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
let codeResult;
|
|
255
|
+
try {
|
|
256
|
+
codeResult = await Promise.race([server.waitForCode(), timeout]);
|
|
257
|
+
} finally {
|
|
258
|
+
// 无论成功还是失败,都清掉超时计时器(double-unref)
|
|
259
|
+
clearTimeout(timerId);
|
|
260
|
+
await server.close();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 校验 state 一致(防回调被伪造)
|
|
264
|
+
if (codeResult.state && codeResult.state !== state) {
|
|
265
|
+
throw new Error('授权回调的防伪标记不匹配,已中止(安全保护)。');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
log('授权成功,正在换取登录凭证…');
|
|
269
|
+
const tokenObj = await exchangeToken(baseUrl, {
|
|
270
|
+
code: codeResult.code,
|
|
271
|
+
codeVerifier: verifier,
|
|
272
|
+
redirectUri: server.redirectUri,
|
|
273
|
+
clientId,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const creds = { ...tokenObj, base_url: baseUrl };
|
|
277
|
+
const file = saveCredentials(creds, credDir ? { dir: credDir } : {});
|
|
278
|
+
log(`登录完成,凭证已保存到 ${file}`);
|
|
279
|
+
return creds;
|
|
280
|
+
}
|
package/src/pkce.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// PKCE(RFC 7636)+ state 生成。纯算法、无网络、无副作用。
|
|
2
|
+
//
|
|
3
|
+
// 命令行登录用「授权码 + PKCE」流程:
|
|
4
|
+
// 1. 本地随机生成一段 verifier(暗号原文)
|
|
5
|
+
// 2. 算出它的 S256 摘要 challenge(暗号摘要),随授权请求发给平台
|
|
6
|
+
// 3. 换码时把 verifier 原文发回平台,平台重算摘要核对 → 防授权码被中途截走盗用
|
|
7
|
+
//
|
|
8
|
+
// ⚠ challenge 算法必须与 Linger 后端口径分毫不差:
|
|
9
|
+
// 后端(a2a-backend/app/routes/oauth.py _handle_authorization_code):
|
|
10
|
+
// base64url( sha256(verifier) ) 去掉结尾 '=' 补位。
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
/** base64url 编码(无 padding):把 + / 换成 - _,去掉结尾的 =。 */
|
|
15
|
+
function base64url(buf) {
|
|
16
|
+
return buf
|
|
17
|
+
.toString('base64')
|
|
18
|
+
.replace(/\+/g, '-')
|
|
19
|
+
.replace(/\//g, '_')
|
|
20
|
+
.replace(/=+$/, '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 生成 PKCE code_verifier(暗号原文)。
|
|
25
|
+
* RFC 7636 §4.1:43~128 个 unreserved 字符。
|
|
26
|
+
* 32 字节随机 → base64url 后约 43 字符,落在合规区间。
|
|
27
|
+
*/
|
|
28
|
+
export function generateVerifier() {
|
|
29
|
+
return base64url(crypto.randomBytes(32));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 由 verifier 算出 S256 code_challenge(暗号摘要)。
|
|
34
|
+
* 必须与后端口径一致:base64url(sha256(verifier)),无 padding。
|
|
35
|
+
*/
|
|
36
|
+
export function challengeFromVerifier(verifier) {
|
|
37
|
+
const digest = crypto.createHash('sha256').update(verifier).digest();
|
|
38
|
+
return base64url(digest);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 生成 OAuth state(防伪标记 · 防登录回调被人伪造/CSRF)。
|
|
43
|
+
* 16 字节随机 → base64url。
|
|
44
|
+
*/
|
|
45
|
+
export function generateState() {
|
|
46
|
+
return base64url(crypto.randomBytes(16));
|
|
47
|
+
}
|