@autional-cn/shared 0.1.0-rc

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 (81) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +47 -0
  3. package/src/api/__tests__/entry-plane.test.ts +119 -0
  4. package/src/api/bff-client.ts +94 -0
  5. package/src/api/client.ts +174 -0
  6. package/src/api/entry-plane.ts +61 -0
  7. package/src/api/trace.ts +21 -0
  8. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  9. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  10. package/src/auth/__tests__/build-login-url.test.ts +219 -0
  11. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  12. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  13. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  14. package/src/auth/__tests__/session-client-id.test.ts +124 -0
  15. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  16. package/src/auth/auth-machine.ts +151 -0
  17. package/src/auth/auth-queries.ts +118 -0
  18. package/src/auth/build-tenant-urls.ts +48 -0
  19. package/src/auth/oauth-client-from-slug.ts +119 -0
  20. package/src/auth/oauth-client-id-store.ts +41 -0
  21. package/src/auth/oauth-login.ts +291 -0
  22. package/src/auth/pkce.ts +141 -0
  23. package/src/auth/portal-config.ts +68 -0
  24. package/src/auth/roles.ts +102 -0
  25. package/src/auth/service.ts +368 -0
  26. package/src/auth/slug-from-url.ts +92 -0
  27. package/src/auth/store.ts +226 -0
  28. package/src/auth/tenant-route-middleware.ts +113 -0
  29. package/src/auth/tenant-slug-context.tsx +9 -0
  30. package/src/branding/BrandingInitializer.tsx +60 -0
  31. package/src/branding/brand-color.ts +155 -0
  32. package/src/branding/branding-cache.ts +71 -0
  33. package/src/branding/extract-branding.ts +27 -0
  34. package/src/branding/index.ts +15 -0
  35. package/src/branding/tenant-store.ts +13 -0
  36. package/src/branding/types.ts +14 -0
  37. package/src/branding/useBranding.ts +85 -0
  38. package/src/components/AuditStatsOnly.tsx +65 -0
  39. package/src/components/OAuthCallbackPage.tsx +56 -0
  40. package/src/components/PortalGuard.tsx +105 -0
  41. package/src/components/RequireAuth.tsx +142 -0
  42. package/src/components/TenantIndexGuard.tsx +131 -0
  43. package/src/components/__tests__/RequireAuth.test.tsx +244 -0
  44. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  45. package/src/config/__tests__/portal-resolver.test.ts +247 -0
  46. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  47. package/src/config/constants.ts +27 -0
  48. package/src/config/index.ts +411 -0
  49. package/src/config/resolve-basename.ts +57 -0
  50. package/src/constants/api-paths.ts +450 -0
  51. package/src/generated/api.ts +12566 -0
  52. package/src/generated/queries.ts +12623 -0
  53. package/src/generated/types.ts +13888 -0
  54. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  55. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  56. package/src/hooks/useAuth.ts +239 -0
  57. package/src/hooks/useBootstrap.ts +74 -0
  58. package/src/hooks/useCurrentRole.ts +18 -0
  59. package/src/hooks/useLogout.ts +33 -0
  60. package/src/hooks/usePermission.ts +116 -0
  61. package/src/hooks/useSodMode.ts +73 -0
  62. package/src/index.ts +209 -0
  63. package/src/lib/push.ts +41 -0
  64. package/src/seo/dom-utils.ts +94 -0
  65. package/src/seo/index.ts +6 -0
  66. package/src/seo/jsonld.ts +29 -0
  67. package/src/seo/types.ts +29 -0
  68. package/src/seo/usePageMeta.ts +17 -0
  69. package/src/seo/usePageTitle.ts +14 -0
  70. package/src/seo/useSEO.ts +80 -0
  71. package/src/types/index.ts +185 -0
  72. package/src/utils/__tests__/mock-test.ts +15 -0
  73. package/src/utils/browser.ts +6 -0
  74. package/src/utils/case.ts +44 -0
  75. package/src/utils/error.ts +26 -0
  76. package/src/utils/format.ts +63 -0
  77. package/src/utils/password-transmission.test.ts +145 -0
  78. package/src/utils/password-transmission.ts +253 -0
  79. package/src/utils/response.ts +62 -0
  80. package/src/utils/vite.ts +4 -0
  81. package/src/vite-env.d.ts +18 -0
