@autional-cn/shared 0.1.0-rc.2 → 0.1.0-rc.4
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/package.json +7 -2
- package/src/auth/__tests__/auth-trace.test.ts +237 -0
- package/src/auth/__tests__/build-login-url.test.ts +41 -1
- package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -0
- package/src/auth/auth-trace.ts +473 -0
- package/src/auth/oauth-login.ts +15 -3
- package/src/auth/roles.ts +16 -0
- package/src/auth/service.ts +47 -20
- package/src/components/RequireAuth.tsx +6 -1
- package/src/components/TenantRootRedirect.tsx +3 -2
- package/src/components/__tests__/RequireAuth.test.tsx +42 -5
- package/src/components/__tests__/TenantRootRedirect.test.tsx +13 -5
- package/src/hooks/useLogout.ts +3 -1
- package/src/index.ts +12 -7
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authTrace —— auth 整页跳转的统一出口与三层可观测(#48)。
|
|
3
|
+
*
|
|
4
|
+
* 三层:
|
|
5
|
+
* ① interstitial 提示:**被动**整页跳转(会话终结 / 未认证深链)执行前显示提示条
|
|
6
|
+
* (原因 + 目标 host + 停留 / 立即前往),短延迟后自动前往。「停留」取消本次跳转;
|
|
7
|
+
* 页面数据面虽已失效,但用户知情(消除「静默弹走」)。
|
|
8
|
+
* ② confirm 确认页:`?debug=auth`(或 __authTrace.on())时所有整页跳转不自动执行,
|
|
9
|
+
* 停在遮罩确认页(跳转详情 + 最近 trace 列表 + 继续 / 留)。debug 标志随跳转
|
|
10
|
+
* 跨站透传,排障可全程跟随。
|
|
11
|
+
* ③ 结构化日志:`[auth-trace]` console 行 + sessionStorage 环形缓冲(跨 SPA 路由、
|
|
12
|
+
* 同 tab 存活)+ `?rt=` 跨站面包屑(目标 URL 追加,下游站读取接续)+
|
|
13
|
+
* `__authTrace.dump()` 一键导出(排障取证)。
|
|
14
|
+
*
|
|
15
|
+
* kind 语义(提示层的适用面):
|
|
16
|
+
* - 'interstitial':被动跳转(会话终结 / buildLoginUrl 弹登录)→ 提示 + 延迟(可停留)。
|
|
17
|
+
* - 'funnel'(缺省):预期漏斗(裸根 → brand / 登出 / OAuth 发起与回跳)→ 静默直跳,
|
|
18
|
+
* 仅记 trace(用户主动或流程本身的必经步骤,加延迟只拖慢)。
|
|
19
|
+
* - debug 模式不分 kind,一律确认页。
|
|
20
|
+
*
|
|
21
|
+
* 隐私红线:缓冲与面包屑中的 URL 一律剥除凭据类 query(token/code/state 等),
|
|
22
|
+
* 不落任何 PII。模块零依赖(不 import React / 各站设施),显式调用即用。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const BUFFER_KEY = 'auth-trace-v1';
|
|
26
|
+
const DEBUG_KEY = 'auth-trace-debug';
|
|
27
|
+
const RT_PARAM = 'rt';
|
|
28
|
+
const DEBUG_PARAM = 'debug';
|
|
29
|
+
const DEBUG_VALUE = 'auth';
|
|
30
|
+
const BUFFER_LIMIT = 50;
|
|
31
|
+
const INTERSTITIAL_DELAY_MS = 1500;
|
|
32
|
+
const Z_INDEX = '2147483000';
|
|
33
|
+
|
|
34
|
+
/** URL query 中不得进入 trace 缓冲的凭据类键(前缀匹配)。 */
|
|
35
|
+
const SENSITIVE_KEYS = ['token', 'access_token', 'refresh_token', 'id_token', 'code', 'state', 'secret', 'password'];
|
|
36
|
+
|
|
37
|
+
export interface AuthTraceEntry {
|
|
38
|
+
/** epoch ms */
|
|
39
|
+
t: number;
|
|
40
|
+
/** 事件类型:redirect / handoff / stay / suppressed / error-exit / … */
|
|
41
|
+
ev: string;
|
|
42
|
+
reason?: string;
|
|
43
|
+
from?: string;
|
|
44
|
+
to?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AuthTraceRedirectOptions {
|
|
48
|
+
reason: string;
|
|
49
|
+
kind?: 'interstitial' | 'funnel';
|
|
50
|
+
/** true 用 location.href(新增历史),缺省 false 用 location.replace */
|
|
51
|
+
assign?: boolean;
|
|
52
|
+
/** 覆盖缺省延迟(ms),仅 interstitial 生效 */
|
|
53
|
+
delayMs?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isBrowser(): boolean {
|
|
57
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function station(): string {
|
|
61
|
+
if (!isBrowser()) return 'ssr';
|
|
62
|
+
return window.location.hostname.split('.')[0] || 'unknown';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isZh(): boolean {
|
|
66
|
+
if (!isBrowser()) return false;
|
|
67
|
+
return (document.documentElement.lang || '').toLowerCase().startsWith('zh');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 剥除 URL 中的凭据类 query 参数(前缀命中 SENSITIVE_KEYS 即剥),保留其余。 */
|
|
71
|
+
function sanitizeUrl(raw: string): string {
|
|
72
|
+
try {
|
|
73
|
+
const u = new URL(raw, isBrowser() ? window.location.origin : 'https://x.invalid');
|
|
74
|
+
for (const key of Array.from(u.searchParams.keys())) {
|
|
75
|
+
if (SENSITIVE_KEYS.some((s) => key.toLowerCase() === s || key.toLowerCase().startsWith(`${s}_`))) {
|
|
76
|
+
u.searchParams.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return u.toString();
|
|
80
|
+
} catch {
|
|
81
|
+
return raw.split(/[?#]/)[0];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------- 缓冲(环形)
|
|
86
|
+
|
|
87
|
+
let bufferLoaded = false;
|
|
88
|
+
let buffer: AuthTraceEntry[] = [];
|
|
89
|
+
|
|
90
|
+
function readBuffer(): AuthTraceEntry[] {
|
|
91
|
+
if (!isBrowser()) return [];
|
|
92
|
+
if (!bufferLoaded) {
|
|
93
|
+
bufferLoaded = true;
|
|
94
|
+
try {
|
|
95
|
+
const raw = sessionStorage.getItem(BUFFER_KEY);
|
|
96
|
+
const parsed = raw ? JSON.parse(raw) : [];
|
|
97
|
+
buffer = Array.isArray(parsed) ? parsed.slice(-BUFFER_LIMIT) : [];
|
|
98
|
+
} catch {
|
|
99
|
+
buffer = [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return buffer;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeBuffer(): void {
|
|
106
|
+
if (!isBrowser()) return;
|
|
107
|
+
try {
|
|
108
|
+
sessionStorage.setItem(BUFFER_KEY, JSON.stringify(buffer.slice(-BUFFER_LIMIT)));
|
|
109
|
+
} catch {
|
|
110
|
+
// 存储受限(隐私模式 / 配额)时静默降级为仅 console
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function push(ev: string, extra?: Partial<AuthTraceEntry>): AuthTraceEntry {
|
|
115
|
+
const entry: AuthTraceEntry = { t: Date.now(), ev, ...extra };
|
|
116
|
+
readBuffer().push(entry);
|
|
117
|
+
if (buffer.length > BUFFER_LIMIT) buffer = buffer.slice(-BUFFER_LIMIT);
|
|
118
|
+
writeBuffer();
|
|
119
|
+
if (isBrowser()) {
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
console.info('[auth-trace]', entry.ev, entry.reason ?? '', entry.to ?? '');
|
|
122
|
+
}
|
|
123
|
+
return entry;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------- debug 与水印
|
|
127
|
+
|
|
128
|
+
function readRTP(): string | null {
|
|
129
|
+
if (!isBrowser()) return null;
|
|
130
|
+
try {
|
|
131
|
+
return new URLSearchParams(window.location.search).get(RT_PARAM);
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function inDebug(): boolean {
|
|
138
|
+
if (!isBrowser()) return false;
|
|
139
|
+
try {
|
|
140
|
+
if (new URLSearchParams(window.location.search).get(DEBUG_PARAM) === DEBUG_VALUE) return true;
|
|
141
|
+
return sessionStorage.getItem(DEBUG_KEY) === '1';
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let initialized = false;
|
|
148
|
+
|
|
149
|
+
/** 懒初始化:吸收跨站面包屑(?rt=)与 debug 标志;同 URL 重复调用无副作用。 */
|
|
150
|
+
function ensureInit(): void {
|
|
151
|
+
if (!isBrowser() || initialized) return;
|
|
152
|
+
initialized = true;
|
|
153
|
+
|
|
154
|
+
let urlDebug = false;
|
|
155
|
+
try {
|
|
156
|
+
urlDebug = new URLSearchParams(window.location.search).get(DEBUG_PARAM) === DEBUG_VALUE;
|
|
157
|
+
if (urlDebug) sessionStorage.setItem(DEBUG_KEY, '1');
|
|
158
|
+
} catch {
|
|
159
|
+
/* noop */
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rt = readRTP();
|
|
163
|
+
if (rt) {
|
|
164
|
+
const [prevStation, prevReason, prevTs] = rt.split('.');
|
|
165
|
+
if (prevStation && prevReason) {
|
|
166
|
+
push('handoff', {
|
|
167
|
+
from: `${prevStation}@${prevTs ? parseInt(prevTs, 36) : ''}`,
|
|
168
|
+
reason: prevReason,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
(window as any).__authTrace = {
|
|
174
|
+
version: 1,
|
|
175
|
+
dump: traceDump,
|
|
176
|
+
entries: () => readBuffer().slice(),
|
|
177
|
+
on: () => {
|
|
178
|
+
try {
|
|
179
|
+
sessionStorage.setItem(DEBUG_KEY, '1');
|
|
180
|
+
} catch {
|
|
181
|
+
/* noop */
|
|
182
|
+
}
|
|
183
|
+
return 'auth-trace debug ON(整页跳转将停在确认页;随跳转跨站透传)';
|
|
184
|
+
},
|
|
185
|
+
off: () => {
|
|
186
|
+
try {
|
|
187
|
+
sessionStorage.removeItem(DEBUG_KEY);
|
|
188
|
+
} catch {
|
|
189
|
+
/* noop */
|
|
190
|
+
}
|
|
191
|
+
return 'auth-trace debug OFF';
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (urlDebug || rt) {
|
|
196
|
+
// eslint-disable-next-line no-console
|
|
197
|
+
console.info('[auth-trace] station', station(), urlDebug ? '(debug=on)' : '', rt ? `(handoff from ${rt.split('.')[0]})` : '');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------- 目标 URL 装饰
|
|
202
|
+
|
|
203
|
+
/** 目标 URL 追加 rt=(本站 + 原因 + 时间)与 debug 透传(debug 开启时)。 */
|
|
204
|
+
function decorateTarget(target: string, reason: string): string {
|
|
205
|
+
if (!isBrowser()) return target;
|
|
206
|
+
try {
|
|
207
|
+
const u = new URL(target, window.location.origin);
|
|
208
|
+
if (!u.searchParams.has(RT_PARAM)) {
|
|
209
|
+
u.searchParams.set(RT_PARAM, `${station()}.${reason}.${Date.now().toString(36)}`);
|
|
210
|
+
}
|
|
211
|
+
if (inDebug() && !u.searchParams.has(DEBUG_PARAM)) {
|
|
212
|
+
u.searchParams.set(DEBUG_PARAM, DEBUG_VALUE);
|
|
213
|
+
}
|
|
214
|
+
return u.toString();
|
|
215
|
+
} catch {
|
|
216
|
+
return target;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------- 跳转执行
|
|
221
|
+
|
|
222
|
+
let pending = false;
|
|
223
|
+
|
|
224
|
+
function go(target: string, assign: boolean): void {
|
|
225
|
+
pending = true;
|
|
226
|
+
if (assign) window.location.href = target;
|
|
227
|
+
else window.location.replace(target);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function removeNode(id: string): void {
|
|
231
|
+
if (!isBrowser()) return;
|
|
232
|
+
const el = document.getElementById(id);
|
|
233
|
+
if (el && el.parentNode) el.parentNode.removeChild(el);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------- 层 ① 提示条
|
|
237
|
+
|
|
238
|
+
const NOTICE_ID = 'auth-trace-notice';
|
|
239
|
+
|
|
240
|
+
function reasonText(reason: string): { zh: string; en: string } {
|
|
241
|
+
const map: Record<string, { zh: string; en: string }> = {
|
|
242
|
+
'session-expired': { zh: '会话已过期', en: 'Session expired' },
|
|
243
|
+
unauthenticated: { zh: '需要登录', en: 'Sign-in required' },
|
|
244
|
+
};
|
|
245
|
+
return map[reason] ?? { zh: '即将离开本页', en: 'Leaving this page' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function showInterstitial(target: string, opts: AuthTraceRedirectOptions, onStay: () => void): void {
|
|
249
|
+
const zh = isZh();
|
|
250
|
+
const text = reasonText(opts.reason);
|
|
251
|
+
const delay = opts.delayMs ?? INTERSTITIAL_DELAY_MS;
|
|
252
|
+
|
|
253
|
+
removeNode(NOTICE_ID);
|
|
254
|
+
const box = document.createElement('div');
|
|
255
|
+
box.id = NOTICE_ID;
|
|
256
|
+
box.setAttribute('role', 'status');
|
|
257
|
+
box.style.cssText = [
|
|
258
|
+
'position:fixed',
|
|
259
|
+
'left:50%',
|
|
260
|
+
'bottom:24px',
|
|
261
|
+
'transform:translateX(-50%)',
|
|
262
|
+
`z-index:${Z_INDEX}`,
|
|
263
|
+
'display:flex',
|
|
264
|
+
'align-items:center',
|
|
265
|
+
'gap:12px',
|
|
266
|
+
'max-width:calc(100vw - 32px)',
|
|
267
|
+
'padding:10px 14px',
|
|
268
|
+
'border-radius:8px',
|
|
269
|
+
'background:rgba(17,24,39,.94)',
|
|
270
|
+
'color:#f9fafb',
|
|
271
|
+
'font-size:13px',
|
|
272
|
+
'line-height:1.4',
|
|
273
|
+
'font-family:system-ui,-apple-system,sans-serif',
|
|
274
|
+
'box-shadow:0 8px 24px rgba(0,0,0,.28)',
|
|
275
|
+
].join(';');
|
|
276
|
+
|
|
277
|
+
const label = document.createElement('span');
|
|
278
|
+
label.textContent = zh
|
|
279
|
+
? `${text.zh},即将前往登录(${delay / 1000} 秒后自动跳转)`
|
|
280
|
+
: `${text.en} — redirecting to sign-in in ${delay / 1000}s`;
|
|
281
|
+
box.appendChild(label);
|
|
282
|
+
|
|
283
|
+
const mkButton = (txt: string, primary: boolean, onClick: () => void) => {
|
|
284
|
+
const b = document.createElement('button');
|
|
285
|
+
b.type = 'button';
|
|
286
|
+
b.textContent = txt;
|
|
287
|
+
b.style.cssText = [
|
|
288
|
+
'border:0',
|
|
289
|
+
'border-radius:6px',
|
|
290
|
+
'padding:5px 10px',
|
|
291
|
+
'font-size:13px',
|
|
292
|
+
'cursor:pointer',
|
|
293
|
+
primary ? 'background:#2563eb;color:#fff' : 'background:rgba(255,255,255,.14);color:#f9fafb',
|
|
294
|
+
].join(';');
|
|
295
|
+
b.addEventListener('click', onClick);
|
|
296
|
+
return b;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
removeNode(NOTICE_ID);
|
|
301
|
+
go(target, !!opts.assign);
|
|
302
|
+
}, delay);
|
|
303
|
+
|
|
304
|
+
box.appendChild(mkButton(zh ? '停留' : 'Stay', false, () => {
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
removeNode(NOTICE_ID);
|
|
307
|
+
onStay();
|
|
308
|
+
}));
|
|
309
|
+
box.appendChild(mkButton(zh ? '立即前往' : 'Go now', true, () => {
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
removeNode(NOTICE_ID);
|
|
312
|
+
go(target, !!opts.assign);
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
document.body.appendChild(box);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------- 层 ② 确认页
|
|
319
|
+
|
|
320
|
+
const CONFIRM_ID = 'auth-trace-confirm';
|
|
321
|
+
|
|
322
|
+
function showConfirm(target: string, opts: AuthTraceRedirectOptions, onStay: () => void): void {
|
|
323
|
+
const zh = isZh();
|
|
324
|
+
removeNode(CONFIRM_ID);
|
|
325
|
+
|
|
326
|
+
const overlay = document.createElement('div');
|
|
327
|
+
overlay.id = CONFIRM_ID;
|
|
328
|
+
overlay.style.cssText = [
|
|
329
|
+
'position:fixed',
|
|
330
|
+
'inset:0',
|
|
331
|
+
`z-index:${Z_INDEX}`,
|
|
332
|
+
'display:flex',
|
|
333
|
+
'align-items:center',
|
|
334
|
+
'justify-content:center',
|
|
335
|
+
'background:rgba(15,23,42,.55)',
|
|
336
|
+
'font-family:system-ui,-apple-system,sans-serif',
|
|
337
|
+
].join(';');
|
|
338
|
+
|
|
339
|
+
const card = document.createElement('div');
|
|
340
|
+
card.style.cssText = [
|
|
341
|
+
'width:min(560px,calc(100vw - 40px))',
|
|
342
|
+
'max-height:80vh',
|
|
343
|
+
'overflow:auto',
|
|
344
|
+
'background:#fff',
|
|
345
|
+
'color:#111827',
|
|
346
|
+
'border-radius:10px',
|
|
347
|
+
'padding:18px 20px',
|
|
348
|
+
'box-shadow:0 20px 48px rgba(0,0,0,.32)',
|
|
349
|
+
'font-size:13px',
|
|
350
|
+
].join(';');
|
|
351
|
+
|
|
352
|
+
const title = document.createElement('h2');
|
|
353
|
+
title.textContent = zh ? '整页跳转确认(authTrace debug)' : 'Full-page redirect (authTrace debug)';
|
|
354
|
+
title.style.cssText = 'margin:0 0 10px;font-size:15px;font-weight:600';
|
|
355
|
+
card.appendChild(title);
|
|
356
|
+
|
|
357
|
+
const detail = document.createElement('pre');
|
|
358
|
+
detail.textContent = [
|
|
359
|
+
`reason: ${opts.reason}`,
|
|
360
|
+
`kind: ${opts.kind ?? 'funnel'}`,
|
|
361
|
+
`from: ${sanitizeUrl(window.location.href)}`,
|
|
362
|
+
`to: ${target}`,
|
|
363
|
+
'',
|
|
364
|
+
...(readBuffer().slice(-10).map((e) => `${new Date(e.t).toISOString()} ${e.ev} ${e.reason ?? ''} ${e.to ?? e.from ?? ''}`)),
|
|
365
|
+
].join('\n');
|
|
366
|
+
detail.style.cssText = [
|
|
367
|
+
'margin:0 0 14px',
|
|
368
|
+
'padding:10px',
|
|
369
|
+
'border-radius:6px',
|
|
370
|
+
'background:#f3f4f6',
|
|
371
|
+
'white-space:pre-wrap',
|
|
372
|
+
'word-break:break-all',
|
|
373
|
+
'font-size:12px',
|
|
374
|
+
'line-height:1.5',
|
|
375
|
+
'max-height:46vh',
|
|
376
|
+
'overflow:auto',
|
|
377
|
+
].join(';');
|
|
378
|
+
card.appendChild(detail);
|
|
379
|
+
|
|
380
|
+
const mkButton = (txt: string, primary: boolean, onClick: () => void) => {
|
|
381
|
+
const b = document.createElement('button');
|
|
382
|
+
b.type = 'button';
|
|
383
|
+
b.textContent = txt;
|
|
384
|
+
b.style.cssText = [
|
|
385
|
+
primary ? 'background:#2563eb;color:#fff' : 'background:#e5e7eb;color:#111827',
|
|
386
|
+
'border:0',
|
|
387
|
+
'border-radius:6px',
|
|
388
|
+
'padding:7px 14px',
|
|
389
|
+
'font-size:13px',
|
|
390
|
+
'cursor:pointer',
|
|
391
|
+
'margin-right:8px',
|
|
392
|
+
].join(';');
|
|
393
|
+
b.addEventListener('click', onClick);
|
|
394
|
+
return b;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
card.appendChild(mkButton(zh ? '继续前往' : 'Continue', true, () => {
|
|
398
|
+
removeNode(CONFIRM_ID);
|
|
399
|
+
go(target, !!opts.assign);
|
|
400
|
+
}));
|
|
401
|
+
card.appendChild(mkButton(zh ? '留在本页' : 'Stay', false, () => {
|
|
402
|
+
removeNode(CONFIRM_ID);
|
|
403
|
+
onStay();
|
|
404
|
+
}));
|
|
405
|
+
|
|
406
|
+
overlay.appendChild(card);
|
|
407
|
+
document.body.appendChild(overlay);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------- 公开 API
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 统一整页跳转出口:记 trace →(debug 时)确认页 →(interstitial 时)提示条 →
|
|
414
|
+
* 执行 location.replace/href。
|
|
415
|
+
*
|
|
416
|
+
* 重入语义:pending 期间新的 interstitial 请求被抑制(记 suppressed,防双提示/双跳);
|
|
417
|
+
* funnel 请求不被抑制(用户主动 / 流程必经,优先且覆盖 pending)。
|
|
418
|
+
*/
|
|
419
|
+
export function traceRedirect(target: string, opts: AuthTraceRedirectOptions): void {
|
|
420
|
+
if (!isBrowser()) return;
|
|
421
|
+
ensureInit();
|
|
422
|
+
const kind = opts.kind ?? 'funnel';
|
|
423
|
+
const decorated = decorateTarget(target, opts.reason);
|
|
424
|
+
const from = sanitizeUrl(window.location.href);
|
|
425
|
+
|
|
426
|
+
if (pending && kind === 'interstitial') {
|
|
427
|
+
push('suppressed', { reason: opts.reason, from, to: sanitizeUrl(decorated) });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
push('redirect', { reason: opts.reason, from, to: sanitizeUrl(decorated) });
|
|
432
|
+
|
|
433
|
+
const onStay = () => {
|
|
434
|
+
pending = false;
|
|
435
|
+
push('stay', { reason: opts.reason, from, to: sanitizeUrl(decorated) });
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
if (inDebug()) {
|
|
439
|
+
pending = true;
|
|
440
|
+
showConfirm(decorated, opts, onStay);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (kind === 'interstitial') {
|
|
445
|
+
pending = true;
|
|
446
|
+
showInterstitial(decorated, opts, onStay);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
go(decorated, !!opts.assign);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 记一条非跳转 trace 事件(如 error 页出口、OAuth 链路里程碑)。 */
|
|
454
|
+
export function traceEvent(ev: string, extra?: { reason?: string; from?: string; to?: string }): void {
|
|
455
|
+
if (!isBrowser()) return;
|
|
456
|
+
ensureInit();
|
|
457
|
+
push(ev, extra);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** 导出全部缓冲 + 环境摘要(`copy(__authTrace.dump())` 取证用)。 */
|
|
461
|
+
export function traceDump(): string {
|
|
462
|
+
if (!isBrowser()) return '';
|
|
463
|
+
ensureInit();
|
|
464
|
+
const header = [
|
|
465
|
+
`authTrace v1 @${station()}`,
|
|
466
|
+
`url: ${sanitizeUrl(window.location.href)}`,
|
|
467
|
+
`debug: ${inDebug() ? 'on' : 'off'}`,
|
|
468
|
+
`ua: ${navigator.userAgent}`,
|
|
469
|
+
`entries: ${readBuffer().length}`,
|
|
470
|
+
];
|
|
471
|
+
const lines = readBuffer().map((e) => `${new Date(e.t).toISOString()} ${e.ev} ${e.reason ?? ''} ${e.to ?? e.from ?? ''}`);
|
|
472
|
+
return [...header, '', ...lines].join('\n');
|
|
473
|
+
}
|
package/src/auth/oauth-login.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { AuthService } from './service';
|
|
|
14
14
|
import { generatePKCE } from './pkce';
|
|
15
15
|
import { getPortalUrl } from '../config';
|
|
16
16
|
import { persistOAuthClientId } from './oauth-client-id-store';
|
|
17
|
+
import { traceRedirect } from './auth-trace';
|
|
17
18
|
import type { User } from '../types';
|
|
18
19
|
|
|
19
20
|
function getAppConfig(): Record<string, string> | undefined {
|
|
@@ -157,7 +158,13 @@ export async function initiateOAuthLogin(
|
|
|
157
158
|
code_challenge_method: 'S256',
|
|
158
159
|
state,
|
|
159
160
|
});
|
|
160
|
-
|
|
161
|
+
// interstitial:未认证深链被动弹去登录 → authTrace 提示(可停留);
|
|
162
|
+
// 「停留」取消后 10s 防重入窗口过期即失效,用户刷新/再次交互可重新发起。
|
|
163
|
+
traceRedirect(`${cfg.authorizeUrl}?${params}`, {
|
|
164
|
+
reason: 'oauth-initiate',
|
|
165
|
+
kind: 'interstitial',
|
|
166
|
+
assign: true,
|
|
167
|
+
});
|
|
161
168
|
} catch {
|
|
162
169
|
const params = new URLSearchParams({
|
|
163
170
|
response_type: 'code',
|
|
@@ -166,7 +173,11 @@ export async function initiateOAuthLogin(
|
|
|
166
173
|
scope: cfg.scope,
|
|
167
174
|
state,
|
|
168
175
|
});
|
|
169
|
-
|
|
176
|
+
traceRedirect(`${cfg.authorizeUrl}?${params}`, {
|
|
177
|
+
reason: 'oauth-initiate',
|
|
178
|
+
kind: 'interstitial',
|
|
179
|
+
assign: true,
|
|
180
|
+
});
|
|
170
181
|
}
|
|
171
182
|
}
|
|
172
183
|
|
|
@@ -287,5 +298,6 @@ export async function handleOAuthCallback(): Promise<void> {
|
|
|
287
298
|
}
|
|
288
299
|
|
|
289
300
|
// Full page navigation to the target (auth gateway will route to dashboard)
|
|
290
|
-
|
|
301
|
+
// funnel:登录成功回跳(预期行为,静默直跳,assign 保持原语义);仅记 trace。
|
|
302
|
+
traceRedirect(target, { reason: 'oauth-callback', assign: true });
|
|
291
303
|
}
|
package/src/auth/roles.ts
CHANGED
|
@@ -51,6 +51,22 @@ export function buildLogoutUrl(returnUrl?: string): string {
|
|
|
51
51
|
return `${base}/?${redirect}logout=1`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 当前页是否已是 auth 的「登录承接面」——裸根入口(`/`、`/login`,由入口路由承接)、
|
|
56
|
+
* `/<slug>/login` 登录页、`/error` 错误页。
|
|
57
|
+
*
|
|
58
|
+
* 会话终结(onUnauthorized)时若已停在承接面,整页 replace 不但无益(目标就是本页),
|
|
59
|
+
* 还会因 buildLoginUrl 的防嵌套截断返回不带 redirect 的本页 —— 把 URL 中的回程目标
|
|
60
|
+
* 一并丢掉,且构成一次整页闪烁(F-W8b 修复②:存量过期 token 在登录页「静默弹走」)。
|
|
61
|
+
*/
|
|
62
|
+
export function isAuthLoginSurface(): boolean {
|
|
63
|
+
if (typeof window === 'undefined') return false;
|
|
64
|
+
const segs = window.location.pathname.split('/').filter(Boolean);
|
|
65
|
+
if (segs.length === 0) return true; // 裸根 `/`
|
|
66
|
+
if (segs[0] === 'login' || segs[0] === 'error') return true; // `/login`、`/error`
|
|
67
|
+
return segs.length === 2 && segs[1] === 'login'; // `/<slug>/login`
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
export function buildLoginUrl(returnUrl?: string, fromRequireAuth?: boolean): string {
|
|
55
71
|
const base = getPortalUrl('auth');
|
|
56
72
|
if (!base) return '/';
|
package/src/auth/service.ts
CHANGED
|
@@ -23,13 +23,13 @@ import {
|
|
|
23
23
|
isTokenExpired as checkExpired,
|
|
24
24
|
} from './store';
|
|
25
25
|
import { loginWithTokens } from './store';
|
|
26
|
-
import { buildLoginUrl } from './roles';
|
|
26
|
+
import { buildLoginUrl, isAuthLoginSurface } from './roles';
|
|
27
|
+
import { traceRedirect } from './auth-trace';
|
|
27
28
|
import { resolveClientIdForSession } from './oauth-client-id-store';
|
|
28
29
|
import type { User } from '../types';
|
|
29
30
|
|
|
30
31
|
// ============ Internal State ============
|
|
31
32
|
|
|
32
|
-
let redirectingToLogin = false;
|
|
33
33
|
let refreshPromise: Promise<string | null> | null = null;
|
|
34
34
|
|
|
35
35
|
// ============ OAuth PKCE Bootstrap Lock ============
|
|
@@ -259,19 +259,30 @@ export const AuthService = {
|
|
|
259
259
|
/**
|
|
260
260
|
* 刷新 access token。
|
|
261
261
|
* 防重入:同一时间只会有一个刷新请求进行中。
|
|
262
|
-
*
|
|
262
|
+
*
|
|
263
|
+
* 失败处置(onFailure,缺省 'redirect'):
|
|
264
|
+
* - 'redirect':onUnauthorized —— 清理 + 引导登录(常规路径);
|
|
265
|
+
* - 'clear' :clearSession —— 只清理不导航(调用方自控唯一导航出口,
|
|
266
|
+
* 如会话过期横幅 → error 页,避免与整页跳转双跳竞争);
|
|
267
|
+
* - 'none' :零副作用静默(尽力续期场景:如过期前 5 分钟的自动续期尝试,
|
|
268
|
+
* 失败不清理不打断——会话此刻仍有效,等真正过期再统一处置)。
|
|
263
269
|
*/
|
|
264
|
-
refreshToken: async (
|
|
270
|
+
refreshToken: async (opts?: {
|
|
271
|
+
onFailure?: 'redirect' | 'clear' | 'none';
|
|
272
|
+
}): Promise<string | null> => {
|
|
265
273
|
if (refreshPromise) return refreshPromise;
|
|
274
|
+
const onFailure = opts?.onFailure ?? 'redirect';
|
|
275
|
+
const handleFailure = () => {
|
|
276
|
+
if (onFailure === 'redirect') AuthService.onUnauthorized();
|
|
277
|
+
else if (onFailure === 'clear') AuthService.clearSession();
|
|
278
|
+
};
|
|
266
279
|
|
|
267
280
|
refreshPromise = (async () => {
|
|
268
281
|
const rt = AuthService.getRefreshToken();
|
|
269
282
|
if (!rt) {
|
|
270
283
|
// During OAuth PKCE bootstrap (no token yet), suppress redirect.
|
|
271
284
|
// RequireAuth will handle OAuth PKCE vs BFF fallback once tenantRoute resolves.
|
|
272
|
-
if (!_bootstrapLocked)
|
|
273
|
-
AuthService.onUnauthorized();
|
|
274
|
-
}
|
|
285
|
+
if (!_bootstrapLocked) handleFailure();
|
|
275
286
|
return null;
|
|
276
287
|
}
|
|
277
288
|
|
|
@@ -315,7 +326,7 @@ export const AuthService = {
|
|
|
315
326
|
return newAT || null;
|
|
316
327
|
} catch (err) {
|
|
317
328
|
console.warn('[AuthService] Token refresh failed', (err as Error)?.message || '');
|
|
318
|
-
|
|
329
|
+
handleFailure();
|
|
319
330
|
return null;
|
|
320
331
|
} finally {
|
|
321
332
|
refreshPromise = null;
|
|
@@ -328,14 +339,10 @@ export const AuthService = {
|
|
|
328
339
|
// ==================== 401/未授权处理 ====================
|
|
329
340
|
|
|
330
341
|
/**
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* 2. 重定向到登录页(防重入锁)
|
|
342
|
+
* 仅清除会话状态(store + localStorage),不做任何导航。
|
|
343
|
+
* 供 silent 刷新失败、以及需要自控导航出口的调用方使用。
|
|
334
344
|
*/
|
|
335
|
-
|
|
336
|
-
// Suppress redirect during OAuth PKCE bootstrap — RequireAuth handles it
|
|
337
|
-
if (_bootstrapLocked) return;
|
|
338
|
-
// 先清除状态,不等待 logout 的异步吊销
|
|
345
|
+
clearSession: (): void => {
|
|
339
346
|
useAuthStore.getState().clearAuth();
|
|
340
347
|
if (typeof window !== 'undefined') {
|
|
341
348
|
// 清除 localStorage(同步,立即生效)
|
|
@@ -343,12 +350,32 @@ export const AuthService = {
|
|
|
343
350
|
localStorage.removeItem('access_token');
|
|
344
351
|
localStorage.removeItem('refresh_token');
|
|
345
352
|
}
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 处理 401 未授权响应:
|
|
357
|
+
* 1. 清除所有状态(clearSession)
|
|
358
|
+
* 2. 经 authTrace 提示(会话已过期 → 即将前往登录,可停留)后整页弹登录页
|
|
359
|
+
*
|
|
360
|
+
* 例外:已在 auth 登录承接面(裸根入口 / `/<slug>/login` / `/error`)时只清理不导航 ——
|
|
361
|
+
* 目标就是当前页,整页 replace 反而会丢掉 URL 里的 redirect 回程目标并造成闪烁
|
|
362
|
+
* (F-W8b 修复②);承接面页面对 401 有自己的收口(登录页落表单、error 页走倒计时)。
|
|
363
|
+
*
|
|
364
|
+
* 重入抑制在 authTrace 内(pending 单例;并发 401 只提示一次、只跳一次)。
|
|
365
|
+
*/
|
|
366
|
+
onUnauthorized: (): void => {
|
|
367
|
+
// Suppress redirect during OAuth PKCE bootstrap — RequireAuth handles it
|
|
368
|
+
if (_bootstrapLocked) return;
|
|
369
|
+
// 先清除状态,不等待 logout 的异步吊销
|
|
370
|
+
AuthService.clearSession();
|
|
346
371
|
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
372
|
+
if (isAuthLoginSurface()) return;
|
|
373
|
+
|
|
374
|
+
if (typeof window !== 'undefined') {
|
|
375
|
+
traceRedirect(buildLoginUrl(window.location.href, true), {
|
|
376
|
+
reason: 'session-expired',
|
|
377
|
+
kind: 'interstitial',
|
|
378
|
+
});
|
|
352
379
|
}
|
|
353
380
|
},
|
|
354
381
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { AuthService, setBootstrapLock } from '../auth/service';
|
|
3
3
|
import { buildLoginUrl } from '../auth/roles';
|
|
4
|
+
import { traceRedirect } from '../auth/auth-trace';
|
|
4
5
|
import { initiateOAuthLogin } from '../auth/oauth-login';
|
|
5
6
|
import {
|
|
6
7
|
useTenantRoute,
|
|
@@ -103,8 +104,12 @@ export function RequireAuth({
|
|
|
103
104
|
// Cross-domain or no client: redirect to auth-pages login
|
|
104
105
|
// from_requireauth=1 与 service.ts 的 onUnauthorized 同口径:auth 侧据此先做会话复检
|
|
105
106
|
// (有会话直接回跳,避免「已登录还被要求再登一次」)
|
|
107
|
+
// interstitial:未认证深链被动弹登录 → authTrace 提示(可停留);重入抑制在其内。
|
|
106
108
|
setTimeout(() => {
|
|
107
|
-
|
|
109
|
+
traceRedirect(buildLoginUrl(window.location.href, true), {
|
|
110
|
+
reason: 'unauthenticated',
|
|
111
|
+
kind: 'interstitial',
|
|
112
|
+
});
|
|
108
113
|
}, 0);
|
|
109
114
|
}, [machine.status, tenantRoute]);
|
|
110
115
|
|