@autional/shared 0.0.0-stage → 0.1.0-rc.33

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.
Files changed (115) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +49 -3
  3. package/src/api/__tests__/contract.test.ts +172 -0
  4. package/src/api/__tests__/entry-plane.test.ts +119 -0
  5. package/src/api/__tests__/namespace.test.ts +27 -0
  6. package/src/api/__tests__/preflight-refresh.test.ts +89 -0
  7. package/src/api/bff-client.ts +94 -0
  8. package/src/api/client.ts +191 -0
  9. package/src/api/entry-plane.ts +61 -0
  10. package/src/api/namespace.ts +14 -0
  11. package/src/api/trace.ts +21 -0
  12. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  13. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  14. package/src/auth/__tests__/auth-trace.test.ts +321 -0
  15. package/src/auth/__tests__/build-login-url.test.ts +259 -0
  16. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  17. package/src/auth/__tests__/jwt-payload.test.ts +49 -0
  18. package/src/auth/__tests__/logout-redirect.test.ts +44 -0
  19. package/src/auth/__tests__/logout-revoke.test.ts +219 -0
  20. package/src/auth/__tests__/oauth-callback-error-codes.test.ts +69 -0
  21. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  22. package/src/auth/__tests__/oauth-initiate-stay.test.ts +118 -0
  23. package/src/auth/__tests__/oauth-login-user-resolution.test.ts +185 -0
  24. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  25. package/src/auth/__tests__/session-client-id.test.ts +147 -0
  26. package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -0
  27. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  28. package/src/auth/auth-machine.ts +151 -0
  29. package/src/auth/auth-queries.ts +118 -0
  30. package/src/auth/auth-trace.ts +511 -0
  31. package/src/auth/build-tenant-urls.ts +48 -0
  32. package/src/auth/jwt-payload.ts +21 -0
  33. package/src/auth/oauth-client-from-slug.ts +119 -0
  34. package/src/auth/oauth-client-id-store.ts +41 -0
  35. package/src/auth/oauth-login.ts +388 -0
  36. package/src/auth/pkce.ts +141 -0
  37. package/src/auth/portal-config.ts +68 -0
  38. package/src/auth/roles.ts +120 -0
  39. package/src/auth/service.ts +433 -0
  40. package/src/auth/slug-from-url.ts +92 -0
  41. package/src/auth/store.ts +227 -0
  42. package/src/auth/tenant-route-middleware.ts +113 -0
  43. package/src/auth/tenant-slug-context.tsx +9 -0
  44. package/src/branding/BrandingInitializer.tsx +60 -0
  45. package/src/branding/__tests__/brand-vars.test.ts +126 -0
  46. package/src/branding/brand-color.ts +193 -0
  47. package/src/branding/branding-cache.ts +71 -0
  48. package/src/branding/extract-branding.ts +27 -0
  49. package/src/branding/index.ts +15 -0
  50. package/src/branding/tenant-store.ts +13 -0
  51. package/src/branding/types.ts +14 -0
  52. package/src/branding/useBranding.ts +101 -0
  53. package/src/components/AuditStatsOnly.tsx +65 -0
  54. package/src/components/OAuthCallbackPage.tsx +83 -0
  55. package/src/components/PortalGuard.tsx +105 -0
  56. package/src/components/RequireAuth.tsx +220 -0
  57. package/src/components/TenantIndexGuard.tsx +131 -0
  58. package/src/components/TenantRootRedirect.tsx +49 -0
  59. package/src/components/__tests__/RequireAuth.adversarial.test.tsx +165 -0
  60. package/src/components/__tests__/RequireAuth.stay.test.tsx +131 -0
  61. package/src/components/__tests__/RequireAuth.test.tsx +296 -0
  62. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  63. package/src/components/__tests__/TenantRootRedirect.test.tsx +180 -0
  64. package/src/config/__tests__/portal-resolver.test.ts +302 -0
  65. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  66. package/src/config/constants.ts +27 -0
  67. package/src/config/index.ts +426 -0
  68. package/src/config/resolve-basename.ts +57 -0
  69. package/src/constants/api-paths.ts +450 -0
  70. package/src/generated/api.ts +13012 -0
  71. package/src/generated/queries.ts +12984 -0
  72. package/src/generated/types.ts +13982 -0
  73. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  74. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  75. package/src/hooks/__tests__/useCurrentTenantIdOr.test.tsx +38 -0
  76. package/src/hooks/__tests__/useLogout.test.tsx +61 -0
  77. package/src/hooks/__tests__/usePermission.map.test.tsx +156 -0
  78. package/src/hooks/__tests__/usePortalCatalog.test.tsx +301 -0
  79. package/src/hooks/__tests__/useSodMode.test.tsx +205 -0
  80. package/src/hooks/useAuth.ts +239 -0
  81. package/src/hooks/useBootstrap.ts +74 -0
  82. package/src/hooks/useCurrentRole.ts +18 -0
  83. package/src/hooks/useCurrentTenantIdOr.ts +29 -0
  84. package/src/hooks/useLogout.ts +42 -0
  85. package/src/hooks/usePermission.ts +128 -0
  86. package/src/hooks/usePortalCatalog.ts +199 -0
  87. package/src/hooks/useSodMode.ts +81 -0
  88. package/src/index.ts +247 -0
  89. package/src/lib/__tests__/push.test.ts +30 -0
  90. package/src/lib/push.ts +44 -0
  91. package/src/seo/dom-utils.ts +94 -0
  92. package/src/seo/index.ts +6 -0
  93. package/src/seo/jsonld.ts +29 -0
  94. package/src/seo/types.ts +29 -0
  95. package/src/seo/usePageMeta.ts +17 -0
  96. package/src/seo/usePageTitle.ts +14 -0
  97. package/src/seo/useSEO.ts +80 -0
  98. package/src/types/index.ts +190 -0
  99. package/src/utils/__tests__/case.test.ts +94 -0
  100. package/src/utils/__tests__/error.test.ts +72 -0
  101. package/src/utils/__tests__/mock-test.ts +15 -0
  102. package/src/utils/__tests__/page.test.ts +54 -0
  103. package/src/utils/__tests__/query-state.test.ts +120 -0
  104. package/src/utils/browser.ts +15 -0
  105. package/src/utils/case.ts +59 -0
  106. package/src/utils/error.ts +35 -0
  107. package/src/utils/format.ts +63 -0
  108. package/src/utils/page.ts +57 -0
  109. package/src/utils/password-transmission.test.ts +145 -0
  110. package/src/utils/password-transmission.ts +253 -0
  111. package/src/utils/query-state.ts +85 -0
  112. package/src/utils/response.ts +71 -0
  113. package/src/utils/vite.ts +4 -0
  114. package/src/vite-env.d.ts +19 -0
  115. package/README.md +0 -3
