z-biz-tool-shared 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/sso/index.d.ts +26 -0
- package/dist/sso/index.d.ts.map +1 -0
- package/dist/sso/index.js +26 -0
- package/dist/sso/index.js.map +1 -0
- package/dist/sso/oauth.d.ts +213 -0
- package/dist/sso/oauth.d.ts.map +1 -0
- package/dist/sso/oauth.js +498 -0
- package/dist/sso/oauth.js.map +1 -0
- package/dist/sso/session.d.ts +121 -0
- package/dist/sso/session.d.ts.map +1 -0
- package/dist/sso/session.js +156 -0
- package/dist/sso/session.js.map +1 -0
- package/dist/sso/types.d.ts +141 -0
- package/dist/sso/types.d.ts.map +1 -0
- package/dist/sso/types.js +34 -0
- package/dist/sso/types.js.map +1 -0
- package/package.json +5 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zBiz 家族统一 SSO(ctc 侧)。
|
|
3
|
+
*
|
|
4
|
+
* ## 分层
|
|
5
|
+
*
|
|
6
|
+
* | 层 | 在哪 | 管什么 |
|
|
7
|
+
* |----|----|--------|
|
|
8
|
+
* | 类型与配置 | `types.ts` | SsoConfig / SsoUser / SsoSession / 错误码 |
|
|
9
|
+
* | 协议流程 | `oauth.ts` | 授权码流程(纯逻辑,依赖全注入) |
|
|
10
|
+
* | **环境适配** | **各仓自己** | 回环服务器 / 开浏览器 / HTTP / SHA-256 |
|
|
11
|
+
*
|
|
12
|
+
* ## 为什么环境适配不进共享层
|
|
13
|
+
*
|
|
14
|
+
* 回环服务器要占端口、开浏览器要走 Tauri 的 opener 插件、SHA-256 要走 WebCrypto ——
|
|
15
|
+
* 这些在 node、浏览器、Tauri 三种环境里实现方式完全不同。
|
|
16
|
+
*
|
|
17
|
+
* 硬塞进共享层会带来两个后果:共享层要么依赖 Tauri(那它就不能被 node 测试和
|
|
18
|
+
* web 版复用),要么塞进一堆 `if (isTauri)` 分支(那它就不是「一份实现」了)。
|
|
19
|
+
*
|
|
20
|
+
* 所以共享层只留**流程**,把三件事留成注入的依赖。各仓写一份几十行的适配器,
|
|
21
|
+
* 但**流程本身只有一份** —— 这才是要消除的重复。
|
|
22
|
+
*/
|
|
23
|
+
export * from './types.js';
|
|
24
|
+
export * from './oauth.js';
|
|
25
|
+
export * from './session.js';
|
|
26
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sso/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,cAAc,SAAS,CAAC;AACxB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zBiz 家族统一 SSO(ctc 侧)。
|
|
3
|
+
*
|
|
4
|
+
* ## 分层
|
|
5
|
+
*
|
|
6
|
+
* | 层 | 在哪 | 管什么 |
|
|
7
|
+
* |----|----|--------|
|
|
8
|
+
* | 类型与配置 | `types.ts` | SsoConfig / SsoUser / SsoSession / 错误码 |
|
|
9
|
+
* | 协议流程 | `oauth.ts` | 授权码流程(纯逻辑,依赖全注入) |
|
|
10
|
+
* | **环境适配** | **各仓自己** | 回环服务器 / 开浏览器 / HTTP / SHA-256 |
|
|
11
|
+
*
|
|
12
|
+
* ## 为什么环境适配不进共享层
|
|
13
|
+
*
|
|
14
|
+
* 回环服务器要占端口、开浏览器要走 Tauri 的 opener 插件、SHA-256 要走 WebCrypto ——
|
|
15
|
+
* 这些在 node、浏览器、Tauri 三种环境里实现方式完全不同。
|
|
16
|
+
*
|
|
17
|
+
* 硬塞进共享层会带来两个后果:共享层要么依赖 Tauri(那它就不能被 node 测试和
|
|
18
|
+
* web 版复用),要么塞进一堆 `if (isTauri)` 分支(那它就不是「一份实现」了)。
|
|
19
|
+
*
|
|
20
|
+
* 所以共享层只留**流程**,把三件事留成注入的依赖。各仓写一份几十行的适配器,
|
|
21
|
+
* 但**流程本身只有一份** —— 这才是要消除的重复。
|
|
22
|
+
*/
|
|
23
|
+
export * from './types.js';
|
|
24
|
+
export * from './oauth.js';
|
|
25
|
+
export * from './session.js';
|
|
26
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sso/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,cAAc,SAAS,CAAC;AACxB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC"}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zBiz 家族统一 SSO(ctc 侧)的 **OAuth2 / OIDC 授权码流程**。
|
|
3
|
+
*
|
|
4
|
+
* ## 流程
|
|
5
|
+
*
|
|
6
|
+
* ```text
|
|
7
|
+
* ┌──────────┐ 1.占本地端口 ┌──────────────┐
|
|
8
|
+
* │ 桌面应用 │ ──────────────→ │ 本地回环服务 │
|
|
9
|
+
* └──────────┘ └──────────────┘
|
|
10
|
+
* │ │
|
|
11
|
+
* │ 2.跳浏览器 → ctc 授权页 │ 5.浏览器带 code 回调
|
|
12
|
+
* ↓ ↓
|
|
13
|
+
* ┌──────────┐ ┌──────────────┐
|
|
14
|
+
* │ ctc 授权页 │ ─── 6.code ───→ │ 本地回环服务 │
|
|
15
|
+
* └──────────┘ └──────────────┘
|
|
16
|
+
* │ │
|
|
17
|
+
* │ 3.用户登录并同意 │ 4.用 code + PKCE 换 token
|
|
18
|
+
* ↓ ↓
|
|
19
|
+
* ┌──────────┐ ┌──────────────┐
|
|
20
|
+
* │ 账号页 │ │ ctc 授权服务器 │
|
|
21
|
+
* └──────────┘ └──────────────┘
|
|
22
|
+
* │
|
|
23
|
+
* 7.拿 userinfo(可选)
|
|
24
|
+
* ↓
|
|
25
|
+
* ┌────────────────┐
|
|
26
|
+
* │ 身份 + token │
|
|
27
|
+
* └────────────────┘
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* ## 全部依赖靠注入
|
|
31
|
+
*
|
|
32
|
+
* 本模块**不 import Tauri、不 import React、不碰文件系统**。理由:
|
|
33
|
+
* - 它要能在 node 单测里跑(浏览器跳转、回环服务器、token 交换都能用假实现测);
|
|
34
|
+
* - 它要能被浏览器预览(web 版)复用,那边没有 Tauri 的 opener 插件。
|
|
35
|
+
*
|
|
36
|
+
* 所以开浏览器、开本地服务、发 HTTP 请求这三件事都是注入进来的。
|
|
37
|
+
*/
|
|
38
|
+
import type { SsoConfig, SsoResult, SsoSession, SsoUser } from "./types.js";
|
|
39
|
+
/**
|
|
40
|
+
* 本地回环服务的句柄。
|
|
41
|
+
*
|
|
42
|
+
* `port` 是实际绑定的端口 —— 授权前必须先知道它,否则拼出来的
|
|
43
|
+
* `redirect_uri` 与 ctc 侧登记的不一致,会拿到 `redirect_uri_mismatch`。
|
|
44
|
+
*/
|
|
45
|
+
export interface LocalCallbackServer {
|
|
46
|
+
/** 实际监听的端口。 */
|
|
47
|
+
port: number;
|
|
48
|
+
/**
|
|
49
|
+
* 等浏览器回调。
|
|
50
|
+
*
|
|
51
|
+
* resolve 的值是**回调的查询串**(不含 `?`)—— 解析成 code/error 是调用方的责任,
|
|
52
|
+
* 这样「解析出 error 而不是 code」这件事在测试里能被直接观察到。
|
|
53
|
+
*
|
|
54
|
+
* 用户点了「拒绝授权」时,浏览器仍会回调(带 `error=access_denied`),
|
|
55
|
+
* 所以这里**不是**异常路径,是正常结果的一部分。
|
|
56
|
+
*/
|
|
57
|
+
waitForCallback: () => Promise<string>;
|
|
58
|
+
/** 关闭服务。 */
|
|
59
|
+
close: () => Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* SSO 的运行环境(全部注入)。
|
|
63
|
+
*/
|
|
64
|
+
export interface SsoEnvironment {
|
|
65
|
+
/**
|
|
66
|
+
* 启动本地回环 HTTP 服务,等待浏览器回调。
|
|
67
|
+
*
|
|
68
|
+
* @param redirectPath 回环服务要处理的路径(通常是 `/callback`)
|
|
69
|
+
*/
|
|
70
|
+
startCallbackServer: (redirectPath: string) => Promise<LocalCallbackServer>;
|
|
71
|
+
/** 用系统默认浏览器打开授权页。 */
|
|
72
|
+
openBrowser: (url: string) => Promise<void>;
|
|
73
|
+
/** 发 HTTP 请求(拿 token、拉 userinfo)。 */
|
|
74
|
+
fetch: (url: string, init: {
|
|
75
|
+
method: string;
|
|
76
|
+
headers: Record<string, string>;
|
|
77
|
+
body?: string;
|
|
78
|
+
}) => Promise<{
|
|
79
|
+
ok: boolean;
|
|
80
|
+
status: number;
|
|
81
|
+
text: () => Promise<string>;
|
|
82
|
+
}>;
|
|
83
|
+
/** 生成随机串(state / code_verifier / nonce)。 */
|
|
84
|
+
random: (bytes: number) => string;
|
|
85
|
+
/** 计算 SHA-256 的 base64url 编码(PKCE 的 S256 challenge)。 */
|
|
86
|
+
sha256Base64Url: (text: string) => Promise<string>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 生成 PKCE 的 code_verifier。
|
|
90
|
+
*
|
|
91
|
+
* 长度取 64 个字符(RFC 7636 允许 43–128)。取 64 而不是最短的 43:
|
|
92
|
+
* base64url 的熵在去掉 `=` 填充后每字符不到 6 bit,43 字符约 258 bit,
|
|
93
|
+
* 64 字符约 384 bit —— 都够,但 64 让「随机串长度」这个参数一眼可读。
|
|
94
|
+
*/
|
|
95
|
+
export declare function createCodeVerifier(random: (bytes: number) => string): string;
|
|
96
|
+
/**
|
|
97
|
+
* 由 code_verifier 算 PKCE 的 code_challenge(S256)。
|
|
98
|
+
*
|
|
99
|
+
* `S256` 而不是 `plain` —— plain 的 challenge 就是 verifier 本身,
|
|
100
|
+
* 等于没有保护。ctc 若只支持 plain,本函数要显式报错而不是悄悄降级。
|
|
101
|
+
*/
|
|
102
|
+
export declare function createCodeChallenge(verifier: string, sha256Base64Url: (text: string) => Promise<string>): Promise<string>;
|
|
103
|
+
/**
|
|
104
|
+
* 拼授权 URL。
|
|
105
|
+
*
|
|
106
|
+
* 参数顺序固定(`response_type` / `client_id` / `redirect_uri` / `scope` / `state` /
|
|
107
|
+
* `code_challenge` / `code_challenge_method`),这样同一份输入永远得到同一串字节 ——
|
|
108
|
+
* 单测可以拿整串做金标,出问题时也能直接贴给 ctc 侧看。
|
|
109
|
+
*/
|
|
110
|
+
export declare function buildAuthorizeUrl(config: SsoConfig, params: {
|
|
111
|
+
state: string;
|
|
112
|
+
codeChallenge: string | null;
|
|
113
|
+
}): string;
|
|
114
|
+
/**
|
|
115
|
+
* 从授权回调的查询串里取出 code / error。
|
|
116
|
+
*
|
|
117
|
+
* 放在独立函数里是因为「回调解析」是最容易被写成「只查 code 忘了查 error」的地方:
|
|
118
|
+
* 用户在 ctc 页面点「拒绝授权」时,回调里**没有 code**,只有 error。
|
|
119
|
+
* 忘了查 error 的代码会一路走到换 token 才失败,报出来的是一句
|
|
120
|
+
* 「invalid_grant」—— 用户完全看不懂自己刚才干了什么。
|
|
121
|
+
*/
|
|
122
|
+
export declare function parseCallbackQuery(query: string): {
|
|
123
|
+
ok: true;
|
|
124
|
+
code: string;
|
|
125
|
+
state: string | null;
|
|
126
|
+
} | {
|
|
127
|
+
ok: false;
|
|
128
|
+
error: string;
|
|
129
|
+
description: string | null;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* 用授权码换 token。
|
|
133
|
+
*
|
|
134
|
+
* 三个细节容易写错:
|
|
135
|
+
* 1. `Content-Type` 必须是 `application/x-www-form-urlencoded`(不是 JSON)——
|
|
136
|
+
* 这是 OAuth2 规范规定的,`application/json` 有的服务端会静默返回 200 + 空 body。
|
|
137
|
+
* 2. `client_id` 要带上。桌面端没有 client_secret,但 client_id 仍需传递。
|
|
138
|
+
* 3. PKCE 的 `code_verifier` 要带上,且必须与当初算 challenge 时用的那一个**完全一致**。
|
|
139
|
+
*/
|
|
140
|
+
export declare function exchangeCodeForToken(config: SsoConfig, env: SsoEnvironment, params: {
|
|
141
|
+
code: string;
|
|
142
|
+
codeVerifier: string | null;
|
|
143
|
+
}): Promise<SsoResult<{
|
|
144
|
+
accessToken: string;
|
|
145
|
+
refreshToken: string | null;
|
|
146
|
+
expiresAt: number | null;
|
|
147
|
+
tokenType: string;
|
|
148
|
+
idToken: string | null;
|
|
149
|
+
}>>;
|
|
150
|
+
/**
|
|
151
|
+
* 把 token 端点的错误响应变成一句人能看懂的话。
|
|
152
|
+
*
|
|
153
|
+
* OAuth2 错误响应是 JSON(`{"error":"invalid_grant","error_description":"..."}`),
|
|
154
|
+
* 但也有服务端返回 HTML 错误页或纯文本。逐个降级处理,
|
|
155
|
+
* 最终兜底给出状态码 —— 至少能让排查的人有线索。
|
|
156
|
+
*/
|
|
157
|
+
export declare function describeTokenError(status: number, text: string): string;
|
|
158
|
+
/**
|
|
159
|
+
* 解析 id_token 的 payload。
|
|
160
|
+
*
|
|
161
|
+
* **不解验签** —— id_token 的可信度由「它是通过 TLS 从 token 端点直接拿到的」保证,
|
|
162
|
+
* 而非 token 端点返回的那段 payload 本身没被篡改(传输层已保真)。
|
|
163
|
+
*
|
|
164
|
+
* 这是 OIDC 的一个真实取舍:严格的实现应当验签(JWT 的 JWK 签名),
|
|
165
|
+
* 但桌面端验签要引入 JWKS 拉取与缓存、密钥轮换处理 —— 复杂度与收益不匹配,
|
|
166
|
+
* 因为 access_token 才是真正用于访问 API 的凭据,而 id_token 在本家族里
|
|
167
|
+
* 只用于**显示用户名**(不是用于授权判定)。
|
|
168
|
+
*
|
|
169
|
+
* 若将来要用 id_token 做授权判定,**必须补验签**,这里已用注释钉住这一点。
|
|
170
|
+
*/
|
|
171
|
+
export declare function parseIdTokenPayload(idToken: string): Record<string, unknown> | null;
|
|
172
|
+
/**
|
|
173
|
+
* base64url → 字节数组。
|
|
174
|
+
*
|
|
175
|
+
* 不用 `atob`:`atob` 只处理 latin1,直接 `JSON.parse(atob(x))` 会把中文解成乱码
|
|
176
|
+
* ("李四" → "æ\x9D\x8Eå\x9B\x9B")。真实 ctc 返回的 name 很可能就是中文,
|
|
177
|
+
* 所以这里自己按字节解,再用 TextDecoder 按 UTF-8 读。
|
|
178
|
+
*/
|
|
179
|
+
export declare function base64UrlToBytes(input: string): Uint8Array;
|
|
180
|
+
/**
|
|
181
|
+
* 把 userinfo / id_token 里的声明整理成统一的 `SsoUser`。
|
|
182
|
+
*/
|
|
183
|
+
export declare function toSsoUser(claims: Record<string, unknown>): SsoUser | null;
|
|
184
|
+
/**
|
|
185
|
+
* 拉取 userinfo。
|
|
186
|
+
*
|
|
187
|
+
* 没有 `userInfoUrl` 时返回「不适用」而不是错误 —— 有些 ctc 部署只给 id_token。
|
|
188
|
+
*/
|
|
189
|
+
export declare function fetchUserInfo(config: SsoConfig, env: SsoEnvironment, accessToken: string): Promise<SsoResult<Record<string, unknown> | null>>;
|
|
190
|
+
/**
|
|
191
|
+
* 走完整个授权码流程,拿到一次登录会话。
|
|
192
|
+
*
|
|
193
|
+
* @param config ctc 侧的接入配置
|
|
194
|
+
* @param env 运行环境(浏览器 / 回环服务 / HTTP / 随机数 / SHA-256,全部注入)
|
|
195
|
+
*/
|
|
196
|
+
export declare function authorizeWithSso(config: SsoConfig, env: SsoEnvironment): Promise<SsoResult<SsoSession>>;
|
|
197
|
+
/**
|
|
198
|
+
* 等浏览器回调。
|
|
199
|
+
*
|
|
200
|
+
* 这个函数的实际等待由注入的 `startCallbackServer` 承担 ——
|
|
201
|
+
* 它返回一个 promise,在浏览器回调到达时 resolve。
|
|
202
|
+
* 这里只是把类型定下来。
|
|
203
|
+
*/
|
|
204
|
+
export type CallbackWaiter = (server: LocalCallbackServer) => Promise<{
|
|
205
|
+
ok: true;
|
|
206
|
+
code: string;
|
|
207
|
+
state: string | null;
|
|
208
|
+
} | {
|
|
209
|
+
ok: false;
|
|
210
|
+
code: "cancelled" | "local_server";
|
|
211
|
+
reason: string;
|
|
212
|
+
}>;
|
|
213
|
+
//# sourceMappingURL=oauth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/sso/oauth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zBiz 家族统一 SSO(ctc 侧)的 **OAuth2 / OIDC 授权码流程**。
|
|
3
|
+
*
|
|
4
|
+
* ## 流程
|
|
5
|
+
*
|
|
6
|
+
* ```text
|
|
7
|
+
* ┌──────────┐ 1.占本地端口 ┌──────────────┐
|
|
8
|
+
* │ 桌面应用 │ ──────────────→ │ 本地回环服务 │
|
|
9
|
+
* └──────────┘ └──────────────┘
|
|
10
|
+
* │ │
|
|
11
|
+
* │ 2.跳浏览器 → ctc 授权页 │ 5.浏览器带 code 回调
|
|
12
|
+
* ↓ ↓
|
|
13
|
+
* ┌──────────┐ ┌──────────────┐
|
|
14
|
+
* │ ctc 授权页 │ ─── 6.code ───→ │ 本地回环服务 │
|
|
15
|
+
* └──────────┘ └──────────────┘
|
|
16
|
+
* │ │
|
|
17
|
+
* │ 3.用户登录并同意 │ 4.用 code + PKCE 换 token
|
|
18
|
+
* ↓ ↓
|
|
19
|
+
* ┌──────────┐ ┌──────────────┐
|
|
20
|
+
* │ 账号页 │ │ ctc 授权服务器 │
|
|
21
|
+
* └──────────┘ └──────────────┘
|
|
22
|
+
* │
|
|
23
|
+
* 7.拿 userinfo(可选)
|
|
24
|
+
* ↓
|
|
25
|
+
* ┌────────────────┐
|
|
26
|
+
* │ 身份 + token │
|
|
27
|
+
* └────────────────┘
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* ## 全部依赖靠注入
|
|
31
|
+
*
|
|
32
|
+
* 本模块**不 import Tauri、不 import React、不碰文件系统**。理由:
|
|
33
|
+
* - 它要能在 node 单测里跑(浏览器跳转、回环服务器、token 交换都能用假实现测);
|
|
34
|
+
* - 它要能被浏览器预览(web 版)复用,那边没有 Tauri 的 opener 插件。
|
|
35
|
+
*
|
|
36
|
+
* 所以开浏览器、开本地服务、发 HTTP 请求这三件事都是注入进来的。
|
|
37
|
+
*/
|
|
38
|
+
/* ------------------------------------------------------------------ *
|
|
39
|
+
* PKCE
|
|
40
|
+
* ------------------------------------------------------------------ */
|
|
41
|
+
/**
|
|
42
|
+
* PKCE 的 code_verifier 字符集。
|
|
43
|
+
*
|
|
44
|
+
* RFC 7636 规定是 `A-Z a-z 0-9 - . _ ~`,共 66 个字符。
|
|
45
|
+
* 字符集外的字符在某些 ctc 实现里会被拒(或者被悄悄改写,导致 challenge 对不上)。
|
|
46
|
+
*/
|
|
47
|
+
const VERIFIER_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
48
|
+
/**
|
|
49
|
+
* 生成 PKCE 的 code_verifier。
|
|
50
|
+
*
|
|
51
|
+
* 长度取 64 个字符(RFC 7636 允许 43–128)。取 64 而不是最短的 43:
|
|
52
|
+
* base64url 的熵在去掉 `=` 填充后每字符不到 6 bit,43 字符约 258 bit,
|
|
53
|
+
* 64 字符约 384 bit —— 都够,但 64 让「随机串长度」这个参数一眼可读。
|
|
54
|
+
*/
|
|
55
|
+
export function createCodeVerifier(random) {
|
|
56
|
+
// 每字节能出 2 个 base64 字符,32 字节 → 64 字符。
|
|
57
|
+
const raw = random(32);
|
|
58
|
+
let out = "";
|
|
59
|
+
for (let i = 0; i < 32; i++) {
|
|
60
|
+
const b = raw.charCodeAt(i) & 0xff;
|
|
61
|
+
out += VERIFIER_CHARS[b % VERIFIER_CHARS.length];
|
|
62
|
+
out += VERIFIER_CHARS[(b >> 4) % VERIFIER_CHARS.length];
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 由 code_verifier 算 PKCE 的 code_challenge(S256)。
|
|
68
|
+
*
|
|
69
|
+
* `S256` 而不是 `plain` —— plain 的 challenge 就是 verifier 本身,
|
|
70
|
+
* 等于没有保护。ctc 若只支持 plain,本函数要显式报错而不是悄悄降级。
|
|
71
|
+
*/
|
|
72
|
+
export async function createCodeChallenge(verifier, sha256Base64Url) {
|
|
73
|
+
return sha256Base64Url(verifier);
|
|
74
|
+
}
|
|
75
|
+
/* ------------------------------------------------------------------ *
|
|
76
|
+
* URL 构造
|
|
77
|
+
* ------------------------------------------------------------------ */
|
|
78
|
+
/**
|
|
79
|
+
* 拼授权 URL。
|
|
80
|
+
*
|
|
81
|
+
* 参数顺序固定(`response_type` / `client_id` / `redirect_uri` / `scope` / `state` /
|
|
82
|
+
* `code_challenge` / `code_challenge_method`),这样同一份输入永远得到同一串字节 ——
|
|
83
|
+
* 单测可以拿整串做金标,出问题时也能直接贴给 ctc 侧看。
|
|
84
|
+
*/
|
|
85
|
+
export function buildAuthorizeUrl(config, params) {
|
|
86
|
+
const url = new URL(config.authorizeUrl);
|
|
87
|
+
url.searchParams.set("response_type", "code");
|
|
88
|
+
url.searchParams.set("client_id", config.clientId);
|
|
89
|
+
url.searchParams.set("redirect_uri", config.redirectUri);
|
|
90
|
+
url.searchParams.set("scope", config.scope);
|
|
91
|
+
url.searchParams.set("state", params.state);
|
|
92
|
+
if (params.codeChallenge) {
|
|
93
|
+
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
94
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
95
|
+
}
|
|
96
|
+
return url.toString();
|
|
97
|
+
}
|
|
98
|
+
/* ------------------------------------------------------------------ *
|
|
99
|
+
* 响应解析
|
|
100
|
+
* ------------------------------------------------------------------ */
|
|
101
|
+
/**
|
|
102
|
+
* 从授权回调的查询串里取出 code / error。
|
|
103
|
+
*
|
|
104
|
+
* 放在独立函数里是因为「回调解析」是最容易被写成「只查 code 忘了查 error」的地方:
|
|
105
|
+
* 用户在 ctc 页面点「拒绝授权」时,回调里**没有 code**,只有 error。
|
|
106
|
+
* 忘了查 error 的代码会一路走到换 token 才失败,报出来的是一句
|
|
107
|
+
* 「invalid_grant」—— 用户完全看不懂自己刚才干了什么。
|
|
108
|
+
*/
|
|
109
|
+
export function parseCallbackQuery(query) {
|
|
110
|
+
const params = new URLSearchParams(query.startsWith("?") ? query.slice(1) : query);
|
|
111
|
+
const error = params.get("error");
|
|
112
|
+
if (error) {
|
|
113
|
+
const description = params.get("error_description");
|
|
114
|
+
return { ok: false, error, description };
|
|
115
|
+
}
|
|
116
|
+
const code = params.get("code");
|
|
117
|
+
if (!code) {
|
|
118
|
+
return { ok: false, error: "no_code", description: null };
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, code, state: params.get("state") };
|
|
121
|
+
}
|
|
122
|
+
/* ------------------------------------------------------------------ *
|
|
123
|
+
* token 交换
|
|
124
|
+
* ------------------------------------------------------------------ */
|
|
125
|
+
/**
|
|
126
|
+
* 用授权码换 token。
|
|
127
|
+
*
|
|
128
|
+
* 三个细节容易写错:
|
|
129
|
+
* 1. `Content-Type` 必须是 `application/x-www-form-urlencoded`(不是 JSON)——
|
|
130
|
+
* 这是 OAuth2 规范规定的,`application/json` 有的服务端会静默返回 200 + 空 body。
|
|
131
|
+
* 2. `client_id` 要带上。桌面端没有 client_secret,但 client_id 仍需传递。
|
|
132
|
+
* 3. PKCE 的 `code_verifier` 要带上,且必须与当初算 challenge 时用的那一个**完全一致**。
|
|
133
|
+
*/
|
|
134
|
+
export async function exchangeCodeForToken(config, env, params) {
|
|
135
|
+
const body = new URLSearchParams();
|
|
136
|
+
body.set("grant_type", "authorization_code");
|
|
137
|
+
body.set("code", params.code);
|
|
138
|
+
body.set("redirect_uri", config.redirectUri);
|
|
139
|
+
body.set("client_id", config.clientId);
|
|
140
|
+
if (params.codeVerifier)
|
|
141
|
+
body.set("code_verifier", params.codeVerifier);
|
|
142
|
+
let res;
|
|
143
|
+
try {
|
|
144
|
+
res = await env.fetch(config.tokenUrl, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
147
|
+
body: body.toString(),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
code: "network",
|
|
154
|
+
reason: `连不上 ctc 的令牌服务:${e instanceof Error ? e.message : String(e)}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const text = await res.text();
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
code: "token",
|
|
162
|
+
reason: describeTokenError(res.status, text),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
let json;
|
|
166
|
+
try {
|
|
167
|
+
json = JSON.parse(text);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
code: "malformed",
|
|
173
|
+
reason: "ctc 的令牌服务返回的不是 JSON",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const accessToken = json.access_token;
|
|
177
|
+
if (typeof accessToken !== "string" || !accessToken) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
code: "malformed",
|
|
181
|
+
reason: "ctc 的令牌服务没有返回 access_token",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const expiresIn = typeof json.expires_in === "number" ? json.expires_in : null;
|
|
185
|
+
const refreshToken = typeof json.refresh_token === "string" ? json.refresh_token : null;
|
|
186
|
+
const idToken = typeof json.id_token === "string" ? json.id_token : null;
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
value: {
|
|
190
|
+
accessToken,
|
|
191
|
+
refreshToken,
|
|
192
|
+
expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : null,
|
|
193
|
+
tokenType: typeof json.token_type === "string" ? json.token_type : "Bearer",
|
|
194
|
+
idToken,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 把 token 端点的错误响应变成一句人能看懂的话。
|
|
200
|
+
*
|
|
201
|
+
* OAuth2 错误响应是 JSON(`{"error":"invalid_grant","error_description":"..."}`),
|
|
202
|
+
* 但也有服务端返回 HTML 错误页或纯文本。逐个降级处理,
|
|
203
|
+
* 最终兜底给出状态码 —— 至少能让排查的人有线索。
|
|
204
|
+
*/
|
|
205
|
+
export function describeTokenError(status, text) {
|
|
206
|
+
let detail = "";
|
|
207
|
+
try {
|
|
208
|
+
const json = JSON.parse(text);
|
|
209
|
+
if (json.error_description)
|
|
210
|
+
detail = json.error_description;
|
|
211
|
+
else if (json.error)
|
|
212
|
+
detail = json.error;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// 不是 JSON:用正文的一小段当线索,但要截断(HTML 错误页可能很长)。
|
|
216
|
+
const trimmed = text.trim().slice(0, 160);
|
|
217
|
+
if (trimmed)
|
|
218
|
+
detail = trimmed;
|
|
219
|
+
}
|
|
220
|
+
const base = `ctc 的令牌服务返回 HTTP ${status}`;
|
|
221
|
+
if (!detail)
|
|
222
|
+
return base;
|
|
223
|
+
return `${base}:${detail}`;
|
|
224
|
+
}
|
|
225
|
+
/* ------------------------------------------------------------------ *
|
|
226
|
+
* 用户信息
|
|
227
|
+
* ------------------------------------------------------------------ */
|
|
228
|
+
/**
|
|
229
|
+
* 解析 id_token 的 payload。
|
|
230
|
+
*
|
|
231
|
+
* **不解验签** —— id_token 的可信度由「它是通过 TLS 从 token 端点直接拿到的」保证,
|
|
232
|
+
* 而非 token 端点返回的那段 payload 本身没被篡改(传输层已保真)。
|
|
233
|
+
*
|
|
234
|
+
* 这是 OIDC 的一个真实取舍:严格的实现应当验签(JWT 的 JWK 签名),
|
|
235
|
+
* 但桌面端验签要引入 JWKS 拉取与缓存、密钥轮换处理 —— 复杂度与收益不匹配,
|
|
236
|
+
* 因为 access_token 才是真正用于访问 API 的凭据,而 id_token 在本家族里
|
|
237
|
+
* 只用于**显示用户名**(不是用于授权判定)。
|
|
238
|
+
*
|
|
239
|
+
* 若将来要用 id_token 做授权判定,**必须补验签**,这里已用注释钉住这一点。
|
|
240
|
+
*/
|
|
241
|
+
export function parseIdTokenPayload(idToken) {
|
|
242
|
+
const parts = idToken.split(".");
|
|
243
|
+
if (parts.length < 2)
|
|
244
|
+
return null;
|
|
245
|
+
try {
|
|
246
|
+
const payload = parts[1];
|
|
247
|
+
const bytes = base64UrlToBytes(payload);
|
|
248
|
+
// **必须按 UTF-8 解**:`atob` 给的是 latin1 字符串,
|
|
249
|
+
// 直接 JSON.parse 会把中文解成乱码("李四" → "æ\x9D\x8Eå\x9B\x9B")。
|
|
250
|
+
// 真实 ctc 返回的 name 很可能就是中文 —— 这不是理论问题。
|
|
251
|
+
const json = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
252
|
+
const parsed = JSON.parse(json);
|
|
253
|
+
if (!parsed || typeof parsed !== "object")
|
|
254
|
+
return null;
|
|
255
|
+
return parsed;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* base64url → 字节数组。
|
|
263
|
+
*
|
|
264
|
+
* 不用 `atob`:`atob` 只处理 latin1,直接 `JSON.parse(atob(x))` 会把中文解成乱码
|
|
265
|
+
* ("李四" → "æ\x9D\x8Eå\x9B\x9B")。真实 ctc 返回的 name 很可能就是中文,
|
|
266
|
+
* 所以这里自己按字节解,再用 TextDecoder 按 UTF-8 读。
|
|
267
|
+
*/
|
|
268
|
+
export function base64UrlToBytes(input) {
|
|
269
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
270
|
+
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
271
|
+
// 去掉尾部填充后算真实字节数:`=` 不携带数据,按 4 字符→3 字节会多算。
|
|
272
|
+
const dataChars = normalized.length;
|
|
273
|
+
const out = new Uint8Array(Math.floor((dataChars * 3) / 4));
|
|
274
|
+
let o = 0;
|
|
275
|
+
for (let i = 0; i < padded.length; i += 4) {
|
|
276
|
+
const c0 = base64Value(padded.charCodeAt(i));
|
|
277
|
+
const c1 = base64Value(padded.charCodeAt(i + 1));
|
|
278
|
+
const c2 = base64Value(padded.charCodeAt(i + 2));
|
|
279
|
+
const c3 = base64Value(padded.charCodeAt(i + 3));
|
|
280
|
+
const n = (c0 << 18) | (c1 << 12) | (c2 << 6) | c3;
|
|
281
|
+
if (o < out.length)
|
|
282
|
+
out[o++] = (n >> 16) & 0xff;
|
|
283
|
+
if (o < out.length)
|
|
284
|
+
out[o++] = (n >> 8) & 0xff;
|
|
285
|
+
if (o < out.length)
|
|
286
|
+
out[o++] = n & 0xff;
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
/** 单个 base64 字符的数值。非法字符(含填充 `=`)按 0 处理。 */
|
|
291
|
+
function base64Value(code) {
|
|
292
|
+
if (code >= 65 && code <= 90)
|
|
293
|
+
return code - 65; // A-Z
|
|
294
|
+
if (code >= 97 && code <= 122)
|
|
295
|
+
return code - 71; // a-z → 26
|
|
296
|
+
if (code >= 48 && code <= 57)
|
|
297
|
+
return code + 4; // 0-9 → 52
|
|
298
|
+
if (code === 43)
|
|
299
|
+
return 62; // +
|
|
300
|
+
if (code === 47)
|
|
301
|
+
return 63; // /
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* 把 userinfo / id_token 里的声明整理成统一的 `SsoUser`。
|
|
306
|
+
*/
|
|
307
|
+
export function toSsoUser(claims) {
|
|
308
|
+
const subject = claims.sub;
|
|
309
|
+
if (typeof subject !== "string" || !subject)
|
|
310
|
+
return null;
|
|
311
|
+
const str = (v) => (typeof v === "string" && v ? v : null);
|
|
312
|
+
return {
|
|
313
|
+
subject,
|
|
314
|
+
displayName: str(claims.name) ?? str(claims.nickname) ?? str(claims.preferred_username),
|
|
315
|
+
email: str(claims.email),
|
|
316
|
+
username: str(claims.preferred_username) ?? str(claims.name),
|
|
317
|
+
raw: claims,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* 拉取 userinfo。
|
|
322
|
+
*
|
|
323
|
+
* 没有 `userInfoUrl` 时返回「不适用」而不是错误 —— 有些 ctc 部署只给 id_token。
|
|
324
|
+
*/
|
|
325
|
+
export async function fetchUserInfo(config, env, accessToken) {
|
|
326
|
+
if (!config.userInfoUrl)
|
|
327
|
+
return { ok: true, value: null };
|
|
328
|
+
let res;
|
|
329
|
+
try {
|
|
330
|
+
res = await env.fetch(config.userInfoUrl, {
|
|
331
|
+
method: "GET",
|
|
332
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
catch (e) {
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
code: "network",
|
|
339
|
+
reason: `连不上 ctc 的用户信息服务:${e instanceof Error ? e.message : String(e)}`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
const text = await res.text();
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
code: "userinfo",
|
|
347
|
+
reason: `ctc 的用户信息服务返回 HTTP ${res.status}`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const json = JSON.parse(text);
|
|
352
|
+
if (!json || typeof json !== "object") {
|
|
353
|
+
return { ok: false, code: "malformed", reason: "ctc 的用户信息服务返回的不是对象" };
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, value: json };
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return { ok: false, code: "malformed", reason: "ctc 的用户信息服务返回的不是 JSON" };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/* ------------------------------------------------------------------ *
|
|
362
|
+
* 主流程
|
|
363
|
+
* ------------------------------------------------------------------ */
|
|
364
|
+
/**
|
|
365
|
+
* 走完整个授权码流程,拿到一次登录会话。
|
|
366
|
+
*
|
|
367
|
+
* @param config ctc 侧的接入配置
|
|
368
|
+
* @param env 运行环境(浏览器 / 回环服务 / HTTP / 随机数 / SHA-256,全部注入)
|
|
369
|
+
*/
|
|
370
|
+
export async function authorizeWithSso(config, env) {
|
|
371
|
+
// ── 0. 配置自检:错在这里比错在后面省事得多 ──────────────────────
|
|
372
|
+
const missing = ["authorizeUrl", "tokenUrl", "clientId", "scope", "redirectUri"].filter((k) => !config[k] || !String(config[k]).trim());
|
|
373
|
+
if (missing.length > 0) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
code: "config",
|
|
377
|
+
reason: `SSO 配置不完整,缺少:${missing.join("、")}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
// ── 1. 生成 state 与 PKCE ─────────────────────────────────────────
|
|
381
|
+
const state = env.random(16);
|
|
382
|
+
const usePkce = config.pkce !== false;
|
|
383
|
+
const codeVerifier = usePkce ? createCodeVerifier(env.random) : null;
|
|
384
|
+
const codeChallenge = codeVerifier
|
|
385
|
+
? await createCodeChallenge(codeVerifier, env.sha256Base64Url)
|
|
386
|
+
: null;
|
|
387
|
+
// ── 2. 起本地回环服务(必须先于跳浏览器) ────────────────────────
|
|
388
|
+
let server;
|
|
389
|
+
try {
|
|
390
|
+
server = await env.startCallbackServer("/callback");
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
return {
|
|
394
|
+
ok: false,
|
|
395
|
+
code: "local_server",
|
|
396
|
+
reason: `起不来本地回调服务:${e instanceof Error ? e.message : String(e)}`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
// ── 3. 跳浏览器 ────────────────────────────────────────────────
|
|
401
|
+
const redirect = new URL(config.redirectUri);
|
|
402
|
+
// 本地实际端口优先于配置里的端口 —— 配置里写死端口是为了和 ctc 侧登记一致,
|
|
403
|
+
// 但真要靠「配置端口被占用就失败」,不如把实际端口回填进去(若配置未固定端口)。
|
|
404
|
+
if (!redirect.port && server.port)
|
|
405
|
+
redirect.port = String(server.port);
|
|
406
|
+
const effectiveConfig = { ...config, redirectUri: redirect.toString() };
|
|
407
|
+
const authorizeUrl = buildAuthorizeUrl(effectiveConfig, { state, codeChallenge });
|
|
408
|
+
try {
|
|
409
|
+
await env.openBrowser(authorizeUrl);
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
code: "browser",
|
|
415
|
+
reason: `打不开浏览器:${e instanceof Error ? e.message : String(e)}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
// ── 4. 等回调 ──────────────────────────────────────────────────
|
|
419
|
+
let query;
|
|
420
|
+
try {
|
|
421
|
+
query = await server.waitForCallback();
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
code: "cancelled",
|
|
427
|
+
reason: `登录已取消或回调失败:${e instanceof Error ? e.message : String(e)}`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const callback = parseCallbackQuery(query);
|
|
431
|
+
if (!callback.ok) {
|
|
432
|
+
// 用户点了「拒绝授权」是正常分支,不该报成「出错了」。
|
|
433
|
+
const cancelled = callback.error === "access_denied";
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
code: cancelled ? "cancelled" : "authorization",
|
|
437
|
+
reason: cancelled
|
|
438
|
+
? "你取消了授权"
|
|
439
|
+
: `ctc 拒绝了这次授权:${callback.error}${callback.description ? `(${callback.description})` : ""}`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
// ── 5. 校验 state(防 CSRF) ───────────────────────────────────
|
|
443
|
+
if (callback.state !== state) {
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
code: "authorization",
|
|
447
|
+
reason: "登录回调的 state 对不上,已拒绝(可能是被伪造的回调)",
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
// ── 6. 换 token ───────────────────────────────────────────────
|
|
451
|
+
const token = await exchangeCodeForToken(effectiveConfig, env, {
|
|
452
|
+
code: callback.code,
|
|
453
|
+
codeVerifier,
|
|
454
|
+
});
|
|
455
|
+
if (!token.ok)
|
|
456
|
+
return token;
|
|
457
|
+
// ── 7. 取身份:userinfo 优先,退回 id_token ────────────────────
|
|
458
|
+
const info = await fetchUserInfo(effectiveConfig, env, token.value.accessToken);
|
|
459
|
+
if (!info.ok)
|
|
460
|
+
return info;
|
|
461
|
+
let claims = info.value;
|
|
462
|
+
if (!claims && token.value.idToken) {
|
|
463
|
+
claims = parseIdTokenPayload(token.value.idToken);
|
|
464
|
+
}
|
|
465
|
+
const requireIdToken = config.requireIdToken !== false;
|
|
466
|
+
if (requireIdToken && !claims) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
code: "malformed",
|
|
470
|
+
reason: "没能从 ctc 拿到用户身份(userinfo 为空且 id_token 不可解析)",
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
const user = toSsoUser(claims ?? {});
|
|
474
|
+
if (!user) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
code: "malformed",
|
|
478
|
+
reason: "ctc 返回的用户身份里没有 sub(用户唯一标识)",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
ok: true,
|
|
483
|
+
value: {
|
|
484
|
+
accessToken: token.value.accessToken,
|
|
485
|
+
refreshToken: token.value.refreshToken,
|
|
486
|
+
expiresAt: token.value.expiresAt,
|
|
487
|
+
tokenType: token.value.tokenType,
|
|
488
|
+
user,
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
// 无论成败都要关掉回环服务 —— 否则端口一直被占着,
|
|
494
|
+
// 用户第二次登录会拿到「端口被占用」。
|
|
495
|
+
await server.close().catch(() => undefined);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.js","sourceRoot":"","sources":["../../src/sso/oauth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zBiz 家族统一 SSO 的**会话存储**。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这一层单独存在
|
|
5
|
+
*
|
|
6
|
+
* SSO 拿到的 access_token 是** bearer 凭据** —— 谁拿着它就能以用户的身份
|
|
7
|
+
* 调 ctc 的接口。放在 `localStorage` 里有三个具体问题:
|
|
8
|
+
*
|
|
9
|
+
* 1. **同源的任何脚本都能读**。我们的应用是 Tauri WebView,
|
|
10
|
+
* `localStorage` 对应用内的所有页面(包括任何被注入的第三方内容)都是可读的。
|
|
11
|
+
* 2. **明文落盘**。`~/.z-biz-tool-remote/state.json` 里如果是明文 token,
|
|
12
|
+
* 任何能读用户家目录的进程都能拿到。
|
|
13
|
+
* 3. **卸载残留**。localStorage 在应用退出后还在,用户重装应用后旧 token 仍然有效。
|
|
14
|
+
*
|
|
15
|
+
* ## 这里的策略
|
|
16
|
+
*
|
|
17
|
+
* - **access_token / refresh_token 不进 localStorage**。它们只放内存 +
|
|
18
|
+
* 由各仓注入的**安全存储**(OS Keychain / credential store)。
|
|
19
|
+
* - **localStorage 只放非敏感的展示信息**(用户 id、显示名、邮箱、过期时间),
|
|
20
|
+
* 用途只有一个:让应用启动时能立刻显示「上次是谁登录的」,
|
|
21
|
+
* 而不用等 Keychain 读取完成。
|
|
22
|
+
* - 真正能用的登录态以安全存储里的 token 为准。展示信息与 token 不一致时,
|
|
23
|
+
* **以 token 为准**(token 过期了就当没登录,而不是显示一个假的登录态)。
|
|
24
|
+
*
|
|
25
|
+
* ## 依赖注入
|
|
26
|
+
*
|
|
27
|
+
* `SecureStore` 是注入的 —— 共享层不碰 Tauri 的 keyring 插件,
|
|
28
|
+
* 各仓自己接(db/note 已经有 `SecretStore` trait,可以直接复用)。
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* 安全存储的抽象。
|
|
32
|
+
*
|
|
33
|
+
* 各仓已有类似实现(db/note 的 `SecretStore` trait、pet 的 `secret.rs`),
|
|
34
|
+
* 照着实现一份就能接上。
|
|
35
|
+
*/
|
|
36
|
+
export interface SecureStore {
|
|
37
|
+
/** 读一个值;没有则返回 null。 */
|
|
38
|
+
get: (key: string) => Promise<string | null>;
|
|
39
|
+
/** 写一个值。 */
|
|
40
|
+
set: (key: string, value: string) => Promise<void>;
|
|
41
|
+
/** 删一个值。 */
|
|
42
|
+
delete: (key: string) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 可以安全落盘的展示信息。
|
|
46
|
+
*
|
|
47
|
+
* **不含任何 token**。它的唯一用途是「启动时先给用户看一句『你好,某某』」。
|
|
48
|
+
*/
|
|
49
|
+
export interface SsoProfile {
|
|
50
|
+
/** 用户唯一标识。 */
|
|
51
|
+
subject: string;
|
|
52
|
+
/** 显示名。 */
|
|
53
|
+
displayName: string | null;
|
|
54
|
+
/** 邮箱。 */
|
|
55
|
+
email: string | null;
|
|
56
|
+
/** 用户名。 */
|
|
57
|
+
username: string | null;
|
|
58
|
+
/** access token 的过期时间(毫秒时间戳)。 */
|
|
59
|
+
expiresAt: number | null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* token 的落盘形态。
|
|
63
|
+
*
|
|
64
|
+
* refresh_token 存在时一并无存 —— 否则每次会话过期都要重新走一遍浏览器授权,
|
|
65
|
+
* 那对用户是灾难性的体验(每 1 小时弹一次浏览器)。
|
|
66
|
+
*/
|
|
67
|
+
interface StoredTokens {
|
|
68
|
+
accessToken: string;
|
|
69
|
+
refreshToken: string | null;
|
|
70
|
+
expiresAt: number | null;
|
|
71
|
+
tokenType: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* SSO 会话存储。
|
|
75
|
+
*
|
|
76
|
+
* @param secureStore 安全存储(Keychain 等)
|
|
77
|
+
* @param profileStore 展示信息的存储。默认用 localStorage。
|
|
78
|
+
*/
|
|
79
|
+
export declare class SsoSessionStore {
|
|
80
|
+
private readonly secure;
|
|
81
|
+
private readonly profile;
|
|
82
|
+
constructor(secure: SecureStore, profile?: Pick<Storage, "getItem" | "setItem" | "removeItem">);
|
|
83
|
+
/**
|
|
84
|
+
* 存一次登录。
|
|
85
|
+
*
|
|
86
|
+
* token 进安全存储,展示信息进 localStorage。
|
|
87
|
+
*/
|
|
88
|
+
save(session: {
|
|
89
|
+
accessToken: string;
|
|
90
|
+
refreshToken: string | null;
|
|
91
|
+
expiresAt: number | null;
|
|
92
|
+
tokenType: string;
|
|
93
|
+
user: {
|
|
94
|
+
subject: string;
|
|
95
|
+
displayName: string | null;
|
|
96
|
+
email: string | null;
|
|
97
|
+
username: string | null;
|
|
98
|
+
};
|
|
99
|
+
}): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* 读当前登录态。
|
|
102
|
+
*
|
|
103
|
+
* **以安全存储为准**:localStorage 的展示信息只是缓存。
|
|
104
|
+
* 安全存储里没有 → 就是没登录(哪怕 localStorage 里还留着上次的展示信息)。
|
|
105
|
+
*/
|
|
106
|
+
load(): Promise<{
|
|
107
|
+
tokens: StoredTokens;
|
|
108
|
+
profile: SsoProfile | null;
|
|
109
|
+
} | null>;
|
|
110
|
+
/**
|
|
111
|
+
* 当前 access token 是否已过期。
|
|
112
|
+
*
|
|
113
|
+
* 提前 60 秒判过期 —— 网络往返 + 服务端时钟偏差都可能吃掉最后几十秒,
|
|
114
|
+
* 到点才发现过期的话,用户会看到一次本不该出现的「登录已失效」。
|
|
115
|
+
*/
|
|
116
|
+
static isExpired(tokens: StoredTokens, skewMs?: number, now?: number): boolean;
|
|
117
|
+
/** 清除登录态(退出登录)。 */
|
|
118
|
+
clear(): Promise<void>;
|
|
119
|
+
}
|
|
120
|
+
export {};
|
|
121
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/sso/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAMH;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,uBAAuB;IACvB,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7C,YAAY;IACZ,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,YAAY;IACZ,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC;AAMD;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,cAAc;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW;IACX,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU;IACV,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,WAAW;IACX,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,iCAAiC;IACjC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAYD;;;;;GAKG;AACH,UAAU,YAAY;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IACrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsD;gBAG5E,MAAM,EAAE,WAAW,EACnB,OAAO,CAAC,EAAE,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,GAAG,YAAY,CAAC;IAc/D;;;;OAIG;IACG,IAAI,CAAC,OAAO,EAAE;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;QAC5B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;QACzB,SAAS,EAAE,MAAM,CAAC;QAClB,IAAI,EAAE;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;YAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;YAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;SAAE,CAAC;KACtG,GAAG,OAAO,CAAC,IAAI,CAAC;IAuBjB;;;;;OAKG;IACG,IAAI,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,YAAY,CAAC;QAAC,OAAO,EAAE,UAAU,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;IAgClF;;;;;OAKG;IACH,MAAM,CAAC,SAAS,CAAC,MAAM,EAAE,YAAY,EAAE,MAAM,SAAS,EAAE,GAAG,SAAa,GAAG,OAAO;IAKlF,mBAAmB;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAQ7B"}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zBiz 家族统一 SSO 的**会话存储**。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这一层单独存在
|
|
5
|
+
*
|
|
6
|
+
* SSO 拿到的 access_token 是** bearer 凭据** —— 谁拿着它就能以用户的身份
|
|
7
|
+
* 调 ctc 的接口。放在 `localStorage` 里有三个具体问题:
|
|
8
|
+
*
|
|
9
|
+
* 1. **同源的任何脚本都能读**。我们的应用是 Tauri WebView,
|
|
10
|
+
* `localStorage` 对应用内的所有页面(包括任何被注入的第三方内容)都是可读的。
|
|
11
|
+
* 2. **明文落盘**。`~/.z-biz-tool-remote/state.json` 里如果是明文 token,
|
|
12
|
+
* 任何能读用户家目录的进程都能拿到。
|
|
13
|
+
* 3. **卸载残留**。localStorage 在应用退出后还在,用户重装应用后旧 token 仍然有效。
|
|
14
|
+
*
|
|
15
|
+
* ## 这里的策略
|
|
16
|
+
*
|
|
17
|
+
* - **access_token / refresh_token 不进 localStorage**。它们只放内存 +
|
|
18
|
+
* 由各仓注入的**安全存储**(OS Keychain / credential store)。
|
|
19
|
+
* - **localStorage 只放非敏感的展示信息**(用户 id、显示名、邮箱、过期时间),
|
|
20
|
+
* 用途只有一个:让应用启动时能立刻显示「上次是谁登录的」,
|
|
21
|
+
* 而不用等 Keychain 读取完成。
|
|
22
|
+
* - 真正能用的登录态以安全存储里的 token 为准。展示信息与 token 不一致时,
|
|
23
|
+
* **以 token 为准**(token 过期了就当没登录,而不是显示一个假的登录态)。
|
|
24
|
+
*
|
|
25
|
+
* ## 依赖注入
|
|
26
|
+
*
|
|
27
|
+
* `SecureStore` 是注入的 —— 共享层不碰 Tauri 的 keyring 插件,
|
|
28
|
+
* 各仓自己接(db/note 已经有 `SecretStore` trait,可以直接复用)。
|
|
29
|
+
*/
|
|
30
|
+
/* ------------------------------------------------------------------ *
|
|
31
|
+
* 存储实现
|
|
32
|
+
* ------------------------------------------------------------------ */
|
|
33
|
+
/** 安全存储里放 token 的键名。 */
|
|
34
|
+
const TOKEN_KEY = "z-biz-tool.sso.tokens";
|
|
35
|
+
/** localStorage 里放展示信息的键名。 */
|
|
36
|
+
const PROFILE_KEY = "z-biz-tool.sso.profile";
|
|
37
|
+
/**
|
|
38
|
+
* SSO 会话存储。
|
|
39
|
+
*
|
|
40
|
+
* @param secureStore 安全存储(Keychain 等)
|
|
41
|
+
* @param profileStore 展示信息的存储。默认用 localStorage。
|
|
42
|
+
*/
|
|
43
|
+
export class SsoSessionStore {
|
|
44
|
+
constructor(secure, profile) {
|
|
45
|
+
Object.defineProperty(this, "secure", {
|
|
46
|
+
enumerable: true,
|
|
47
|
+
configurable: true,
|
|
48
|
+
writable: true,
|
|
49
|
+
value: void 0
|
|
50
|
+
});
|
|
51
|
+
Object.defineProperty(this, "profile", {
|
|
52
|
+
enumerable: true,
|
|
53
|
+
configurable: true,
|
|
54
|
+
writable: true,
|
|
55
|
+
value: void 0
|
|
56
|
+
});
|
|
57
|
+
this.secure = secure;
|
|
58
|
+
this.profile =
|
|
59
|
+
profile ??
|
|
60
|
+
(typeof localStorage !== "undefined"
|
|
61
|
+
? localStorage
|
|
62
|
+
: {
|
|
63
|
+
getItem: () => null,
|
|
64
|
+
setItem: () => undefined,
|
|
65
|
+
removeItem: () => undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 存一次登录。
|
|
70
|
+
*
|
|
71
|
+
* token 进安全存储,展示信息进 localStorage。
|
|
72
|
+
*/
|
|
73
|
+
async save(session) {
|
|
74
|
+
const tokens = {
|
|
75
|
+
accessToken: session.accessToken,
|
|
76
|
+
refreshToken: session.refreshToken,
|
|
77
|
+
expiresAt: session.expiresAt,
|
|
78
|
+
tokenType: session.tokenType,
|
|
79
|
+
};
|
|
80
|
+
await this.secure.set(TOKEN_KEY, JSON.stringify(tokens));
|
|
81
|
+
const p = {
|
|
82
|
+
subject: session.user.subject,
|
|
83
|
+
displayName: session.user.displayName,
|
|
84
|
+
email: session.user.email,
|
|
85
|
+
username: session.user.username,
|
|
86
|
+
expiresAt: session.expiresAt,
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
this.profile.setItem(PROFILE_KEY, JSON.stringify(p));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* 展示信息存不下不影响登录态 —— token 在安全存储里 */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 读当前登录态。
|
|
97
|
+
*
|
|
98
|
+
* **以安全存储为准**:localStorage 的展示信息只是缓存。
|
|
99
|
+
* 安全存储里没有 → 就是没登录(哪怕 localStorage 里还留着上次的展示信息)。
|
|
100
|
+
*/
|
|
101
|
+
async load() {
|
|
102
|
+
let raw;
|
|
103
|
+
try {
|
|
104
|
+
raw = await this.secure.get(TOKEN_KEY);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (!raw)
|
|
110
|
+
return null;
|
|
111
|
+
let tokens;
|
|
112
|
+
try {
|
|
113
|
+
tokens = JSON.parse(raw);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// 存坏了就当没登录,但要把坏数据清掉 —— 否则每次启动都读一次坏数据。
|
|
117
|
+
await this.secure.delete(TOKEN_KEY).catch(() => undefined);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (!tokens || typeof tokens.accessToken !== "string" || !tokens.accessToken) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
let profile = null;
|
|
124
|
+
try {
|
|
125
|
+
const rawProfile = this.profile.getItem(PROFILE_KEY);
|
|
126
|
+
if (rawProfile)
|
|
127
|
+
profile = JSON.parse(rawProfile);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* 展示信息坏了不影响登录态 */
|
|
131
|
+
}
|
|
132
|
+
return { tokens, profile };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 当前 access token 是否已过期。
|
|
136
|
+
*
|
|
137
|
+
* 提前 60 秒判过期 —— 网络往返 + 服务端时钟偏差都可能吃掉最后几十秒,
|
|
138
|
+
* 到点才发现过期的话,用户会看到一次本不该出现的「登录已失效」。
|
|
139
|
+
*/
|
|
140
|
+
static isExpired(tokens, skewMs = 60000, now = Date.now()) {
|
|
141
|
+
if (tokens.expiresAt === null)
|
|
142
|
+
return false; // ctc 没给过期时间 = 长期有效
|
|
143
|
+
return tokens.expiresAt - skewMs <= now;
|
|
144
|
+
}
|
|
145
|
+
/** 清除登录态(退出登录)。 */
|
|
146
|
+
async clear() {
|
|
147
|
+
await this.secure.delete(TOKEN_KEY).catch(() => undefined);
|
|
148
|
+
try {
|
|
149
|
+
this.profile.removeItem(PROFILE_KEY);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* 清不掉展示信息不影响正确性 */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/sso/session.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zBiz 家族统一 SSO(ctc 侧)的**类型与配置**。
|
|
3
|
+
*
|
|
4
|
+
* ## 这一层解决什么
|
|
5
|
+
*
|
|
6
|
+
* 家族有 17 个仓,任何一个要接 ctc 统一登录,都得重复实现同一套东西:
|
|
7
|
+
* 拼授权 URL → 跳浏览器 → 本地回环收 code → 换 token → 解析用户信息 → 存盘。
|
|
8
|
+
*
|
|
9
|
+
* 这套流程里有 **6 个容易各自写错**的点:
|
|
10
|
+
*
|
|
11
|
+
* 1. **PKCE**:桌面端是公开客户端,必须带 code_verifier/challenge,
|
|
12
|
+
* 不带的话 ctc 侧按「可被冒充的客户端」处理,多半直接拒。
|
|
13
|
+
* 2. **state**:防 CSRF。少写这一行意味着有人能构造一个链接把你的账号
|
|
14
|
+
* 登进别人的身份里。**不能因为「本地应用没人攻击」就省**。
|
|
15
|
+
* 3. **回环端口**:授权码模式下 redirect_uri 必须是本机端口,
|
|
16
|
+
* 而且要先占端口再跳浏览器 —— 顺序反了会拿到「回调地址被占用」。
|
|
17
|
+
* 4. **code 只能用一次**:重放第二次会被 ctc 拒绝,表现为「刚登录成功又报失败」。
|
|
18
|
+
* 5. **token 落盘**:access_token 是敏感凭据,不能明文放 localStorage。
|
|
19
|
+
* 6. **错误要说人话**:`redirect_uri_mismatch` 这类 code 直抛给用户等于没报错。
|
|
20
|
+
*
|
|
21
|
+
* 所以这里把**流程**定死,把 **ctc 的具体参数**(域名、client_id、scope)
|
|
22
|
+
* 全部留成配置项。各仓只填配置,不写流程。
|
|
23
|
+
*
|
|
24
|
+
* ## 为什么是「可选的」
|
|
25
|
+
*
|
|
26
|
+
* SSO 是**可选登录方式**,不是必选门槛:
|
|
27
|
+
* - 用户不登录 → 本地功能照常用(家族定位本来就是「用户自带 Key / 本地优先」)。
|
|
28
|
+
* - 用户登录 → 拿到身份标识,用于云端同步等增值能力。
|
|
29
|
+
*
|
|
30
|
+
* 所以本模块的所有入口都返回「成功 / 未登录 / 出错」三态,
|
|
31
|
+
* 绝不把「没登录」当成错误抛给用户。
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* ctc 统一 SSO 的接入配置。
|
|
35
|
+
*
|
|
36
|
+
* 由各仓在自己的配置层提供(可以是编译期常量,也可以是用户填的),
|
|
37
|
+
* 本模块不内置任何 ctc 的真实值 —— 那是会过期且不该散落在 17 个仓里的数据。
|
|
38
|
+
*/
|
|
39
|
+
export interface SsoConfig {
|
|
40
|
+
/** ctc 授权服务器的授权端点(不含 query)。 */
|
|
41
|
+
authorizeUrl: string;
|
|
42
|
+
/** ctc 授权服务器的 token 端点。 */
|
|
43
|
+
tokenUrl: string;
|
|
44
|
+
/** OIDC 用户信息端点。没有就不拉 userinfo,身份只用 id_token 里的声明。 */
|
|
45
|
+
userInfoUrl?: string;
|
|
46
|
+
/** 在 ctc 侧注册的应用 id。 */
|
|
47
|
+
clientId: string;
|
|
48
|
+
/** 申请的权限范围。空格分隔(如 `openid profile email`)。 */
|
|
49
|
+
scope: string;
|
|
50
|
+
/**
|
|
51
|
+
* 本地回环重定向地址。
|
|
52
|
+
*
|
|
53
|
+
* **必须与 ctc 侧登记的一致**(通常登记 `http://127.0.0.1:<固定端口>/callback`)。
|
|
54
|
+
* OAuth2 授权码模式下这个地址必须精确匹配,`redirect_uri_mismatch` 的头号成因
|
|
55
|
+
* 就是「本地端口随机、登记的是固定端口」。
|
|
56
|
+
*/
|
|
57
|
+
redirectUri: string;
|
|
58
|
+
/** 是否强制 PKCE。默认 true —— 桌面端是公开客户端,不带会被拒。 */
|
|
59
|
+
pkce?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* 是否要求 OIDC 的 id_token。
|
|
62
|
+
*
|
|
63
|
+
* 默认 true:只要了 `openid` scope,OIDC 规范要求 id_token 必须存在。
|
|
64
|
+
* 关掉它只在 ctc 不是严格 OIDC 时才需要。
|
|
65
|
+
*/
|
|
66
|
+
requireIdToken?: boolean;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* SSO 拿到的用户身份。
|
|
70
|
+
*
|
|
71
|
+
* 字段名取 OIDC UserInfo 的标准名(`sub` / `name` / `email` / `preferred_username`),
|
|
72
|
+
* 这样 ctc 换成标准 OIDC Provider 时不需要改各仓。
|
|
73
|
+
*/
|
|
74
|
+
export interface SsoUser {
|
|
75
|
+
/** 用户唯一标识(OIDC 的 `sub`)。跨应用识别同一个人靠它。 */
|
|
76
|
+
subject: string;
|
|
77
|
+
/** 显示名(OIDC 的 `name`)。可能为空。 */
|
|
78
|
+
displayName: string | null;
|
|
79
|
+
/** 邮箱(OIDC 的 `email`)。可能为空。 */
|
|
80
|
+
email: string | null;
|
|
81
|
+
/** 用户名(OIDC 的 `preferred_username`)。可能为空。 */
|
|
82
|
+
username: string | null;
|
|
83
|
+
/** ctc 侧的角色/租户信息,原样透传。 */
|
|
84
|
+
raw: Record<string, unknown>;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 一次成功的登录所产生的结果。
|
|
88
|
+
*/
|
|
89
|
+
export interface SsoSession {
|
|
90
|
+
/** 访问令牌。给 ctc 的 API 带上它。 */
|
|
91
|
+
accessToken: string;
|
|
92
|
+
/** 刷新令牌。用于换新的 access token。 */
|
|
93
|
+
refreshToken: string | null;
|
|
94
|
+
/** access token 的过期时间(毫秒时间戳)。ctc 没给就为 null。 */
|
|
95
|
+
expiresAt: number | null;
|
|
96
|
+
/** token 类型,通常是 `Bearer`。 */
|
|
97
|
+
tokenType: string;
|
|
98
|
+
/** 用户身份。 */
|
|
99
|
+
user: SsoUser;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* SSO 操作的结果。
|
|
103
|
+
*
|
|
104
|
+
* 三态而不是两态 —— 因为「用户没登录」和「登录出错」在界面上的处理完全不同:
|
|
105
|
+
* 前者不该弹红字(那是正常状态),后者必须弹。
|
|
106
|
+
*/
|
|
107
|
+
export type SsoResult<T> = {
|
|
108
|
+
ok: true;
|
|
109
|
+
value: T;
|
|
110
|
+
} | {
|
|
111
|
+
ok: false;
|
|
112
|
+
reason: string;
|
|
113
|
+
code: SsoErrorCode;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* SSO 错误码。
|
|
117
|
+
*
|
|
118
|
+
* 分开的意义:界面可以对「用户取消」静默,对「配置错了」给出明确指引。
|
|
119
|
+
*/
|
|
120
|
+
export type SsoErrorCode =
|
|
121
|
+
/** 用户主动取消/关闭浏览器。 */
|
|
122
|
+
"cancelled"
|
|
123
|
+
/** 本地回环端口拿不到 / 起不来。 */
|
|
124
|
+
| "local_server"
|
|
125
|
+
/** 跳浏览器失败。 */
|
|
126
|
+
| "browser"
|
|
127
|
+
/** 授权服务器返回了错误(error/error_description)。 */
|
|
128
|
+
| "authorization"
|
|
129
|
+
/** 换 token 失败。 */
|
|
130
|
+
| "token"
|
|
131
|
+
/** 拉 userinfo 失败。 */
|
|
132
|
+
| "userinfo"
|
|
133
|
+
/** 拿到的响应不符合预期(缺字段、不是 JSON)。 */
|
|
134
|
+
| "malformed"
|
|
135
|
+
/** 配置不完整或非法。 */
|
|
136
|
+
| "config"
|
|
137
|
+
/** 网络错误。 */
|
|
138
|
+
| "network"
|
|
139
|
+
/** 不该发生的兜底。 */
|
|
140
|
+
| "unknown";
|
|
141
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/sso/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAMH;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACxB,gCAAgC;IAChC,YAAY,EAAE,MAAM,CAAC;IAErB,2BAA2B;IAC3B,QAAQ,EAAE,MAAM,CAAC;IAEjB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,uBAAuB;IACvB,QAAQ,EAAE,MAAM,CAAC;IAEjB,8CAA8C;IAC9C,KAAK,EAAE,MAAM,CAAC;IAEd;;;;;;OAMG;IACH,WAAW,EAAE,MAAM,CAAC;IAEpB,4CAA4C;IAC5C,IAAI,CAAC,EAAE,OAAO,CAAC;IAEf;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAMD;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,wCAAwC;IACxC,OAAO,EAAE,MAAM,CAAC;IAEhB,+BAA+B;IAC/B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAE3B,+BAA+B;IAC/B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAErB,6CAA6C;IAC7C,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAExB,0BAA0B;IAC1B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B;AAMD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,4BAA4B;IAC5B,WAAW,EAAE,MAAM,CAAC;IAEpB,+BAA+B;IAC/B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAE5B,+CAA+C;IAC/C,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAEzB,6BAA6B;IAC7B,SAAS,EAAE,MAAM,CAAC;IAElB,YAAY;IACZ,IAAI,EAAE,OAAO,CAAC;CACf;AAMD;;;;;GAKG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,IACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GACtB;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,YAAY,CAAA;CAAE,CAAC;AAEtD;;;;GAIG;AACH,MAAM,MAAM,YAAY;AACtB,oBAAoB;AAClB,WAAW;AACb,uBAAuB;GACrB,cAAc;AAChB,cAAc;GACZ,SAAS;AACX,2CAA2C;GACzC,eAAe;AACjB,kBAAkB;GAChB,OAAO;AACT,qBAAqB;GACnB,UAAU;AACZ,+BAA+B;GAC7B,WAAW;AACb,gBAAgB;GACd,QAAQ;AACV,YAAY;GACV,SAAS;AACX,eAAe;GACb,SAAS,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zBiz 家族统一 SSO(ctc 侧)的**类型与配置**。
|
|
3
|
+
*
|
|
4
|
+
* ## 这一层解决什么
|
|
5
|
+
*
|
|
6
|
+
* 家族有 17 个仓,任何一个要接 ctc 统一登录,都得重复实现同一套东西:
|
|
7
|
+
* 拼授权 URL → 跳浏览器 → 本地回环收 code → 换 token → 解析用户信息 → 存盘。
|
|
8
|
+
*
|
|
9
|
+
* 这套流程里有 **6 个容易各自写错**的点:
|
|
10
|
+
*
|
|
11
|
+
* 1. **PKCE**:桌面端是公开客户端,必须带 code_verifier/challenge,
|
|
12
|
+
* 不带的话 ctc 侧按「可被冒充的客户端」处理,多半直接拒。
|
|
13
|
+
* 2. **state**:防 CSRF。少写这一行意味着有人能构造一个链接把你的账号
|
|
14
|
+
* 登进别人的身份里。**不能因为「本地应用没人攻击」就省**。
|
|
15
|
+
* 3. **回环端口**:授权码模式下 redirect_uri 必须是本机端口,
|
|
16
|
+
* 而且要先占端口再跳浏览器 —— 顺序反了会拿到「回调地址被占用」。
|
|
17
|
+
* 4. **code 只能用一次**:重放第二次会被 ctc 拒绝,表现为「刚登录成功又报失败」。
|
|
18
|
+
* 5. **token 落盘**:access_token 是敏感凭据,不能明文放 localStorage。
|
|
19
|
+
* 6. **错误要说人话**:`redirect_uri_mismatch` 这类 code 直抛给用户等于没报错。
|
|
20
|
+
*
|
|
21
|
+
* 所以这里把**流程**定死,把 **ctc 的具体参数**(域名、client_id、scope)
|
|
22
|
+
* 全部留成配置项。各仓只填配置,不写流程。
|
|
23
|
+
*
|
|
24
|
+
* ## 为什么是「可选的」
|
|
25
|
+
*
|
|
26
|
+
* SSO 是**可选登录方式**,不是必选门槛:
|
|
27
|
+
* - 用户不登录 → 本地功能照常用(家族定位本来就是「用户自带 Key / 本地优先」)。
|
|
28
|
+
* - 用户登录 → 拿到身份标识,用于云端同步等增值能力。
|
|
29
|
+
*
|
|
30
|
+
* 所以本模块的所有入口都返回「成功 / 未登录 / 出错」三态,
|
|
31
|
+
* 绝不把「没登录」当成错误抛给用户。
|
|
32
|
+
*/
|
|
33
|
+
export {};
|
|
34
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/sso/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "z-biz-tool-shared",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "z-biz-tool 核心共享模块(AI/Agent/主题/快捷键/组件)",
|
|
6
6
|
"type": "module",
|
|
@@ -63,6 +63,10 @@
|
|
|
63
63
|
"types": "./dist/protocol/index.d.ts",
|
|
64
64
|
"default": "./dist/protocol/index.js"
|
|
65
65
|
},
|
|
66
|
+
"./sso": {
|
|
67
|
+
"types": "./dist/sso/index.d.ts",
|
|
68
|
+
"default": "./dist/sso/index.js"
|
|
69
|
+
},
|
|
66
70
|
"./testkit": {
|
|
67
71
|
"types": "./dist/testkit/index.d.ts",
|
|
68
72
|
"default": "./dist/testkit/index.js"
|