dsh-armory 1.10.0 → 2.0.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/README.en.md +57 -13
- package/README.md +70 -21
- package/docs/mcp-market-design.md +62 -0
- package/docs/prd-awesome-dsh-plugins.md +105 -0
- package/docs/prd-workbuddy-parity.md +128 -0
- package/docs/system-design-awesome.md +269 -0
- package/docs/system_design.md +530 -0
- package/docs/workbuddy-market-gap-analysis.md +197 -0
- package/lib/client.js +1631 -136
- package/lib/expert-market.js +13 -2
- package/lib/index.js +356 -7
- package/lib/mcp-oauth.js +806 -0
- package/lib/typert.js +24 -0
- package/lib/wb-auth-rules.js +128 -0
- package/lib/wb-awesome-market.js +453 -0
- package/lib/wb-install-state.js +151 -0
- package/lib/wb-market.js +213 -8
- package/lib/wb-plugin-market.js +291 -0
- package/lib/wb-registry.js +224 -0
- package/lib/whats-new.js +37 -0
- package/package.json +2 -2
package/lib/mcp-oauth.js
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth 客户端 —— 让「需要 OAuth 的远程 MCP」在 DSH 里也能装上。
|
|
3
|
+
*
|
|
4
|
+
* 背景:`@deepseek-ai/dsh-mcp-client` 的 transport 只认 `requestInit.headers`,
|
|
5
|
+
* 没有 authProvider,也不做交互式授权。所以 OAuth 必须由本插件在「安装前」跑完,
|
|
6
|
+
* 把 `authorization: Bearer <token>` 静态写进 patch 的 headers 里,此后由 mcp-client
|
|
7
|
+
* 原样带出去。token 到期由本模块刷新。
|
|
8
|
+
*
|
|
9
|
+
* 覆盖的规范:
|
|
10
|
+
* - RFC 9728 受保护资源元数据(`WWW-Authenticate: Bearer resource_metadata="…"`)
|
|
11
|
+
* - RFC 8414 授权服务器元数据(`/.well-known/oauth-authorization-server`)
|
|
12
|
+
* - RFC 7591 动态客户端注册(DCR)
|
|
13
|
+
* - RFC 7636 PKCE(S256)
|
|
14
|
+
* - RFC 8707 resource 参数
|
|
15
|
+
*
|
|
16
|
+
* 回调通道按服务器能力降级:
|
|
17
|
+
* 1. `loopback` —— 本地 127.0.0.1:<随机端口>/callback,自动收 code(主流实现)
|
|
18
|
+
* 2. `manual-callback` —— DCR 只接受自定义 scheme(例如 workbuddy://…)时,让用户把
|
|
19
|
+
* 浏览器里那条打不开的回调地址整条粘回来
|
|
20
|
+
* 3. 手动 token —— 上面两条都不通时,让用户直接粘一个 Bearer token
|
|
21
|
+
*
|
|
22
|
+
* 本模块只做纯逻辑 + 网络 + 一个临时 http 监听,不接触 Electron/DOM。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
26
|
+
import { createServer } from "node:http";
|
|
27
|
+
import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
|
|
31
|
+
/** 插件版本:DCR 的 clientInfo 与 UA 共用一处,避免两边各自漂移。 */
|
|
32
|
+
const PLUGIN_VERSION = "2.0.0";
|
|
33
|
+
const USER_AGENT = `dsh-armory/${PLUGIN_VERSION} (+mcp-oauth)`;
|
|
34
|
+
const NET_TIMEOUT_MS = 15_000;
|
|
35
|
+
/** 授权流程整体存活时间:超过就判过期,避免会话表无限增长。 */
|
|
36
|
+
const FLOW_TIMEOUT_MS = 10 * 60_000;
|
|
37
|
+
/** 授权后回调等待时间(用户要开浏览器、登录、点同意)。 */
|
|
38
|
+
const CALLBACK_TIMEOUT_MS = 8 * 60_000;
|
|
39
|
+
|
|
40
|
+
/** sessionId -> session。进程内存储;DSH 重启后需重新授权(token 已落盘,不需要)。 */
|
|
41
|
+
const sessions = new Map();
|
|
42
|
+
|
|
43
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function b64url(input) {
|
|
46
|
+
return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function str(value, fallback = "") {
|
|
50
|
+
return typeof value === "string" && value ? value : fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function trimText(text, max = 300) {
|
|
54
|
+
const t = String(text || "").replace(/\s+/g, " ").trim();
|
|
55
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sha256hex(text) {
|
|
59
|
+
return createHash("sha256").update(String(text)).digest("hex");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** PKCE 授权码流程的 verifier/challenge(S256)。 */
|
|
63
|
+
export function createPkce() {
|
|
64
|
+
const verifier = b64url(randomBytes(48));
|
|
65
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
66
|
+
return { verifier, challenge, method: "S256" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createState() {
|
|
70
|
+
return b64url(randomBytes(18));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 解析 `WWW-Authenticate` 头。
|
|
75
|
+
* 只处理一层参数(够用):`Bearer resource_metadata="…", scope="…"`。
|
|
76
|
+
*/
|
|
77
|
+
export function parseWwwAuthenticate(raw) {
|
|
78
|
+
const text = String(raw || "").trim();
|
|
79
|
+
if (!text) return null;
|
|
80
|
+
const scheme = String(text.split(/\s+/)[0] || "").toLowerCase();
|
|
81
|
+
const params = Object.create(null);
|
|
82
|
+
const re = /([A-Za-z0-9_.-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^,\s]+))/g;
|
|
83
|
+
let match;
|
|
84
|
+
while ((match = re.exec(text))) {
|
|
85
|
+
const key = match[1].toLowerCase();
|
|
86
|
+
const value = match[2] !== undefined ? match[2].replace(/\\(.)/g, "$1") : match[3] || "";
|
|
87
|
+
params[key] = value;
|
|
88
|
+
}
|
|
89
|
+
return { scheme, params };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
93
|
+
return await fetch(url, { ...init, signal: AbortSignal.timeout(NET_TIMEOUT_MS) });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function fetchJson(url, init = {}) {
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetchWithTimeout(url, {
|
|
99
|
+
...init,
|
|
100
|
+
headers: { accept: "application/json", "user-agent": USER_AGENT, ...(init.headers || {}) }
|
|
101
|
+
});
|
|
102
|
+
const text = await response.text();
|
|
103
|
+
if (!response.ok) return { ok: false, status: response.status, text, json: null };
|
|
104
|
+
try {
|
|
105
|
+
return { ok: true, status: response.status, text, json: JSON.parse(text) };
|
|
106
|
+
} catch {
|
|
107
|
+
return { ok: false, status: response.status, text, json: null };
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return { ok: false, status: 0, text: String(error?.message || error), json: null };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 探测 ────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 打一次 MCP initialize,拿回状态码与 `WWW-Authenticate`。
|
|
118
|
+
* 只用于确认「这颗 server 是不是 401 且声明了 OAuth」,不做其它判断。
|
|
119
|
+
*/
|
|
120
|
+
export async function probeServer(serverUrl, headers = {}) {
|
|
121
|
+
const body = JSON.stringify({
|
|
122
|
+
jsonrpc: "2.0",
|
|
123
|
+
id: 1,
|
|
124
|
+
method: "initialize",
|
|
125
|
+
params: {
|
|
126
|
+
protocolVersion: "2025-03-26",
|
|
127
|
+
capabilities: {},
|
|
128
|
+
clientInfo: { name: "dsh-armory", version: PLUGIN_VERSION }
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const normalizedHeaders = Object.fromEntries(
|
|
132
|
+
Object.entries(headers || {}).map(([key, value]) => [String(key).toLowerCase(), String(value)])
|
|
133
|
+
);
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetchWithTimeout(serverUrl, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
"content-type": "application/json",
|
|
139
|
+
accept: "application/json, text/event-stream",
|
|
140
|
+
...normalizedHeaders,
|
|
141
|
+
"user-agent": USER_AGENT
|
|
142
|
+
},
|
|
143
|
+
body
|
|
144
|
+
});
|
|
145
|
+
// 读掉 body,避免 socket 悬挂
|
|
146
|
+
await response.text().catch(() => "");
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
status: response.status,
|
|
150
|
+
wwwAuthenticate: response.headers.get("www-authenticate") || "",
|
|
151
|
+
error: ""
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { ok: false, status: 0, wwwAuthenticate: "", error: String(error?.message || error) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── 元数据发现 ──────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* RFC 9728:找到受保护资源元数据。
|
|
162
|
+
* 优先用 `WWW-Authenticate` 里声明的 `resource_metadata`,退回根路径 well-known
|
|
163
|
+
* (很多实现只挂根路径,路径级 well-known 反而 404)。
|
|
164
|
+
*/
|
|
165
|
+
export async function resolveProtectedResource({ serverUrl, wwwAuthenticate = "" } = {}) {
|
|
166
|
+
let origin = "";
|
|
167
|
+
try {
|
|
168
|
+
origin = new URL(serverUrl).origin;
|
|
169
|
+
} catch {
|
|
170
|
+
return { ok: false, error: `无法解析 server url:${serverUrl}` };
|
|
171
|
+
}
|
|
172
|
+
const challenge = parseWwwAuthenticate(wwwAuthenticate);
|
|
173
|
+
const hinted = str(challenge?.params?.resource_metadata);
|
|
174
|
+
const candidates = [];
|
|
175
|
+
if (hinted) candidates.push(hinted);
|
|
176
|
+
candidates.push(new URL("/.well-known/oauth-protected-resource", origin).href);
|
|
177
|
+
const tried = [];
|
|
178
|
+
for (const candidate of candidates) {
|
|
179
|
+
if (tried.includes(candidate)) continue;
|
|
180
|
+
tried.push(candidate);
|
|
181
|
+
const result = await fetchJson(candidate);
|
|
182
|
+
const servers = Array.isArray(result.json?.authorization_servers)
|
|
183
|
+
? result.json.authorization_servers.map((item) => String(item)).filter(Boolean)
|
|
184
|
+
: [];
|
|
185
|
+
if (result.ok && servers.length) {
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
resource: str(result.json?.resource, serverUrl),
|
|
189
|
+
authorizationServers: servers,
|
|
190
|
+
metadataUrl: candidate,
|
|
191
|
+
source: candidate === hinted ? "challenge" : "well-known",
|
|
192
|
+
error: ""
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// 没有元数据也别放弃:按同源授权服务器兜底再试一次。
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
resource: serverUrl,
|
|
200
|
+
authorizationServers: [origin],
|
|
201
|
+
metadataUrl: "",
|
|
202
|
+
source: "fallback",
|
|
203
|
+
error: ""
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* RFC 8414:拿授权服务器元数据。两个 well-known 都试(OIDC 兼容实现只挂后者)。
|
|
209
|
+
*/
|
|
210
|
+
export async function resolveAuthorizationServer(authorizationServerUrl) {
|
|
211
|
+
const base = String(authorizationServerUrl || "").replace(/\/+$/, "");
|
|
212
|
+
if (!base) return { ok: false, error: "缺少授权服务器地址" };
|
|
213
|
+
const candidates = [
|
|
214
|
+
`${base}/.well-known/oauth-authorization-server`,
|
|
215
|
+
`${base}/.well-known/openid-configuration`
|
|
216
|
+
];
|
|
217
|
+
const tried = [];
|
|
218
|
+
for (const candidate of candidates) {
|
|
219
|
+
tried.push(candidate);
|
|
220
|
+
const result = await fetchJson(candidate);
|
|
221
|
+
const meta = result.json;
|
|
222
|
+
if (result.ok && meta?.authorization_endpoint && meta?.token_endpoint) {
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
issuer: str(meta.issuer, base),
|
|
226
|
+
authorizationEndpoint: String(meta.authorization_endpoint),
|
|
227
|
+
tokenEndpoint: String(meta.token_endpoint),
|
|
228
|
+
registrationEndpoint: str(meta.registration_endpoint),
|
|
229
|
+
scopesSupported: Array.isArray(meta.scopes_supported) ? meta.scopes_supported.map(String) : [],
|
|
230
|
+
tokenEndpointAuthMethods: Array.isArray(meta.token_endpoint_auth_methods_supported)
|
|
231
|
+
? meta.token_endpoint_auth_methods_supported.map(String)
|
|
232
|
+
: ["none"],
|
|
233
|
+
metadataUrl: candidate,
|
|
234
|
+
error: ""
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { ok: false, error: `未能从 ${base} 取得 authorization_endpoint / token_endpoint(试过 ${tried.join("、")})` };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 动态客户端注册(RFC 7591) ───────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @returns {{ok:boolean, clientId:string, error:string}}
|
|
245
|
+
* 失败时 error 里可能带服务器期望的 redirect_uri(例如只认某个自定义 scheme)。
|
|
246
|
+
*/
|
|
247
|
+
export async function registerClient(as, { redirectUri, clientName, scope } = {}) {
|
|
248
|
+
if (!as?.registrationEndpoint) {
|
|
249
|
+
return { ok: false, clientId: "", error: "授权服务器未提供 registration_endpoint(不支持动态注册)" };
|
|
250
|
+
}
|
|
251
|
+
const payload = {
|
|
252
|
+
redirect_uris: [redirectUri],
|
|
253
|
+
client_name: String(clientName || "DeepSeek Harness"),
|
|
254
|
+
token_endpoint_auth_method: "none",
|
|
255
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
256
|
+
response_types: ["code"]
|
|
257
|
+
};
|
|
258
|
+
if (scope) payload.scope = scope;
|
|
259
|
+
const result = await fetchJson(as.registrationEndpoint, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: { "content-type": "application/json" },
|
|
262
|
+
body: JSON.stringify(payload)
|
|
263
|
+
});
|
|
264
|
+
if (!result.ok) {
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
clientId: "",
|
|
268
|
+
error: `动态注册失败(HTTP ${result.status || "网络错误"}):${readableRegistrationError(result.text)}`,
|
|
269
|
+
raw: result.text
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const clientId = str(result.json?.client_id);
|
|
273
|
+
if (!clientId) return { ok: false, clientId: "", error: `动态注册响应缺少 client_id:${trimText(result.text)}`, raw: result.text };
|
|
274
|
+
return { ok: true, clientId, error: "" };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 有些服务器把错误体里的 `<` `>` 写成 JSON 转义 `\u003c` `\u003e`
|
|
279
|
+
* (Python 的 json.dumps 默认就会这样),必须先还原成字面字符,
|
|
280
|
+
* 否则 `<name>` 占位符识别不出来。
|
|
281
|
+
*/
|
|
282
|
+
export function unescapeUnicodeEscapes(text) {
|
|
283
|
+
return String(text || "").replace(/\\u([0-9a-fA-F]{4})/g, (_all, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 从 DCR 报错里抠出服务器期望的 redirect_uri(自定义 scheme 场景)。 */
|
|
287
|
+
export function extractExpectedRedirectUri(errorText) {
|
|
288
|
+
const text = unescapeUnicodeEscapes(errorText);
|
|
289
|
+
// 服务器常把地址写成模板:`workbuddy://workbuddy/mcp/connector:<name>/oauth/callback`,
|
|
290
|
+
// 所以 `<` `>` 也要视作地址的一部分,否则会被截断成半截 URL。
|
|
291
|
+
const match = text.match(/\b([a-z][a-z0-9+.-]*:\/\/[^\s"'`)<>]*(?:<[^>\s]+>[^\s"'`)<>]*)*)/i);
|
|
292
|
+
if (!match) return "";
|
|
293
|
+
const candidate = match[1].replace(/[.,;)]+$/, "");
|
|
294
|
+
// 只认「带路径的绝对 URI」,避免把 https:// 的文档链接当回调地址。
|
|
295
|
+
return /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/.+/i.test(candidate) ? candidate : "";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 把回调地址模板里的占位符换成真实客户端名(`<name>` / `{name}` / `${name}`)。 */
|
|
299
|
+
export function substituteRedirectPlaceholder(redirectUri, name) {
|
|
300
|
+
const slug = String(name || "")
|
|
301
|
+
.toLowerCase()
|
|
302
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
303
|
+
.replace(/^-+|-+$/g, "")
|
|
304
|
+
.slice(0, 48) || "dsh-armory";
|
|
305
|
+
return unescapeUnicodeEscapes(redirectUri).replace(/<([^>\s]*)>|\$\{([^}]*)\}|\{([^}]*)\}/g, (_all, angle, dollar, brace) => {
|
|
306
|
+
const key = String(angle ?? dollar ?? brace ?? "").toLowerCase();
|
|
307
|
+
return /name|client|connector|app|id/.test(key) ? slug : _all;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 把 DCR 的原始错误体变成一句人话(服务器常回一大坨 JSON)。 */
|
|
312
|
+
export function readableRegistrationError(text) {
|
|
313
|
+
const raw = String(text || "").trim();
|
|
314
|
+
if (!raw) return "(空响应)";
|
|
315
|
+
try {
|
|
316
|
+
const parsed = JSON.parse(raw);
|
|
317
|
+
const description =
|
|
318
|
+
str(parsed?.error?.error_description) ||
|
|
319
|
+
str(parsed?.error_description) ||
|
|
320
|
+
str(parsed?.error?.error) ||
|
|
321
|
+
str(parsed?.error) ||
|
|
322
|
+
"";
|
|
323
|
+
if (description) {
|
|
324
|
+
const code = str(parsed?.error?.error);
|
|
325
|
+
return code && code !== description ? `${code}: ${description}` : description;
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
/* 不是 JSON,按原文截断 */
|
|
329
|
+
}
|
|
330
|
+
return trimText(raw);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── 本地回调监听 ────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 起一个只收一次 code 的临时 http 服务,绑 127.0.0.1 随机端口。
|
|
337
|
+
* @returns {Promise<{redirectUri:string, wait:Promise<object>, close:Function}>}
|
|
338
|
+
*/
|
|
339
|
+
export async function startLoopback({ timeoutMs = CALLBACK_TIMEOUT_MS } = {}) {
|
|
340
|
+
let settle = () => {};
|
|
341
|
+
let settled = false;
|
|
342
|
+
const wait = new Promise((resolve) => {
|
|
343
|
+
settle = (value) => {
|
|
344
|
+
if (settled) return;
|
|
345
|
+
settled = true;
|
|
346
|
+
resolve(value);
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
const server = createServer((request, response) => {
|
|
350
|
+
let parsed;
|
|
351
|
+
try {
|
|
352
|
+
parsed = new URL(String(request.url || "/"), "http://127.0.0.1");
|
|
353
|
+
} catch {
|
|
354
|
+
response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("bad request");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (parsed.pathname !== "/callback") {
|
|
358
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const code = parsed.searchParams.get("code") || "";
|
|
362
|
+
const state = parsed.searchParams.get("state") || "";
|
|
363
|
+
const error = parsed.searchParams.get("error") || "";
|
|
364
|
+
const errorDescription = parsed.searchParams.get("error_description") || "";
|
|
365
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", connection: "close" });
|
|
366
|
+
response.end(renderCallbackPage(error ? "授权未完成" : "授权完成", error ? errorDescription || error : "可以关闭这个页面,回到 DeepSeek Harness 继续。"));
|
|
367
|
+
settle({ code, state, error, errorDescription });
|
|
368
|
+
});
|
|
369
|
+
await new Promise((resolve, reject) => {
|
|
370
|
+
server.once("error", reject);
|
|
371
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
372
|
+
});
|
|
373
|
+
const address = server.address();
|
|
374
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
375
|
+
const timer = setTimeout(() => settle({ code: "", state: "", error: "timeout", errorDescription: "等待授权回调超时" }), Math.max(1_000, timeoutMs));
|
|
376
|
+
timer.unref?.();
|
|
377
|
+
const close = () => {
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
try {
|
|
380
|
+
server.close();
|
|
381
|
+
} catch {
|
|
382
|
+
/* 已经关了 */
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
return { redirectUri: `http://127.0.0.1:${port}/callback`, wait, close };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function renderCallbackPage(title, detail) {
|
|
389
|
+
const escape = (text) => String(text).replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]);
|
|
390
|
+
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>${escape(title)}</title></head>
|
|
391
|
+
<body style="font:15px/1.7 system-ui,-apple-system,'Segoe UI',sans-serif;margin:0;display:grid;place-items:center;min-height:100vh;background:#0f1115;color:#e6e8ee">
|
|
392
|
+
<div style="max-width:34rem;padding:2.5rem 2rem;border:1px solid #262b36;border-radius:16px;background:#151922">
|
|
393
|
+
<h1 style="margin:0 0 .75rem;font-size:1.25rem">${escape(title)}</h1>
|
|
394
|
+
<p style="margin:0;color:#9aa4b8">${escape(detail)}</p></div></body></html>`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── token 落盘 ──────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/** 存储目录:优先 DSH_HOME,其次 ~/.dsh(与其它 armory 状态同源)。 */
|
|
400
|
+
export function storeDirectory() {
|
|
401
|
+
const home = str(process.env.DSH_HOME) || join(homedir(), ".dsh");
|
|
402
|
+
return join(home, "armory-oauth");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function recordPathFor(serverUrl) {
|
|
406
|
+
return join(storeDirectory(), `${sha256hex(serverUrl).slice(0, 32)}.json`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** 读一条已存的授权记录;没有或损坏返回 null。 */
|
|
410
|
+
export async function readTokenRecord(serverUrl) {
|
|
411
|
+
try {
|
|
412
|
+
const text = await readFile(recordPathFor(serverUrl), "utf8");
|
|
413
|
+
const record = JSON.parse(text);
|
|
414
|
+
if (!record || typeof record !== "object") return null;
|
|
415
|
+
if (str(record.serverUrl) !== String(serverUrl)) return null;
|
|
416
|
+
return record;
|
|
417
|
+
} catch {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function writeTokenRecord(serverUrl, patch) {
|
|
423
|
+
const path = recordPathFor(serverUrl);
|
|
424
|
+
const previous = (await readTokenRecord(serverUrl)) || {};
|
|
425
|
+
const record = { ...previous, ...patch, serverUrl: String(serverUrl), savedAt: new Date().toISOString() };
|
|
426
|
+
await mkdir(storeDirectory(), { recursive: true });
|
|
427
|
+
await writeFile(path, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
428
|
+
return record;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export async function clearTokenRecord(serverUrl) {
|
|
432
|
+
try {
|
|
433
|
+
await unlink(recordPathFor(serverUrl));
|
|
434
|
+
return true;
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 记录里是否有一个「还没过期」的 access token(留 60s 余量)。 */
|
|
441
|
+
export function tokenIsFresh(record) {
|
|
442
|
+
if (!record || !str(record.accessToken)) return false;
|
|
443
|
+
if (!record.expiresAt) return true; // 服务器没给 expires_in:视为长期有效,等 401 再说
|
|
444
|
+
// 落盘时写的是 ISO 串(见 exchangeCode),但令牌库是纯 JSON、可能被人手改或由
|
|
445
|
+
// 旧版本写成数字毫秒,两种都认:数字直接当时间戳,字符串才走 Date.parse。
|
|
446
|
+
const expiresAt = typeof record.expiresAt === "number"
|
|
447
|
+
? record.expiresAt
|
|
448
|
+
: Date.parse(String(record.expiresAt));
|
|
449
|
+
if (!Number.isFinite(expiresAt)) return true;
|
|
450
|
+
return expiresAt - 60_000 > Date.now();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 拿一个可用 token:本地还有效就直接用,过期但有 refresh_token 就刷。
|
|
455
|
+
* @returns {Promise<{ok:boolean, accessToken:string, refreshed:boolean, error:string}>}
|
|
456
|
+
*/
|
|
457
|
+
export async function ensureAccessToken(serverUrl, { force = false } = {}) {
|
|
458
|
+
const record = await readTokenRecord(serverUrl);
|
|
459
|
+
if (!record) return { ok: false, accessToken: "", refreshed: false, error: "没有已保存的授权" };
|
|
460
|
+
if (!force && tokenIsFresh(record)) return { ok: true, accessToken: record.accessToken, refreshed: false, error: "" };
|
|
461
|
+
if (!str(record.refreshToken) || !str(record.tokenEndpoint)) {
|
|
462
|
+
return { ok: false, accessToken: "", refreshed: false, error: "授权已过期,且服务器未提供 refresh_token,需要重新授权" };
|
|
463
|
+
}
|
|
464
|
+
const refreshed = await refreshAccessToken(record);
|
|
465
|
+
if (!refreshed.ok) return { ok: false, accessToken: "", refreshed: false, error: refreshed.error };
|
|
466
|
+
return { ok: true, accessToken: refreshed.accessToken, refreshed: true, error: "" };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function refreshAccessToken(record) {
|
|
470
|
+
const body = new URLSearchParams({
|
|
471
|
+
grant_type: "refresh_token",
|
|
472
|
+
refresh_token: String(record.refreshToken),
|
|
473
|
+
client_id: String(record.clientId || "")
|
|
474
|
+
});
|
|
475
|
+
if (str(record.resource)) body.set("resource", String(record.resource));
|
|
476
|
+
const result = await fetchJson(String(record.tokenEndpoint), {
|
|
477
|
+
method: "POST",
|
|
478
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
479
|
+
body: body.toString()
|
|
480
|
+
});
|
|
481
|
+
if (!result.ok || !str(result.json?.access_token)) {
|
|
482
|
+
return { ok: false, accessToken: "", error: `刷新 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}` };
|
|
483
|
+
}
|
|
484
|
+
await writeTokenRecord(record.serverUrl, tokenRecordPatch(result.json));
|
|
485
|
+
return { ok: true, accessToken: String(result.json.access_token), error: "" };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function tokenRecordPatch(tokenResponse) {
|
|
489
|
+
const expiresIn = Number(tokenResponse?.expires_in);
|
|
490
|
+
return {
|
|
491
|
+
accessToken: String(tokenResponse?.access_token || ""),
|
|
492
|
+
tokenType: str(tokenResponse?.token_type, "Bearer"),
|
|
493
|
+
refreshToken: str(tokenResponse?.refresh_token),
|
|
494
|
+
scope: str(tokenResponse?.scope),
|
|
495
|
+
expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? new Date(Date.now() + expiresIn * 1000).toISOString() : ""
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── 完整授权流程 ────────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* 走完「发现 → 注册 → 授权页」并把 authorizeUrl 交给调用方去开浏览器。
|
|
503
|
+
*
|
|
504
|
+
* @param {object} options
|
|
505
|
+
* @param {string} options.serverName MCP server 名(仅用于提示文案)
|
|
506
|
+
* @param {string} options.serverUrl MCP endpoint(streamable-http url)
|
|
507
|
+
* @param {object} [options.headers] mcp.json 里已有的静态头(探活时带上)
|
|
508
|
+
* @param {string} [options.clientName]
|
|
509
|
+
* @returns {Promise<{ok:boolean, sessionId:string, authorizeUrl:string, mode:string,
|
|
510
|
+
* redirectUri:string, hint:string, error:string}>}
|
|
511
|
+
*/
|
|
512
|
+
export async function beginAuthorization({ serverName, serverUrl, headers = {}, clientName = "DeepSeek Harness" } = {}) {
|
|
513
|
+
if (!serverUrl) return fail("", "", "", "缺少 server url");
|
|
514
|
+
const probe = await probeServer(serverUrl, headers);
|
|
515
|
+
if (!probe.ok) return fail("", "", "", `无法连接 ${serverUrl}:${probe.error}`);
|
|
516
|
+
if (probe.status !== 401 && probe.status !== 403) {
|
|
517
|
+
// 有些服务器首次 initialize 回 200,鉴权在 tools/call 才触发;这里仍允许继续试一次。
|
|
518
|
+
if (probe.status >= 400 && probe.status !== 401 && probe.status !== 403) {
|
|
519
|
+
return fail("", "", "", `服务器返回 HTTP ${probe.status},不是预期的 401/403,无法确认其 OAuth 要求`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const resource = await resolveProtectedResource({ serverUrl, wwwAuthenticate: probe.wwwAuthenticate });
|
|
524
|
+
if (!resource.ok) return fail("", "", "", resource.error);
|
|
525
|
+
const as = await resolveAuthorizationServer(resource.authorizationServers[0]);
|
|
526
|
+
if (!as.ok) return fail("", "", "", as.error);
|
|
527
|
+
if (!as.tokenEndpointAuthMethods.includes("none")) {
|
|
528
|
+
return fail("", "", "", `该授权服务器要求客户端认证(${as.tokenEndpointAuthMethods.join("/")}),本插件只支持公共客户端(none)`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const scope = as.scopesSupported.length ? as.scopesSupported.join(" ") : "";
|
|
532
|
+
const pkce = createPkce();
|
|
533
|
+
const state = createState();
|
|
534
|
+
|
|
535
|
+
// 1) 先按 loopback 注册;2) 被拒且报错里点名了自定义 scheme,就用它再注册一次。
|
|
536
|
+
const loopback = await startLoopback();
|
|
537
|
+
let redirectUri = loopback.redirectUri;
|
|
538
|
+
let mode = "loopback";
|
|
539
|
+
let registration = await registerClient(as, { redirectUri, clientName, scope });
|
|
540
|
+
if (!registration.ok) {
|
|
541
|
+
// 有些服务器(例如只认 workbuddy:// 的那类)拒绝 loopback,并在报错里点名只接受
|
|
542
|
+
// 某个自定义 scheme 的回调地址——有时还是个带 <name> 占位符的模板。
|
|
543
|
+
const template = extractExpectedRedirectUri(registration.raw || registration.error);
|
|
544
|
+
if (!template) {
|
|
545
|
+
loopback.close();
|
|
546
|
+
return fail("", "", "", registration.error);
|
|
547
|
+
}
|
|
548
|
+
const expected = substituteRedirectPlaceholder(template, serverName || clientName);
|
|
549
|
+
const retry = await registerClient(as, { redirectUri: expected, clientName, scope });
|
|
550
|
+
if (!retry.ok) {
|
|
551
|
+
loopback.close();
|
|
552
|
+
return fail("", "", "", `${registration.error}\n改用服务器指定的回调地址 ${expected} 重试仍失败:${retry.error}`);
|
|
553
|
+
}
|
|
554
|
+
registration = retry;
|
|
555
|
+
redirectUri = expected;
|
|
556
|
+
mode = "manual-callback";
|
|
557
|
+
loopback.close();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
561
|
+
authorizationEndpoint: as.authorizationEndpoint,
|
|
562
|
+
clientId: registration.clientId,
|
|
563
|
+
redirectUri,
|
|
564
|
+
scope,
|
|
565
|
+
state,
|
|
566
|
+
codeChallenge: pkce.challenge,
|
|
567
|
+
resource: resource.resource
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const sessionId = b64url(randomBytes(12));
|
|
571
|
+
const session = {
|
|
572
|
+
id: sessionId,
|
|
573
|
+
serverName: str(serverName),
|
|
574
|
+
serverUrl,
|
|
575
|
+
resource,
|
|
576
|
+
as,
|
|
577
|
+
clientId: registration.clientId,
|
|
578
|
+
pkce,
|
|
579
|
+
state,
|
|
580
|
+
redirectUri,
|
|
581
|
+
mode,
|
|
582
|
+
loopback: mode === "loopback" ? loopback : null,
|
|
583
|
+
status: "pending",
|
|
584
|
+
error: "",
|
|
585
|
+
token: null,
|
|
586
|
+
createdAt: Date.now()
|
|
587
|
+
};
|
|
588
|
+
sessions.set(sessionId, session);
|
|
589
|
+
pruneSessions();
|
|
590
|
+
|
|
591
|
+
if (session.loopback) {
|
|
592
|
+
// 后台等回调,谁先到都行:http 回调 or 超时。
|
|
593
|
+
session.loopback.wait.then((result) => finishSession(session, result)).catch(() => {});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
ok: true,
|
|
598
|
+
sessionId,
|
|
599
|
+
authorizeUrl,
|
|
600
|
+
mode,
|
|
601
|
+
redirectUri,
|
|
602
|
+
hint: mode === "loopback"
|
|
603
|
+
? "已在浏览器打开授权页;登录并同意后会自动完成,无需手动操作。"
|
|
604
|
+
: `该服务器只接受自定义回调地址(${redirectUri})。授权后浏览器会跳到一条打不开的地址,把地址栏里那条完整 URL 复制回来粘贴即可。`,
|
|
605
|
+
error: ""
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function buildAuthorizeUrl({ authorizationEndpoint, clientId, redirectUri, scope, state, codeChallenge, resource }) {
|
|
610
|
+
const url = new URL(authorizationEndpoint);
|
|
611
|
+
url.searchParams.set("response_type", "code");
|
|
612
|
+
url.searchParams.set("client_id", clientId);
|
|
613
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
614
|
+
if (scope) url.searchParams.set("scope", scope);
|
|
615
|
+
url.searchParams.set("state", state);
|
|
616
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
617
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
618
|
+
if (resource) url.searchParams.set("resource", resource);
|
|
619
|
+
return url.href;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function fail(sessionId, authorizeUrl, mode, error) {
|
|
623
|
+
return { ok: false, sessionId, authorizeUrl, mode, redirectUri: "", hint: "", error };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function pruneSessions() {
|
|
627
|
+
const now = Date.now();
|
|
628
|
+
for (const [id, session] of sessions) {
|
|
629
|
+
if (now - session.createdAt > FLOW_TIMEOUT_MS) {
|
|
630
|
+
session.loopback?.close?.();
|
|
631
|
+
sessions.delete(id);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function finishSession(session, result) {
|
|
637
|
+
if (session.status !== "pending") return;
|
|
638
|
+
if (result?.error || !result?.code) {
|
|
639
|
+
session.status = "failed";
|
|
640
|
+
session.error = result?.error === "timeout"
|
|
641
|
+
? "等待授权回调超时(10 分钟内没有收到)"
|
|
642
|
+
: String(result?.errorDescription || result?.error || "未收到授权码");
|
|
643
|
+
session.loopback?.close?.();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (session.state && result.state && result.state !== session.state) {
|
|
647
|
+
session.status = "failed";
|
|
648
|
+
session.error = "回调 state 校验失败(可能是过期或伪造的回调)";
|
|
649
|
+
session.loopback?.close?.();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
await exchangeCode(session, result.code);
|
|
653
|
+
session.loopback?.close?.();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function exchangeCode(session, code) {
|
|
657
|
+
const body = new URLSearchParams({
|
|
658
|
+
grant_type: "authorization_code",
|
|
659
|
+
code: String(code),
|
|
660
|
+
redirect_uri: session.redirectUri,
|
|
661
|
+
client_id: session.clientId,
|
|
662
|
+
code_verifier: session.pkce.verifier
|
|
663
|
+
});
|
|
664
|
+
if (str(session.resource?.resource)) body.set("resource", String(session.resource.resource));
|
|
665
|
+
const result = await fetchJson(session.as.tokenEndpoint, {
|
|
666
|
+
method: "POST",
|
|
667
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
668
|
+
body: body.toString()
|
|
669
|
+
});
|
|
670
|
+
if (!result.ok || !str(result.json?.access_token)) {
|
|
671
|
+
session.status = "failed";
|
|
672
|
+
session.error = `换取 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}`;
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const patch = tokenRecordPatch(result.json);
|
|
676
|
+
const record = await writeTokenRecord(session.serverUrl, {
|
|
677
|
+
...patch,
|
|
678
|
+
clientId: session.clientId,
|
|
679
|
+
tokenEndpoint: session.as.tokenEndpoint,
|
|
680
|
+
resource: str(session.resource?.resource),
|
|
681
|
+
redirectUri: session.redirectUri
|
|
682
|
+
});
|
|
683
|
+
session.status = "authorized";
|
|
684
|
+
session.error = "";
|
|
685
|
+
session.token = {
|
|
686
|
+
accessToken: record.accessToken,
|
|
687
|
+
expiresAt: record.expiresAt,
|
|
688
|
+
scope: record.scope
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* 查询授权进度。`pending` / `authorized` / `failed`。
|
|
694
|
+
* authorized 时带上 token 概要(不返回完整 token,避免在面板日志里裸露)。
|
|
695
|
+
*/
|
|
696
|
+
export function authorizationStatus(sessionId) {
|
|
697
|
+
const session = sessions.get(String(sessionId || ""));
|
|
698
|
+
if (!session) return { ok: false, status: "unknown", error: "授权会话不存在或已过期,请重新发起", token: null };
|
|
699
|
+
return {
|
|
700
|
+
ok: true,
|
|
701
|
+
status: session.status,
|
|
702
|
+
error: session.error,
|
|
703
|
+
mode: session.mode,
|
|
704
|
+
redirectUri: session.redirectUri,
|
|
705
|
+
serverName: session.serverName,
|
|
706
|
+
token: session.token
|
|
707
|
+
? { expiresAt: session.token.expiresAt, scope: session.token.scope, tokenLength: session.token.accessToken.length }
|
|
708
|
+
: null
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 手动回调模式:用户把浏览器地址栏那条 URL 粘回来。
|
|
714
|
+
* 也接受「只粘 code 本身」这种偷懒输入。
|
|
715
|
+
*/
|
|
716
|
+
export async function submitCallback(sessionId, callbackUrl) {
|
|
717
|
+
const session = sessions.get(String(sessionId || ""));
|
|
718
|
+
if (!session) return { ok: false, status: "unknown", error: "授权会话不存在或已过期,请重新发起" };
|
|
719
|
+
if (session.status === "authorized") return { ok: true, status: "authorized", error: "" };
|
|
720
|
+
if (session.status === "failed") return { ok: false, status: "failed", error: session.error };
|
|
721
|
+
const raw = String(callbackUrl || "").trim();
|
|
722
|
+
if (!raw) return { ok: false, status: "pending", error: "请粘贴回调地址" };
|
|
723
|
+
|
|
724
|
+
let code = "";
|
|
725
|
+
let state = "";
|
|
726
|
+
let error = "";
|
|
727
|
+
let errorDescription = "";
|
|
728
|
+
const looksLikeUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("/");
|
|
729
|
+
if (looksLikeUrl) {
|
|
730
|
+
try {
|
|
731
|
+
const parsed = new URL(raw, session.redirectUri);
|
|
732
|
+
code = parsed.searchParams.get("code") || "";
|
|
733
|
+
state = parsed.searchParams.get("state") || "";
|
|
734
|
+
error = parsed.searchParams.get("error") || "";
|
|
735
|
+
errorDescription = parsed.searchParams.get("error_description") || "";
|
|
736
|
+
} catch {
|
|
737
|
+
return { ok: false, status: "pending", error: "这条地址无法解析,请确认复制完整" };
|
|
738
|
+
}
|
|
739
|
+
} else if (/^[A-Za-z0-9._~-]{8,}$/.test(raw)) {
|
|
740
|
+
code = raw;
|
|
741
|
+
} else {
|
|
742
|
+
return { ok: false, status: "pending", error: "既不是回调地址也不像授权码,请重新复制" };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (error) {
|
|
746
|
+
session.status = "failed";
|
|
747
|
+
session.error = `授权服务器返回错误:${errorDescription || error}`;
|
|
748
|
+
return { ok: false, status: "failed", error: session.error };
|
|
749
|
+
}
|
|
750
|
+
if (!code) return { ok: false, status: "pending", error: "这条地址里没有 code 参数" };
|
|
751
|
+
if (session.state && state && state !== session.state) {
|
|
752
|
+
return { ok: false, status: "pending", error: "state 不匹配,请确认粘贴的是本次授权产生的回调地址" };
|
|
753
|
+
}
|
|
754
|
+
session.loopback?.close?.();
|
|
755
|
+
await exchangeCode(session, code);
|
|
756
|
+
return { ok: session.status === "authorized", status: session.status, error: session.error };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export function cancelAuthorization(sessionId) {
|
|
760
|
+
const session = sessions.get(String(sessionId || ""));
|
|
761
|
+
if (!session) return { ok: true };
|
|
762
|
+
session.loopback?.close?.();
|
|
763
|
+
if (session.status === "pending") {
|
|
764
|
+
session.status = "failed";
|
|
765
|
+
session.error = "已取消";
|
|
766
|
+
}
|
|
767
|
+
sessions.delete(String(sessionId));
|
|
768
|
+
return { ok: true };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* 手动写入 Bearer token(第三条兜底路)。token 可以带也可以不带 "Bearer " 前缀。
|
|
773
|
+
*/
|
|
774
|
+
export async function saveManualToken({ serverUrl, serverName = "", token, tokenEndpoint = "" } = {}) {
|
|
775
|
+
const raw = String(token || "").trim();
|
|
776
|
+
if (!serverUrl) return { ok: false, error: "缺少 server url" };
|
|
777
|
+
if (!raw) return { ok: false, error: "token 不能为空" };
|
|
778
|
+
const accessToken = raw.replace(/^Bearer\s+/i, "").trim();
|
|
779
|
+
if (!accessToken) return { ok: false, error: "token 不能为空" };
|
|
780
|
+
await writeTokenRecord(serverUrl, {
|
|
781
|
+
accessToken,
|
|
782
|
+
tokenType: "Bearer",
|
|
783
|
+
refreshToken: "",
|
|
784
|
+
scope: "",
|
|
785
|
+
expiresAt: "",
|
|
786
|
+
tokenEndpoint: str(tokenEndpoint),
|
|
787
|
+
serverName: str(serverName)
|
|
788
|
+
});
|
|
789
|
+
return { ok: true, error: "" };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** 面板用:读回某 server 的授权概况(不含 token 本体)。 */
|
|
793
|
+
export async function authorizationOverview(serverUrl) {
|
|
794
|
+
const record = await readTokenRecord(serverUrl);
|
|
795
|
+
if (!record) return { authorized: false, expiresAt: "", scope: "", refreshable: false, savedAt: "" };
|
|
796
|
+
return {
|
|
797
|
+
authorized: Boolean(str(record.accessToken)),
|
|
798
|
+
fresh: tokenIsFresh(record),
|
|
799
|
+
expiresAt: str(record.expiresAt),
|
|
800
|
+
scope: str(record.scope),
|
|
801
|
+
refreshable: Boolean(str(record.refreshToken)),
|
|
802
|
+
savedAt: str(record.savedAt)
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export const __testing = { sessions, recordPathFor, buildAuthorizeUrl, tokenRecordPatch };
|