@authms/core 0.1.0

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.
@@ -0,0 +1,368 @@
1
+ import type { AuthmsPlatform } from './platform/types';
2
+ import type { TokenManager } from './token-manager';
3
+ import type {
4
+ AuthResult, LoginRequest, RegisterRequest, OAuthOptions,
5
+ TenantAuthConfig, PasswordPolicyConfig,
6
+ } from './types';
7
+ import { AuthmsAuthError, AuthmsNetworkError } from './errors';
8
+ import { processPasswordForTransmission } from './crypto/password-transmission';
9
+ import { solveProofOfWork } from './crypto/pow-solver';
10
+ import type { KeyExchangeFn } from './crypto/password-transmission';
11
+
12
+ interface AuthClientConfig {
13
+ tokenManager: TokenManager;
14
+ http: AuthmsPlatform['http'];
15
+ baseUrl: string;
16
+ keyExchangeFn?: KeyExchangeFn;
17
+ }
18
+
19
+ const MAX_CAPTCHA_RETRIES = 3;
20
+ const CACHE_TTL_MS = 5 * 60 * 1000;
21
+
22
+ export class AuthClient {
23
+ private tokenManager: TokenManager;
24
+ private http: AuthmsPlatform['http'];
25
+ private baseUrl: string;
26
+ private keyExchangeFn?: KeyExchangeFn;
27
+ private configCache: Map<string, { data: TenantAuthConfig; at: number }> = new Map();
28
+
29
+ constructor(config: AuthClientConfig) {
30
+ this.tokenManager = config.tokenManager;
31
+ this.http = config.http;
32
+ this.baseUrl = config.baseUrl;
33
+ this.keyExchangeFn = config.keyExchangeFn;
34
+ }
35
+
36
+ async fetchAuthConfig(tenantId?: string): Promise<TenantAuthConfig> {
37
+ const key = tenantId || '__default__';
38
+ const cached = this.configCache.get(key);
39
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
40
+ return cached.data;
41
+ }
42
+
43
+ const path = tenantId
44
+ ? `/identity/api/v1/public/auth-config/${tenantId}`
45
+ : '/identity/api/v1/public/auth-config';
46
+
47
+ const response = await this.http.request(`${this.baseUrl}${path}`);
48
+ const json = await response.json() as Record<string, unknown>;
49
+ const data = (json.data ?? json) as Record<string, unknown>;
50
+ const pp = (data.password_policy ?? {}) as Record<string, unknown>;
51
+
52
+ const config: TenantAuthConfig = {
53
+ tenantId: (data.tenant_id as string) || '',
54
+ tenantName: (data.tenant_name as string) || '',
55
+ displayName: (data.display_name as string) || '',
56
+ membershipApproval: (data.membership_approval as string) || 'open',
57
+ loginMethods: (data.login_methods as string[]) || [],
58
+ oauthProviders: (data.oauth_providers as string[]) || [],
59
+ passwordPolicy: {
60
+ mode: (pp.password_transmission as string) || (data.password_transmission as string) || 'plain',
61
+ minLength: (pp.min_length as number) || 8,
62
+ maxLength: (pp.max_length as number) || 128,
63
+ requireUpper: (pp.require_upper as boolean) || false,
64
+ requireLower: (pp.require_lower as boolean) || false,
65
+ requireDigit: (pp.require_digit as boolean) || false,
66
+ requireSpecial: (pp.require_special as boolean) || false,
67
+ tenantId: data.tenant_id as string || '',
68
+ publicKey: data.transmission_public_key as string || '',
69
+ },
70
+ captchaEnabled: (data.captcha_enabled as boolean) || false,
71
+ captchaProvider: (data.captcha_provider as string) || 'pow',
72
+ silentChallengeEnabled: (data.silent_challenge_enabled as boolean) || false,
73
+ transmissionPublicKey: (data.transmission_public_key as string) || '',
74
+ oauthClientId: (data.oauth_client_id as string) || '',
75
+ passkeyEnabled: (data.passkey_enabled as boolean) || false,
76
+ magicLinkEnabled: (data.magic_link_enabled as boolean) || false,
77
+ branding: (data.branding ?? null) as TenantAuthConfig['branding'],
78
+ };
79
+
80
+ this.configCache.set(key, { data: config, at: Date.now() });
81
+ return config;
82
+ }
83
+
84
+ clearConfigCache(): void {
85
+ this.configCache.clear();
86
+ }
87
+
88
+ async login(credentials: LoginRequest): Promise<AuthResult> {
89
+ const authConfig = await this.fetchAuthConfig(credentials.tenantId);
90
+ let captchaRetries = 0;
91
+
92
+ while (true) {
93
+ const body = await this.buildLoginBody(credentials, authConfig);
94
+
95
+ if (!body['captcha_token'] && authConfig.silentChallengeEnabled && authConfig.captchaProvider === 'pow') {
96
+ try {
97
+ const token = await this.solveCaptchaChallenge();
98
+ body['captcha_token'] = token;
99
+ body['captcha_provider'] = 'pow';
100
+ } catch {
101
+ // PoW solver failed — proceed without
102
+ }
103
+ }
104
+
105
+ const response = await this.http.request(`${this.baseUrl}/identity/api/v1/auth/login`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify(body),
109
+ });
110
+
111
+ if (response.status === 401) {
112
+ const errJson = await response.json().catch(() => ({})) as Record<string, unknown>;
113
+ const code = String(errJson.code ?? '');
114
+ if (code.includes('captcha') && captchaRetries < MAX_CAPTCHA_RETRIES) {
115
+ captchaRetries++;
116
+ continue;
117
+ }
118
+ throw new AuthmsAuthError(code, (errJson.message as string) || `Login failed`, 401);
119
+ }
120
+
121
+ if (!response.ok) {
122
+ const errJson = await response.json().catch(() => ({})) as Record<string, unknown>;
123
+ throw new AuthmsAuthError(
124
+ String(errJson.code ?? response.status),
125
+ (errJson.message as string) || `Login failed`,
126
+ response.status,
127
+ );
128
+ }
129
+
130
+ const json = await response.json() as Record<string, unknown>;
131
+ return this.handleAuthResponse(json);
132
+ }
133
+ }
134
+
135
+ async register(data: RegisterRequest): Promise<AuthResult> {
136
+ const authConfig = await this.fetchAuthConfig(data.tenantId);
137
+
138
+ const processed = await processPasswordForTransmission(
139
+ data.password,
140
+ {
141
+ mode: authConfig.passwordPolicy.mode,
142
+ tenantId: authConfig.tenantId || data.tenantId || '',
143
+ requireUpper: false,
144
+ minLength: 0,
145
+ publicKey: authConfig.transmissionPublicKey || '',
146
+ },
147
+ this.keyExchangeFn,
148
+ );
149
+
150
+ const body: Record<string, unknown> = {
151
+ ...data as unknown as Record<string, unknown>,
152
+ password: processed.password,
153
+ password_transmission: processed.passwordTransmission,
154
+ };
155
+ if (processed.keyExchangeId) body.key_exchange_id = processed.keyExchangeId;
156
+ if (processed.clientPubKey) body.client_pub_key = processed.clientPubKey;
157
+
158
+ const response = await this.http.request(`${this.baseUrl}/identity/api/v1/auth/register`, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify(body),
162
+ });
163
+
164
+ if (!response.ok) {
165
+ const errJson = await response.json().catch(() => ({})) as Record<string, unknown>;
166
+ throw new AuthmsAuthError(
167
+ String(errJson.code ?? response.status),
168
+ (errJson.message as string) || `Registration failed`,
169
+ response.status,
170
+ );
171
+ }
172
+
173
+ const json = await response.json() as Record<string, unknown>;
174
+ const payload = (json.data ?? json) as Record<string, unknown>;
175
+
176
+ if (payload.access_token) {
177
+ const result = this.handleAuthResponse(json);
178
+ return result;
179
+ }
180
+
181
+ return { user: payload as any, accessToken: '', refreshToken: '', expiresIn: 0, tokenType: '' };
182
+ }
183
+
184
+ async changePassword(currentPassword: string, newPassword: string): Promise<void> {
185
+ const authConfig = await this.fetchAuthConfig();
186
+
187
+ const processed = await processPasswordForTransmission(
188
+ newPassword,
189
+ {
190
+ mode: authConfig.passwordPolicy.mode,
191
+ tenantId: authConfig.tenantId,
192
+ requireUpper: false,
193
+ minLength: 0,
194
+ publicKey: authConfig.transmissionPublicKey || '',
195
+ },
196
+ this.keyExchangeFn,
197
+ );
198
+
199
+ const body: Record<string, unknown> = {
200
+ current_password: currentPassword,
201
+ password: processed.password,
202
+ password_transmission: processed.passwordTransmission,
203
+ };
204
+ if (processed.keyExchangeId) body.key_exchange_id = processed.keyExchangeId;
205
+ if (processed.clientPubKey) body.client_pub_key = processed.clientPubKey;
206
+
207
+ const response = await this.http.request(`${this.baseUrl}/identity/api/v1/auth/me/password`, {
208
+ method: 'PUT',
209
+ headers: { 'Content-Type': 'application/json' },
210
+ body: JSON.stringify(body),
211
+ });
212
+
213
+ if (!response.ok) {
214
+ const errJson = await response.json().catch(() => ({})) as Record<string, unknown>;
215
+ throw new AuthmsAuthError(
216
+ String(errJson.code ?? response.status),
217
+ (errJson.message as string) || `Password change failed`,
218
+ response.status,
219
+ );
220
+ }
221
+ }
222
+
223
+ async loginWithOAuth(options: OAuthOptions): Promise<void> {
224
+ const redirectUri = options.redirectUri || (typeof window !== 'undefined' ? window.location.origin + '/oauth/callback' : '');
225
+ const params = new URLSearchParams({ provider: options.provider, redirect_uri: redirectUri });
226
+ if (typeof window !== 'undefined') {
227
+ window.location.href = `${this.baseUrl}/oauth/api/v1/oauth/${options.provider}/authorize?${params.toString()}`;
228
+ } else {
229
+ throw new AuthmsAuthError('NOT_BROWSER', 'OAuth login requires a browser environment', 400);
230
+ }
231
+ }
232
+
233
+ async handleOAuthCallback(url: string): Promise<AuthResult> {
234
+ const urlObj = new URL(url);
235
+ const code = urlObj.searchParams.get('code');
236
+ const state = urlObj.searchParams.get('state');
237
+ if (!code) throw new AuthmsAuthError('OAUTH_FAILED', 'No authorization code in callback URL', 400);
238
+
239
+ const response = await this.http.request(`${this.baseUrl}/oauth/api/v1/oauth/token`, {
240
+ method: 'POST',
241
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
242
+ body: new URLSearchParams({
243
+ grant_type: 'authorization_code', code, state: state ?? '',
244
+ redirect_uri: typeof window !== 'undefined' ? window.location.origin + '/oauth/callback' : '',
245
+ }).toString(),
246
+ });
247
+
248
+ if (!response.ok) throw new AuthmsNetworkError(`OAuth token exchange failed (${response.status})`);
249
+ const json = await response.json() as Record<string, unknown>;
250
+ return this.handleAuthResponse(json);
251
+ }
252
+
253
+ async refreshToken(): Promise<void> {
254
+ const refreshToken = this.tokenManager.getRefreshToken();
255
+ if (!refreshToken) throw new AuthmsAuthError('NO_REFRESH_TOKEN', 'No refresh token available', 401);
256
+
257
+ const response = await this.http.request(`${this.baseUrl}/identity/api/v1/auth/refresh`, {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify({ refresh_token: refreshToken }),
261
+ });
262
+
263
+ if (!response.ok) {
264
+ const errJson = await response.json().catch(() => ({})) as Record<string, unknown>;
265
+ const code = String(errJson.code ?? '');
266
+ if (code.startsWith('400002')) {
267
+ this.tokenManager.clear();
268
+ this.tokenManager.persist();
269
+ throw new AuthmsAuthError('TOKEN_REUSE', 'Refresh token reused — all sessions revoked', 401);
270
+ }
271
+ this.tokenManager.clear();
272
+ this.tokenManager.persist();
273
+ throw new AuthmsAuthError('REFRESH_FAILED', 'Token refresh failed', 401);
274
+ }
275
+
276
+ const json = await response.json() as Record<string, unknown>;
277
+ const data = (json.data ?? json) as Record<string, unknown>;
278
+ this.tokenManager.setTokens(
279
+ data.access_token as string,
280
+ (data.refresh_token as string) || refreshToken,
281
+ (data.expires_in as number) || 900,
282
+ );
283
+ if (data.user) this.tokenManager.setUser(data.user as Record<string, unknown>);
284
+ this.tokenManager.persist();
285
+ }
286
+
287
+ async logout(): Promise<void> {
288
+ const accessToken = this.tokenManager.getAccessToken();
289
+ const refreshToken = this.tokenManager.getRefreshToken();
290
+ this.tokenManager.clear();
291
+ this.tokenManager.persist();
292
+ if (accessToken) {
293
+ this.http.request(`${this.baseUrl}/identity/api/v1/auth/logout`, {
294
+ method: 'POST',
295
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
296
+ body: JSON.stringify({ refresh_token: refreshToken }),
297
+ }).catch(() => {});
298
+ }
299
+ if (typeof window !== 'undefined') window.localStorage.removeItem('authms_auth_tokens');
300
+ }
301
+
302
+ async getProfile(): Promise<Record<string, unknown> | null> {
303
+ const response = await this.http.request(`${this.baseUrl}/identity/api/v1/auth/me`, {
304
+ method: 'GET',
305
+ headers: { 'Authorization': `Bearer ${this.tokenManager.getAccessToken()}` },
306
+ });
307
+ if (response.status === 401) return null;
308
+ if (!response.ok) return null;
309
+ const json = await response.json() as Record<string, unknown>;
310
+ const data = (json.data ?? json) as Record<string, unknown>;
311
+ this.tokenManager.setUser(data);
312
+ return data;
313
+ }
314
+
315
+ private async buildLoginBody(credentials: LoginRequest, authConfig: TenantAuthConfig): Promise<Record<string, unknown>> {
316
+ const processed = await processPasswordForTransmission(
317
+ credentials.password,
318
+ {
319
+ mode: authConfig.passwordPolicy.mode,
320
+ tenantId: authConfig.tenantId || credentials.tenantId || '',
321
+ requireUpper: false,
322
+ minLength: 0,
323
+ publicKey: authConfig.transmissionPublicKey || '',
324
+ },
325
+ this.keyExchangeFn,
326
+ );
327
+
328
+ const body: Record<string, unknown> = {
329
+ identity: credentials.email || credentials.phone || credentials.username || '',
330
+ password: processed.password,
331
+ password_transmission: processed.passwordTransmission,
332
+ };
333
+ if (credentials.tenantId) body['tenant_id'] = credentials.tenantId;
334
+ if (processed.keyExchangeId) body['key_exchange_id'] = processed.keyExchangeId;
335
+ if (processed.clientPubKey) body['client_pub_key'] = processed.clientPubKey;
336
+ if (credentials.captchaToken) body['captcha_token'] = credentials.captchaToken;
337
+ if (credentials.captchaProvider) body['captcha_provider'] = credentials.captchaProvider;
338
+ if (credentials.captchaChallengeId) body['captcha_challenge_id'] = credentials.captchaChallengeId;
339
+ return body;
340
+ }
341
+
342
+ private async solveCaptchaChallenge(): Promise<string> {
343
+ const challengeResp = await this.http.request(
344
+ `${this.baseUrl}/identity/api/v1/auth/captcha/challenge?provider=pow&difficulty=4`,
345
+ );
346
+ const cJson = await challengeResp.json() as Record<string, unknown>;
347
+ const cData = (cJson.data ?? cJson) as Record<string, unknown>;
348
+ const cd = (cData.data ?? cData) as Record<string, unknown> ?? cData;
349
+ const powChallenge = String(cd.challenge ?? cd['challenge'] ?? '');
350
+ const difficulty = Number(cd.difficulty ?? cd['difficulty'] ?? 4);
351
+ return solveProofOfWork(powChallenge, difficulty);
352
+ }
353
+
354
+ private handleAuthResponse(json: Record<string, unknown>): AuthResult {
355
+ const data = (json.data ?? json) as Record<string, unknown>;
356
+ const result: AuthResult = {
357
+ accessToken: data.access_token as string,
358
+ refreshToken: data.refresh_token as string,
359
+ expiresIn: (data.expires_in as number) || 900,
360
+ tokenType: (data.token_type as string) || 'Bearer',
361
+ user: (data.user as AuthResult['user']) || { id: data.user_id as string || '' },
362
+ };
363
+ this.tokenManager.setTokens(result.accessToken, result.refreshToken, result.expiresIn);
364
+ this.tokenManager.setUser(result.user as Record<string, unknown>);
365
+ this.tokenManager.persist();
366
+ return result;
367
+ }
368
+ }
package/src/authms.ts ADDED
@@ -0,0 +1,244 @@
1
+ import { TokenManager } from './token-manager';
2
+ import { ApiClient } from './api-client';
3
+ import { AuthClient } from './auth-client';
4
+ import { Discovery } from './discovery';
5
+ import { TabSync } from './sync';
6
+ import { AuthmsError } from './errors';
7
+ import type { AuthmsPlatform } from './platform/types';
8
+ import type { AuthmsPlugin } from './plugin';
9
+ import type {
10
+ User, AuthResult, LoginRequest, RegisterRequest,
11
+ OAuthOptions, AuthmsEvent, SecurityAlert, TenantAuthConfig,
12
+ } from './types';
13
+
14
+ export interface AuthmsConfig {
15
+ appId: string;
16
+ issuer: string;
17
+ apiUrl?: string;
18
+ /** @deprecated 使用 issuer + apiUrl */
19
+ authUrl?: string;
20
+ platform: AuthmsPlatform;
21
+ storagePrefix?: string;
22
+ syncTabs?: boolean;
23
+ }
24
+
25
+ type EventHandler = (...args: unknown[]) => void;
26
+
27
+ export class AuthMS {
28
+ private config: AuthmsConfig;
29
+ tokenManager: TokenManager;
30
+ api: ApiClient;
31
+ private authClient: AuthClient;
32
+ private discovery: Discovery;
33
+ private tabSync: TabSync | null = null;
34
+ private eventHandlers: Map<string, Set<EventHandler>> = new Map();
35
+ private _ready = false;
36
+ private _userCache: Record<string, unknown> | null = null;
37
+ private _authConfig: Record<string, unknown> | null = null;
38
+
39
+ constructor(config: AuthmsConfig) {
40
+ if (!config.appId) throw new AuthmsError('CONFIG_ERROR', 'appId is required', 500);
41
+ if (!config.issuer) throw new AuthmsError('CONFIG_ERROR', 'issuer is required', 500);
42
+
43
+ this.config = config;
44
+ const apiUrl = config.apiUrl ?? config.issuer;
45
+
46
+ this.tokenManager = new TokenManager(config.platform.storage, config.storagePrefix);
47
+ this.discovery = new Discovery(config.platform.http, config.apiUrl);
48
+ this.authClient = new AuthClient({
49
+ tokenManager: this.tokenManager,
50
+ http: config.platform.http,
51
+ baseUrl: apiUrl,
52
+ });
53
+
54
+ this.api = new ApiClient({
55
+ baseUrl: apiUrl,
56
+ tokenManager: this.tokenManager,
57
+ http: config.platform.http,
58
+ refreshTokenFn: () => this.authClient.refreshToken(),
59
+ onForceLogout: () => {
60
+ this.emit('LOGGED_OUT');
61
+ this.tabSync?.broadcast('LOGOUT');
62
+ },
63
+ });
64
+
65
+ if (config.syncTabs !== false) {
66
+ this.tabSync = new TabSync(
67
+ () => {
68
+ this.tokenManager.clear();
69
+ this._userCache = null;
70
+ this.emit('LOGGED_OUT');
71
+ },
72
+ async () => {
73
+ await this.tokenManager.load();
74
+ this._userCache = this.tokenManager.getUser();
75
+ this.emit('TOKEN_CHANGED');
76
+ this.emit('USER_CHANGED');
77
+ },
78
+ );
79
+ this.tabSync.listen();
80
+ }
81
+ }
82
+
83
+ async initialize(): Promise<void> {
84
+ await this.tokenManager.load();
85
+ try {
86
+ await this.discovery.discover(this.config.issuer);
87
+ } catch {
88
+ // OIDC Discovery 失败 — API 调用不受影响(apiUrl 已配置)
89
+ }
90
+
91
+ const accessToken = this.tokenManager.getAccessToken();
92
+ if (accessToken) {
93
+ this._userCache = this.tokenManager.getUser();
94
+ if (!this._userCache) {
95
+ try {
96
+ this._userCache = await this.authClient.getProfile();
97
+ if (this._userCache) {
98
+ this.tokenManager.setUser(this._userCache);
99
+ this.tokenManager.persist();
100
+ }
101
+ } catch {
102
+ if (!this.tokenManager.isTokenExpired(accessToken)) {
103
+ const claims = this.tokenManager.decodeToken(accessToken);
104
+ this._userCache = claims ?? {};
105
+ }
106
+ }
107
+ }
108
+
109
+ this.tokenManager.onTokenChange((token) => {
110
+ if (!token) {
111
+ this._userCache = null;
112
+ this.emit('LOGGED_OUT');
113
+ }
114
+ this.emit('TOKEN_CHANGED', { token });
115
+ this.emit('USER_CHANGED');
116
+ });
117
+ }
118
+
119
+ this._ready = true;
120
+ this.emit('READY');
121
+
122
+ if (this._userCache) {
123
+ this.emit('USER_CHANGED');
124
+ }
125
+ }
126
+
127
+ isReady(): boolean {
128
+ return this._ready;
129
+ }
130
+
131
+ get user(): User | null {
132
+ return (this._userCache ?? this.tokenManager.getUser()) as unknown as User | null;
133
+ }
134
+
135
+ get authConfig(): Record<string, unknown> | null {
136
+ return this._authConfig;
137
+ }
138
+
139
+ async fetchAuthConfig(tenantId?: string): Promise<Record<string, unknown>> {
140
+ const config = await (this.authClient as any).fetchAuthConfig(tenantId);
141
+ this._authConfig = config;
142
+ return config;
143
+ }
144
+
145
+ async getAccessToken(): Promise<string | null> {
146
+ const token = this.tokenManager.getAccessToken();
147
+ if (token) return token;
148
+
149
+ try {
150
+ await this.authClient.refreshToken();
151
+ return this.tokenManager.getAccessToken();
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ getRefreshToken(): string | null {
158
+ return this.tokenManager.getRefreshToken();
159
+ }
160
+
161
+ isAuthenticated(): boolean {
162
+ return this.tokenManager.isAuthenticated();
163
+ }
164
+
165
+ async login(credentials: LoginRequest): Promise<AuthResult> {
166
+ const result = await this.authClient.login(credentials);
167
+ this._userCache = result.user as unknown as Record<string, unknown>;
168
+ this.emit('USER_CHANGED');
169
+ this.emit('TOKEN_CHANGED');
170
+ this.tabSync?.broadcast('LOGIN');
171
+ return result;
172
+ }
173
+
174
+ async loginWithOAuth(options: OAuthOptions): Promise<void> {
175
+ await this.authClient.loginWithOAuth(options);
176
+ }
177
+
178
+ async handleOAuthCallback(url: string): Promise<AuthResult> {
179
+ const result = await this.authClient.handleOAuthCallback(url);
180
+ this._userCache = result.user as unknown as Record<string, unknown>;
181
+ this.emit('USER_CHANGED');
182
+ this.tabSync?.broadcast('LOGIN');
183
+ return result;
184
+ }
185
+
186
+ async register(data: RegisterRequest): Promise<AuthResult> {
187
+ const result = await this.authClient.register(data);
188
+ if (result.accessToken) {
189
+ this._userCache = result.user as unknown as Record<string, unknown>;
190
+ this.emit('USER_CHANGED');
191
+ this.tabSync?.broadcast('LOGIN');
192
+ }
193
+ return result;
194
+ }
195
+
196
+ async logout(): Promise<void> {
197
+ await this.authClient.logout();
198
+ this._userCache = null;
199
+ this.emit('LOGGED_OUT');
200
+ this.emit('USER_CHANGED');
201
+ this.tabSync?.broadcast('LOGOUT');
202
+ }
203
+
204
+ async getProfile(): Promise<Record<string, unknown> | null> {
205
+ return this.authClient.getProfile();
206
+ }
207
+
208
+ setTenantId(tenantId: string | null): void {
209
+ this.tokenManager.setTenantId(tenantId);
210
+ this.tokenManager.persist();
211
+ this._authConfig = null;
212
+ try { this.authClient.clearConfigCache(); } catch {}
213
+ }
214
+
215
+ getTenantId(): string | null {
216
+ return this.tokenManager.getTenantId();
217
+ }
218
+
219
+ use(plugin: AuthmsPlugin): void {
220
+ plugin.install(this);
221
+ }
222
+
223
+ on(event: AuthmsEvent, handler: EventHandler): () => void {
224
+ if (!this.eventHandlers.has(event)) {
225
+ this.eventHandlers.set(event, new Set());
226
+ }
227
+ this.eventHandlers.get(event)!.add(handler);
228
+ return () => this.eventHandlers.get(event)?.delete(handler);
229
+ }
230
+
231
+ emit(event: AuthmsEvent, ...args: unknown[]): void {
232
+ const handlers = this.eventHandlers.get(event);
233
+ if (handlers) {
234
+ handlers.forEach(fn => {
235
+ try { fn(...args); } catch { /* handler error shouldn't break others */ }
236
+ });
237
+ }
238
+ }
239
+
240
+ dispose(): void {
241
+ this.tabSync?.close();
242
+ this.eventHandlers.clear();
243
+ }
244
+ }