@@ -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,291 @@
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 type { User } from '../types';
18
+
19
+ function getAppConfig(): Record<string, string> | undefined {
20
+ if (typeof window !== 'undefined') {
21
+ return (window as any).__APP_CONFIG__;
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ const SK = {
27
+ PKCE: 'oauth_pkce_verifier',
28
+ STATE: 'oauth_state',
29
+ INIT: 'oauth_init',
30
+ INIT_TS: 'oauth_init_ts',
31
+ CID: 'oauth_client_id',
32
+ };
33
+
34
+ // 防重入拦截后的一次性重试 timer(避免 10 秒窗口内快速刷新导致 OAuth 跳转被吞、页面卡住)
35
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
36
+
37
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
38
+ try {
39
+ const parts = token.split('.');
40
+ if (parts.length !== 3) return null;
41
+ return JSON.parse(atob(parts[1]));
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function parseUserFromToken(token?: string, apiUser?: unknown): User {
48
+ if (token) {
49
+ const p = decodeJwtPayload(token);
50
+ if (p) {
51
+ return {
52
+ id: (p.sub || p.user_id) as string,
53
+ username: ((p as any).custom?.username || (p as any).username) as string,
54
+ email: p.email as string,
55
+ status: 'active',
56
+ avatar_url: p.picture as string,
57
+ } as unknown as User;
58
+ }
59
+ }
60
+ return apiUser as User;
61
+ }
62
+
63
+ function envVar(key: string): string {
64
+ const cfg = getAppConfig();
65
+ if (cfg && cfg[key]) return cfg[key];
66
+ try {
67
+ return (import.meta as any).env?.[key] || '';
68
+ } catch {
69
+ return '';
70
+ }
71
+ }
72
+
73
+ function getConfig() {
74
+ const base = getPortalUrl('auth');
75
+ const callbackPath = (window as any).__APP_CONFIG__?.BASE_PATH || '/';
76
+ const origin = window.location.origin;
77
+ return {
78
+ clientId: envVar('VITE_OAUTH_CLIENT_ID'),
79
+ authorizeUrl: `${base}/oauth/api/v1/oauth/authorize`,
80
+ redirectUri:
81
+ callbackPath === '/' ? `${origin}/oauth/callback` : `${origin}${callbackPath}/oauth/callback`,
82
+ scope: 'openid profile email offline_access',
83
+ };
84
+ }
85
+
86
+ export function isOAuthEnabled(): boolean {
87
+ return !!envVar('VITE_OAUTH_CLIENT_ID');
88
+ }
89
+
90
+ function randomState(): string {
91
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
92
+ try {
93
+ return crypto.randomUUID();
94
+ } catch {
95
+ /* fall through to getRandomValues */
96
+ }
97
+ }
98
+ // crypto.getRandomValues is available in non-secure contexts too (unlike
99
+ // randomUUID, which requires HTTPS/localhost). This value is used as the
100
+ // OAuth state CSRF token — never degrade to a state-recoverable, predictable
101
+ // PRNG or a timestamp (both are attackable for CSRF forgery). Fail loud.
102
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
103
+ const bytes = new Uint8Array(16);
104
+ crypto.getRandomValues(bytes);
105
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
106
+ }
107
+ throw new Error('No CSPRNG available — cannot generate secure OAuth state');
108
+ }
109
+
110
+ export async function initiateOAuthLogin(
111
+ clientId?: string,
112
+ redirectTarget?: string,
113
+ ): Promise<void> {
114
+ const ts = sessionStorage.getItem(SK.INIT_TS);
115
+ if (ts && Date.now() - parseInt(ts, 10) < 10000) {
116
+ // 防重入:10 秒内已有一次 OAuth 初始化(如快速刷新/重复导航)。
117
+ // 直接 return 会吞掉跳转导致 RequireAuth 卡在空白页(无 re-render 不会重试),
118
+ // 因此在窗口过期后安排一次自动重试,保证最终完成跳转。
119
+ const wait = 10000 - (Date.now() - parseInt(ts, 10)) + 100;
120
+ if (!retryTimer) {
121
+ retryTimer = setTimeout(() => {
122
+ retryTimer = null;
123
+ void initiateOAuthLogin(clientId, redirectTarget);
124
+ }, wait);
125
+ }
126
+ return;
127
+ }
128
+ sessionStorage.setItem(SK.INIT_TS, String(Date.now()));
129
+ sessionStorage.setItem(SK.INIT, '1');
130
+
131
+ // Determine the URL to redirect to after successful OAuth
132
+ const target = redirectTarget || window.location.href;
133
+
134
+ // Derive OAuth config (redirectUri computed from BASE_PATH, consistent with handleOAuthCallback)
135
+ const cfg = getConfig();
136
+ const cid = clientId || cfg.clientId;
137
+ if (!cid) throw new Error('OAuth client_id is not available');
138
+
139
+ // Always encode state as JSON with CSRF token + redirect target
140
+ const csrf = randomState();
141
+ const state = JSON.stringify({ csrf, redirect: target });
142
+
143
+ sessionStorage.setItem(SK.STATE, csrf);
144
+ sessionStorage.setItem(SK.CID, cid);
145
+ // 交棒时落持久层:刷新/登出在 React 上下文外,拿不到 slug 解析结果(TASK-09 / D8)
146
+ persistOAuthClientId(cid);
147
+
148
+ try {
149
+ const pkce = await generatePKCE();
150
+ sessionStorage.setItem(SK.PKCE, pkce.verifier);
151
+ const params = new URLSearchParams({
152
+ response_type: 'code',
153
+ client_id: cid,
154
+ redirect_uri: cfg.redirectUri,
155
+ scope: cfg.scope,
156
+ code_challenge: pkce.challenge,
157
+ code_challenge_method: 'S256',
158
+ state,
159
+ });
160
+ window.location.href = `${cfg.authorizeUrl}?${params}`;
161
+ } catch {
162
+ const params = new URLSearchParams({
163
+ response_type: 'code',
164
+ client_id: cid,
165
+ redirect_uri: cfg.redirectUri,
166
+ scope: cfg.scope,
167
+ state,
168
+ });
169
+ window.location.href = `${cfg.authorizeUrl}?${params}`;
170
+ }
171
+ }
172
+
173
+ export async function handleOAuthCallback(): Promise<void> {
174
+ const q = new URLSearchParams(window.location.search);
175
+
176
+ const err = q.get('error');
177
+ if (err) {
178
+ throw new Error(q.get('error_description') || `OAuth error: ${err}`);
179
+ }
180
+
181
+ const code = q.get('code');
182
+ if (!code) throw new Error('Missing authorization code');
183
+
184
+ const storedState = sessionStorage.getItem(SK.STATE) || '';
185
+ const verifier = sessionStorage.getItem(SK.PKCE) || '';
186
+ const cid = sessionStorage.getItem(SK.CID) || '';
187
+
188
+ // State is always JSON: { csrf, redirect }
189
+ let oauthRedirectTarget: string | null = null;
190
+ const rawState = q.get('state');
191
+ if (rawState) {
192
+ try {
193
+ const parsed = JSON.parse(rawState);
194
+ if (parsed.csrf && parsed.csrf === storedState) {
195
+ oauthRedirectTarget = parsed.redirect || null;
196
+ } else if (parsed.csrf) {
197
+ throw new Error('State mismatch — possible CSRF attack');
198
+ }
199
+ } catch {
200
+ // Backward compat: plain string state (should not happen after Fix 3)
201
+ if (rawState !== storedState) {
202
+ throw new Error('State mismatch — possible CSRF attack');
203
+ }
204
+ }
205
+ }
206
+
207
+ if (!verifier) throw new Error('Missing PKCE verifier — page reload may have cleared storage');
208
+
209
+ // Derive redirectUri from getConfig() for consistency with initiateOAuthLogin
210
+ const cbCfg = getConfig();
211
+ const redirectUri = cbCfg.redirectUri;
212
+
213
+ const res = await apiClient.post(
214
+ '/oauth/api/v1/oauth/token', // @generated-api-exempt
215
+ new URLSearchParams({
216
+ grant_type: 'authorization_code',
217
+ code,
218
+ client_id: cid,
219
+ client_secret: '',
220
+ redirect_uri: redirectUri,
221
+ code_verifier: verifier,
222
+ }).toString(),
223
+ { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
224
+ );
225
+
226
+ const t = res.data as any;
227
+ const accessToken = t.access_token;
228
+ const refreshToken = t.refresh_token || null;
229
+
230
+ // Prefer id_token for user info, fallback to /auth/me.
231
+ // IMPORTANT: loginWithTokens (which populates the auth store) runs AFTER this block,
232
+ // so the interceptor would send NO Authorization header here. Attach the freshly
233
+ // exchanged access_token explicitly — otherwise /auth/me 401s and the apiClient
234
+ // interceptor routes it through onUnauthorized (no refresh token yet) → bounce back
235
+ // to the login page with the now-consumed code → infinite redirect loop.
236
+ let user: User | null = parseUserFromToken(t.id_token);
237
+ if (!user && accessToken) {
238
+ try {
239
+ const me = await apiClient.get('/identity/api/v1/auth/me', {
240
+ // @generated-api-exempt
241
+ headers: { Authorization: `Bearer ${accessToken}` },
242
+ });
243
+ user = parseUserFromToken(undefined, me.data);
244
+ } catch {
245
+ // /auth/me may fail; loginWithTokens will still succeed with partial user
246
+ }
247
+ }
248
+ if (!user && accessToken) {
249
+ // id_token 缺失 + /auth/me 失败时,用 access_token JWT 兜底解析 user
250
+ // (access_token 由身份服务签发,payload 含 sub/custom.username/email/role)
251
+ user = parseUserFromToken(accessToken);
252
+ }
253
+ if (!user) {
254
+ user = { id: '', username: '', email: '' } as User;
255
+ }
256
+
257
+ // 兑换成功后落持久层(同 TASK-09 / D8;兑换前的持久值可能来自旧租户)
258
+ persistOAuthClientId(cid);
259
+
260
+ // Clean up temporary OAuth flow storage
261
+ sessionStorage.removeItem(SK.STATE);
262
+ sessionStorage.removeItem(SK.PKCE);
263
+ sessionStorage.removeItem(SK.CID);
264
+
265
+ // Persist to Zustand store (in-memory).
266
+ // Zustand persist subscriber will automatically write to localStorage
267
+ // via partialize — no manual localStorage.setItem needed.
268
+ loginWithTokens(accessToken, refreshToken, user);
269
+
270
+ // OAuth access_token 由身份服务签发,payload 含 tenant_id claim。
271
+ // 登录成功后初始化 currentTenantId,供 useTenantId()/useTenantIdOr() 等
272
+ // 页面使用真实租户(否则 fallback 'default-tenant' 导致 data-classification 等 404)。
273
+ const parsedToken = accessToken ? decodeJwtPayload(accessToken) : null;
274
+ const tokenTenantId = parsedToken?.tenant_id || (parsedToken as any)?.tenantId;
275
+ if (tokenTenantId) {
276
+ AuthService.updateCurrentTenant(tokenTenantId as string);
277
+ }
278
+
279
+ // Compute navigation target
280
+ let target: string;
281
+ if (oauthRedirectTarget) {
282
+ target = oauthRedirectTarget;
283
+ } else {
284
+ // Fallback to BASE_PATH from app config or root
285
+ const fallbackBase = (window as any).__APP_CONFIG__?.BASE_PATH || '/';
286
+ target = fallbackBase === '/' ? '/' : `${window.location.origin}${fallbackBase}/`;
287
+ }
288
+
289
+ // Full page navigation to the target (auth gateway will route to dashboard)
290
+ window.location.href = target;
291
+ }
@@ -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-cn/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;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Autional 共享登录 URL 构建工具(仅 buildLoginUrl)
3
+ *
4
+ * 职责划分(AC-008):
5
+ * - 前端角色仅用于 UI 呈现(UX-only);安全判断由后端 rbac-backed 授权强制。
6
+ * - 角色获取统一走 userinfo-first:`getCurrentRole()`(store 派生,见 ./store)
7
+ * 与 `useCurrentRole()`(响应式 hook,见 hooks/useCurrentRole)。
8
+ */
9
+
10
+ import { getPortalUrl } from '../config';
11
+
12
+ /** auth-pages 的静态/受保护路由 — 首段不会是租户 slug */
13
+ const AUTH_ROUTE_RESERVED = new Set([
14
+ 'oauth',
15
+ 'login',
16
+ 'register',
17
+ 'forgot-password',
18
+ 'reset-password',
19
+ 'terms',
20
+ 'privacy',
21
+ 'error',
22
+ 'logout',
23
+ 'passkey',
24
+ 'reapply',
25
+ 'mfa',
26
+ 'account',
27
+ 'dashboard',
28
+ 'magic-link',
29
+ 'verify-email',
30
+ 'verify-phone',
31
+ 'mfa-challenge',
32
+ 'mfa-setup',
33
+ 'change-password',
34
+ 'recover-account',
35
+ 'account-deletion',
36
+ 'verify-identity',
37
+ ]);
38
+
39
+ /**
40
+ * 登出回程 URL:auth 域**裸根**入口 + `logout=1` 显式登出意图标记。
41
+ *
42
+ * 刻意不复用 buildLoginUrl:其租户 slug 保留会把 auth 域页面的登出落到
43
+ * `/<slug>/login`(登录页路由,不经入口路由),`logout=1` 在那里被静默忽略;
44
+ * 带会话回程甚至会被登录页 checkAndRedirect 直接放行成静默重登(F-W5c)。
45
+ * 裸根 = EntryRouter 唯一登出闸门:先终结 auth 域会话,再按「无会话」落 brand。
46
+ */
47
+ export function buildLogoutUrl(returnUrl?: string): string {
48
+ const base = getPortalUrl('auth');
49
+ if (!base) return '/';
50
+ const redirect = returnUrl ? `redirect=${encodeURIComponent(returnUrl)}&` : '';
51
+ return `${base}/?${redirect}logout=1`;
52
+ }
53
+
54
+ export function buildLoginUrl(returnUrl?: string, fromRequireAuth?: boolean): string {
55
+ const base = getPortalUrl('auth');
56
+ if (!base) return '/';
57
+
58
+ // 保留租户 slug:如果 returnUrl 落在 auth 域且首段是租户 slug,
59
+ // 跳转到 {base}/{slug}/login 而不是根路径(根路径是租户选择页)
60
+ let loginBase = base;
61
+ if (returnUrl) {
62
+ try {
63
+ const clean = new URL(returnUrl, typeof window !== 'undefined' ? window.location.origin : '');
64
+ const baseOrigin = new URL(base).origin;
65
+ const firstSeg = clean.pathname.split('/').filter(Boolean)[0];
66
+ if (
67
+ clean.origin === baseOrigin &&
68
+ firstSeg &&
69
+ !AUTH_ROUTE_RESERVED.has(firstSeg.toLowerCase())
70
+ ) {
71
+ loginBase = `${base}/${firstSeg}/login`;
72
+ }
73
+ } catch {
74
+ /* invalid URL — fall through */
75
+ }
76
+ }
77
+
78
+ if (returnUrl) {
79
+ // 防止嵌套 redirect:仅当 returnUrl 落在 auth 域且本身已是登录页时才截断
80
+ try {
81
+ const clean = new URL(returnUrl, typeof window !== 'undefined' ? window.location.origin : '');
82
+ const baseOrigin = new URL(base).origin;
83
+ if (
84
+ clean.origin === baseOrigin &&
85
+ (clean.pathname === '/' || clean.pathname.endsWith('/login') || clean.href === base)
86
+ ) {
87
+ return fromRequireAuth ? `${loginBase}?from_requireauth=1` : loginBase;
88
+ }
89
+ } catch {
90
+ /* invalid URL — fall through */
91
+ }
92
+ const qs = `?redirect=${encodeURIComponent(returnUrl)}`;
93
+ if (fromRequireAuth) {
94
+ return `${loginBase}${qs}&from_requireauth=1`;
95
+ }
96
+ return `${loginBase}${qs}`;
97
+ }
98
+ if (fromRequireAuth) {
99
+ return `${loginBase}?from_requireauth=1`;
100
+ }
101
+ return loginBase;
102
+ }