@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
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@autional-cn/shared",
3
+ "version": "0.1.0-rc",
4
+ "description": "Autional shared layer — API client, auth, types, hooks, utils",
5
+ "license": "AGPL-3.0",
6
+ "main": "./src/index.ts",
7
+ "module": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./branding": "./src/branding/index.ts",
12
+ "./slug-from-url": "./src/auth/slug-from-url.ts",
13
+ "./generated/api": "./src/generated/api.ts",
14
+ "./generated/types": "./src/generated/types.ts",
15
+ "./generated/queries": "./src/generated/queries.ts",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "sideEffects": false,
22
+ "dependencies": {
23
+ "@opentelemetry/api": "1.9.1",
24
+ "@tanstack/react-query": "5.100.9",
25
+ "axios": "1.16.0",
26
+ "lodash-es": "4.18.1",
27
+ "zustand": "5.0.13"
28
+ },
29
+ "devDependencies": {
30
+ "@types/lodash-es": "4.17.12",
31
+ "@types/node": "26.1.2",
32
+ "@types/react": "19.2.14",
33
+ "@types/react-dom": "19.2.3",
34
+ "react": "19.2.5",
35
+ "react-dom": "19.2.5",
36
+ "typescript": "5.8.3",
37
+ "vitest": "3.2.0"
38
+ },
39
+ "peerDependencies": {
40
+ "react": ">=18.0.0",
41
+ "react-dom": ">=18.0.0"
42
+ },
43
+ "scripts": {
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "vitest run"
46
+ }
47
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveEntryPlane } from '../entry-plane';
3
+
4
+ describe('resolveEntryPlane', () => {
5
+ it('redirect 指向 admin 门户主机 → admin', () => {
6
+ expect(resolveEntryPlane('https://admin.iam.tianv.com/dashboard', 'auth.iam.tianv.com')).toBe(
7
+ 'admin',
8
+ );
9
+ });
10
+
11
+ it('redirect 指向 platform 门户主机 → platform', () => {
12
+ expect(resolveEntryPlane('https://platform.iam.tianv.com/tenants', 'auth.iam.tianv.com')).toBe(
13
+ 'platform',
14
+ );
15
+ });
16
+
17
+ it('redirect 指向非管理门户(user)→ 回落当前主机(auth)→ api', () => {
18
+ expect(resolveEntryPlane('https://user.iam.tianv.com/dashboard', 'auth.iam.tianv.com')).toBe(
19
+ 'api',
20
+ );
21
+ });
22
+
23
+ it('redirect 指向安全仪表盘主机 → admin(SOC 门户为 admin 受众)', () => {
24
+ expect(
25
+ resolveEntryPlane('https://security.iam.tianv.com/risk-dashboard', 'auth.iam.tianv.com'),
26
+ ).toBe('admin');
27
+ });
28
+
29
+ it('无 redirect、当前主机为安全仪表盘 → admin', () => {
30
+ expect(resolveEntryPlane(null, 'security.iam.tianv.com')).toBe('admin');
31
+ });
32
+
33
+ it('无 redirect、当前主机为 admin 门户 → admin', () => {
34
+ expect(resolveEntryPlane(null, 'admin.iam.tianv.com')).toBe('admin');
35
+ });
36
+
37
+ it('无 redirect、当前主机为 auth 门户 → api', () => {
38
+ expect(resolveEntryPlane(null, 'auth.iam.tianv.com')).toBe('api');
39
+ });
40
+
41
+ it('redirect 为相对路径时按当前主机判定', () => {
42
+ expect(resolveEntryPlane('/admin/dashboard', 'platform.iam.tianv.com')).toBe('platform');
43
+ expect(resolveEntryPlane('/dashboard', 'auth.iam.tianv.com')).toBe('api');
44
+ });
45
+
46
+ it('redirect 非法时回落当前主机判定', () => {
47
+ expect(resolveEntryPlane('http://[invalid', 'auth.iam.tianv.com')).toBe('api');
48
+ });
49
+
50
+ it('主机前缀大小写不敏感、带端口仍匹配', () => {
51
+ expect(resolveEntryPlane('https://ADMIN.iam.tianv.com/', 'auth.iam.tianv.com')).toBe('admin');
52
+ expect(resolveEntryPlane('https://admin.iam.tianv.com:8443/', 'auth.iam.tianv.com')).toBe(
53
+ 'admin',
54
+ );
55
+ });
56
+ });
57
+
58
+ // 冷启动链:同意页无会话 → `<slug>/login?redirect=<authorize URL>`(oauth-cold-start TASK-07)。
59
+ // authorize URL 宿主是 auth 门户 ⇒ 宿主判定为空,必须下钻内嵌目标(redirect_uri / state.redirect),
60
+ // 否则回程门户拿到的 token 平面恒为 api,admin/platform 受众端点全部 403 40000503(F-W5)。
61
+ describe('resolveEntryPlane(authorize URL 作为登录页 redirect)', () => {
62
+ /** 复刻 initiateOAuthLogin 的 authorize URL 组装(oauth-login.ts)。 */
63
+ function authorizeUrl(params: Record<string, string>): string {
64
+ const qs = new URLSearchParams({ response_type: 'code', client_id: 'cid-1', ...params });
65
+ return `https://auth.iam.tianv.com/oauth/api/v1/oauth/authorize?${qs}`;
66
+ }
67
+
68
+ it('redirect_uri 指向 admin 门户回调 → admin', () => {
69
+ const url = authorizeUrl({
70
+ redirect_uri: 'https://admin.iam.tianv.com/oauth/callback',
71
+ state: JSON.stringify({ csrf: 'x', redirect: 'https://admin.iam.tianv.com/demo/dashboard' }),
72
+ });
73
+ expect(resolveEntryPlane(url, 'auth.iam.tianv.com')).toBe('admin');
74
+ });
75
+
76
+ it('redirect_uri 缺失时以 state.redirect(发起页 URL)判定 → platform', () => {
77
+ const url = authorizeUrl({
78
+ state: JSON.stringify({ csrf: 'x', redirect: 'https://platform.iam.tianv.com/tenants' }),
79
+ });
80
+ expect(resolveEntryPlane(url, 'auth.iam.tianv.com')).toBe('platform');
81
+ });
82
+
83
+ it('内嵌目标为安全仪表盘 → admin(SOC 门户与 admin 同平面)', () => {
84
+ const url = authorizeUrl({ redirect_uri: 'https://security.iam.tianv.com/oauth/callback' });
85
+ expect(resolveEntryPlane(url, 'auth.iam.tianv.com')).toBe('admin');
86
+ });
87
+
88
+ it('内嵌目标为 user 门户 / 第三方应用 → 回落 api', () => {
89
+ expect(
90
+ resolveEntryPlane(
91
+ authorizeUrl({ redirect_uri: 'https://user.iam.tianv.com/oauth/callback' }),
92
+ 'auth.iam.tianv.com',
93
+ ),
94
+ ).toBe('api');
95
+ expect(
96
+ resolveEntryPlane(
97
+ authorizeUrl({ redirect_uri: 'https://app.example.com/oauth/callback' }),
98
+ 'auth.iam.tianv.com',
99
+ ),
100
+ ).toBe('api');
101
+ });
102
+
103
+ it('state 非 JSON(旧式纯串)不抛错,按 redirect_uri 判定', () => {
104
+ const url = authorizeUrl({
105
+ redirect_uri: 'https://admin.iam.tianv.com/oauth/callback',
106
+ state: 'plain-state',
107
+ });
108
+ expect(resolveEntryPlane(url, 'auth.iam.tianv.com')).toBe('admin');
109
+ });
110
+
111
+ it('非 authorize 路径的 redirect_uri 参数不参与判定(防普通深链伪造平面)', () => {
112
+ expect(
113
+ resolveEntryPlane(
114
+ 'https://user.iam.tianv.com/demo/?redirect_uri=https://admin.iam.tianv.com/',
115
+ 'auth.iam.tianv.com',
116
+ ),
117
+ ).toBe('api');
118
+ });
119
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * BFF (Backend-For-Frontend) Auth Client
3
+ * 使用 httpOnly session cookie 替代 localStorage token,消除 XSS 令牌窃取风险。
4
+ *
5
+ * BFF 统一路由后:所有 API 通过 /bff/identity/api/v1/auth/* 访问。
6
+ */
7
+
8
+ const BFF_LOGIN = '/bff/identity/api/v1/auth/login';
9
+ const BFF_REFRESH = '/bff/identity/api/v1/auth/refresh';
10
+ const BFF_LOGOUT = '/bff/identity/api/v1/auth/logout';
11
+
12
+ interface BFFLoginResult {
13
+ code: number;
14
+ message: string;
15
+ data?: {
16
+ user?: { id: string; username?: string; email?: string };
17
+ expires_in?: number;
18
+ requires_mfa?: boolean;
19
+ mfa_check_reason?: string;
20
+ challenge_token?: string;
21
+ must_change_password?: boolean;
22
+ password_warning?: string;
23
+ password_expires_in?: number;
24
+ };
25
+ }
26
+
27
+ export async function bffLogin(
28
+ identity: string,
29
+ password: string,
30
+ tenantId?: string,
31
+ ): Promise<BFFLoginResult> {
32
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
33
+ if (tenantId) {
34
+ headers['X-Tenant-ID'] = tenantId;
35
+ }
36
+ const resp = await fetch(BFF_LOGIN, {
37
+ method: 'POST',
38
+ headers,
39
+ body: JSON.stringify({ identity, password, ...(tenantId ? { tenant_id: tenantId } : {}) }),
40
+ });
41
+ if (!resp.ok) {
42
+ return { code: resp.status, message: `BFF unavailable (HTTP ${resp.status})` };
43
+ }
44
+ try {
45
+ return await resp.json();
46
+ } catch {
47
+ return { code: -1, message: 'BFF returned invalid response' };
48
+ }
49
+ }
50
+
51
+ export async function bffRefresh(): Promise<BFFLoginResult> {
52
+ const resp = await fetch(BFF_REFRESH, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ });
56
+ if (!resp.ok) {
57
+ return { code: resp.status, message: `BFF unavailable (HTTP ${resp.status})` };
58
+ }
59
+ try {
60
+ return await resp.json();
61
+ } catch {
62
+ return { code: -1, message: 'BFF returned invalid response' };
63
+ }
64
+ }
65
+
66
+ export async function bffLogout(): Promise<void> {
67
+ // BFF cookie 模式未启用时此调用无凭据可依(identity logout 必 401),不发起无意义请求。
68
+ // 导航归调用方(useLogout / useAuthActions.logout 自身跳转),此处只做会话吊销。
69
+ if (!isBFFAvailable()) return;
70
+ await fetch(BFF_LOGOUT, {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ });
74
+ }
75
+
76
+ /**
77
+ * BFF 是否可用。
78
+ * 默认基于 __APP_CONFIG__ 中的 VITE_BFF_ENABLED 环境变量。
79
+ * 也可以在调用前通过 isBFFAvailable.override 设置(用于测试或动态切换)。
80
+ */
81
+ export function isBFFAvailable(): boolean {
82
+ if (typeof window !== 'undefined') {
83
+ const cfg = (window as any).__APP_CONFIG__;
84
+ if (cfg?.VITE_BFF_ENABLED === 'true') return true;
85
+ if (cfg?.VITE_BFF_ENABLED === 'false') return false;
86
+ }
87
+ // Static override (for testing or dynamic switching)
88
+ if (isBFFAvailable._override !== undefined) return isBFFAvailable._override;
89
+ // Default: false (auth-pages opt-in via env var)
90
+ return false;
91
+ }
92
+
93
+ // Allow runtime override (e.g. for testing or feature-flag driven toggling)
94
+ isBFFAvailable._override = undefined as boolean | undefined;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Autional 统一 API Client
3
+ * - 所有前端应用共用同一个 axios 实例
4
+ * - 自动注入 Bearer token + X-Tenant-ID
5
+ * - 自动 unwrap 后端响应 + PascalCase→camelCase 转换
6
+ * - 自动 token refresh + 401 处理
7
+ */
8
+
9
+ import axios, { AxiosError } from 'axios';
10
+ import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
11
+ import { camelCaseKeys, snakeCaseKeys } from '../utils/case';
12
+ import { getApiBaseUrl } from '../config';
13
+ import { getTraceParentHeader } from './trace';
14
+ import { ENTRY_PLANE_HEADER, resolveEntryPlane } from './entry-plane';
15
+ import { AuthService } from '../auth/service';
16
+
17
+ function setupInterceptors(instance: AxiosInstance) {
18
+ // ============ Request Interceptor ============
19
+ instance.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
20
+ // 预判式 Token 刷新:如果 Token 已过期或即将过期(60s 缓冲),先刷新再发请求
21
+ const token = AuthService.getAccessToken();
22
+ if (token && AuthService.isTokenExpired(token)) {
23
+ try {
24
+ const newToken = await AuthService.refreshToken();
25
+ if (newToken) {
26
+ config.headers.Authorization = `Bearer ${newToken}`;
27
+ return config;
28
+ }
29
+ } catch (e) {
30
+ console.warn('[API] token pre-refresh failed', (e as Error)?.message || '');
31
+ }
32
+ }
33
+ if (token) {
34
+ config.headers.Authorization = `Bearer ${token}`;
35
+ }
36
+
37
+ const tenantId = AuthService.getCurrentTenantId();
38
+ if (tenantId) {
39
+ config.headers['X-Tenant-ID'] = tenantId;
40
+ }
41
+
42
+ // 请求体 camelCase → snake_case 转换(后端 Go 期望 snake_case)
43
+ if (config.data && typeof config.data === 'object' && !(config.data instanceof FormData)) {
44
+ config.data = snakeCaseKeys(config.data);
45
+ }
46
+
47
+ // 查询参数 camelCase → snake_case 转换(后端 Go 期望 snake_case form tag)
48
+ if (config.params && typeof config.params === 'object') {
49
+ config.params = snakeCaseKeys(config.params);
50
+ }
51
+
52
+ // 按入口登录(P5):登录端点声明入口平面,identity 据此签发 entry_plane claim
53
+ // (admin./platform. 门户的 redirect 目标决定平面;缺省 api)
54
+ if (config.url?.includes('/api/v1/auth/login') && typeof window !== 'undefined') {
55
+ const redirect = new URLSearchParams(window.location.search).get('redirect');
56
+ config.headers[ENTRY_PLANE_HEADER] = resolveEntryPlane(redirect, window.location.hostname);
57
+ }
58
+
59
+ const traceparent = getTraceParentHeader();
60
+ if (traceparent) {
61
+ config.headers['traceparent'] = traceparent;
62
+ }
63
+
64
+ return config;
65
+ });
66
+
67
+ // ============ Response Interceptor ============
68
+ instance.interceptors.response.use(
69
+ (res) => {
70
+ const data = res.data;
71
+
72
+ // 只处理后端标准响应格式 { code, message, ... }
73
+ if (data && typeof data === 'object' && 'code' in data) {
74
+ let payload: unknown;
75
+
76
+ // 嵌套 data 格式: { code, message, data: {...}, timestamp }
77
+ if ('data' in data && data.data !== undefined) {
78
+ payload = data.data;
79
+ }
80
+ // 扁平列表格式: { code, message, items, total, pagination, timestamp }
81
+ else if ('items' in data) {
82
+ payload = {
83
+ items: data.items,
84
+ total: data.total,
85
+ pagination: data.pagination,
86
+ };
87
+ }
88
+ // 无 data/items 的简单响应 (如删除成功)
89
+ else {
90
+ payload = data;
91
+ }
92
+
93
+ // PascalCase → camelCase 转换
94
+ res.data = camelCaseKeys(payload);
95
+ }
96
+
97
+ return res;
98
+ },
99
+ async (err: AxiosError) => {
100
+ const originalRequest = err.config as InternalAxiosRequestConfig & {
101
+ _retry?: boolean;
102
+ _tenantRetry?: boolean;
103
+ };
104
+ if (!originalRequest) return Promise.reject(err);
105
+
106
+ // 401 → token refresh (skip for login & refresh endpoints — no token to refresh / avoid loops)
107
+ // Also skip OAuth token-exchange: a 401 there is a business error (invalid_grant / invalid_client),
108
+ // NOT session expiry. Treating it as one routes through refreshToken() → onUnauthorized → login
109
+ // page bounce while the auth code is already consumed → infinite redirect loop.
110
+ if (
111
+ err.response?.status === 401 &&
112
+ !originalRequest._retry &&
113
+ !originalRequest.url?.includes('/api/v1/auth/login') &&
114
+ !originalRequest.url?.includes('/api/v1/auth/refresh') &&
115
+ !originalRequest.url?.includes('/oauth/api/v1/oauth/token') &&
116
+ // oauth refresh 端点自身的 401(无效/过期 refresh token)是业务性失败,
117
+ // 不走 refresh 重试(避免 oauth refresh → 401 → 再 refresh 的循环)
118
+ !originalRequest.url?.includes('/oauth/api/v1/oauth/refresh') &&
119
+ // P1-4: export-data 的业务性 401(step-up 拒绝)不是会话过期,
120
+ // 不走 refresh 重试/登出,由页面 catch 展示错误。
121
+ !originalRequest.url?.includes('/auth/me/export-data')
122
+ ) {
123
+ originalRequest._retry = true;
124
+
125
+ // 尝试刷新 token(refreshToken 内部处理无 refresh token / 刷新失败 / onUnauthorized)
126
+ const newToken = await AuthService.refreshToken();
127
+ if (newToken) {
128
+ originalRequest.headers.Authorization = `Bearer ${newToken}`;
129
+ return instance(originalRequest);
130
+ }
131
+
132
+ // 刷新失败 → 已由 refreshToken 内部调用 onUnauthorized
133
+ return Promise.reject(err);
134
+ }
135
+
136
+ // 404 + tenant not found (ErrCodeDomainTenantNotFound=40000102)
137
+ // → clear tenant from store → retry without X-Tenant-ID
138
+ const data = err.response?.data as any;
139
+ if (
140
+ err.response?.status === 404 &&
141
+ data?.code === '40000102' &&
142
+ !originalRequest._tenantRetry
143
+ ) {
144
+ originalRequest._tenantRetry = true;
145
+ AuthService.updateCurrentTenant('');
146
+ // Remove X-Tenant-ID header and retry
147
+ const headers = originalRequest.headers as Record<string, unknown>;
148
+ delete headers['X-Tenant-ID'];
149
+ return instance(originalRequest);
150
+ }
151
+
152
+ return Promise.reject(err);
153
+ },
154
+ );
155
+ }
156
+
157
+ /**
158
+ * 创建自定义 baseURL 的 API client 实例
159
+ * 适用于需要访问多个不同服务前缀的场景(如 admin-console)
160
+ */
161
+ export function createApiClient(baseUrl: string): AxiosInstance {
162
+ const instance = axios.create({
163
+ baseURL: baseUrl,
164
+ timeout: 30000,
165
+ headers: { 'Content-Type': 'application/json' },
166
+ });
167
+ setupInterceptors(instance);
168
+ return instance;
169
+ }
170
+
171
+ /**
172
+ * 默认 API client 实例,baseURL 从 API_BASE_URL 配置读取,fallback '/api/v1'
173
+ */
174
+ export const apiClient: AxiosInstance = createApiClient(getApiBaseUrl());
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 按入口登录:登录请求的入口平面声明(GATEWAY-ENTRY-ISOLATION-DESIGN §4.4)。
3
+ * 值域 api|admin|platform(identity entry_plane.go 契约);仅登录端点消费,
4
+ * refresh/challenge 由服务端从 claim 继承,无需前端声明。
5
+ */
6
+
7
+ export type EntryPlane = 'api' | 'admin' | 'platform';
8
+
9
+ export const ENTRY_PLANE_HEADER = 'X-Entry-Plane';
10
+
11
+ function planeFromHostname(hostname: string): EntryPlane | null {
12
+ const h = hostname.toLowerCase();
13
+ if (h === 'admin' || h.startsWith('admin.')) return 'admin';
14
+ // security.<root>(安全仪表盘/SOC 门户)API 面为 admin 受众(风险看板等 admin 路由),
15
+ // 与 admin 门户同平面(GATEWAY-ENTRY-ISOLATION-DESIGN §3.2/§4.4)。
16
+ if (h === 'security' || h.startsWith('security.')) return 'admin';
17
+ if (h === 'platform' || h.startsWith('platform.')) return 'platform';
18
+ return null;
19
+ }
20
+
21
+ /**
22
+ * 授权流下钻:同意页冷启动时(无会话),登录页的 redirect 参数是 authorize URL 本身
23
+ * (`<slug>/login?redirect=<authorize URL>`,oauth-cold-start/TASK-07),其宿主是 auth
24
+ * 门户 ⇒ 宿主判定为空。发起门户嵌在其查询参数里:`redirect_uri`(发起方回调,宿主即
25
+ * 门户)或 `state.redirect`(发起页 URL,见 oauth-login state 载荷)。不下钻则回程
26
+ * 门户拿到的 token 平面恒为 api,admin/platform 受众端点全部 403 40000503(F-W5)。
27
+ */
28
+ function planeFromAuthorizeRequest(url: URL): EntryPlane | null {
29
+ if (!url.pathname.includes('/oauth/authorize')) return null;
30
+ const candidates: Array<string | null> = [url.searchParams.get('redirect_uri')];
31
+ try {
32
+ const state = JSON.parse(url.searchParams.get('state') || '');
33
+ if (state && typeof state.redirect === 'string') candidates.push(state.redirect);
34
+ } catch {
35
+ // state 非 JSON(旧式纯串)→ 唯一候选 redirect_uri
36
+ }
37
+ for (const candidate of candidates) {
38
+ if (!candidate) continue;
39
+ try {
40
+ const plane = planeFromHostname(new URL(candidate, url.origin).hostname);
41
+ if (plane) return plane;
42
+ } catch {
43
+ // 单个候选非法不阻断后续候选
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** 由登录页 redirect 参数目标(或当前主机)推导入口平面,无法判定时缺省 api。 */
50
+ export function resolveEntryPlane(redirect: string | null, currentHostname: string): EntryPlane {
51
+ if (redirect) {
52
+ try {
53
+ const url = new URL(redirect, `https://${currentHostname || 'localhost'}`);
54
+ const fromTarget = planeFromHostname(url.hostname) ?? planeFromAuthorizeRequest(url);
55
+ if (fromTarget) return fromTarget;
56
+ } catch {
57
+ // redirect 非法时回落当前主机判定
58
+ }
59
+ }
60
+ return planeFromHostname(currentHostname) ?? 'api';
61
+ }
@@ -0,0 +1,21 @@
1
+ const traceparentProviders: Array<() => string | undefined> = [];
2
+
3
+ export function setTraceParentProvider(provider: () => string | undefined): void {
4
+ traceparentProviders.push(provider);
5
+ }
6
+
7
+ export function getTraceParentHeader(): string | undefined {
8
+ for (const provider of traceparentProviders) {
9
+ const tp = provider();
10
+ if (tp) return tp;
11
+ }
12
+ return undefined;
13
+ }
14
+
15
+ export function resetSessionTraceId(): void {
16
+ // trace ID is managed by the OTel bridge
17
+ }
18
+
19
+ export function enableOtelBridge(): void {
20
+ // No-op stub — requires @opentelemetry/api dependency
21
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useAuthStore, getCurrentRole } from '../store';
3
+
4
+ // 注:node 测试环境下 zustand persist 中间件会打印一条无害的 storage 降级警告
5
+ // ("Unable to update item 'authms-auth-v1'"),这是 store.ts 既有 persist 的
6
+ // 标准降级行为,不影响被测的 getCurrentRole 派生逻辑。
7
+
8
+ const TENANTS = [
9
+ { id: 'tenant-a', name: 'Tenant A', role: 'admin' },
10
+ { id: 'tenant-b', name: 'Tenant B', role: 'member' },
11
+ ];
12
+
13
+ /**
14
+ * 构造最小 JWT(base64url header.payload.signature)。
15
+ * 仅用于测试「前端不再读取 token 载荷」——getCurrentRole 只读 store,
16
+ * 因此 signature 内容无关紧要。
17
+ */
18
+ function buildJwt(payload: Record<string, unknown>): string {
19
+ const enc = (obj: unknown) =>
20
+ btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
21
+ return `${enc({ alg: 'none', typ: 'JWT' })}.${enc(payload)}.test-signature`;
22
+ }
23
+
24
+ /** 重置 store 到初始状态,避免跨用例污染 */
25
+ function resetAuthStore(): void {
26
+ useAuthStore.setState({
27
+ user: null,
28
+ accessToken: null,
29
+ refreshToken: null,
30
+ tenants: [],
31
+ currentTenantId: null,
32
+ permissions: [],
33
+ isAuthenticated: false,
34
+ });
35
+ }
36
+
37
+ describe('AC-007 密码/OAuth 双路径行为一致(userinfo-first,角色来自 store)', () => {
38
+ beforeEach(() => {
39
+ resetAuthStore();
40
+ });
41
+
42
+ it('密码场景:token 带 custom.role 但前端不读 → 返回 store 角色', () => {
43
+ // 密码登录 token 载荷带 custom.role(旧解析来源),但前端已不再读取
44
+ const passwordToken = buildJwt({
45
+ sub: 'user-1',
46
+ custom: { role: 'super_admin', username: 'pw-user' },
47
+ });
48
+
49
+ // 角色真正来源:loadAuthExtras 写入的 store
50
+ useAuthStore.setState({
51
+ accessToken: passwordToken,
52
+ tenants: TENANTS,
53
+ currentTenantId: 'tenant-a',
54
+ });
55
+
56
+ // 若前端仍解析 token 会得到 super_admin;正确行为是返回 store 角色 admin
57
+ expect(getCurrentRole()).toBe('admin');
58
+ });
59
+
60
+ it('OAuth 场景:token 无 role + 相同 store → 结果与密码场景一致', () => {
61
+ // OAuth/挂载路径 token 载荷无 role(旧解析无角色来源)
62
+ const oauthToken = buildJwt({ sub: 'user-1' });
63
+
64
+ useAuthStore.setState({
65
+ accessToken: oauthToken,
66
+ tenants: TENANTS,
67
+ currentTenantId: 'tenant-a',
68
+ });
69
+
70
+ expect(getCurrentRole()).toBe('admin');
71
+ });
72
+
73
+ it('双路径矩阵:两场景结果相等(store 预填充为唯一角色来源)', () => {
74
+ const passwordToken = buildJwt({
75
+ sub: 'user-1',
76
+ custom: { role: 'super_admin', username: 'pw-user' },
77
+ });
78
+ const oauthToken = buildJwt({ sub: 'user-1' });
79
+
80
+ // 相同 store 预填充
81
+ useAuthStore.setState({
82
+ accessToken: passwordToken,
83
+ tenants: TENANTS,
84
+ currentTenantId: 'tenant-a',
85
+ });
86
+ const roleFromPassword = getCurrentRole();
87
+
88
+ useAuthStore.setState({
89
+ accessToken: oauthToken,
90
+ tenants: TENANTS,
91
+ currentTenantId: 'tenant-a',
92
+ });
93
+ const roleFromOAuth = getCurrentRole();
94
+
95
+ expect(roleFromPassword).toBe(roleFromOAuth);
96
+ expect(roleFromPassword).toBe('admin');
97
+ expect(roleFromOAuth).toBe('admin');
98
+ });
99
+
100
+ it('token 携带不同 custom.role 时 store 角色仍胜出(证明零 token 载荷读取)', () => {
101
+ const adminToken = buildJwt({
102
+ sub: 'user-1',
103
+ custom: { role: 'super_admin' },
104
+ });
105
+ const memberToken = buildJwt({
106
+ sub: 'user-1',
107
+ custom: { role: 'member' },
108
+ });
109
+
110
+ useAuthStore.setState({
111
+ accessToken: adminToken,
112
+ tenants: TENANTS,
113
+ currentTenantId: 'tenant-a',
114
+ });
115
+ expect(getCurrentRole()).toBe('admin'); // store 角色,非 token 的 super_admin
116
+
117
+ useAuthStore.setState({
118
+ accessToken: memberToken,
119
+ tenants: TENANTS,
120
+ currentTenantId: 'tenant-a',
121
+ });
122
+ expect(getCurrentRole()).toBe('admin'); // token role 变化不影响 store 派生
123
+ });
124
+ });