@harrylabsj/kiwi 0.8.0 → 0.10.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.md +1 -1
- package/dist/a2a/negotiate.d.ts +6 -2
- package/dist/a2a/negotiate.js +10 -3
- package/dist/a2a/negotiate.js.map +1 -1
- package/dist/a2a/node.d.ts +4 -1
- package/dist/a2a/node.js +56 -3
- package/dist/a2a/node.js.map +1 -1
- package/dist/a2a/server/merchant-handler.d.ts +11 -3
- package/dist/a2a/server/merchant-handler.js +84 -26
- package/dist/a2a/server/merchant-handler.js.map +1 -1
- package/dist/agent/buyer/buyer-tools.js +2 -2
- package/dist/agent/buyer/buyer-tools.js.map +1 -1
- package/dist/agent/kernel.js +5 -1
- package/dist/agent/kernel.js.map +1 -1
- package/dist/agent/memory/schema.d.ts +1 -1
- package/dist/agent/memory/schema.js +9 -1
- package/dist/agent/memory/schema.js.map +1 -1
- package/dist/agent/merchant/action-candidate.d.ts +18 -0
- package/dist/agent/merchant/action-candidate.js +51 -0
- package/dist/agent/merchant/action-candidate.js.map +1 -1
- package/dist/agent/merchant/fake-merchant-client.js +6 -1
- package/dist/agent/merchant/fake-merchant-client.js.map +1 -1
- package/dist/agent/merchant/intelligence/default-backend.js +25 -2
- package/dist/agent/merchant/intelligence/default-backend.js.map +1 -1
- package/dist/agent/merchant/merchant-client.d.ts +50 -1
- package/dist/agent/merchant/merchant-client.js +157 -8
- package/dist/agent/merchant/merchant-client.js.map +1 -1
- package/dist/agent/merchant/merchant-presentations.js +25 -2
- package/dist/agent/merchant/merchant-presentations.js.map +1 -1
- package/dist/agent/merchant/merchant-tools.js +102 -188
- package/dist/agent/merchant/merchant-tools.js.map +1 -1
- package/dist/agent/merchant/types.d.ts +6 -2
- package/dist/agent/merchant/types.js +45 -0
- package/dist/agent/merchant/types.js.map +1 -1
- package/dist/auth/merchant-authorization.d.ts +49 -0
- package/dist/auth/merchant-authorization.js +61 -0
- package/dist/auth/merchant-authorization.js.map +1 -0
- package/dist/auth/merchant-oauth.d.ts +255 -0
- package/dist/auth/merchant-oauth.js +764 -0
- package/dist/auth/merchant-oauth.js.map +1 -0
- package/dist/auth/merchant-pairing.d.ts +62 -0
- package/dist/auth/merchant-pairing.js +199 -0
- package/dist/auth/merchant-pairing.js.map +1 -0
- package/dist/auth/merchant-sessions.d.ts +71 -0
- package/dist/auth/merchant-sessions.js +168 -0
- package/dist/auth/merchant-sessions.js.map +1 -0
- package/dist/buyer-core/build-service.d.ts +5 -0
- package/dist/buyer-core/build-service.js +12 -0
- package/dist/buyer-core/build-service.js.map +1 -1
- package/dist/buyer-core/errors.d.ts +1 -1
- package/dist/buyer-core/errors.js +2 -0
- package/dist/buyer-core/errors.js.map +1 -1
- package/dist/buyer-core/merchant-index.d.ts +19 -4
- package/dist/buyer-core/merchant-index.js +137 -16
- package/dist/buyer-core/merchant-index.js.map +1 -1
- package/dist/buyer-core/negotiator.d.ts +0 -10
- package/dist/buyer-core/negotiator.js +12 -1
- package/dist/buyer-core/negotiator.js.map +1 -1
- package/dist/buyer-core/quote-fetcher.js +2 -1
- package/dist/buyer-core/quote-fetcher.js.map +1 -1
- package/dist/buyer-core/service.d.ts +104 -0
- package/dist/buyer-core/service.js +105 -1
- package/dist/buyer-core/service.js.map +1 -1
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +612 -20
- package/dist/cli.js.map +1 -1
- package/dist/config/profile.d.ts +30 -0
- package/dist/config/profile.js +126 -61
- package/dist/config/profile.js.map +1 -1
- package/dist/demo/demo-runner.js +12 -9
- package/dist/demo/demo-runner.js.map +1 -1
- package/dist/discovery/catalog-source/buyer-follows.d.ts +82 -0
- package/dist/discovery/catalog-source/buyer-follows.js +228 -0
- package/dist/discovery/catalog-source/buyer-follows.js.map +1 -0
- package/dist/discovery/catalog-source/connector-identity.d.ts +75 -0
- package/dist/discovery/catalog-source/connector-identity.js +175 -0
- package/dist/discovery/catalog-source/connector-identity.js.map +1 -0
- package/dist/discovery/catalog-source/errors.d.ts +1 -1
- package/dist/discovery/catalog-source/errors.js +2 -0
- package/dist/discovery/catalog-source/errors.js.map +1 -1
- package/dist/discovery/catalog-source/merchant-publications.d.ts +65 -0
- package/dist/discovery/catalog-source/merchant-publications.js +207 -0
- package/dist/discovery/catalog-source/merchant-publications.js.map +1 -0
- package/dist/discovery/catalog-source/register.d.ts +24 -0
- package/dist/discovery/catalog-source/register.js +46 -0
- package/dist/discovery/catalog-source/register.js.map +1 -1
- package/dist/discovery/catalog-source/source.js +2 -1
- package/dist/discovery/catalog-source/source.js.map +1 -1
- package/dist/fs/atomic-write.d.ts +18 -0
- package/dist/fs/atomic-write.js +29 -0
- package/dist/fs/atomic-write.js.map +1 -0
- package/dist/http/server.js +4 -1
- package/dist/http/server.js.map +1 -1
- package/dist/mcp/cli.d.ts +2 -0
- package/dist/mcp/cli.js +7 -6
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/merchant-auth.d.ts +78 -0
- package/dist/mcp/merchant-auth.js +146 -0
- package/dist/mcp/merchant-auth.js.map +1 -0
- package/dist/mcp/merchant-dirs.d.ts +26 -0
- package/dist/mcp/merchant-dirs.js +44 -0
- package/dist/mcp/merchant-dirs.js.map +1 -0
- package/dist/mcp/merchant-resources.d.ts +38 -0
- package/dist/mcp/merchant-resources.js +146 -0
- package/dist/mcp/merchant-resources.js.map +1 -0
- package/dist/mcp/merchant-server.d.ts +106 -0
- package/dist/mcp/merchant-server.js +615 -0
- package/dist/mcp/merchant-server.js.map +1 -0
- package/dist/mcp/merchant-tools.d.ts +103 -0
- package/dist/mcp/merchant-tools.js +517 -0
- package/dist/mcp/merchant-tools.js.map +1 -0
- package/dist/mcp/server.js +4 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.d.ts +5 -3
- package/dist/mcp/tools.js +102 -5
- package/dist/mcp/tools.js.map +1 -1
- package/dist/mcp/types.d.ts +1 -1
- package/dist/mcp/types.js +4 -0
- package/dist/mcp/types.js.map +1 -1
- package/dist/merchant/ops.d.ts +0 -11
- package/dist/merchant/ops.js +13 -1
- package/dist/merchant/ops.js.map +1 -1
- package/dist/merchant/workbench-service.d.ts +189 -0
- package/dist/merchant/workbench-service.js +417 -0
- package/dist/merchant/workbench-service.js.map +1 -0
- package/dist/merchant-admin/pending-page.d.ts +35 -0
- package/dist/merchant-admin/pending-page.js +69 -0
- package/dist/merchant-admin/pending-page.js.map +1 -0
- package/dist/merchant-core/commands.d.ts +99 -0
- package/dist/merchant-core/commands.js +179 -0
- package/dist/merchant-core/commands.js.map +1 -0
- package/dist/merchant-core/executor.d.ts +69 -0
- package/dist/merchant-core/executor.js +218 -0
- package/dist/merchant-core/executor.js.map +1 -0
- package/dist/merchant-core/negotiation-adapters.d.ts +55 -0
- package/dist/merchant-core/negotiation-adapters.js +55 -0
- package/dist/merchant-core/negotiation-adapters.js.map +1 -0
- package/dist/merchant-core/network-checks.d.ts +65 -0
- package/dist/merchant-core/network-checks.js +111 -0
- package/dist/merchant-core/network-checks.js.map +1 -0
- package/dist/merchant-core/operations.d.ts +64 -0
- package/dist/merchant-core/operations.js +136 -0
- package/dist/merchant-core/operations.js.map +1 -0
- package/dist/merchant-core/policy-runtime.d.ts +70 -0
- package/dist/merchant-core/policy-runtime.js +181 -0
- package/dist/merchant-core/policy-runtime.js.map +1 -0
- package/dist/merchant-core/product-import.d.ts +67 -0
- package/dist/merchant-core/product-import.js +160 -0
- package/dist/merchant-core/product-import.js.map +1 -0
- package/dist/merchant-core/service.d.ts +203 -0
- package/dist/merchant-core/service.js +402 -0
- package/dist/merchant-core/service.js.map +1 -0
- package/dist/merchant-gateway/catalog-publications.d.ts +96 -0
- package/dist/merchant-gateway/catalog-publications.js +212 -0
- package/dist/merchant-gateway/catalog-publications.js.map +1 -0
- package/dist/merchant-gateway/catalog-tools.d.ts +46 -0
- package/dist/merchant-gateway/catalog-tools.js +315 -0
- package/dist/merchant-gateway/catalog-tools.js.map +1 -0
- package/dist/merchant-gateway/cli.d.ts +75 -0
- package/dist/merchant-gateway/cli.js +461 -0
- package/dist/merchant-gateway/cli.js.map +1 -0
- package/dist/merchant-gateway/credential-vault.d.ts +44 -0
- package/dist/merchant-gateway/credential-vault.js +111 -0
- package/dist/merchant-gateway/credential-vault.js.map +1 -0
- package/dist/merchant-gateway/entry-server.d.ts +117 -0
- package/dist/merchant-gateway/entry-server.js +747 -0
- package/dist/merchant-gateway/entry-server.js.map +1 -0
- package/dist/merchant-gateway/instance-registration.d.ts +144 -0
- package/dist/merchant-gateway/instance-registration.js +391 -0
- package/dist/merchant-gateway/instance-registration.js.map +1 -0
- package/dist/merchant-gateway/instance-tools.d.ts +58 -0
- package/dist/merchant-gateway/instance-tools.js +186 -0
- package/dist/merchant-gateway/instance-tools.js.map +1 -0
- package/dist/merchant-gateway/mcp-proxy.d.ts +25 -0
- package/dist/merchant-gateway/mcp-proxy.js +192 -0
- package/dist/merchant-gateway/mcp-proxy.js.map +1 -0
- package/dist/merchant-gateway/pinned-fetch.d.ts +54 -0
- package/dist/merchant-gateway/pinned-fetch.js +231 -0
- package/dist/merchant-gateway/pinned-fetch.js.map +1 -0
- package/dist/merchant-gateway/tenant-registry.d.ts +82 -0
- package/dist/merchant-gateway/tenant-registry.js +190 -0
- package/dist/merchant-gateway/tenant-registry.js.map +1 -0
- package/dist/merchant-gateway/tool-bundle.d.ts +26 -0
- package/dist/merchant-gateway/tool-bundle.js +56 -0
- package/dist/merchant-gateway/tool-bundle.js.map +1 -0
- package/dist/merchant-runtime/backup.d.ts +48 -0
- package/dist/merchant-runtime/backup.js +226 -0
- package/dist/merchant-runtime/backup.js.map +1 -0
- package/dist/merchant-runtime/health.d.ts +99 -0
- package/dist/merchant-runtime/health.js +206 -0
- package/dist/merchant-runtime/health.js.map +1 -0
- package/dist/merchant-runtime/jobs.d.ts +64 -0
- package/dist/merchant-runtime/jobs.js +107 -0
- package/dist/merchant-runtime/jobs.js.map +1 -0
- package/dist/merchant-runtime/manager.d.ts +94 -0
- package/dist/merchant-runtime/manager.js +305 -0
- package/dist/merchant-runtime/manager.js.map +1 -0
- package/dist/merchant-runtime/services.d.ts +30 -0
- package/dist/merchant-runtime/services.js +51 -0
- package/dist/merchant-runtime/services.js.map +1 -0
- package/dist/net/url.d.ts +26 -0
- package/dist/net/url.js +32 -0
- package/dist/net/url.js.map +1 -0
- package/dist/product-cli.js +22 -3
- package/dist/product-cli.js.map +1 -1
- package/dist/product-compat.d.ts +9 -1
- package/dist/product-compat.js +12 -1
- package/dist/product-compat.js.map +1 -1
- package/dist/product-init.js +17 -5
- package/dist/product-init.js.map +1 -1
- package/dist/product-publish.js +2 -1
- package/dist/product-publish.js.map +1 -1
- package/package.json +3 -2
- package/skills/kiwi-buyer/SKILL.md +2 -0
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 harrylabsj
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Merchant MCP 的 OAuth 2.1 授权服务器(WorkBuddy Buddy 应用 V2 阶段一)。
|
|
18
|
+
*
|
|
19
|
+
* Spike 结论(https://open.workbuddy.cn/docs/connector):WorkBuddy 内置 OAuth
|
|
20
|
+
* 管理器只做客户端侧(OAuth 2.1 + PKCE、公共客户端、动态注册),服务端端点
|
|
21
|
+
* 须自建。本模块实现:
|
|
22
|
+
* - GET /.well-known/oauth-protected-resource(RFC 9728)
|
|
23
|
+
* - GET /.well-known/oauth-authorization-server(RFC 8414)
|
|
24
|
+
* - POST /oauth/register(RFC 7591 动态客户端注册;只接受公共客户端,
|
|
25
|
+
* 响应回显 redirect_uris)
|
|
26
|
+
* - GET /oauth/authorize(授权确认页:商家名 + 申请 scope + 同意/拒绝;
|
|
27
|
+
* PKCE S256 强制;redirect_uri 字符串精确匹配;state 透传;CSRF 一次性令牌)
|
|
28
|
+
* - POST /oauth/authorize(表单提交 → 授权码 302 回跳)
|
|
29
|
+
* - POST /oauth/token(authorization_code / refresh_token;授权码一次性、
|
|
30
|
+
* 10 分钟有效;access_token 1 小时、refresh_token 30 天,刷新即轮换)
|
|
31
|
+
* - POST /oauth/revoke(RFC 7009)
|
|
32
|
+
*
|
|
33
|
+
* 持久化:授权码/token/客户端注册落状态目录 oauth.sqlite(单 owner 写约束
|
|
34
|
+
* 沿用现有模式;目录 0700)。授权码与 token 只存 sha256 摘要——明文不落盘、
|
|
35
|
+
* 不进日志。错误一律 OAuth 2.1 标准格式({error, error_description?})。
|
|
36
|
+
* 生产强制 HTTPS:issuer 非 https 且非 loopback 时构造即拒绝(fail-closed)。
|
|
37
|
+
*/
|
|
38
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
39
|
+
import { isLoopbackHost } from "../a2a/client/url-policy.js";
|
|
40
|
+
/** 授权码有效期(10 分钟,一次性使用)。 */
|
|
41
|
+
export const OAUTH_CODE_TTL_MS = 10 * 60 * 1000;
|
|
42
|
+
/** access_token 有效期(1 小时)。 */
|
|
43
|
+
export const OAUTH_ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
|
|
44
|
+
/** refresh_token 有效期(30 天)。 */
|
|
45
|
+
export const OAUTH_REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
46
|
+
/** 授权请求(授权页挂起单)有效期(10 分钟)。 */
|
|
47
|
+
export const OAUTH_AUTH_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
48
|
+
/** WorkBuddy 私有协议回调(source 占位替换为实际连接器 source)。 */
|
|
49
|
+
export function workbuddyCallbackUri(source) {
|
|
50
|
+
return `workbuddy://workbuddy/mcp/connector%3A${source}/oauth/callback`;
|
|
51
|
+
}
|
|
52
|
+
/** 解析生效的期望回调地址(显式配置优先,否则按 source 派生)。 */
|
|
53
|
+
export function expectedCallbackUri(connectorSource, policy = {}) {
|
|
54
|
+
return policy.expectedCallbackUri ?? workbuddyCallbackUri(connectorSource);
|
|
55
|
+
}
|
|
56
|
+
/** redirect_uri 白名单判定:期望回调(source 派生或显式配置) 或 http loopback 回退。 */
|
|
57
|
+
export function isAllowedRedirectUri(uri, connectorSource, policy = {}) {
|
|
58
|
+
if (uri === expectedCallbackUri(connectorSource, policy))
|
|
59
|
+
return true;
|
|
60
|
+
if (policy.allowLoopbackFallback === false)
|
|
61
|
+
return false;
|
|
62
|
+
try {
|
|
63
|
+
const url = new URL(uri);
|
|
64
|
+
return url.protocol === "http:" && isLoopbackHost(url.hostname);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function oauthError(status, error, description) {
|
|
71
|
+
return { status, body: { error, error_description: description } };
|
|
72
|
+
}
|
|
73
|
+
function randomToken(prefix) {
|
|
74
|
+
return `${prefix}_${randomBytes(32).toString("base64url")}`;
|
|
75
|
+
}
|
|
76
|
+
/** 摘要落盘(sha256):授权码/token 明文绝不落库。 */
|
|
77
|
+
function digestOf(value) {
|
|
78
|
+
return createHash("sha256").update(value).digest("hex");
|
|
79
|
+
}
|
|
80
|
+
/** PKCE S256:base64url(sha256(verifier))。 */
|
|
81
|
+
export function pkceS256(verifier) {
|
|
82
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
83
|
+
}
|
|
84
|
+
// ── 持久化(oauth.sqlite;单 owner 写)──────────────────────────────────────
|
|
85
|
+
const OAUTH_SCHEMA = `
|
|
86
|
+
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
87
|
+
client_id TEXT PRIMARY KEY,
|
|
88
|
+
client_name TEXT,
|
|
89
|
+
redirect_uris_json TEXT NOT NULL,
|
|
90
|
+
created_at TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
CREATE TABLE IF NOT EXISTS oauth_auth_requests (
|
|
93
|
+
csrf TEXT PRIMARY KEY,
|
|
94
|
+
client_id TEXT NOT NULL,
|
|
95
|
+
redirect_uri TEXT NOT NULL,
|
|
96
|
+
scope TEXT NOT NULL,
|
|
97
|
+
state TEXT,
|
|
98
|
+
code_challenge TEXT NOT NULL,
|
|
99
|
+
merchant_id TEXT NOT NULL,
|
|
100
|
+
principal_id TEXT NOT NULL,
|
|
101
|
+
expires_at TEXT NOT NULL,
|
|
102
|
+
created_at TEXT NOT NULL
|
|
103
|
+
);
|
|
104
|
+
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
105
|
+
code_digest TEXT PRIMARY KEY,
|
|
106
|
+
client_id TEXT NOT NULL,
|
|
107
|
+
redirect_uri TEXT NOT NULL,
|
|
108
|
+
scope TEXT NOT NULL,
|
|
109
|
+
principal_id TEXT NOT NULL,
|
|
110
|
+
merchant_id TEXT NOT NULL,
|
|
111
|
+
code_challenge TEXT NOT NULL,
|
|
112
|
+
expires_at TEXT NOT NULL,
|
|
113
|
+
used_at TEXT,
|
|
114
|
+
created_at TEXT NOT NULL
|
|
115
|
+
);
|
|
116
|
+
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
|
117
|
+
access_digest TEXT PRIMARY KEY,
|
|
118
|
+
refresh_digest TEXT UNIQUE,
|
|
119
|
+
client_id TEXT NOT NULL,
|
|
120
|
+
principal_id TEXT NOT NULL,
|
|
121
|
+
merchant_id TEXT NOT NULL,
|
|
122
|
+
scope TEXT NOT NULL,
|
|
123
|
+
expires_at TEXT NOT NULL,
|
|
124
|
+
revoked_at TEXT,
|
|
125
|
+
created_at TEXT NOT NULL
|
|
126
|
+
);
|
|
127
|
+
`;
|
|
128
|
+
/** BUG-10 迁移:refresh_token 独立过期(老库 ALTER 补列,老数据按 created_at+30d
|
|
129
|
+
* 回填——比库创建时还旧的 refresh 自然过期,fail-closed)。迁移与回填在构造
|
|
130
|
+
* 函数中以单事务执行(见构造函数审查 P2 注释),SQL 已内联。 */
|
|
131
|
+
/** BUG-01 迁移:授权挂起单绑定认证主体(老库补 principal_id 列;老挂起单
|
|
132
|
+
* 10 分钟内过期,给空串占位即不可再被消费——consumeAuthRequest 拒绝)。 */
|
|
133
|
+
const OAUTH_MIGRATION_AUTH_REQUEST_PRINCIPAL = `
|
|
134
|
+
ALTER TABLE oauth_auth_requests ADD COLUMN principal_id TEXT NOT NULL DEFAULT '';
|
|
135
|
+
`;
|
|
136
|
+
/** BUG-02 一次性确认凭证:绑定候选内容摘要 + 主体 + 商家 + 动作 + 有效期,
|
|
137
|
+
* 单次用途(管理确认页批准/拒绝的唯一可信通道;模型 MCP 工具已移除)。 */
|
|
138
|
+
const OAUTH_CONFIRMATIONS_SCHEMA = `
|
|
139
|
+
CREATE TABLE IF NOT EXISTS oauth_confirmations (
|
|
140
|
+
token_digest TEXT PRIMARY KEY,
|
|
141
|
+
candidate_id TEXT NOT NULL,
|
|
142
|
+
candidate_digest TEXT NOT NULL,
|
|
143
|
+
principal_id TEXT NOT NULL,
|
|
144
|
+
merchant_id TEXT NOT NULL,
|
|
145
|
+
action TEXT NOT NULL,
|
|
146
|
+
expires_at TEXT NOT NULL,
|
|
147
|
+
used_at TEXT,
|
|
148
|
+
created_at TEXT NOT NULL
|
|
149
|
+
);
|
|
150
|
+
`;
|
|
151
|
+
export class MerchantOAuthStore {
|
|
152
|
+
db;
|
|
153
|
+
now;
|
|
154
|
+
constructor(options) {
|
|
155
|
+
this.db = options.db;
|
|
156
|
+
this.now = options.now ?? (() => new Date().toISOString());
|
|
157
|
+
this.db.exec(OAUTH_SCHEMA);
|
|
158
|
+
this.db.exec(OAUTH_CONFIRMATIONS_SCHEMA);
|
|
159
|
+
// BUG-10 迁移:老库补 refresh_expires_at 列。审查 P2:ALTER 与回填
|
|
160
|
+
// UPDATE 在同一事务提交——半途崩溃不会留下「列存在但全 NULL」的库
|
|
161
|
+
// (NULL 行会让 refresh 永不过期,fail-open);列已存在时仍幂等重跑
|
|
162
|
+
// 回填,补齐历史中断遗留的 NULL 行。
|
|
163
|
+
const columns = this.db.prepare("PRAGMA table_info(oauth_tokens)").all();
|
|
164
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
165
|
+
try {
|
|
166
|
+
if (!columns.some((c) => c.name === "refresh_expires_at")) {
|
|
167
|
+
this.db.exec("ALTER TABLE oauth_tokens ADD COLUMN refresh_expires_at TEXT;");
|
|
168
|
+
}
|
|
169
|
+
this.db.exec("UPDATE oauth_tokens SET refresh_expires_at = " +
|
|
170
|
+
"strftime('%Y-%m-%dT%H:%M:%fZ', created_at, '+30 days') WHERE refresh_expires_at IS NULL;");
|
|
171
|
+
this.db.exec("COMMIT");
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
this.db.exec("ROLLBACK");
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
// BUG-01 迁移:老库补授权挂起单 principal_id 列(老挂起单不可再消费)。
|
|
178
|
+
const reqColumns = this.db.prepare("PRAGMA table_info(oauth_auth_requests)").all();
|
|
179
|
+
if (!reqColumns.some((c) => c.name === "principal_id")) {
|
|
180
|
+
this.db.exec(OAUTH_MIGRATION_AUTH_REQUEST_PRINCIPAL);
|
|
181
|
+
}
|
|
182
|
+
this.cleanupExpired();
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 过期行清理(审查 P2:此前全库只增不删,长期运行单调膨胀)。只删完全
|
|
186
|
+
* 失效的行:挂起单/授权码/一次性凭证按各自 expires_at;token 需 access
|
|
187
|
+
* 与 refresh 双过期(撤销行在 refresh 未过期前保留可查)。
|
|
188
|
+
*/
|
|
189
|
+
cleanupExpired() {
|
|
190
|
+
const now = this.now();
|
|
191
|
+
this.db.prepare("DELETE FROM oauth_auth_requests WHERE expires_at <= ?").run(now);
|
|
192
|
+
this.db.prepare("DELETE FROM oauth_codes WHERE expires_at <= ?").run(now);
|
|
193
|
+
this.db.prepare("DELETE FROM oauth_confirmations WHERE expires_at <= ?").run(now);
|
|
194
|
+
this.db
|
|
195
|
+
.prepare("DELETE FROM oauth_tokens WHERE expires_at <= ? AND refresh_expires_at <= ?")
|
|
196
|
+
.run(now, now);
|
|
197
|
+
}
|
|
198
|
+
isoAfter(ms) {
|
|
199
|
+
return new Date(Date.parse(this.now()) + ms).toISOString();
|
|
200
|
+
}
|
|
201
|
+
expired(expiresAt) {
|
|
202
|
+
return expiresAt <= this.now();
|
|
203
|
+
}
|
|
204
|
+
// ---- clients ----
|
|
205
|
+
registerClient(input) {
|
|
206
|
+
const row = {
|
|
207
|
+
client_id: randomToken("mcp_client"),
|
|
208
|
+
client_name: input.client_name ?? null,
|
|
209
|
+
redirect_uris: input.redirect_uris,
|
|
210
|
+
created_at: this.now(),
|
|
211
|
+
};
|
|
212
|
+
this.db
|
|
213
|
+
.prepare("INSERT INTO oauth_clients (client_id, client_name, redirect_uris_json, created_at) VALUES (?, ?, ?, ?)")
|
|
214
|
+
.run(row.client_id, row.client_name, JSON.stringify(row.redirect_uris), row.created_at);
|
|
215
|
+
return row;
|
|
216
|
+
}
|
|
217
|
+
getClient(clientId) {
|
|
218
|
+
const row = this.db.prepare("SELECT * FROM oauth_clients WHERE client_id = ?").get(clientId);
|
|
219
|
+
if (row === undefined)
|
|
220
|
+
return undefined;
|
|
221
|
+
return {
|
|
222
|
+
client_id: row.client_id,
|
|
223
|
+
client_name: row.client_name,
|
|
224
|
+
redirect_uris: JSON.parse(row.redirect_uris_json),
|
|
225
|
+
created_at: row.created_at,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// ---- authorization requests(授权页挂起单;csrf 主键一次性消费)----
|
|
229
|
+
createAuthRequest(input) {
|
|
230
|
+
const csrf = randomToken("oauth_req");
|
|
231
|
+
this.db
|
|
232
|
+
.prepare(`INSERT INTO oauth_auth_requests
|
|
233
|
+
(csrf, client_id, redirect_uri, scope, state, code_challenge, merchant_id, principal_id, expires_at, created_at)
|
|
234
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
235
|
+
.run(csrf, input.client_id, input.redirect_uri, input.scope, input.state ?? null, input.code_challenge, input.merchant_id, input.principal_id, this.isoAfter(OAUTH_AUTH_REQUEST_TTL_MS), this.now());
|
|
236
|
+
return csrf;
|
|
237
|
+
}
|
|
238
|
+
/** 取出并删除(一次性;过期或缺认证主体(迁移老数据)视为不存在)。 */
|
|
239
|
+
consumeAuthRequest(csrf) {
|
|
240
|
+
const row = this.db.prepare("SELECT * FROM oauth_auth_requests WHERE csrf = ?").get(csrf);
|
|
241
|
+
if (row === undefined)
|
|
242
|
+
return undefined;
|
|
243
|
+
this.db.prepare("DELETE FROM oauth_auth_requests WHERE csrf = ?").run(csrf);
|
|
244
|
+
if (this.expired(row.expires_at))
|
|
245
|
+
return undefined;
|
|
246
|
+
if (row.principal_id === "")
|
|
247
|
+
return undefined; // 迁移老挂起单:无认证主体
|
|
248
|
+
return {
|
|
249
|
+
client_id: row.client_id,
|
|
250
|
+
redirect_uri: row.redirect_uri,
|
|
251
|
+
scope: row.scope,
|
|
252
|
+
...(row.state !== null ? { state: row.state } : {}),
|
|
253
|
+
code_challenge: row.code_challenge,
|
|
254
|
+
merchant_id: row.merchant_id,
|
|
255
|
+
principal_id: row.principal_id,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// ---- authorization codes(一次性,10 分钟)----
|
|
259
|
+
createCode(input) {
|
|
260
|
+
const code = randomToken("oauth_code");
|
|
261
|
+
this.db
|
|
262
|
+
.prepare(`INSERT INTO oauth_codes
|
|
263
|
+
(code_digest, client_id, redirect_uri, scope, principal_id, merchant_id, code_challenge, expires_at, created_at)
|
|
264
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
265
|
+
.run(digestOf(code), input.client_id, input.redirect_uri, input.scope, input.principal_id, input.merchant_id, input.code_challenge, this.isoAfter(OAUTH_CODE_TTL_MS), this.now());
|
|
266
|
+
return code;
|
|
267
|
+
}
|
|
268
|
+
/** 核销授权码(一次性;过期/已用返回 undefined)。事务 + 条件更新:与
|
|
269
|
+
* rotateRefreshToken 同口径,双进程误开同一 oauth.sqlite 时同一授权码也
|
|
270
|
+
* 只能被核销一次(审查 P2:原 SELECT→UPDATE 无原子性)。 */
|
|
271
|
+
consumeCode(code) {
|
|
272
|
+
const digest = digestOf(code);
|
|
273
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
274
|
+
try {
|
|
275
|
+
const row = this.db.prepare("SELECT * FROM oauth_codes WHERE code_digest = ?").get(digest);
|
|
276
|
+
if (row === undefined || row.used_at !== null || this.expired(row.expires_at)) {
|
|
277
|
+
this.db.exec("ROLLBACK");
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
const used = this.db
|
|
281
|
+
.prepare("UPDATE oauth_codes SET used_at = ? WHERE code_digest = ? AND used_at IS NULL")
|
|
282
|
+
.run(this.now(), digest);
|
|
283
|
+
if (used.changes !== 1) {
|
|
284
|
+
this.db.exec("ROLLBACK"); // 并发核销:另一方先到
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
this.db.exec("COMMIT");
|
|
288
|
+
return row;
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
this.db.exec("ROLLBACK");
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// ---- tokens(access 1h / refresh 30d;刷新即轮换)----
|
|
296
|
+
issueTokenPair(input) {
|
|
297
|
+
const accessToken = randomToken("mcp_at");
|
|
298
|
+
const refreshToken = randomToken("mcp_rt");
|
|
299
|
+
this.db
|
|
300
|
+
.prepare(`INSERT INTO oauth_tokens
|
|
301
|
+
(access_digest, refresh_digest, client_id, principal_id, merchant_id, scope, expires_at, refresh_expires_at, created_at)
|
|
302
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
303
|
+
.run(digestOf(accessToken), digestOf(refreshToken), input.client_id, input.principal_id, input.merchant_id, input.scope, this.isoAfter(OAUTH_ACCESS_TOKEN_TTL_MS),
|
|
304
|
+
// BUG-10:refresh 独立过期(30 天),不再永不过期。
|
|
305
|
+
this.isoAfter(OAUTH_REFRESH_TOKEN_TTL_MS), this.now());
|
|
306
|
+
return {
|
|
307
|
+
access_token: accessToken,
|
|
308
|
+
refresh_token: refreshToken,
|
|
309
|
+
expires_in: Math.floor(OAUTH_ACCESS_TOKEN_TTL_MS / 1000),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/** 查 access token(未过期、未撤销才返回)。 */
|
|
313
|
+
getAccessToken(accessToken) {
|
|
314
|
+
const row = this.db
|
|
315
|
+
.prepare("SELECT * FROM oauth_tokens WHERE access_digest = ?")
|
|
316
|
+
.get(digestOf(accessToken));
|
|
317
|
+
if (row === undefined || row.revoked_at !== null)
|
|
318
|
+
return undefined;
|
|
319
|
+
if (this.expired(row.expires_at))
|
|
320
|
+
return undefined;
|
|
321
|
+
return {
|
|
322
|
+
client_id: row.client_id,
|
|
323
|
+
principal_id: row.principal_id,
|
|
324
|
+
merchant_id: row.merchant_id,
|
|
325
|
+
scope: row.scope.split(" ").filter((s) => s !== ""),
|
|
326
|
+
expires_at: row.expires_at,
|
|
327
|
+
revoked_at: row.revoked_at,
|
|
328
|
+
created_at: row.created_at,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* 刷新轮换(BUG-10):同一事务内验证旧 refresh 未撤销、未过期(独立
|
|
333
|
+
* refresh_expires_at)并核销,再签发新 token 对——并发轮换只有一个成功
|
|
334
|
+
* (UPDATE … WHERE revoked_at IS NULL 的条件更新,未命中即已被轮换)。
|
|
335
|
+
*/
|
|
336
|
+
rotateRefreshToken(refreshToken, expectedClientId) {
|
|
337
|
+
const digest = digestOf(refreshToken);
|
|
338
|
+
const now = this.now();
|
|
339
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
340
|
+
try {
|
|
341
|
+
const row = this.db
|
|
342
|
+
.prepare("SELECT * FROM oauth_tokens WHERE refresh_digest = ?")
|
|
343
|
+
.get(digest);
|
|
344
|
+
if (row === undefined || row.revoked_at !== null) {
|
|
345
|
+
this.db.exec("ROLLBACK");
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
// 独立 refresh 过期检查(审查 P2:NULL = 迁移遗留的不可判定行,
|
|
349
|
+
// fail-closed 拒绝;正常路径构造函数迁移已回填,不会出现 NULL)。
|
|
350
|
+
if (row.refresh_expires_at === null || row.refresh_expires_at <= now) {
|
|
351
|
+
this.db.exec("ROLLBACK");
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
// RFC 6749 §6 / OAuth 2.1:refresh grant 绑定原 client_id(审查 P2)。
|
|
355
|
+
if (expectedClientId !== undefined && row.client_id !== expectedClientId) {
|
|
356
|
+
this.db.exec("ROLLBACK");
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
const revoked = this.db
|
|
360
|
+
.prepare("UPDATE oauth_tokens SET revoked_at = ? WHERE refresh_digest = ? AND revoked_at IS NULL")
|
|
361
|
+
.run(now, digest);
|
|
362
|
+
if (revoked.changes === 0) {
|
|
363
|
+
this.db.exec("ROLLBACK"); // 并发轮换:另一方先核销
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
const pair = this.issueTokenPair({
|
|
367
|
+
client_id: row.client_id,
|
|
368
|
+
principal_id: row.principal_id,
|
|
369
|
+
merchant_id: row.merchant_id,
|
|
370
|
+
scope: row.scope,
|
|
371
|
+
});
|
|
372
|
+
this.db.exec("COMMIT");
|
|
373
|
+
return pair;
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
this.db.exec("ROLLBACK");
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** RFC 7009 撤销(access 或 refresh;幂等,未知 token 也视为成功)。 */
|
|
381
|
+
revokeToken(token) {
|
|
382
|
+
const digest = digestOf(token);
|
|
383
|
+
this.db
|
|
384
|
+
.prepare("UPDATE oauth_tokens SET revoked_at = ? WHERE revoked_at IS NULL AND (access_digest = ? OR refresh_digest = ?)")
|
|
385
|
+
.run(this.now(), digest, digest);
|
|
386
|
+
}
|
|
387
|
+
// ---- 一次性确认凭证(BUG-02:批准/拒绝的唯一可信通道) --------------------
|
|
388
|
+
/**
|
|
389
|
+
* 签发一次性确认凭证(管理确认页渲染时按候选生成;绑定候选内容摘要 +
|
|
390
|
+
* 主体 + 商家 + 动作 + 10 分钟有效期)。返回明文 token(只进表单,不落库)。
|
|
391
|
+
*/
|
|
392
|
+
createConfirmation(input) {
|
|
393
|
+
const token = randomToken("kcfrm");
|
|
394
|
+
this.db
|
|
395
|
+
.prepare(`INSERT INTO oauth_confirmations
|
|
396
|
+
(token_digest, candidate_id, candidate_digest, principal_id, merchant_id, action, expires_at, created_at)
|
|
397
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
398
|
+
.run(digestOf(token), input.candidateId, input.candidateDigest, input.principalId, input.merchantId, input.action, this.isoAfter(OAUTH_CODE_TTL_MS), this.now());
|
|
399
|
+
return token;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* 核销确认凭证(单次用途;逐项核对候选/摘要/主体/商家/动作,未过期未使用
|
|
403
|
+
* 才返回记录)。核对失败返回 undefined(fail-closed)。
|
|
404
|
+
*/
|
|
405
|
+
consumeConfirmation(token, expected) {
|
|
406
|
+
const digest = digestOf(token);
|
|
407
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
408
|
+
try {
|
|
409
|
+
const row = this.db
|
|
410
|
+
.prepare("SELECT * FROM oauth_confirmations WHERE token_digest = ?")
|
|
411
|
+
.get(digest);
|
|
412
|
+
const valid = row !== undefined &&
|
|
413
|
+
row.used_at === null &&
|
|
414
|
+
row.expires_at > this.now() &&
|
|
415
|
+
row.candidate_id === expected.candidateId &&
|
|
416
|
+
row.candidate_digest === expected.candidateDigest &&
|
|
417
|
+
row.principal_id === expected.principalId &&
|
|
418
|
+
row.merchant_id === expected.merchantId &&
|
|
419
|
+
row.action === expected.action;
|
|
420
|
+
if (!valid) {
|
|
421
|
+
this.db.exec("ROLLBACK");
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
this.db
|
|
425
|
+
.prepare("UPDATE oauth_confirmations SET used_at = ? WHERE token_digest = ?")
|
|
426
|
+
.run(this.now(), digest);
|
|
427
|
+
this.db.exec("COMMIT");
|
|
428
|
+
return row === undefined
|
|
429
|
+
? undefined
|
|
430
|
+
: {
|
|
431
|
+
candidate_id: row.candidate_id,
|
|
432
|
+
principal_id: row.principal_id,
|
|
433
|
+
action: row.action,
|
|
434
|
+
created_at: row.created_at,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
this.db.exec("ROLLBACK");
|
|
439
|
+
throw err;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function escapeHtml(text) {
|
|
444
|
+
return text
|
|
445
|
+
.replaceAll("&", "&")
|
|
446
|
+
.replaceAll("<", "<")
|
|
447
|
+
.replaceAll(">", ">")
|
|
448
|
+
.replaceAll('"', """);
|
|
449
|
+
}
|
|
450
|
+
function redirectTo(uri, params) {
|
|
451
|
+
const sep = uri.includes("?") ? "&" : "?";
|
|
452
|
+
return {
|
|
453
|
+
status: 302,
|
|
454
|
+
headers: { location: `${uri}${sep}${new URLSearchParams(params).toString()}` },
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
export class MerchantOAuthServer {
|
|
458
|
+
store;
|
|
459
|
+
issuer;
|
|
460
|
+
resource;
|
|
461
|
+
connectorSource;
|
|
462
|
+
merchantName;
|
|
463
|
+
merchantId;
|
|
464
|
+
scopes;
|
|
465
|
+
loginPath;
|
|
466
|
+
callbackPolicy;
|
|
467
|
+
constructor(options) {
|
|
468
|
+
const issuerUrl = new URL(options.issuer);
|
|
469
|
+
// 生产强制 HTTPS;本地开发可 http loopback(对齐连接器文档的本地回退)。
|
|
470
|
+
if (issuerUrl.protocol !== "https:" && !isLoopbackHost(issuerUrl.hostname)) {
|
|
471
|
+
throw new Error(`OAuth issuer 必须 https(或 loopback http 开发地址):${options.issuer}`);
|
|
472
|
+
}
|
|
473
|
+
if (options.multiMerchant === true) {
|
|
474
|
+
if (options.merchantId !== undefined) {
|
|
475
|
+
throw new Error("通用 OAuth 入口不能同时固定 merchantId");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
else if (!options.merchantId) {
|
|
479
|
+
throw new Error("单商家 OAuth 入口必须配置 merchantId;通用入口须显式 multiMerchant=true");
|
|
480
|
+
}
|
|
481
|
+
this.store = options.store;
|
|
482
|
+
this.issuer = options.issuer.replace(/\/+$/, "");
|
|
483
|
+
this.resource = options.resource;
|
|
484
|
+
this.connectorSource = options.connectorSource;
|
|
485
|
+
this.merchantName = options.merchantName;
|
|
486
|
+
this.merchantId = options.merchantId;
|
|
487
|
+
this.scopes = options.scopes ?? ["merchant:read", "merchant:write"];
|
|
488
|
+
this.loginPath = options.loginPath ?? "/admin/login";
|
|
489
|
+
this.callbackPolicy = options.callbackPolicy ?? {};
|
|
490
|
+
}
|
|
491
|
+
/** RFC 9728 resource metadata URL(401 WWW-Authenticate 指引用)。 */
|
|
492
|
+
get resourceMetadataUrl() {
|
|
493
|
+
return `${this.issuer}/.well-known/oauth-protected-resource`;
|
|
494
|
+
}
|
|
495
|
+
/** RFC 9728 protected resource metadata。 */
|
|
496
|
+
protectedResourceMetadata() {
|
|
497
|
+
return {
|
|
498
|
+
status: 200,
|
|
499
|
+
body: { resource: this.resource, authorization_servers: [this.issuer] },
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/** RFC 8414 authorization server metadata。 */
|
|
503
|
+
authorizationServerMetadata() {
|
|
504
|
+
return {
|
|
505
|
+
status: 200,
|
|
506
|
+
body: {
|
|
507
|
+
issuer: this.issuer,
|
|
508
|
+
authorization_endpoint: `${this.issuer}/oauth/authorize`,
|
|
509
|
+
token_endpoint: `${this.issuer}/oauth/token`,
|
|
510
|
+
registration_endpoint: `${this.issuer}/oauth/register`,
|
|
511
|
+
revocation_endpoint: `${this.issuer}/oauth/revoke`,
|
|
512
|
+
scopes_supported: this.scopes,
|
|
513
|
+
response_types_supported: ["code"],
|
|
514
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
515
|
+
code_challenge_methods_supported: ["S256"],
|
|
516
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/** RFC 7591 动态注册:只接受公共客户端(无 client_secret);回显 redirect_uris。 */
|
|
521
|
+
register(body) {
|
|
522
|
+
const input = (body ?? {});
|
|
523
|
+
const redirectUris = input.redirect_uris;
|
|
524
|
+
if (!Array.isArray(redirectUris) ||
|
|
525
|
+
redirectUris.length === 0 ||
|
|
526
|
+
redirectUris.some((u) => typeof u !== "string" || u === "")) {
|
|
527
|
+
return oauthError(400, "invalid_client_metadata", "redirect_uris 必须是非空字符串数组");
|
|
528
|
+
}
|
|
529
|
+
for (const uri of redirectUris) {
|
|
530
|
+
if (!isAllowedRedirectUri(uri, this.connectorSource, this.callbackPolicy)) {
|
|
531
|
+
return oauthError(400, "invalid_redirect_uri", `redirect_uri 不在白名单(期望 ${expectedCallbackUri(this.connectorSource, this.callbackPolicy)}` +
|
|
532
|
+
`${this.callbackPolicy.allowLoopbackFallback === false ? ",不允许 loopback 回退" : " 或 http loopback"}):${uri}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const client = this.store.registerClient({
|
|
536
|
+
...(typeof input.client_name === "string" ? { client_name: input.client_name } : {}),
|
|
537
|
+
redirect_uris: redirectUris,
|
|
538
|
+
});
|
|
539
|
+
return {
|
|
540
|
+
status: 201,
|
|
541
|
+
body: {
|
|
542
|
+
client_id: client.client_id,
|
|
543
|
+
...(client.client_name !== null ? { client_name: client.client_name } : {}),
|
|
544
|
+
redirect_uris: client.redirect_uris,
|
|
545
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
546
|
+
response_types: ["code"],
|
|
547
|
+
token_endpoint_auth_method: "none",
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/** GET /oauth/authorize:校验请求 → 授权确认页(CSRF 挂起单)。 */
|
|
552
|
+
/**
|
|
553
|
+
* GET /oauth/authorize:校验请求 → 授权确认页(CSRF 挂起单)。
|
|
554
|
+
* BUG-01:授权前必须持有有效管理登录会话——未登录 → 303 登录页(回跳
|
|
555
|
+
* 原授权 URL);会话商家 ≠ 本实例商家 → 拒绝(跨商家授权拒绝)。
|
|
556
|
+
* 挂起单绑定会话认证的 principal_id,授权码主体只能来自认证用户。
|
|
557
|
+
*/
|
|
558
|
+
authorize(query, session) {
|
|
559
|
+
if (session === undefined) {
|
|
560
|
+
const next = `/oauth/authorize?${new URLSearchParams(Object.entries(query).filter((e) => e[1] !== undefined)).toString()}`;
|
|
561
|
+
return {
|
|
562
|
+
status: 303,
|
|
563
|
+
headers: { location: `${this.loginPath}?next=${encodeURIComponent(next)}` },
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
if (!session.merchant_id || !session.principal_id) {
|
|
567
|
+
return oauthError(403, "access_denied", "登录会话缺商家或授权主体");
|
|
568
|
+
}
|
|
569
|
+
if (this.merchantId !== undefined && session.merchant_id !== this.merchantId) {
|
|
570
|
+
return oauthError(403, "access_denied", "登录用户不属于本商家实例(跨商家授权拒绝)");
|
|
571
|
+
}
|
|
572
|
+
const fail = (error, description) => oauthError(400, error, description);
|
|
573
|
+
const redirectUri = query.redirect_uri;
|
|
574
|
+
if (query.response_type !== "code")
|
|
575
|
+
return fail("invalid_request", "response_type 必须是 code");
|
|
576
|
+
if (query.client_id === undefined)
|
|
577
|
+
return fail("invalid_request", "缺 client_id");
|
|
578
|
+
const client = this.store.getClient(query.client_id);
|
|
579
|
+
if (client === undefined)
|
|
580
|
+
return fail("invalid_client", "未知 client_id(请先动态注册)");
|
|
581
|
+
if (redirectUri === undefined || !client.redirect_uris.includes(redirectUri)) {
|
|
582
|
+
// redirect_uri 不可信时绝不回跳(防开放重定向),直接报错。
|
|
583
|
+
return fail("invalid_request", "redirect_uri 与注册值不匹配(字符串精确匹配)");
|
|
584
|
+
}
|
|
585
|
+
// 之后的错误可以安全回跳 redirect_uri(OAuth 2.1 标准)。
|
|
586
|
+
const redirectFail = (error, description) => redirectTo(redirectUri, {
|
|
587
|
+
error,
|
|
588
|
+
error_description: description,
|
|
589
|
+
...(query.state !== undefined ? { state: query.state } : {}),
|
|
590
|
+
});
|
|
591
|
+
if (query.code_challenge === undefined ||
|
|
592
|
+
query.code_challenge === "" ||
|
|
593
|
+
query.code_challenge_method !== "S256") {
|
|
594
|
+
return redirectFail("invalid_request", "必须 PKCE:code_challenge + code_challenge_method=S256");
|
|
595
|
+
}
|
|
596
|
+
const requestedScopes = (query.scope ?? this.scopes.join(" "))
|
|
597
|
+
.split(" ")
|
|
598
|
+
.filter((s) => s !== "");
|
|
599
|
+
const unknownScope = requestedScopes.find((s) => !this.scopes.includes(s));
|
|
600
|
+
if (unknownScope !== undefined) {
|
|
601
|
+
return redirectFail("invalid_scope", `不支持的 scope:${unknownScope}`);
|
|
602
|
+
}
|
|
603
|
+
const csrf = this.store.createAuthRequest({
|
|
604
|
+
client_id: query.client_id,
|
|
605
|
+
redirect_uri: redirectUri,
|
|
606
|
+
scope: requestedScopes.join(" "),
|
|
607
|
+
...(query.state !== undefined ? { state: query.state } : {}),
|
|
608
|
+
code_challenge: query.code_challenge,
|
|
609
|
+
merchant_id: session.merchant_id,
|
|
610
|
+
principal_id: session.principal_id,
|
|
611
|
+
});
|
|
612
|
+
return {
|
|
613
|
+
status: 200,
|
|
614
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
615
|
+
html: `<!doctype html>
|
|
616
|
+
<html lang="zh-CN">
|
|
617
|
+
<head><meta charset="utf-8"><title>授权确认 — Kiwi 商家工作台</title></head>
|
|
618
|
+
<body>
|
|
619
|
+
<h1>Kiwi 商家运营工作台</h1>
|
|
620
|
+
<p>应用「${escapeHtml(client.client_name ?? query.client_id)}」(客户端自报名称)请求访问商家「${escapeHtml(this.merchantName ?? session.merchant_name ?? session.merchant_id)}」的:</p>
|
|
621
|
+
<p>客户端 ID:<code>${escapeHtml(client.client_id)}</code>(注册于 ${escapeHtml(client.created_at)})。请核对该 ID 与连接器文档一致后再授权——client_name 为应用自报,不作为身份依据。</p>
|
|
622
|
+
<ul>
|
|
623
|
+
${requestedScopes.map((s) => `<li>${escapeHtml(s)}</li>`).join("\n ")}
|
|
624
|
+
</ul>
|
|
625
|
+
<form method="post" action="/oauth/authorize">
|
|
626
|
+
<input type="hidden" name="csrf" value="${escapeHtml(csrf)}">
|
|
627
|
+
<button type="submit" name="decision" value="approve">同意授权</button>
|
|
628
|
+
<button type="submit" name="decision" value="deny">拒绝</button>
|
|
629
|
+
</form>
|
|
630
|
+
</body>
|
|
631
|
+
</html>`,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* 授权流程在建立会话之前被中止(商家在目录拒绝了连接、连接失败)时的
|
|
636
|
+
* OAuth 2.1 错误回跳。
|
|
637
|
+
*
|
|
638
|
+
* 与 authorize 同一校验口径:client_id 与 redirect_uri 校验通过才回跳,
|
|
639
|
+
* 否则返回 400(防开放重定向)。客户端据此看到标准的 access_denied /
|
|
640
|
+
* server_error,而不是一个卡住的授权窗口。
|
|
641
|
+
*/
|
|
642
|
+
authorizationError(query, error, description) {
|
|
643
|
+
if (query.response_type !== "code") {
|
|
644
|
+
return oauthError(400, "invalid_request", "response_type 必须是 code");
|
|
645
|
+
}
|
|
646
|
+
if (query.client_id === undefined)
|
|
647
|
+
return oauthError(400, "invalid_request", "缺 client_id");
|
|
648
|
+
const client = this.store.getClient(query.client_id);
|
|
649
|
+
if (client === undefined) {
|
|
650
|
+
return oauthError(400, "invalid_client", "未知 client_id(请先动态注册)");
|
|
651
|
+
}
|
|
652
|
+
const redirectUri = query.redirect_uri;
|
|
653
|
+
if (redirectUri === undefined || !client.redirect_uris.includes(redirectUri)) {
|
|
654
|
+
return oauthError(400, "invalid_request", "redirect_uri 与注册值不匹配(字符串精确匹配)");
|
|
655
|
+
}
|
|
656
|
+
return redirectTo(redirectUri, {
|
|
657
|
+
error,
|
|
658
|
+
error_description: description,
|
|
659
|
+
...(query.state !== undefined ? { state: query.state } : {}),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
/** POST /oauth/authorize(表单):会话 + CSRF 校验 → 授权码回跳 / access_denied。 */
|
|
663
|
+
authorizeSubmit(form, session) {
|
|
664
|
+
const pending = typeof form.csrf === "string" ? this.store.consumeAuthRequest(form.csrf) : undefined;
|
|
665
|
+
if (pending === undefined) {
|
|
666
|
+
return oauthError(400, "invalid_request", "授权请求无效或已过期(CSRF/state 校验失败),请重新发起");
|
|
667
|
+
}
|
|
668
|
+
if (!session.merchant_id ||
|
|
669
|
+
!session.principal_id ||
|
|
670
|
+
(this.merchantId !== undefined && session.merchant_id !== this.merchantId) ||
|
|
671
|
+
session.merchant_id !== pending.merchant_id ||
|
|
672
|
+
session.principal_id !== pending.principal_id) {
|
|
673
|
+
return oauthError(403, "access_denied", "当前登录会话与授权请求主体不匹配");
|
|
674
|
+
}
|
|
675
|
+
if (form.decision !== "approve") {
|
|
676
|
+
return redirectTo(pending.redirect_uri, {
|
|
677
|
+
error: "access_denied",
|
|
678
|
+
error_description: "用户拒绝授权",
|
|
679
|
+
...(pending.state !== undefined ? { state: pending.state } : {}),
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
const code = this.store.createCode({
|
|
683
|
+
client_id: pending.client_id,
|
|
684
|
+
redirect_uri: pending.redirect_uri,
|
|
685
|
+
scope: pending.scope,
|
|
686
|
+
// BUG-01:token 主体来自挂起单中的认证用户(管理登录会话),
|
|
687
|
+
// 不再来自服务启动参数。
|
|
688
|
+
principal_id: pending.principal_id,
|
|
689
|
+
merchant_id: pending.merchant_id,
|
|
690
|
+
code_challenge: pending.code_challenge,
|
|
691
|
+
});
|
|
692
|
+
return redirectTo(pending.redirect_uri, {
|
|
693
|
+
code,
|
|
694
|
+
...(pending.state !== undefined ? { state: pending.state } : {}),
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
/** POST /oauth/token:authorization_code(PKCE 校验)/ refresh_token(轮换)。 */
|
|
698
|
+
token(form) {
|
|
699
|
+
if (form.grant_type === "authorization_code") {
|
|
700
|
+
const code = typeof form.code === "string" ? this.store.consumeCode(form.code) : undefined;
|
|
701
|
+
if (code === undefined) {
|
|
702
|
+
return oauthError(400, "invalid_grant", "授权码无效、已使用或已过期");
|
|
703
|
+
}
|
|
704
|
+
if (form.client_id !== code.client_id) {
|
|
705
|
+
return oauthError(400, "invalid_grant", "client_id 与授权码不匹配");
|
|
706
|
+
}
|
|
707
|
+
if (form.redirect_uri !== code.redirect_uri) {
|
|
708
|
+
return oauthError(400, "invalid_grant", "redirect_uri 与授权码不匹配");
|
|
709
|
+
}
|
|
710
|
+
if (typeof form.code_verifier !== "string" ||
|
|
711
|
+
pkceS256(form.code_verifier) !== code.code_challenge) {
|
|
712
|
+
return oauthError(400, "invalid_grant", "PKCE 校验失败(code_verifier 不匹配)");
|
|
713
|
+
}
|
|
714
|
+
const pair = this.store.issueTokenPair({
|
|
715
|
+
client_id: code.client_id,
|
|
716
|
+
principal_id: code.principal_id,
|
|
717
|
+
merchant_id: code.merchant_id,
|
|
718
|
+
scope: code.scope,
|
|
719
|
+
});
|
|
720
|
+
return {
|
|
721
|
+
status: 200,
|
|
722
|
+
body: {
|
|
723
|
+
access_token: pair.access_token,
|
|
724
|
+
token_type: "Bearer",
|
|
725
|
+
expires_in: pair.expires_in,
|
|
726
|
+
refresh_token: pair.refresh_token,
|
|
727
|
+
scope: code.scope,
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
if (form.grant_type === "refresh_token") {
|
|
732
|
+
if (typeof form.refresh_token !== "string") {
|
|
733
|
+
return oauthError(400, "invalid_request", "缺 refresh_token");
|
|
734
|
+
}
|
|
735
|
+
// RFC 6749 §6 / OAuth 2.1:refresh grant 必须携带原 client_id(审查 P2)。
|
|
736
|
+
if (typeof form.client_id !== "string" || form.client_id === "") {
|
|
737
|
+
return oauthError(400, "invalid_request", "refresh_token grant 缺 client_id");
|
|
738
|
+
}
|
|
739
|
+
const pair = this.store.rotateRefreshToken(form.refresh_token, form.client_id);
|
|
740
|
+
if (pair === undefined) {
|
|
741
|
+
return oauthError(400, "invalid_grant", "refresh_token 无效或已撤销");
|
|
742
|
+
}
|
|
743
|
+
const issued = this.store.getAccessToken(pair.access_token);
|
|
744
|
+
return {
|
|
745
|
+
status: 200,
|
|
746
|
+
body: {
|
|
747
|
+
access_token: pair.access_token,
|
|
748
|
+
token_type: "Bearer",
|
|
749
|
+
expires_in: pair.expires_in,
|
|
750
|
+
refresh_token: pair.refresh_token,
|
|
751
|
+
scope: issued?.scope.join(" ") ?? "",
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
return oauthError(400, "unsupported_grant_type", "grant_type 只支持 authorization_code / refresh_token");
|
|
756
|
+
}
|
|
757
|
+
/** POST /oauth/revoke(RFC 7009;幂等,未知 token 也返回 200)。 */
|
|
758
|
+
revoke(form) {
|
|
759
|
+
if (typeof form.token === "string" && form.token !== "")
|
|
760
|
+
this.store.revokeToken(form.token);
|
|
761
|
+
return { status: 200, body: {} };
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
//# sourceMappingURL=merchant-oauth.js.map
|