@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,120 @@
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 域会话,再决定落点——
46
+ * 回程能解析出真实租户 → 裸 `/<slug>/login`(不带回程参数:登出即"重新开始",
47
+ * 登录后走默认去向,去向选择权交还用户);否则落 brand 裸根。
48
+ */
49
+ export function buildLogoutUrl(returnUrl?: string): string {
50
+ const base = getPortalUrl('auth');
51
+ if (!base) return '/';
52
+ const redirect = returnUrl ? `redirect=${encodeURIComponent(returnUrl)}&` : '';
53
+ return `${base}/?${redirect}logout=1`;
54
+ }
55
+
56
+ /**
57
+ * 当前页是否已是 auth 的「登录承接面」——裸根入口(`/`、`/login`,由入口路由承接)、
58
+ * `/<slug>/login` 登录页、`/error` 错误页。
59
+ *
60
+ * 会话终结(onUnauthorized)时若已停在承接面,整页 replace 不但无益(目标就是本页),
61
+ * 还会因 buildLoginUrl 的防嵌套截断返回不带 redirect 的本页 —— 把 URL 中的回程目标
62
+ * 一并丢掉,且构成一次整页闪烁(F-W8b 修复②:存量过期 token 在登录页「静默弹走」)。
63
+ */
64
+ export function isAuthLoginSurface(): boolean {
65
+ if (typeof window === 'undefined') return false;
66
+ const segs = window.location.pathname.split('/').filter(Boolean);
67
+ if (segs.length === 0) return true; // 裸根 `/`
68
+ if (segs[0] === 'login' || segs[0] === 'error') return true; // `/login`、`/error`
69
+ return segs.length === 2 && segs[1] === 'login'; // `/<slug>/login`
70
+ }
71
+
72
+ export function buildLoginUrl(returnUrl?: string, fromRequireAuth?: boolean): string {
73
+ const base = getPortalUrl('auth');
74
+ if (!base) return '/';
75
+
76
+ // 保留租户 slug:如果 returnUrl 落在 auth 域且首段是租户 slug,
77
+ // 跳转到 {base}/{slug}/login 而不是根路径(根路径是租户选择页)
78
+ let loginBase = base;
79
+ if (returnUrl) {
80
+ try {
81
+ const clean = new URL(returnUrl, typeof window !== 'undefined' ? window.location.origin : '');
82
+ const baseOrigin = new URL(base).origin;
83
+ const firstSeg = clean.pathname.split('/').filter(Boolean)[0];
84
+ if (
85
+ clean.origin === baseOrigin &&
86
+ firstSeg &&
87
+ !AUTH_ROUTE_RESERVED.has(firstSeg.toLowerCase())
88
+ ) {
89
+ loginBase = `${base}/${firstSeg}/login`;
90
+ }
91
+ } catch {
92
+ /* invalid URL — fall through */
93
+ }
94
+ }
95
+
96
+ if (returnUrl) {
97
+ // 防止嵌套 redirect:仅当 returnUrl 落在 auth 域且本身已是登录页时才截断
98
+ try {
99
+ const clean = new URL(returnUrl, typeof window !== 'undefined' ? window.location.origin : '');
100
+ const baseOrigin = new URL(base).origin;
101
+ if (
102
+ clean.origin === baseOrigin &&
103
+ (clean.pathname === '/' || clean.pathname.endsWith('/login') || clean.href === base)
104
+ ) {
105
+ return fromRequireAuth ? `${loginBase}?from_requireauth=1` : loginBase;
106
+ }
107
+ } catch {
108
+ /* invalid URL — fall through */
109
+ }
110
+ const qs = `?redirect=${encodeURIComponent(returnUrl)}`;
111
+ if (fromRequireAuth) {
112
+ return `${loginBase}${qs}&from_requireauth=1`;
113
+ }
114
+ return `${loginBase}${qs}`;
115
+ }
116
+ if (fromRequireAuth) {
117
+ return `${loginBase}?from_requireauth=1`;
118
+ }
119
+ return loginBase;
120
+ }
@@ -0,0 +1,433 @@
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, isAuthLoginSurface } from './roles';
27
+ import { traceClear, traceEvent, traceRedirect } from './auth-trace';
28
+ import { resolveClientIdForSession } from './oauth-client-id-store';
29
+ import { extractItem } from '../utils/response';
30
+ import type { User } from '../types';
31
+
32
+ // ============ Internal State ============
33
+
34
+ let refreshPromise: Promise<string | null> | null = null;
35
+
36
+ // ============ OAuth PKCE Bootstrap Lock ============
37
+
38
+ let _bootstrapLocked = false;
39
+
40
+ /** Suppress onUnauthorized redirects during OAuth PKCE initialization */
41
+ export function setBootstrapLock(locked: boolean): void {
42
+ _bootstrapLocked = locked;
43
+ if (typeof window !== 'undefined') (window as any).__bootstrapLocked = locked;
44
+ }
45
+
46
+ export function isBootstrapLocked(): boolean {
47
+ return _bootstrapLocked;
48
+ }
49
+ const listeners = new Map<string, Set<(data?: unknown) => void>>();
50
+
51
+ function emit(event: string, data?: unknown) {
52
+ listeners.get(event)?.forEach((fn) => {
53
+ try {
54
+ fn(data);
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ });
59
+ }
60
+
61
+ // ============ Cross-tab Sync (BroadcastChannel) ============
62
+
63
+ const BC_CHANNEL = 'autional-auth';
64
+ let bc: BroadcastChannel | null = null;
65
+
66
+ function initBroadcastChannel(): void {
67
+ if (typeof BroadcastChannel === 'undefined') return;
68
+ if (bc) return;
69
+ try {
70
+ bc = new BroadcastChannel(BC_CHANNEL);
71
+ bc.onmessage = (event) => {
72
+ const msg = event.data;
73
+ if (!msg || !msg.type) return;
74
+
75
+ switch (msg.type) {
76
+ case 'auth:logout':
77
+ // 另一个 tab 触发了登出 → 同步清除本 tab 状态
78
+ useAuthStore.getState().clearAuth();
79
+ if (typeof window !== 'undefined') {
80
+ localStorage.removeItem('autional-auth-v1');
81
+ localStorage.removeItem('access_token');
82
+ localStorage.removeItem('refresh_token');
83
+ }
84
+ emit('auth:logout', { source: 'broadcast' });
85
+ break;
86
+
87
+ case 'auth:login':
88
+ // 另一个 tab 登录成功 → 刷新本 tab 状态(可选)
89
+ // 可由消费方决定是否同步
90
+ emit('auth:login-broadcast', msg.data);
91
+ break;
92
+ }
93
+ };
94
+ } catch (e) {
95
+ console.warn('[AuthService] BroadcastChannel not available', (e as Error)?.message || '');
96
+ }
97
+ }
98
+
99
+ function broadcast(event: string, data?: unknown): void {
100
+ if (!bc) return;
101
+ try {
102
+ bc.postMessage({ type: event, data });
103
+ } catch {
104
+ /* ignore */
105
+ }
106
+ }
107
+
108
+ // ============ AuthService ============
109
+
110
+ // Initialize cross-tab sync channel
111
+ initBroadcastChannel();
112
+
113
+ export const AuthService = {
114
+ // ==================== 读取(纯函数,无副作用) ====================
115
+
116
+ /** 获取 access token:Zustand store 优先,localStorage fallback */
117
+ getAccessToken: (): string | null => getAccessToken(),
118
+
119
+ /** 获取 refresh token */
120
+ getRefreshToken: (): string | null => getRefreshToken(),
121
+
122
+ /** 获取当前用户信息 */
123
+ getUser: (): User | null => useAuthStore.getState().user,
124
+
125
+ /** 是否已认证 */
126
+ isAuthenticated: (): boolean => useAuthStore.getState().isAuthenticated,
127
+
128
+ /** token 是否已过期 */
129
+ isTokenExpired: (token?: string): boolean => checkExpired(token || getAccessToken() || ''),
130
+
131
+ /** 获取当前租户 ID */
132
+ getCurrentTenantId: (): string | null => useAuthStore.getState().currentTenantId,
133
+
134
+ /** 获取当前租户列表 */
135
+ getTenants: (): Array<{ id: string; name: string; role: string }> =>
136
+ useAuthStore.getState().tenants,
137
+
138
+ /** 获取当前权限列表 */
139
+ getPermissions: (): string[] => useAuthStore.getState().permissions,
140
+
141
+ /** 获取当前角色(userinfo-first,委托 store 派生) */
142
+ getCurrentRole: (): string | null => getCurrentRole(),
143
+
144
+ // ==================== 写入(唯一变更入口) ====================
145
+
146
+ /** 登录成功后设置完整认证状态 */
147
+ setAuth: (accessToken: string, refreshToken: string, user: User): void => {
148
+ loginWithTokens(accessToken, refreshToken, user);
149
+ emit('auth:login', { userId: user.id, username: user.username });
150
+ broadcast('auth:login', { userId: user.id });
151
+ },
152
+
153
+ /** token 刷新后更新令牌 */
154
+ updateTokens: (accessToken: string, refreshToken: string): void => {
155
+ useAuthStore.getState().setTokens(accessToken, refreshToken);
156
+ emit('auth:tokens-refreshed');
157
+ },
158
+
159
+ /** 更新用户信息(来自 /auth/me) */
160
+ updateUser: (user: Partial<User>): void => {
161
+ const current = useAuthStore.getState().user;
162
+ // 防止跨用户覆盖:如果 store 中有用户但 ID 不匹配,忽略
163
+ if (current && user.id && current.id !== user.id) return;
164
+ useAuthStore.setState({ user: user as User });
165
+ emit('auth:user-updated', { userId: user.id });
166
+ },
167
+
168
+ /** 设置租户列表 */
169
+ updateTenants: (tenants: Array<{ id: string; name: string; role: string }>): void => {
170
+ useAuthStore.getState().setTenants(tenants);
171
+ emit('auth:tenants-updated');
172
+ },
173
+
174
+ /** 设置权限列表 */
175
+ updatePermissions: (permissions: string[]): void => {
176
+ useAuthStore.getState().setPermissions(permissions);
177
+ emit('auth:permissions-updated');
178
+ },
179
+
180
+ /** 切换当前租户 */
181
+ updateCurrentTenant: (tenantId: string): void => {
182
+ useAuthStore.getState().setCurrentTenant(tenantId);
183
+ emit('auth:tenant-changed', { tenantId });
184
+ },
185
+
186
+ // ==================== 登出(唯一实现) ====================
187
+
188
+ /**
189
+ * 统一登出:
190
+ * 1. 清除内存状态(立即生效)
191
+ * 2. 清除所有持久化数据
192
+ * 3. 服务端 token 吊销(best-effort,不阻塞)
193
+ */
194
+ logout: async (): Promise<void> => {
195
+ const at = AuthService.getAccessToken();
196
+ const rt = AuthService.getRefreshToken();
197
+
198
+ // 1. 清除内存状态(立即生效,阻止后续操作使用过期 token)
199
+ useAuthStore.getState().clearAuth();
200
+
201
+ // 2. 清除所有持久化数据
202
+ if (typeof window !== 'undefined') {
203
+ localStorage.removeItem('autional-auth-v1');
204
+ localStorage.removeItem('access_token');
205
+ localStorage.removeItem('refresh_token');
206
+ localStorage.removeItem('__oauth_bridge_token');
207
+ }
208
+
209
+ // 2b. AUTH-08 会话卫生:清本 tab 的 OAuth 发起预置物(含明文 verifier)与
210
+ // trace 缓冲——sessionStorage 跨整页导航天然保留,不清则跨登出残留。
211
+ try {
212
+ const { discardPendingOAuthLogin } = await import('./oauth-login');
213
+ discardPendingOAuthLogin();
214
+ } catch {
215
+ /* best-effort */
216
+ }
217
+ traceClear();
218
+
219
+ // 3. 服务端 token 吊销(best-effort,不阻塞登出流程)。按与 refreshToken
220
+ // 相同的链判据分流(JWT 三段式 rt = identity 链):
221
+ // - OAuth(PKCE)链(不透明 rt + 已解析 client_id):oauth revoke 端点
222
+ // 要求客户端认证(S9-A),公开客户端凭注册 client_id;access/refresh
223
+ // 各自成行、须逐条撤销;
224
+ // - identity 链(rt 为 JWT / 无 client_id):identity /auth/logout 携 AT
225
+ // 是权威撤销——单 token 黑名单(jwt:bl:)+ 会话级撤销(jwt:bls:,覆盖
226
+ // 该会话全部已签发 token)。修复前固定打 oauth revoke 且(会话无
227
+ // client_id 时)不带凭据 → 必 401 静默(AUTH-15),identity 会话从未
228
+ // 被服务端终结。
229
+ try {
230
+ if (at || rt) {
231
+ const { default: axios } = await import('axios');
232
+ const { getApiBaseUrl } = await import('../config');
233
+ const baseUrl = getApiBaseUrl();
234
+ const clientId = resolveClientIdForSession();
235
+ const rtIsJwt = !!rt && rt.split('.').length === 3;
236
+
237
+ const record = (ev: string, chain: string, err?: unknown) => {
238
+ traceEvent(ev, {
239
+ reason: chain,
240
+ to: err ? String((err as any)?.response?.status ?? 'network-error') : 'ok',
241
+ });
242
+ };
243
+
244
+ if (rt && !rtIsJwt && clientId) {
245
+ const revokeParams = (token: string, hint: string) => {
246
+ const params = new URLSearchParams({ token, token_type_hint: hint });
247
+ params.set('client_id', clientId);
248
+ return params.toString();
249
+ };
250
+ if (at) {
251
+ await axios
252
+ .post(`${baseUrl}/oauth/api/v1/oauth/revoke`, revokeParams(at, 'access_token'), {
253
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
254
+ })
255
+ .then(() => record('logout-revoke', 'oauth-access_token'))
256
+ .catch((e) => record('logout-revoke-failed', 'oauth-access_token', e));
257
+ }
258
+ await axios
259
+ .post(`${baseUrl}/oauth/api/v1/oauth/revoke`, revokeParams(rt, 'refresh_token'), {
260
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
261
+ })
262
+ .then(() => record('logout-revoke', 'oauth-refresh_token'))
263
+ .catch((e) => record('logout-revoke-failed', 'oauth-refresh_token', e));
264
+ } else if (at) {
265
+ await axios
266
+ .post(`${baseUrl}/identity/api/v1/auth/logout`, null, {
267
+ headers: { Authorization: `Bearer ${at}` },
268
+ })
269
+ .then(() => record('logout-revoke', 'identity'))
270
+ .catch((e) => record('logout-revoke-failed', 'identity', e));
271
+ } else {
272
+ // 仅剩无法凭 client_id 走 oauth 的 rt(identity 签发的 JWT rt):
273
+ // identity logout 需携 AT → 无凭据可吊销,记 trace 明示跳过
274
+ record('logout-revoke-skipped', 'no-credential');
275
+ }
276
+ }
277
+ } catch {
278
+ /* best-effort */
279
+ }
280
+
281
+ // 4. BFF 会话吊销(best-effort,httpOnly cookie 清理)
282
+ try {
283
+ const { bffLogout } = await import('../api/bff-client');
284
+ await bffLogout();
285
+ } catch {
286
+ /* best-effort */
287
+ }
288
+
289
+ broadcast('auth:logout');
290
+ emit('auth:logout');
291
+ },
292
+
293
+ // ==================== Token 刷新(统一超时防重入) ====================
294
+
295
+ /**
296
+ * 刷新 access token。
297
+ * 防重入:同一时间只会有一个刷新请求进行中。
298
+ *
299
+ * 失败处置(onFailure,缺省 'redirect'):
300
+ * - 'redirect':onUnauthorized —— 清理 + 引导登录(常规路径);
301
+ * - 'clear' :clearSession —— 只清理不导航(调用方自控唯一导航出口,
302
+ * 如会话过期横幅 → error 页,避免与整页跳转双跳竞争);
303
+ * - 'none' :零副作用静默(尽力续期场景:如过期前 5 分钟的自动续期尝试,
304
+ * 失败不清理不打断——会话此刻仍有效,等真正过期再统一处置)。
305
+ */
306
+ refreshToken: async (opts?: {
307
+ onFailure?: 'redirect' | 'clear' | 'none';
308
+ }): Promise<string | null> => {
309
+ if (refreshPromise) return refreshPromise;
310
+ const onFailure = opts?.onFailure ?? 'redirect';
311
+ const handleFailure = () => {
312
+ if (onFailure === 'redirect') AuthService.onUnauthorized();
313
+ else if (onFailure === 'clear') AuthService.clearSession();
314
+ };
315
+
316
+ refreshPromise = (async () => {
317
+ const rt = AuthService.getRefreshToken();
318
+ if (!rt) {
319
+ // During OAuth PKCE bootstrap (no token yet), suppress redirect.
320
+ // RequireAuth will handle OAuth PKCE vs BFF fallback once tenantRoute resolves.
321
+ if (!_bootstrapLocked) handleFailure();
322
+ return null;
323
+ }
324
+
325
+ try {
326
+ const { default: axios } = await import('axios');
327
+ const { getApiBaseUrl } = await import('../config');
328
+ const baseUrl = getApiBaseUrl();
329
+
330
+ // 按 refresh token 格式分流:
331
+ // - JWT(三段式,identity 链签发)→ identity /auth/refresh(现状逻辑)
332
+ // - opaque(rt- 前缀,OAuth PKCE 链签发)→ oauth /oauth/refresh(扁平 JSON,轮换式)
333
+ const isJwtRefreshToken = rt.split('.').length === 3;
334
+ // OAuth 链分流判据:会话内已解析值(持久化)→ env(TASK-09 / D8)
335
+ const clientId = resolveClientIdForSession();
336
+
337
+ let data: {
338
+ access_token?: string;
339
+ accessToken?: string;
340
+ refresh_token?: string;
341
+ refreshToken?: string;
342
+ };
343
+ if (isJwtRefreshToken || !clientId) {
344
+ // identity 链:标准信封响应 { code, message, data: { access_token, refresh_token } }
345
+ // 必须解包(2026-10-03 线上缺陷:未解包 ⇒ 200 被当失败 ⇒ 预警点横幅 +
346
+ // 到期旧 RT 重放 ⇒ 服务端反重放 revoke-all 全量吊销)
347
+ const resp = await axios.post(`${baseUrl}/identity/api/v1/auth/refresh`, {
348
+ refresh_token: rt,
349
+ });
350
+ data = extractItem(resp.data) ?? resp.data;
351
+ } else {
352
+ // OAuth 链:扁平 JSON 响应 { access_token, refresh_token, token_type, expires_in, scope }
353
+ const resp = await axios.post(`${baseUrl}/oauth/api/v1/oauth/refresh`, {
354
+ refresh_token: rt,
355
+ client_id: clientId,
356
+ });
357
+ data = resp.data;
358
+ }
359
+ const newAT = data.access_token || data.accessToken;
360
+ const newRT = data.refresh_token || data.refreshToken;
361
+ if (newAT) {
362
+ AuthService.updateTokens(newAT, newRT || rt);
363
+ }
364
+ return newAT || null;
365
+ } catch (err) {
366
+ console.warn('[AuthService] Token refresh failed', (err as Error)?.message || '');
367
+ handleFailure();
368
+ return null;
369
+ } finally {
370
+ refreshPromise = null;
371
+ }
372
+ })();
373
+
374
+ return refreshPromise;
375
+ },
376
+
377
+ // ==================== 401/未授权处理 ====================
378
+
379
+ /**
380
+ * 仅清除会话状态(store + localStorage),不做任何导航。
381
+ * 供 silent 刷新失败、以及需要自控导航出口的调用方使用。
382
+ */
383
+ clearSession: (): void => {
384
+ useAuthStore.getState().clearAuth();
385
+ if (typeof window !== 'undefined') {
386
+ // 清除 localStorage(同步,立即生效)
387
+ localStorage.removeItem('autional-auth-v1');
388
+ localStorage.removeItem('access_token');
389
+ localStorage.removeItem('refresh_token');
390
+ }
391
+ },
392
+
393
+ /**
394
+ * 处理 401 未授权响应:
395
+ * 1. 清除所有状态(clearSession)
396
+ * 2. 经 authTrace 提示(会话已过期 → 即将前往登录,可停留)后整页弹登录页
397
+ *
398
+ * 例外:已在 auth 登录承接面(裸根入口 / `/<slug>/login` / `/error`)时只清理不导航 ——
399
+ * 目标就是当前页,整页 replace 反而会丢掉 URL 里的 redirect 回程目标并造成闪烁
400
+ * (F-W8b 修复②);承接面页面对 401 有自己的收口(登录页落表单、error 页走倒计时)。
401
+ *
402
+ * 重入抑制在 authTrace 内(pending 单例;并发 401 只提示一次、只跳一次)。
403
+ */
404
+ onUnauthorized: (): void => {
405
+ // Suppress redirect during OAuth PKCE bootstrap — RequireAuth handles it
406
+ if (_bootstrapLocked) return;
407
+ // 先清除状态,不等待 logout 的异步吊销
408
+ AuthService.clearSession();
409
+
410
+ if (isAuthLoginSurface()) return;
411
+
412
+ if (typeof window !== 'undefined') {
413
+ traceRedirect(buildLoginUrl(window.location.href, true), {
414
+ reason: 'session-expired',
415
+ kind: 'interstitial',
416
+ });
417
+ }
418
+ },
419
+
420
+ // ==================== 事件订阅(可观测性) ====================
421
+
422
+ /**
423
+ * 订阅认证事件。
424
+ * @returns 取消订阅函数
425
+ */
426
+ subscribe: (event: string, handler: (data?: unknown) => void): (() => void) => {
427
+ if (!listeners.has(event)) listeners.set(event, new Set());
428
+ listeners.get(event)!.add(handler);
429
+ return () => {
430
+ listeners.get(event)?.delete(handler);
431
+ };
432
+ },
433
+ };
@@ -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
+ }