@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.
- package/LICENSE +661 -0
- package/package.json +47 -0
- package/src/api/__tests__/entry-plane.test.ts +119 -0
- package/src/api/bff-client.ts +94 -0
- package/src/api/client.ts +174 -0
- package/src/api/entry-plane.ts +61 -0
- package/src/api/trace.ts +21 -0
- package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
- package/src/auth/__tests__/auth-machine.test.ts +157 -0
- package/src/auth/__tests__/build-login-url.test.ts +219 -0
- package/src/auth/__tests__/get-current-role.test.ts +61 -0
- package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
- package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
- package/src/auth/__tests__/session-client-id.test.ts +124 -0
- package/src/auth/__tests__/slug-from-url.test.ts +112 -0
- package/src/auth/auth-machine.ts +151 -0
- package/src/auth/auth-queries.ts +118 -0
- package/src/auth/build-tenant-urls.ts +48 -0
- package/src/auth/oauth-client-from-slug.ts +119 -0
- package/src/auth/oauth-client-id-store.ts +41 -0
- package/src/auth/oauth-login.ts +291 -0
- package/src/auth/pkce.ts +141 -0
- package/src/auth/portal-config.ts +68 -0
- package/src/auth/roles.ts +102 -0
- package/src/auth/service.ts +368 -0
- package/src/auth/slug-from-url.ts +92 -0
- package/src/auth/store.ts +226 -0
- package/src/auth/tenant-route-middleware.ts +113 -0
- package/src/auth/tenant-slug-context.tsx +9 -0
- package/src/branding/BrandingInitializer.tsx +60 -0
- package/src/branding/brand-color.ts +155 -0
- package/src/branding/branding-cache.ts +71 -0
- package/src/branding/extract-branding.ts +27 -0
- package/src/branding/index.ts +15 -0
- package/src/branding/tenant-store.ts +13 -0
- package/src/branding/types.ts +14 -0
- package/src/branding/useBranding.ts +85 -0
- package/src/components/AuditStatsOnly.tsx +65 -0
- package/src/components/OAuthCallbackPage.tsx +56 -0
- package/src/components/PortalGuard.tsx +105 -0
- package/src/components/RequireAuth.tsx +142 -0
- package/src/components/TenantIndexGuard.tsx +131 -0
- package/src/components/__tests__/RequireAuth.test.tsx +244 -0
- package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
- package/src/config/__tests__/portal-resolver.test.ts +247 -0
- package/src/config/__tests__/valid-redirect.test.ts +47 -0
- package/src/config/constants.ts +27 -0
- package/src/config/index.ts +411 -0
- package/src/config/resolve-basename.ts +57 -0
- package/src/constants/api-paths.ts +450 -0
- package/src/generated/api.ts +12566 -0
- package/src/generated/queries.ts +12623 -0
- package/src/generated/types.ts +13888 -0
- package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
- package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
- package/src/hooks/useAuth.ts +239 -0
- package/src/hooks/useBootstrap.ts +74 -0
- package/src/hooks/useCurrentRole.ts +18 -0
- package/src/hooks/useLogout.ts +33 -0
- package/src/hooks/usePermission.ts +116 -0
- package/src/hooks/useSodMode.ts +73 -0
- package/src/index.ts +209 -0
- package/src/lib/push.ts +41 -0
- package/src/seo/dom-utils.ts +94 -0
- package/src/seo/index.ts +6 -0
- package/src/seo/jsonld.ts +29 -0
- package/src/seo/types.ts +29 -0
- package/src/seo/usePageMeta.ts +17 -0
- package/src/seo/usePageTitle.ts +14 -0
- package/src/seo/useSEO.ts +80 -0
- package/src/types/index.ts +185 -0
- package/src/utils/__tests__/mock-test.ts +15 -0
- package/src/utils/browser.ts +6 -0
- package/src/utils/case.ts +44 -0
- package/src/utils/error.ts +26 -0
- package/src/utils/format.ts +63 -0
- package/src/utils/password-transmission.test.ts +145 -0
- package/src/utils/password-transmission.ts +253 -0
- package/src/utils/response.ts +62 -0
- package/src/utils/vite.ts +4 -0
- package/src/vite-env.d.ts +18 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autional AuthService — 认证系统的唯一入口
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 统一管理认证状态的读写(取代分散在各文件的直接 store/LS 操作)
|
|
6
|
+
* - 提供 token 刷新、登出、401 处理等标准流程
|
|
7
|
+
* - 提供事件订阅机制用于可观测性
|
|
8
|
+
*
|
|
9
|
+
* 使用原则:
|
|
10
|
+
* - 所有组件必须通过 AuthService 访问认证状态,不得直接 import useAuthStore
|
|
11
|
+
* - 所有组件必须通过 AuthService 变更认证状态,不得直接调 store action
|
|
12
|
+
* - apiClient 必须通过 AuthService.refreshToken() 处理 token 刷新
|
|
13
|
+
* - logout 只有 AuthService.logout() 一个实现
|
|
14
|
+
*
|
|
15
|
+
* @see docs/architecture/auth-service-migration-plan.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
useAuthStore,
|
|
20
|
+
getAccessToken,
|
|
21
|
+
getRefreshToken,
|
|
22
|
+
getCurrentRole,
|
|
23
|
+
isTokenExpired as checkExpired,
|
|
24
|
+
} from './store';
|
|
25
|
+
import { loginWithTokens } from './store';
|
|
26
|
+
import { buildLoginUrl } from './roles';
|
|
27
|
+
import { resolveClientIdForSession } from './oauth-client-id-store';
|
|
28
|
+
import type { User } from '../types';
|
|
29
|
+
|
|
30
|
+
// ============ Internal State ============
|
|
31
|
+
|
|
32
|
+
let redirectingToLogin = false;
|
|
33
|
+
let refreshPromise: Promise<string | null> | null = null;
|
|
34
|
+
|
|
35
|
+
// ============ OAuth PKCE Bootstrap Lock ============
|
|
36
|
+
|
|
37
|
+
let _bootstrapLocked = false;
|
|
38
|
+
|
|
39
|
+
/** Suppress onUnauthorized redirects during OAuth PKCE initialization */
|
|
40
|
+
export function setBootstrapLock(locked: boolean): void {
|
|
41
|
+
_bootstrapLocked = locked;
|
|
42
|
+
if (typeof window !== 'undefined') (window as any).__bootstrapLocked = locked;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isBootstrapLocked(): boolean {
|
|
46
|
+
return _bootstrapLocked;
|
|
47
|
+
}
|
|
48
|
+
const listeners = new Map<string, Set<(data?: unknown) => void>>();
|
|
49
|
+
|
|
50
|
+
function emit(event: string, data?: unknown) {
|
|
51
|
+
listeners.get(event)?.forEach((fn) => {
|
|
52
|
+
try {
|
|
53
|
+
fn(data);
|
|
54
|
+
} catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============ Cross-tab Sync (BroadcastChannel) ============
|
|
61
|
+
|
|
62
|
+
const BC_CHANNEL = 'authms-auth';
|
|
63
|
+
let bc: BroadcastChannel | null = null;
|
|
64
|
+
|
|
65
|
+
function initBroadcastChannel(): void {
|
|
66
|
+
if (typeof BroadcastChannel === 'undefined') return;
|
|
67
|
+
if (bc) return;
|
|
68
|
+
try {
|
|
69
|
+
bc = new BroadcastChannel(BC_CHANNEL);
|
|
70
|
+
bc.onmessage = (event) => {
|
|
71
|
+
const msg = event.data;
|
|
72
|
+
if (!msg || !msg.type) return;
|
|
73
|
+
|
|
74
|
+
switch (msg.type) {
|
|
75
|
+
case 'auth:logout':
|
|
76
|
+
// 另一个 tab 触发了登出 → 同步清除本 tab 状态
|
|
77
|
+
useAuthStore.getState().clearAuth();
|
|
78
|
+
if (typeof window !== 'undefined') {
|
|
79
|
+
localStorage.removeItem('authms-auth-v1');
|
|
80
|
+
localStorage.removeItem('access_token');
|
|
81
|
+
localStorage.removeItem('refresh_token');
|
|
82
|
+
}
|
|
83
|
+
emit('auth:logout', { source: 'broadcast' });
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case 'auth:login':
|
|
87
|
+
// 另一个 tab 登录成功 → 刷新本 tab 状态(可选)
|
|
88
|
+
// 可由消费方决定是否同步
|
|
89
|
+
emit('auth:login-broadcast', msg.data);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.warn('[AuthService] BroadcastChannel not available', (e as Error)?.message || '');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function broadcast(event: string, data?: unknown): void {
|
|
99
|
+
if (!bc) return;
|
|
100
|
+
try {
|
|
101
|
+
bc.postMessage({ type: event, data });
|
|
102
|
+
} catch {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ============ AuthService ============
|
|
108
|
+
|
|
109
|
+
// Initialize cross-tab sync channel
|
|
110
|
+
initBroadcastChannel();
|
|
111
|
+
|
|
112
|
+
export const AuthService = {
|
|
113
|
+
// ==================== 读取(纯函数,无副作用) ====================
|
|
114
|
+
|
|
115
|
+
/** 获取 access token:Zustand store 优先,localStorage fallback */
|
|
116
|
+
getAccessToken: (): string | null => getAccessToken(),
|
|
117
|
+
|
|
118
|
+
/** 获取 refresh token */
|
|
119
|
+
getRefreshToken: (): string | null => getRefreshToken(),
|
|
120
|
+
|
|
121
|
+
/** 获取当前用户信息 */
|
|
122
|
+
getUser: (): User | null => useAuthStore.getState().user,
|
|
123
|
+
|
|
124
|
+
/** 是否已认证 */
|
|
125
|
+
isAuthenticated: (): boolean => useAuthStore.getState().isAuthenticated,
|
|
126
|
+
|
|
127
|
+
/** token 是否已过期 */
|
|
128
|
+
isTokenExpired: (token?: string): boolean => checkExpired(token || getAccessToken() || ''),
|
|
129
|
+
|
|
130
|
+
/** 获取当前租户 ID */
|
|
131
|
+
getCurrentTenantId: (): string | null => useAuthStore.getState().currentTenantId,
|
|
132
|
+
|
|
133
|
+
/** 获取当前租户列表 */
|
|
134
|
+
getTenants: (): Array<{ id: string; name: string; role: string }> =>
|
|
135
|
+
useAuthStore.getState().tenants,
|
|
136
|
+
|
|
137
|
+
/** 获取当前权限列表 */
|
|
138
|
+
getPermissions: (): string[] => useAuthStore.getState().permissions,
|
|
139
|
+
|
|
140
|
+
/** 获取当前角色(userinfo-first,委托 store 派生) */
|
|
141
|
+
getCurrentRole: (): string | null => getCurrentRole(),
|
|
142
|
+
|
|
143
|
+
// ==================== 写入(唯一变更入口) ====================
|
|
144
|
+
|
|
145
|
+
/** 登录成功后设置完整认证状态 */
|
|
146
|
+
setAuth: (accessToken: string, refreshToken: string, user: User): void => {
|
|
147
|
+
loginWithTokens(accessToken, refreshToken, user);
|
|
148
|
+
emit('auth:login', { userId: user.id, username: user.username });
|
|
149
|
+
broadcast('auth:login', { userId: user.id });
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/** token 刷新后更新令牌 */
|
|
153
|
+
updateTokens: (accessToken: string, refreshToken: string): void => {
|
|
154
|
+
useAuthStore.getState().setTokens(accessToken, refreshToken);
|
|
155
|
+
emit('auth:tokens-refreshed');
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/** 更新用户信息(来自 /auth/me) */
|
|
159
|
+
updateUser: (user: Partial<User>): void => {
|
|
160
|
+
const current = useAuthStore.getState().user;
|
|
161
|
+
// 防止跨用户覆盖:如果 store 中有用户但 ID 不匹配,忽略
|
|
162
|
+
if (current && user.id && current.id !== user.id) return;
|
|
163
|
+
useAuthStore.setState({ user: user as User });
|
|
164
|
+
emit('auth:user-updated', { userId: user.id });
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/** 设置租户列表 */
|
|
168
|
+
updateTenants: (tenants: Array<{ id: string; name: string; role: string }>): void => {
|
|
169
|
+
useAuthStore.getState().setTenants(tenants);
|
|
170
|
+
emit('auth:tenants-updated');
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/** 设置权限列表 */
|
|
174
|
+
updatePermissions: (permissions: string[]): void => {
|
|
175
|
+
useAuthStore.getState().setPermissions(permissions);
|
|
176
|
+
emit('auth:permissions-updated');
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/** 切换当前租户 */
|
|
180
|
+
updateCurrentTenant: (tenantId: string): void => {
|
|
181
|
+
useAuthStore.getState().setCurrentTenant(tenantId);
|
|
182
|
+
emit('auth:tenant-changed', { tenantId });
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
// ==================== 登出(唯一实现) ====================
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 统一登出:
|
|
189
|
+
* 1. 清除内存状态(立即生效)
|
|
190
|
+
* 2. 清除所有持久化数据
|
|
191
|
+
* 3. 服务端 token 吊销(best-effort,不阻塞)
|
|
192
|
+
*/
|
|
193
|
+
logout: async (): Promise<void> => {
|
|
194
|
+
const at = AuthService.getAccessToken();
|
|
195
|
+
const rt = AuthService.getRefreshToken();
|
|
196
|
+
|
|
197
|
+
// 1. 清除内存状态(立即生效,阻止后续操作使用过期 token)
|
|
198
|
+
useAuthStore.getState().clearAuth();
|
|
199
|
+
|
|
200
|
+
// 2. 清除所有持久化数据
|
|
201
|
+
if (typeof window !== 'undefined') {
|
|
202
|
+
localStorage.removeItem('authms-auth-v1');
|
|
203
|
+
localStorage.removeItem('access_token');
|
|
204
|
+
localStorage.removeItem('refresh_token');
|
|
205
|
+
localStorage.removeItem('__oauth_bridge_token');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 3. 服务端 token 吊销(best-effort,不阻塞登出流程)
|
|
209
|
+
try {
|
|
210
|
+
if (at || rt) {
|
|
211
|
+
const { default: axios } = await import('axios');
|
|
212
|
+
const { getApiBaseUrl } = await import('../config');
|
|
213
|
+
const baseUrl = getApiBaseUrl();
|
|
214
|
+
// S9-A:撤销端点要求客户端认证;PKCE 公开客户端凭注册 client_id(对应 none)
|
|
215
|
+
// 来源:会话内已解析值(持久化)→ env(TASK-09 / D8)
|
|
216
|
+
const clientId = resolveClientIdForSession();
|
|
217
|
+
const revokeParams = (token: string, hint: string) => {
|
|
218
|
+
const params = new URLSearchParams({ token, token_type_hint: hint });
|
|
219
|
+
if (clientId) params.set('client_id', clientId);
|
|
220
|
+
return params.toString();
|
|
221
|
+
};
|
|
222
|
+
if (at) {
|
|
223
|
+
await axios
|
|
224
|
+
.post(
|
|
225
|
+
`${baseUrl}/oauth/api/v1/oauth/revoke`,
|
|
226
|
+
revokeParams(at, 'access_token'),
|
|
227
|
+
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
|
228
|
+
)
|
|
229
|
+
.catch(() => {});
|
|
230
|
+
}
|
|
231
|
+
if (rt) {
|
|
232
|
+
await axios
|
|
233
|
+
.post(
|
|
234
|
+
`${baseUrl}/oauth/api/v1/oauth/revoke`,
|
|
235
|
+
revokeParams(rt, 'refresh_token'),
|
|
236
|
+
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
|
237
|
+
)
|
|
238
|
+
.catch(() => {});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
/* best-effort */
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 4. BFF 会话吊销(best-effort,httpOnly cookie 清理)
|
|
246
|
+
try {
|
|
247
|
+
const { bffLogout } = await import('../api/bff-client');
|
|
248
|
+
await bffLogout();
|
|
249
|
+
} catch {
|
|
250
|
+
/* best-effort */
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
broadcast('auth:logout');
|
|
254
|
+
emit('auth:logout');
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
// ==================== Token 刷新(统一超时防重入) ====================
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 刷新 access token。
|
|
261
|
+
* 防重入:同一时间只会有一个刷新请求进行中。
|
|
262
|
+
* 刷新失败时自动调用 onUnauthorized。
|
|
263
|
+
*/
|
|
264
|
+
refreshToken: async (): Promise<string | null> => {
|
|
265
|
+
if (refreshPromise) return refreshPromise;
|
|
266
|
+
|
|
267
|
+
refreshPromise = (async () => {
|
|
268
|
+
const rt = AuthService.getRefreshToken();
|
|
269
|
+
if (!rt) {
|
|
270
|
+
// During OAuth PKCE bootstrap (no token yet), suppress redirect.
|
|
271
|
+
// RequireAuth will handle OAuth PKCE vs BFF fallback once tenantRoute resolves.
|
|
272
|
+
if (!_bootstrapLocked) {
|
|
273
|
+
AuthService.onUnauthorized();
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const { default: axios } = await import('axios');
|
|
280
|
+
const { getApiBaseUrl } = await import('../config');
|
|
281
|
+
const baseUrl = getApiBaseUrl();
|
|
282
|
+
|
|
283
|
+
// 按 refresh token 格式分流:
|
|
284
|
+
// - JWT(三段式,identity 链签发)→ identity /auth/refresh(现状逻辑)
|
|
285
|
+
// - opaque(rt- 前缀,OAuth PKCE 链签发)→ oauth /oauth/refresh(扁平 JSON,轮换式)
|
|
286
|
+
const isJwtRefreshToken = rt.split('.').length === 3;
|
|
287
|
+
// OAuth 链分流判据:会话内已解析值(持久化)→ env(TASK-09 / D8)
|
|
288
|
+
const clientId = resolveClientIdForSession();
|
|
289
|
+
|
|
290
|
+
let data: {
|
|
291
|
+
access_token?: string;
|
|
292
|
+
accessToken?: string;
|
|
293
|
+
refresh_token?: string;
|
|
294
|
+
refreshToken?: string;
|
|
295
|
+
};
|
|
296
|
+
if (isJwtRefreshToken || !clientId) {
|
|
297
|
+
// identity 链:信封响应 { code, data: { access_token, refresh_token } }
|
|
298
|
+
const resp = await axios.post(`${baseUrl}/identity/api/v1/auth/refresh`, {
|
|
299
|
+
refresh_token: rt,
|
|
300
|
+
});
|
|
301
|
+
data = resp.data;
|
|
302
|
+
} else {
|
|
303
|
+
// OAuth 链:扁平 JSON 响应 { access_token, refresh_token, token_type, expires_in, scope }
|
|
304
|
+
const resp = await axios.post(`${baseUrl}/oauth/api/v1/oauth/refresh`, {
|
|
305
|
+
refresh_token: rt,
|
|
306
|
+
client_id: clientId,
|
|
307
|
+
});
|
|
308
|
+
data = resp.data;
|
|
309
|
+
}
|
|
310
|
+
const newAT = data.access_token || data.accessToken;
|
|
311
|
+
const newRT = data.refresh_token || data.refreshToken;
|
|
312
|
+
if (newAT) {
|
|
313
|
+
AuthService.updateTokens(newAT, newRT || rt);
|
|
314
|
+
}
|
|
315
|
+
return newAT || null;
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.warn('[AuthService] Token refresh failed', (err as Error)?.message || '');
|
|
318
|
+
AuthService.onUnauthorized();
|
|
319
|
+
return null;
|
|
320
|
+
} finally {
|
|
321
|
+
refreshPromise = null;
|
|
322
|
+
}
|
|
323
|
+
})();
|
|
324
|
+
|
|
325
|
+
return refreshPromise;
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
// ==================== 401/未授权处理 ====================
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 处理 401 未授权响应:
|
|
332
|
+
* 1. 登出(清除所有状态)
|
|
333
|
+
* 2. 重定向到登录页(防重入锁)
|
|
334
|
+
*/
|
|
335
|
+
onUnauthorized: (): void => {
|
|
336
|
+
// Suppress redirect during OAuth PKCE bootstrap — RequireAuth handles it
|
|
337
|
+
if (_bootstrapLocked) return;
|
|
338
|
+
// 先清除状态,不等待 logout 的异步吊销
|
|
339
|
+
useAuthStore.getState().clearAuth();
|
|
340
|
+
if (typeof window !== 'undefined') {
|
|
341
|
+
// 清除 localStorage(同步,立即生效)
|
|
342
|
+
localStorage.removeItem('authms-auth-v1');
|
|
343
|
+
localStorage.removeItem('access_token');
|
|
344
|
+
localStorage.removeItem('refresh_token');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!redirectingToLogin && typeof window !== 'undefined') {
|
|
348
|
+
redirectingToLogin = true;
|
|
349
|
+
setTimeout(() => {
|
|
350
|
+
window.location.replace(buildLoginUrl(window.location.href, true));
|
|
351
|
+
}, 0);
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
// ==================== 事件订阅(可观测性) ====================
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 订阅认证事件。
|
|
359
|
+
* @returns 取消订阅函数
|
|
360
|
+
*/
|
|
361
|
+
subscribe: (event: string, handler: (data?: unknown) => void): (() => void) => {
|
|
362
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
363
|
+
listeners.get(event)!.add(handler);
|
|
364
|
+
return () => {
|
|
365
|
+
listeners.get(event)?.delete(handler);
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract tenant slug from current URL path.
|
|
5
|
+
* Works for patterns like /{slug}/admin, /{slug}/developer, /{slug}/dashboard, etc.
|
|
6
|
+
* Returns undefined if no slug pattern detected.
|
|
7
|
+
*
|
|
8
|
+
* Portals and reserved paths are excluded from slug detection:
|
|
9
|
+
* - Reserved paths: /oauth, /bff, /api, /health, /assets, etc.
|
|
10
|
+
* - Known portal prefixes at root: /admin, /developer, /security, etc.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const RESERVED_SEGMENTS = [
|
|
14
|
+
'oauth',
|
|
15
|
+
'bff',
|
|
16
|
+
'api',
|
|
17
|
+
'api-v1',
|
|
18
|
+
'health',
|
|
19
|
+
'metrics',
|
|
20
|
+
'assets',
|
|
21
|
+
'docs',
|
|
22
|
+
'scalar',
|
|
23
|
+
'fonts',
|
|
24
|
+
'env.js',
|
|
25
|
+
'sdk',
|
|
26
|
+
'static',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const ROOT_PORTAL_PREFIXES: string[] = []; // No longer needed — each portal is now on its own domain
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 各 portal 自己的**业务路由首段**——它们不是租户 slug。
|
|
33
|
+
*
|
|
34
|
+
* 为什么是「注册」而不是在这里写死一份名单:这是**应用知识**,不是通用知识。
|
|
35
|
+
* auth 的 /login、/mfa-challenge、/account-deletion 对其他 portal 没有意义;
|
|
36
|
+
* 反过来 admin 的 /wallets、/settings 也不该塞进通用层。写死一份大名单,
|
|
37
|
+
* 等于把某一个 app 的路由表变成所有 portal 的契约。
|
|
38
|
+
*
|
|
39
|
+
* 实测(2026-09-29):这份名单缺席时,extractSlugFromPath 会把**一切业务路由**
|
|
40
|
+
* 判成租户——/settings → 'settings'、/users/123 → 'users'、/dashboard → 'dashboard'。
|
|
41
|
+
* auth 站正是因此自己复制了一份带 24 项白名单的实现,而不是复用这里的函数。
|
|
42
|
+
*/
|
|
43
|
+
const NON_TENANT_SEGMENTS = new Set<string>();
|
|
44
|
+
|
|
45
|
+
/** 由各 portal 在启动时注册自己的业务路由首段(大小写不敏感)。可多次调用。 */
|
|
46
|
+
export function registerNonTenantSegments(segments: readonly string[]): void {
|
|
47
|
+
for (const s of segments) {
|
|
48
|
+
const v = String(s || '').trim().toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
49
|
+
if (v) NON_TENANT_SEGMENTS.add(v);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 测试与多租户切换场景用:清空已注册的业务路由。 */
|
|
54
|
+
export function clearNonTenantSegments(): void {
|
|
55
|
+
NON_TENANT_SEGMENTS.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isNonTenant = (seg: string) => RESERVED_SEGMENTS.includes(seg) || NON_TENANT_SEGMENTS.has(seg);
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
export function extractSlugFromPath(pathname: string): string | undefined {
|
|
62
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
63
|
+
if (segments.length === 0) return undefined;
|
|
64
|
+
|
|
65
|
+
const first = segments[0];
|
|
66
|
+
|
|
67
|
+
// Reserved segment at root level — not a slug
|
|
68
|
+
if (isNonTenant(first.toLowerCase())) return undefined;
|
|
69
|
+
|
|
70
|
+
// If only one segment and it looks like a portal prefix — not a slug
|
|
71
|
+
if (segments.length === 1 && ROOT_PORTAL_PREFIXES.includes(first.toLowerCase())) return undefined;
|
|
72
|
+
|
|
73
|
+
// Two+ segments: first is a slug unless it's a reserved segment
|
|
74
|
+
// With independent domains, the pattern is /{slug}/route, not /{slug}/{portal}/route
|
|
75
|
+
if (segments.length >= 2) {
|
|
76
|
+
if (!isNonTenant(first.toLowerCase())) {
|
|
77
|
+
return first;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Single segment that's not a reserved path or portal prefix — could be a slug
|
|
82
|
+
if (segments.length === 1) {
|
|
83
|
+
return first;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function useTenantSlugFromUrl(): string | undefined {
|
|
90
|
+
if (typeof window === 'undefined') return undefined;
|
|
91
|
+
return extractSlugFromPath(window.location.pathname);
|
|
92
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autional 全局认证状态管理 (Zustand)
|
|
3
|
+
* 所有前端应用共用同一个 auth store
|
|
4
|
+
*
|
|
5
|
+
* 职责划分(AC-008):前端角色(tenants[].role 派生)仅用于 UX 呈现;
|
|
6
|
+
* 安全判断由后端 rbac-backed 授权强制,前端不做凭角色放行的敏感操作。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { create } from 'zustand';
|
|
10
|
+
import { persist } from 'zustand/middleware';
|
|
11
|
+
import type { User } from '../types';
|
|
12
|
+
|
|
13
|
+
interface AuthState {
|
|
14
|
+
// State
|
|
15
|
+
user: User | null;
|
|
16
|
+
accessToken: string | null;
|
|
17
|
+
refreshToken: string | null;
|
|
18
|
+
tenants: Array<{ id: string; name: string; role: string }>;
|
|
19
|
+
currentTenantId: string | null;
|
|
20
|
+
permissions: string[];
|
|
21
|
+
isAuthenticated: boolean;
|
|
22
|
+
|
|
23
|
+
// Actions
|
|
24
|
+
setAuth: (accessToken: string, refreshToken: string, user: User) => void;
|
|
25
|
+
setTokens: (accessToken: string, refreshToken: string) => void;
|
|
26
|
+
setUser: (user: User) => void;
|
|
27
|
+
setTenants: (tenants: Array<{ id: string; name: string; role: string }>) => void;
|
|
28
|
+
setCurrentTenant: (tenantId: string) => void;
|
|
29
|
+
switchTenant: (tenantId: string) => void;
|
|
30
|
+
setPermissions: (permissions: string[]) => void;
|
|
31
|
+
clearAuth: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const useAuthStore = create<AuthState>()(
|
|
35
|
+
persist(
|
|
36
|
+
(set) => ({
|
|
37
|
+
user: null,
|
|
38
|
+
accessToken: null,
|
|
39
|
+
refreshToken: null,
|
|
40
|
+
tenants: [],
|
|
41
|
+
currentTenantId: null,
|
|
42
|
+
permissions: [],
|
|
43
|
+
isAuthenticated: false,
|
|
44
|
+
|
|
45
|
+
setAuth: (accessToken, refreshToken, user) => {
|
|
46
|
+
if (!user) return;
|
|
47
|
+
const validToken = accessToken && accessToken !== 'undefined' && accessToken !== 'null';
|
|
48
|
+
set({
|
|
49
|
+
accessToken: validToken ? accessToken : null,
|
|
50
|
+
refreshToken: validToken ? refreshToken : null,
|
|
51
|
+
user,
|
|
52
|
+
isAuthenticated: true,
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
|
|
57
|
+
|
|
58
|
+
setUser: (user) => set({ user }),
|
|
59
|
+
|
|
60
|
+
setTenants: (tenants) => set({ tenants }),
|
|
61
|
+
|
|
62
|
+
setCurrentTenant: (tenantId) => set({ currentTenantId: tenantId }),
|
|
63
|
+
|
|
64
|
+
switchTenant: (tenantId) => set({ currentTenantId: tenantId }),
|
|
65
|
+
|
|
66
|
+
setPermissions: (permissions) => set({ permissions }),
|
|
67
|
+
|
|
68
|
+
clearAuth: () =>
|
|
69
|
+
set({
|
|
70
|
+
user: null,
|
|
71
|
+
accessToken: null,
|
|
72
|
+
refreshToken: null,
|
|
73
|
+
tenants: [],
|
|
74
|
+
currentTenantId: null,
|
|
75
|
+
permissions: [],
|
|
76
|
+
isAuthenticated: false,
|
|
77
|
+
}),
|
|
78
|
+
}),
|
|
79
|
+
{
|
|
80
|
+
name: 'authms-auth-v1',
|
|
81
|
+
version: 1,
|
|
82
|
+
// Custom merge: validate persisted data integrity before merging into store.
|
|
83
|
+
// This prevents corrupted localStorage entries from silently breaking auth.
|
|
84
|
+
merge: (persisted, current) => {
|
|
85
|
+
if (!persisted || typeof persisted !== 'object') return current;
|
|
86
|
+
const state = { ...current, ...persisted };
|
|
87
|
+
|
|
88
|
+
// Fix: has accessToken but isAuthenticated is false
|
|
89
|
+
if (state.accessToken && !state.isAuthenticated) {
|
|
90
|
+
state.isAuthenticated = true;
|
|
91
|
+
}
|
|
92
|
+
// Fix: no accessToken but isAuthenticated is true (token was evicted)
|
|
93
|
+
if (!state.accessToken && state.isAuthenticated) {
|
|
94
|
+
state.isAuthenticated = false;
|
|
95
|
+
}
|
|
96
|
+
// Fix: has accessToken but user is missing — recover from JWT
|
|
97
|
+
if (state.accessToken && !state.user) {
|
|
98
|
+
try {
|
|
99
|
+
const payload = JSON.parse(atob(state.accessToken.split('.')[1]));
|
|
100
|
+
state.user = {
|
|
101
|
+
id: (payload.sub || payload.user_id) as string,
|
|
102
|
+
username: (payload.custom?.username || payload.username) as string,
|
|
103
|
+
email: (payload.email as string) || '',
|
|
104
|
+
status: 'active',
|
|
105
|
+
};
|
|
106
|
+
} catch {
|
|
107
|
+
/* keep null user */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return state;
|
|
112
|
+
},
|
|
113
|
+
migrate: (persisted) => {
|
|
114
|
+
// v0 → v1: no structural changes, data is compatible
|
|
115
|
+
return persisted as any;
|
|
116
|
+
},
|
|
117
|
+
partialize: (state) => {
|
|
118
|
+
return {
|
|
119
|
+
accessToken:
|
|
120
|
+
state.accessToken && state.accessToken !== 'undefined' && state.accessToken !== 'null'
|
|
121
|
+
? state.accessToken
|
|
122
|
+
: null,
|
|
123
|
+
refreshToken:
|
|
124
|
+
state.refreshToken &&
|
|
125
|
+
state.refreshToken !== 'undefined' &&
|
|
126
|
+
state.refreshToken !== 'null'
|
|
127
|
+
? state.refreshToken
|
|
128
|
+
: null,
|
|
129
|
+
user: state.user,
|
|
130
|
+
tenants: state.tenants,
|
|
131
|
+
currentTenantId: state.currentTenantId,
|
|
132
|
+
permissions: state.permissions,
|
|
133
|
+
isAuthenticated: state.isAuthenticated,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
onRehydrateStorage: () => (state, error) => {
|
|
137
|
+
if (error || !state) return;
|
|
138
|
+
// merge already handles all fixups; this is a safety net
|
|
139
|
+
if (state.accessToken && !state.isAuthenticated) {
|
|
140
|
+
useAuthStore.setState({ isAuthenticated: true });
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 获取 access token
|
|
149
|
+
*/
|
|
150
|
+
export function getAccessToken(): string | null {
|
|
151
|
+
const state = useAuthStore.getState().accessToken;
|
|
152
|
+
if (state && state !== 'undefined' && state !== 'null') return state;
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 获取 refresh token
|
|
159
|
+
*/
|
|
160
|
+
export function getRefreshToken(): string | null {
|
|
161
|
+
const state = useAuthStore.getState().refreshToken;
|
|
162
|
+
if (state && state !== 'undefined' && state !== 'null') return state;
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 获取当前租户 ID
|
|
168
|
+
*/
|
|
169
|
+
export function getCurrentTenantId(): string | null {
|
|
170
|
+
return useAuthStore.getState().currentTenantId;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 获取当前租户下的角色(userinfo-first,非响应式)
|
|
175
|
+
* 派生自 store.tenants + currentTenantId —— 不解析 token 载荷。
|
|
176
|
+
* 前端角色仅用于 UX 呈现;安全判断由后端 rbac-backed 授权强制(AC-008)。
|
|
177
|
+
*/
|
|
178
|
+
export function getCurrentRole(): string | null {
|
|
179
|
+
const { tenants, currentTenantId } = useAuthStore.getState();
|
|
180
|
+
if (!tenants || tenants.length === 0) return null;
|
|
181
|
+
if (!currentTenantId) return null;
|
|
182
|
+
return tenants.find((t) => t.id === currentTenantId)?.role ?? null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 使用 token 登录(设置认证状态)
|
|
187
|
+
*/
|
|
188
|
+
export function loginWithTokens(
|
|
189
|
+
accessToken: string | null,
|
|
190
|
+
refreshToken: string | null,
|
|
191
|
+
user: User,
|
|
192
|
+
): void {
|
|
193
|
+
useAuthStore.getState().setAuth(accessToken || '', refreshToken || '', user);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 退出登录(清除认证状态 + localStorage/cookie)
|
|
198
|
+
* 委托给 AuthService.logout() 统一实现
|
|
199
|
+
* 使用动态 import 避免 circular dependency (service.ts → store.ts)
|
|
200
|
+
*/
|
|
201
|
+
export async function logout(_redirectTo?: string): Promise<void> {
|
|
202
|
+
const { AuthService } = await import('./service');
|
|
203
|
+
return AuthService.logout();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function decodeJwtPayload(token: string): { exp?: number } | null {
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(atob(token.split('.')[1]));
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function isTokenExpired(token: string): boolean {
|
|
215
|
+
const payload = decodeJwtPayload(token);
|
|
216
|
+
if (!payload?.exp) return true;
|
|
217
|
+
return Date.now() >= payload.exp * 1000;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function refreshAccessToken(): Promise<string | null> {
|
|
221
|
+
// 统一委托 AuthService.refreshToken()(含并发去重 + JWT/opaque 分流 + onUnauthorized 处理),
|
|
222
|
+
// 消除双实现:store 版不再持有重复的 refresh 逻辑(此前与 service 版不一致——无并发去重)。
|
|
223
|
+
// 动态 import 避免 store.ts ↔ service.ts 循环依赖。
|
|
224
|
+
const { AuthService } = await import('./service');
|
|
225
|
+
return AuthService.refreshToken();
|
|
226
|
+
}
|