@@ -0,0 +1,41 @@
1
+ /**
2
+ * OAuth client_id 持久化(TASK-09 / D8)。
3
+ *
4
+ * 门户的 client_id 平时由 slug 实时解析(`oauth-client-from-slug.ts`)或 env 固定;
5
+ * 但刷新(`AuthService.refreshToken`)与登出吊销(`AuthService.logout`)不在 React
6
+ * 上下文里,拿不到 slug 解析结果 —— 故 OAuth 交棒时把已解析的 client_id 落 localStorage,
7
+ * 两处按「会话值(持久化)→ env → 空」顺序读取(`resolveClientIdForSession`)。
8
+ *
9
+ * 该值属于「本部署用哪个 OAuth 客户端」,非用户私有数据 ⇒ 登出不清除。
10
+ */
11
+
12
+ const STORAGE_KEY = 'oauth_client_id';
13
+
14
+ /** OAuth 交棒(PKCE 发起 / 回调兑换成功)时落库 */
15
+ export function persistOAuthClientId(clientId: string): void {
16
+ if (typeof window === 'undefined' || !clientId) return;
17
+ try {
18
+ localStorage.setItem(STORAGE_KEY, clientId);
19
+ } catch {
20
+ /* 存储不可用(隐私模式)时静默:刷新回落 env,行为与接线前一致 */
21
+ }
22
+ }
23
+
24
+ export function getPersistedOAuthClientId(): string {
25
+ if (typeof window === 'undefined') return '';
26
+ try {
27
+ return localStorage.getItem(STORAGE_KEY) || '';
28
+ } catch {
29
+ return '';
30
+ }
31
+ }
32
+
33
+ /** 刷新/登出用:会话内已解析值优先,env 兜底 */
34
+ export function resolveClientIdForSession(): string {
35
+ const persisted = getPersistedOAuthClientId();
36
+ if (persisted) return persisted;
37
+ if (typeof window !== 'undefined') {
38
+ return (window as any).__APP_CONFIG__?.VITE_OAUTH_CLIENT_ID || '';
39
+ }
40
+ return '';
41
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Dev-mode OAuth PKCE login utilities
3
+ *
4
+ * Used by RequireAuth when VITE_OAUTH_CLIENT_ID is configured.
5
+ * Supports 3 profiles:
6
+ * - dev: API=localhost, OAuth=off (password login, no OAuth)
7
+ * - oauth: API=localhost, OAuth=on (local Docker API, tianv.local OAuth)
8
+ * - prod: API=production, OAuth=on (production API, iam.tianv.com OAuth)
9
+ */
10
+
11
+ import { apiClient } from '../api/client';
12
+ import { loginWithTokens } from './store';
13
+ import { AuthService } from './service';
14
+ import { generatePKCE } from './pkce';
15
+ import { getPortalUrl } from '../config';
16
+ import { persistOAuthClientId } from './oauth-client-id-store';
17
+ import { traceRedirect } from './auth-trace';
18
+ import { decodeJwtPayload } from './jwt-payload';
19
+ import type { User } from '../types';
20
+
21
+ function getAppConfig(): Record<string, string> | undefined {
22
+ if (typeof window !== 'undefined') {
23
+ return (window as any).__APP_CONFIG__;
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ const SK = {
29
+ PKCE: 'oauth_pkce_verifier',
30
+ STATE: 'oauth_state',
31
+ INIT_TS: 'oauth_init_ts',
32
+ CID: 'oauth_client_id',
33
+ };
34
+
35
+ export interface OAuthInitiateOptions {
36
+ /** 「停留」取消本次跳转时回调(AUTH-03,调用方据此恢复 UI);内部已先清预置物 |
37
+ * 与待重试定时器(AUTH-08),再次交互可全新发起。 */
38
+ onStay?: () => void;
39
+ /** 跳过 10s 防重入窗口(用户显式点「前往登录」重试时用,AUTH-03)。 */
40
+ force?: boolean;
41
+ }
42
+
43
+ /**
44
+ * AUTH-46②:OAuth 回调失败错误——携稳定 code,消费方(OAuthCallbackPage 等)按 code
45
+ * 映射本地化文案;message 保留英文原文供 console/诊断。此前直接 throw 英文裸句
46
+ * ("State mismatch — possible CSRF attack" 等)落用户屏。
47
+ */
48
+ export class OAuthCallbackError extends Error {
49
+ readonly code: string;
50
+ constructor(code: string, message: string) {
51
+ super(message);
52
+ this.name = 'OAuthCallbackError';
53
+ this.code = code;
54
+ }
55
+ }
56
+
57
+ // 防重入拦截后的一次性重试 timer(避免 10 秒窗口内快速刷新导致 OAuth 跳转被吞、页面卡住)
58
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
59
+
60
+ /**
61
+ * 丢弃未完成的 OAuth 发起留下的全部预置物(AUTH-08 会话卫生):
62
+ * 取消待重试定时器 + 清 sessionStorage 四键(state/verifier/client_id/init_ts)。
63
+ * 「停留」(handleStay)与登出(AuthService.logout)共用——两者都意味着
64
+ * 「本次发起已作废」:不清则 ① 窗口过期后自动重试仍会把用户弹走;
65
+ * ② 明文 verifier 与 state 跨登出残留在 tab 里。
66
+ */
67
+ export function discardPendingOAuthLogin(): void {
68
+ if (typeof window === 'undefined') return;
69
+ if (retryTimer) {
70
+ clearTimeout(retryTimer);
71
+ retryTimer = null;
72
+ }
73
+ sessionStorage.removeItem(SK.STATE);
74
+ sessionStorage.removeItem(SK.PKCE);
75
+ sessionStorage.removeItem(SK.CID);
76
+ sessionStorage.removeItem(SK.INIT_TS);
77
+ }
78
+
79
+ function parseUserFromToken(token?: string, apiUser?: unknown): User {
80
+ if (token) {
81
+ const p = decodeJwtPayload(token);
82
+ if (p) {
83
+ return {
84
+ id: (p.sub || p.user_id) as string,
85
+ username: ((p as any).custom?.username || (p as any).username) as string,
86
+ email: p.email as string,
87
+ status: 'active',
88
+ avatarUrl: p.picture as string,
89
+ } as unknown as User;
90
+ }
91
+ }
92
+ return apiUser as User;
93
+ }
94
+
95
+ function envVar(key: string): string {
96
+ const cfg = getAppConfig();
97
+ if (cfg && cfg[key]) return cfg[key];
98
+ try {
99
+ return (import.meta as any).env?.[key] || '';
100
+ } catch {
101
+ return '';
102
+ }
103
+ }
104
+
105
+ function getConfig() {
106
+ const base = getPortalUrl('auth');
107
+ const callbackPath = (window as any).__APP_CONFIG__?.BASE_PATH || '/';
108
+ const origin = window.location.origin;
109
+ return {
110
+ clientId: envVar('VITE_OAUTH_CLIENT_ID'),
111
+ authorizeUrl: `${base}/oauth/api/v1/oauth/authorize`,
112
+ redirectUri:
113
+ callbackPath === '/' ? `${origin}/oauth/callback` : `${origin}${callbackPath}/oauth/callback`,
114
+ scope: 'openid profile email offline_access',
115
+ };
116
+ }
117
+
118
+ export function isOAuthEnabled(): boolean {
119
+ return !!envVar('VITE_OAUTH_CLIENT_ID');
120
+ }
121
+
122
+ function randomState(): string {
123
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
124
+ try {
125
+ return crypto.randomUUID();
126
+ } catch {
127
+ /* fall through to getRandomValues */
128
+ }
129
+ }
130
+ // crypto.getRandomValues is available in non-secure contexts too (unlike
131
+ // randomUUID, which requires HTTPS/localhost). This value is used as the
132
+ // OAuth state CSRF token — never degrade to a state-recoverable, predictable
133
+ // PRNG or a timestamp (both are attackable for CSRF forgery). Fail loud.
134
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
135
+ const bytes = new Uint8Array(16);
136
+ crypto.getRandomValues(bytes);
137
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
138
+ }
139
+ throw new Error('No CSPRNG available — cannot generate secure OAuth state');
140
+ }
141
+
142
+ export async function initiateOAuthLogin(
143
+ clientId?: string,
144
+ redirectTarget?: string,
145
+ opts?: OAuthInitiateOptions,
146
+ ): Promise<void> {
147
+ const ts = sessionStorage.getItem(SK.INIT_TS);
148
+ if (!opts?.force && ts && Date.now() - parseInt(ts, 10) < 10000) {
149
+ // 防重入:10 秒内已有一次 OAuth 初始化(如快速刷新/重复导航)。
150
+ // 直接 return 会吞掉跳转导致 RequireAuth 卡在空白页(无 re-render 不会重试),
151
+ // 因此在窗口过期后安排一次自动重试,保证最终完成跳转。
152
+ const wait = 10000 - (Date.now() - parseInt(ts, 10)) + 100;
153
+ if (!retryTimer) {
154
+ retryTimer = setTimeout(() => {
155
+ retryTimer = null;
156
+ void initiateOAuthLogin(clientId, redirectTarget);
157
+ }, wait);
158
+ }
159
+ return;
160
+ }
161
+ // 本次发起取代任何待重试定时器(含 force 绕过窗口时的遗留)
162
+ if (retryTimer) {
163
+ clearTimeout(retryTimer);
164
+ retryTimer = null;
165
+ }
166
+ sessionStorage.setItem(SK.INIT_TS, String(Date.now()));
167
+
168
+ // Determine the URL to redirect to after successful OAuth
169
+ const target = redirectTarget || window.location.href;
170
+
171
+ // Derive OAuth config (redirectUri computed from BASE_PATH, consistent with handleOAuthCallback)
172
+ const cfg = getConfig();
173
+ const cid = clientId || cfg.clientId;
174
+ if (!cid) throw new Error('OAuth client_id is not available');
175
+
176
+ // Always encode state as JSON with CSRF token + redirect target
177
+ const csrf = randomState();
178
+ const state = JSON.stringify({ csrf, redirect: target });
179
+
180
+ sessionStorage.setItem(SK.STATE, csrf);
181
+ sessionStorage.setItem(SK.CID, cid);
182
+ // 交棒时落持久层:刷新/登出在 React 上下文外,拿不到 slug 解析结果(TASK-09 / D8)
183
+ persistOAuthClientId(cid);
184
+
185
+ // 「停留」= 放弃本次发起:清预置物 + 取消待重试定时器,防止窗口过期后
186
+ // 自动重试再把用户弹走;随后通知调用方恢复 UI(AUTH-03/08)。
187
+ const handleStay = () => {
188
+ discardPendingOAuthLogin();
189
+ opts?.onStay?.();
190
+ };
191
+
192
+ try {
193
+ const pkce = await generatePKCE();
194
+ sessionStorage.setItem(SK.PKCE, pkce.verifier);
195
+ const params = new URLSearchParams({
196
+ response_type: 'code',
197
+ client_id: cid,
198
+ redirect_uri: cfg.redirectUri,
199
+ scope: cfg.scope,
200
+ code_challenge: pkce.challenge,
201
+ code_challenge_method: 'S256',
202
+ state,
203
+ });
204
+ // interstitial:未认证深链被动弹去登录 → authTrace 提示(可停留)。
205
+ traceRedirect(`${cfg.authorizeUrl}?${params}`, {
206
+ reason: 'oauth-initiate',
207
+ kind: 'interstitial',
208
+ assign: true,
209
+ onStay: handleStay,
210
+ });
211
+ } catch {
212
+ const params = new URLSearchParams({
213
+ response_type: 'code',
214
+ client_id: cid,
215
+ redirect_uri: cfg.redirectUri,
216
+ scope: cfg.scope,
217
+ state,
218
+ });
219
+ traceRedirect(`${cfg.authorizeUrl}?${params}`, {
220
+ reason: 'oauth-initiate',
221
+ kind: 'interstitial',
222
+ assign: true,
223
+ onStay: handleStay,
224
+ });
225
+ }
226
+ }
227
+
228
+ export async function handleOAuthCallback(): Promise<void> {
229
+ const q = new URLSearchParams(window.location.search);
230
+
231
+ const err = q.get('error');
232
+ if (err) {
233
+ throw new OAuthCallbackError(
234
+ 'oauth.provider_error',
235
+ q.get('error_description') || `OAuth error: ${err}`,
236
+ );
237
+ }
238
+
239
+ const code = q.get('code');
240
+ if (!code) throw new OAuthCallbackError('oauth.missing_code', 'Missing authorization code');
241
+
242
+ const storedState = sessionStorage.getItem(SK.STATE) || '';
243
+ const verifier = sessionStorage.getItem(SK.PKCE) || '';
244
+ const cid = sessionStorage.getItem(SK.CID) || '';
245
+
246
+ // State is always JSON: { csrf, redirect }
247
+ let oauthRedirectTarget: string | null = null;
248
+ const rawState = q.get('state');
249
+ if (rawState) {
250
+ try {
251
+ const parsed = JSON.parse(rawState);
252
+ if (parsed.csrf && parsed.csrf === storedState) {
253
+ oauthRedirectTarget = parsed.redirect || null;
254
+ } else if (parsed.csrf) {
255
+ throw new OAuthCallbackError(
256
+ 'oauth.state_mismatch',
257
+ 'State mismatch — possible CSRF attack',
258
+ );
259
+ }
260
+ } catch (e) {
261
+ if (e instanceof OAuthCallbackError) throw e;
262
+ // Backward compat: plain string state (should not happen after Fix 3)
263
+ if (rawState !== storedState) {
264
+ throw new OAuthCallbackError(
265
+ 'oauth.state_mismatch',
266
+ 'State mismatch — possible CSRF attack',
267
+ );
268
+ }
269
+ }
270
+ }
271
+
272
+ if (!verifier) {
273
+ throw new OAuthCallbackError(
274
+ 'oauth.pkce_cleared',
275
+ 'Missing PKCE verifier — page reload may have cleared storage',
276
+ );
277
+ }
278
+
279
+ // Derive redirectUri from getConfig() for consistency with initiateOAuthLogin
280
+ const cbCfg = getConfig();
281
+ const redirectUri = cbCfg.redirectUri;
282
+
283
+ const res = await apiClient.post(
284
+ '/oauth/api/v1/oauth/token', // @generated-api-exempt
285
+ new URLSearchParams({
286
+ grant_type: 'authorization_code',
287
+ code,
288
+ client_id: cid,
289
+ client_secret: '',
290
+ redirect_uri: redirectUri,
291
+ code_verifier: verifier,
292
+ }).toString(),
293
+ { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
294
+ );
295
+
296
+ // 用毕即焚(AUTH-08):兑换请求已消费 code_verifier/state/client_id,立即清除;
297
+ // 原清理点在 userinfo、/auth/me 多次 await 之后,明文 verifier 驻留窗口被无谓拉长。
298
+ sessionStorage.removeItem(SK.PKCE);
299
+ sessionStorage.removeItem(SK.STATE);
300
+ sessionStorage.removeItem(SK.CID);
301
+
302
+ const t = res.data as any;
303
+ const accessToken = t.access_token;
304
+ const refreshToken = t.refresh_token || null;
305
+
306
+ // Prefer id_token for user info, fallback to /auth/me.
307
+ // IMPORTANT: loginWithTokens (which populates the auth store) runs AFTER this block,
308
+ // so the interceptor would send NO Authorization header here. Attach the freshly
309
+ // exchanged access_token explicitly — otherwise /auth/me 401s and the apiClient
310
+ // interceptor routes it through onUnauthorized (no refresh token yet) → bounce back
311
+ // to the login page with the now-consumed code → infinite redirect loop.
312
+ // oauth 服务的 id_token(oidc_signer.go SignIDToken)只含 sub/aud/iss/exp 等标准 claim,
313
+ // 不含 email/username。若直接采信,/auth/me 兜底被短路 → store 落残缺 user →
314
+ // UserMenu 等消费方显示「未登录」。缺身份字段的解析结果视为信息不足,交棒 /auth/me。
315
+ let user: User | null = parseUserFromToken(t.id_token);
316
+ if (user && !user.username && !user.email) {
317
+ user = null;
318
+ }
319
+ if (!user && accessToken) {
320
+ try {
321
+ const me = await apiClient.get('/identity/api/v1/auth/me', {
322
+ // @generated-api-exempt
323
+ headers: { Authorization: `Bearer ${accessToken}` },
324
+ });
325
+ user = parseUserFromToken(undefined, me.data);
326
+ } catch {
327
+ // /auth/me may fail; loginWithTokens will still succeed with partial user
328
+ }
329
+ }
330
+ if (!user && accessToken) {
331
+ // 最后兜底:/auth/me 也失败时用 access_token JWT 解析 user
332
+ // (identity 签发,payload 实测含 sub/tenant_id/custom.*,可能无 email/username)
333
+ user = parseUserFromToken(accessToken);
334
+ }
335
+ if (!user) {
336
+ user = { id: '', username: '', email: '' } as User;
337
+ }
338
+
339
+ // OIDC userinfo(oauth 服务经 BFF:/bff/oauth/api/v1/oauth/userinfo)补充展示身份:
340
+ // id_token 与 /auth/me 都不含展示名(name/nickname/picture)。同样须显式带 access_token
341
+ // (loginWithTokens 尚未执行,拦截器无 token 可用)。失败静默降级——缺 displayName 时
342
+ // 消费方按 displayName → username → email 链回落,不影响登录。
343
+ if (accessToken) {
344
+ try {
345
+ const res = await apiClient.get('/oauth/api/v1/oauth/userinfo', {
346
+ // @generated-api-exempt
347
+ headers: { Authorization: `Bearer ${accessToken}` },
348
+ });
349
+ const info = res.data as { nickname?: string; name?: string; picture?: string };
350
+ const displayName = (info?.nickname || '').trim() || (info?.name || '').trim();
351
+ if (displayName) user.displayName = displayName;
352
+ if (info?.picture) user.avatarUrl = info.picture;
353
+ } catch {
354
+ // userinfo 失败不阻断登录(同上,按缺省链回落)
355
+ }
356
+ }
357
+
358
+ // 兑换成功后落持久层(同 TASK-09 / D8;兑换前的持久值可能来自旧租户)
359
+ persistOAuthClientId(cid);
360
+
361
+ // Persist to Zustand store (in-memory).
362
+ // Zustand persist subscriber will automatically write to localStorage
363
+ // via partialize — no manual localStorage.setItem needed.
364
+ loginWithTokens(accessToken, refreshToken, user);
365
+
366
+ // OAuth access_token 由身份服务签发,payload 含 tenant_id claim。
367
+ // 登录成功后初始化 currentTenantId,供 useTenantId()/useTenantIdOr() 等
368
+ // 页面使用真实租户(否则 fallback 'default-tenant' 导致 data-classification 等 404)。
369
+ const parsedToken = accessToken ? decodeJwtPayload(accessToken) : null;
370
+ const tokenTenantId = parsedToken?.tenant_id || (parsedToken as any)?.tenantId;
371
+ if (tokenTenantId) {
372
+ AuthService.updateCurrentTenant(tokenTenantId as string);
373
+ }
374
+
375
+ // Compute navigation target
376
+ let target: string;
377
+ if (oauthRedirectTarget) {
378
+ target = oauthRedirectTarget;
379
+ } else {
380
+ // Fallback to BASE_PATH from app config or root
381
+ const fallbackBase = (window as any).__APP_CONFIG__?.BASE_PATH || '/';
382
+ target = fallbackBase === '/' ? '/' : `${window.location.origin}${fallbackBase}/`;
383
+ }
384
+
385
+ // Full page navigation to the target (auth gateway will route to dashboard)
386
+ // funnel:登录成功回跳(预期行为,静默直跳,assign 保持原语义);仅记 trace。
387
+ traceRedirect(target, { reason: 'oauth-callback', assign: true });
388
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * RFC 7636 — OAuth2 PKCE (Proof Key for Code Exchange)
3
+ *
4
+ * Uses Web Crypto API (crypto.subtle) when available.
5
+ * Falls back to a compact, verified pure-JS SHA-256 implementation for non-secure contexts (HTTP).
6
+ */
7
+
8
+ function base64URLEncode(buffer: ArrayBuffer): string {
9
+ const bytes = new Uint8Array(buffer);
10
+ let binary = '';
11
+ for (let i = 0; i < bytes.length; i++) {
12
+ binary += String.fromCharCode(bytes[i]);
13
+ }
14
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
15
+ }
16
+
17
+ function generateRandomString(length: number): string {
18
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; // 66 chars
19
+ if (typeof crypto === 'undefined' || !crypto.getRandomValues) {
20
+ // Never silently degrade to a weak, state-recoverable PRNG — the PKCE
21
+ // verifier is a security credential. Fail loud instead.
22
+ throw new Error('crypto.getRandomValues is unavailable — cannot generate PKCE verifier');
23
+ }
24
+ // Rejection sampling to remove modulo bias: 256 % 66 = 58, so a naive
25
+ // `byte % 66` makes chars 0-57 ~3% likelier than chars 58-65. Instead we
26
+ // build a 198-char lookup table (chars repeated 3×, each char exactly 3
27
+ // times) and only accept bytes < 198 — pool[byte] is then perfectly
28
+ // uniform (every char has probability 3/198 = 1/66).
29
+ const maxAccepted = Math.floor(256 / chars.length) * chars.length; // 198
30
+ const pool = chars.repeat(maxAccepted / chars.length); // 66 × 3 = 198 chars
31
+ let result = '';
32
+ while (result.length < length) {
33
+ const array = new Uint8Array(length - result.length);
34
+ crypto.getRandomValues(array);
35
+ for (const byte of array) {
36
+ if (byte < maxAccepted) {
37
+ result += pool[byte];
38
+ }
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+
44
+ export interface PKCEPair {
45
+ verifier: string;
46
+ challenge: string;
47
+ }
48
+
49
+ export async function generatePKCE(): Promise<PKCEPair> {
50
+ const verifier = generateRandomString(64);
51
+ const challenge = await sha256Base64URL(verifier);
52
+ return { verifier, challenge };
53
+ }
54
+
55
+ async function sha256Base64URL(input: string): Promise<string> {
56
+ const encoder = new TextEncoder();
57
+ const data = encoder.encode(input);
58
+ try {
59
+ const hash = await crypto.subtle.digest('SHA-256', data);
60
+ return base64URLEncode(hash);
61
+ } catch {
62
+ return sha256PureJS(input);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Pure-JS SHA-256 — verified against NIST test vectors.
68
+ * Based on a minimal, audited implementation.
69
+ */
70
+ function sha256PureJS(input: string): string {
71
+ const msg = new TextEncoder().encode(input);
72
+ const msgBits = msg.length * 8;
73
+ const buf = new Uint8Array(((msg.length + 9 + 63) >>> 6) << 6);
74
+ buf.set(msg);
75
+ buf[msg.length] = 0x80;
76
+ const view = new DataView(buf.buffer);
77
+ view.setUint32(buf.length - 4, msgBits);
78
+
79
+ const K = new Uint32Array([
80
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
81
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
82
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
83
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
84
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
85
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
86
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
87
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
88
+ ]);
89
+
90
+ const H = new Uint32Array([
91
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
92
+ ]);
93
+ const W = new Uint32Array(64);
94
+
95
+ for (let off = 0; off < buf.length; off += 64) {
96
+ for (let i = 0; i < 16; i++) {
97
+ W[i] = view.getUint32(off + i * 4);
98
+ }
99
+ for (let i = 16; i < 64; i++) {
100
+ const s0 = ror(W[i - 15], 7) ^ ror(W[i - 15], 18) ^ (W[i - 15] >>> 3);
101
+ const s1 = ror(W[i - 2], 17) ^ ror(W[i - 2], 19) ^ (W[i - 2] >>> 10);
102
+ W[i] = (W[i - 16] + s0 + W[i - 7] + s1) | 0;
103
+ }
104
+ let [a, b, c, d, e, f, g, h] = H;
105
+ for (let i = 0; i < 64; i++) {
106
+ const S1 = ror(e, 6) ^ ror(e, 11) ^ ror(e, 25);
107
+ const ch = (e & f) ^ (~e & g);
108
+ const t1 = (h + S1 + ch + K[i] + W[i]) | 0;
109
+ const S0 = ror(a, 2) ^ ror(a, 13) ^ ror(a, 22);
110
+ const maj = (a & b) ^ (a & c) ^ (b & c);
111
+ const t2 = (S0 + maj) | 0;
112
+ h = g;
113
+ g = f;
114
+ f = e;
115
+ e = (d + t1) | 0;
116
+ d = c;
117
+ c = b;
118
+ b = a;
119
+ a = (t1 + t2) | 0;
120
+ }
121
+ H[0] = (H[0] + a) | 0;
122
+ H[1] = (H[1] + b) | 0;
123
+ H[2] = (H[2] + c) | 0;
124
+ H[3] = (H[3] + d) | 0;
125
+ H[4] = (H[4] + e) | 0;
126
+ H[5] = (H[5] + f) | 0;
127
+ H[6] = (H[6] + g) | 0;
128
+ H[7] = (H[7] + h) | 0;
129
+ }
130
+
131
+ const digest = new Uint8Array(32);
132
+ const dv = new DataView(digest.buffer);
133
+ for (let i = 0; i < 8; i++) dv.setUint32(i * 4, H[i]);
134
+ return base64URLEncode(digest.buffer);
135
+ }
136
+
137
+ function ror(x: number, n: number): number {
138
+ return (x >>> n) | (x << (32 - n));
139
+ }
140
+
141
+ export { generatePKCE as generateCodeChallenge };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * PortalAuthConfig — Portal 认证配置注册表
3
+ *
4
+ * 集中管理所有 Portal 的角色定义和 Guard 配置,
5
+ * 消除跨 Portal 的角色常量重复定义。
6
+ *
7
+ * 用法:
8
+ * import { ADMIN_GUARDS, AdminGuard } from '@autional/shared';
9
+ * // 路由中: <AdminGuard><Page /></AdminGuard>
10
+ * 替代: <RequireAuth allowedRoles={['super_admin', 'admin']}><Page /></RequireAuth>
11
+ *
12
+ * @see docs/architecture/auth-next-issues-analysis.md
13
+ */
14
+
15
+ // ============ Role Sets ============
16
+
17
+ /** 超级管理员(系统级) */
18
+ export const ROLE_SUPER_ADMIN = ['super_admin', 'admin'] as const;
19
+ /** 用户管理 */
20
+ export const ROLE_USER_MGMT = ['super_admin', 'admin', 'user_manager'] as const;
21
+ /** 安全只读(含 admin,用于 admin-console 路由守卫) */
22
+ export const ROLE_SECURITY_READ = ['super_admin', 'admin', 'security_admin'] as const;
23
+ /** security-dashboard 基础准入(不含 admin — admin 不直接登录 security-dashboard) */
24
+ export const ROLE_SECURITY_ENTRY = ['super_admin', 'security_admin', 'auditor'] as const;
25
+ /** 审计安全(含 auditor 只读,不含 admin — admin 审计经 admin-console + sod_mode 控制) */
26
+ export const ROLE_AUDIT_READ = ['super_admin', 'security_admin', 'auditor'] as const;
27
+ /** 安全操作(含配置权限,不含 auditor) */
28
+ export const ROLE_SECURITY_FULL = ['super_admin', 'security_admin'] as const;
29
+
30
+ // ============ Portal Guards ============
31
+
32
+ /**
33
+ * Portal Guard 配置。
34
+ * 如需新增 Guard,在此添加 role 集即可。
35
+ */
36
+ export const ADMIN_GUARDS = {
37
+ admin: { roles: ROLE_SUPER_ADMIN },
38
+ userMgmt: { roles: ROLE_USER_MGMT },
39
+ securityRead: { roles: ROLE_SECURITY_READ },
40
+ auditRead: { roles: ROLE_AUDIT_READ }, // admin-console 审计证据路由
41
+ securityAdmin: { roles: ROLE_SECURITY_FULL }, // admin-console 安全配置管理
42
+ } as const;
43
+
44
+ export const PLATFORM_GUARDS = {
45
+ superAdmin: { roles: ROLE_SUPER_ADMIN },
46
+ } as const;
47
+
48
+ export const SECURITY_GUARDS = {
49
+ security: { roles: ROLE_SECURITY_ENTRY }, // 基础准入(不含 admin)
50
+ auditRead: { roles: ROLE_AUDIT_READ }, // 审计证据页(auditor 只读)
51
+ securityAdmin: { roles: ROLE_SECURITY_FULL }, // 安全配置/平台级操作
52
+ auditor: { roles: ['auditor'] }, // 仅 auditor
53
+ } as const;
54
+
55
+ // ============ Portal Config ============
56
+
57
+ /** 所有 Portal 的认证配置映射 */
58
+ export const PORTAL_AUTH_CONFIG = {
59
+ 'admin-console': { guards: ADMIN_GUARDS },
60
+ 'platform-console': { guards: PLATFORM_GUARDS },
61
+ 'security-dashboard': { guards: SECURITY_GUARDS },
62
+ 'end-user-portal': { guards: null }, // 仅需 token,无角色限制
63
+ // developer-portal removed — merged into admin per ADR-006
64
+ 'authenticator-app': { guards: null }, // 仅需 token,无角色限制
65
+ 'auth-pages': { guards: null }, // 混合(大部分公开)
66
+ } as const;
67
+
68
+ export type PortalName = keyof typeof PORTAL_AUTH_CONFIG;