@55387.ai/uniauth-client 1.0.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.
package/src/index.ts ADDED
@@ -0,0 +1,1278 @@
1
+ /**
2
+ * UniAuth Client SDK
3
+ * 统一认证前端 SDK
4
+ *
5
+ * Usage:
6
+ * ```typescript
7
+ * import { UniAuthClient } from '@uniauth/client';
8
+ *
9
+ * const auth = new UniAuthClient({
10
+ * baseUrl: 'https://auth.example.com',
11
+ * appKey: 'your-app-key',
12
+ * });
13
+ *
14
+ * // Send verification code
15
+ * await auth.sendCode('+8613800138000');
16
+ *
17
+ * // Login with code
18
+ * const result = await auth.loginWithCode('+8613800138000', '123456');
19
+ *
20
+ * // Get current user
21
+ * const user = await auth.getCurrentUser();
22
+ *
23
+ * // Logout
24
+ * await auth.logout();
25
+ * ```
26
+ */
27
+
28
+ import {
29
+ fetchWithRetry,
30
+ generateCodeVerifier,
31
+ generateCodeChallenge,
32
+ storeCodeVerifier,
33
+ getAndClearCodeVerifier,
34
+ type FetchWithRetryOptions
35
+ } from './http.js';
36
+
37
+ // Types
38
+ export interface UniAuthConfig {
39
+ /** API base URL */
40
+ baseUrl: string;
41
+ /** Application key */
42
+ appKey?: string;
43
+ /** OAuth2 Client ID (for OAuth flows) */
44
+ clientId?: string;
45
+ /** Storage type for tokens */
46
+ storage?: 'localStorage' | 'sessionStorage' | 'memory';
47
+ /** Callback when tokens are refreshed */
48
+ onTokenRefresh?: (tokens: TokenPair) => void;
49
+ /** Callback when auth error occurs */
50
+ onAuthError?: (error: AuthError) => void;
51
+ /** Enable request retry with exponential backoff */
52
+ enableRetry?: boolean;
53
+ /** Request timeout in milliseconds */
54
+ timeout?: number;
55
+ }
56
+
57
+ export interface UserInfo {
58
+ id: string;
59
+ phone: string | null;
60
+ email: string | null;
61
+ nickname: string | null;
62
+ avatar_url: string | null;
63
+ }
64
+
65
+ export interface TokenPair {
66
+ access_token: string;
67
+ refresh_token: string;
68
+ expires_in: number;
69
+ }
70
+
71
+ export interface LoginResult {
72
+ user: UserInfo;
73
+ access_token: string;
74
+ refresh_token: string;
75
+ expires_in: number;
76
+ is_new_user: boolean;
77
+ /** MFA is required, use mfa_token with verifyMFA() */
78
+ mfa_required?: boolean;
79
+ /** Temporary token for MFA verification */
80
+ mfa_token?: string;
81
+ /** Available MFA methods */
82
+ mfa_methods?: string[];
83
+ }
84
+
85
+ export interface SendCodeResult {
86
+ expires_in: number;
87
+ retry_after: number;
88
+ }
89
+
90
+ export interface AuthError {
91
+ code: string;
92
+ message: string;
93
+ }
94
+
95
+ export interface ApiResponse<T> {
96
+ success: boolean;
97
+ data?: T;
98
+ message?: string;
99
+ error?: AuthError;
100
+ }
101
+
102
+ /**
103
+ * OAuth Provider Information
104
+ * OAuth 提供商信息
105
+ */
106
+ export interface OAuthProvider {
107
+ id: string;
108
+ name: string;
109
+ enabled: boolean;
110
+ icon?: string;
111
+ }
112
+
113
+ /**
114
+ * Auth state change callback
115
+ * 认证状态变更回调
116
+ */
117
+ export type AuthStateChangeCallback = (user: UserInfo | null, isAuthenticated: boolean) => void;
118
+
119
+ /**
120
+ * Error codes for UniAuth operations
121
+ * UniAuth 操作错误码
122
+ */
123
+ export const AuthErrorCode = {
124
+ // Authentication errors
125
+ SEND_CODE_FAILED: 'SEND_CODE_FAILED',
126
+ VERIFY_FAILED: 'VERIFY_FAILED',
127
+ LOGIN_FAILED: 'LOGIN_FAILED',
128
+ OAUTH_FAILED: 'OAUTH_FAILED',
129
+ MFA_REQUIRED: 'MFA_REQUIRED',
130
+ MFA_FAILED: 'MFA_FAILED',
131
+ REGISTER_FAILED: 'REGISTER_FAILED',
132
+
133
+ // Token errors
134
+ NOT_AUTHENTICATED: 'NOT_AUTHENTICATED',
135
+ TOKEN_EXPIRED: 'TOKEN_EXPIRED',
136
+ REFRESH_FAILED: 'REFRESH_FAILED',
137
+
138
+ // Configuration errors
139
+ CONFIG_ERROR: 'CONFIG_ERROR',
140
+ SSO_NOT_CONFIGURED: 'SSO_NOT_CONFIGURED',
141
+ INVALID_STATE: 'INVALID_STATE',
142
+
143
+ // Network errors
144
+ NETWORK_ERROR: 'NETWORK_ERROR',
145
+ TIMEOUT: 'TIMEOUT',
146
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
147
+ } as const;
148
+
149
+ export type AuthErrorCodeType = typeof AuthErrorCode[keyof typeof AuthErrorCode];
150
+
151
+ /**
152
+ * Custom error class for UniAuth operations
153
+ * UniAuth 操作自定义错误类
154
+ */
155
+ export class UniAuthError extends Error {
156
+ code: AuthErrorCodeType | string;
157
+ statusCode?: number;
158
+ details?: unknown;
159
+
160
+ constructor(code: AuthErrorCodeType | string, message: string, statusCode?: number, details?: unknown) {
161
+ super(message);
162
+ this.name = 'UniAuthError';
163
+ this.code = code;
164
+ this.statusCode = statusCode;
165
+ this.details = details;
166
+
167
+ // Maintain proper stack trace for where error was thrown
168
+ if (Error.captureStackTrace) {
169
+ Error.captureStackTrace(this, UniAuthError);
170
+ }
171
+ }
172
+ }
173
+
174
+
175
+ export interface OAuth2AuthorizeOptions {
176
+ redirectUri: string;
177
+ scope?: string;
178
+ state?: string;
179
+ /** Use PKCE (recommended for public clients) */
180
+ usePKCE?: boolean;
181
+ }
182
+
183
+ export interface OAuth2TokenResult {
184
+ access_token: string;
185
+ token_type: string;
186
+ expires_in: number;
187
+ refresh_token?: string;
188
+ }
189
+
190
+ /**
191
+ * SSO Configuration
192
+ * SSO 配置
193
+ */
194
+ export interface SSOConfig {
195
+ /** SSO service URL (e.g., https://sso.example.com) */
196
+ ssoUrl: string;
197
+ /** OAuth client ID for this application */
198
+ clientId: string;
199
+ /** Redirect URI after SSO login */
200
+ redirectUri: string;
201
+ /** OAuth scope (default: 'openid profile email') */
202
+ scope?: string;
203
+ }
204
+
205
+ /**
206
+ * SSO Login Options
207
+ * SSO 登录选项
208
+ */
209
+ export interface SSOLoginOptions {
210
+ /** Use PKCE (recommended, default: true) */
211
+ usePKCE?: boolean;
212
+ /** Custom state parameter */
213
+ state?: string;
214
+ /** Whether to use popup instead of redirect */
215
+ usePopup?: boolean;
216
+ }
217
+
218
+ // Token storage
219
+ interface TokenStorage {
220
+ getAccessToken(): string | null;
221
+ setAccessToken(token: string): void;
222
+ getRefreshToken(): string | null;
223
+ setRefreshToken(token: string): void;
224
+ clear(): void;
225
+ }
226
+
227
+ class LocalStorageAdapter implements TokenStorage {
228
+ private accessTokenKey = 'uniauth_access_token';
229
+ private refreshTokenKey = 'uniauth_refresh_token';
230
+
231
+ getAccessToken(): string | null {
232
+ if (typeof localStorage === 'undefined') return null;
233
+ return localStorage.getItem(this.accessTokenKey);
234
+ }
235
+
236
+ setAccessToken(token: string): void {
237
+ if (typeof localStorage === 'undefined') return;
238
+ localStorage.setItem(this.accessTokenKey, token);
239
+ }
240
+
241
+ getRefreshToken(): string | null {
242
+ if (typeof localStorage === 'undefined') return null;
243
+ return localStorage.getItem(this.refreshTokenKey);
244
+ }
245
+
246
+ setRefreshToken(token: string): void {
247
+ if (typeof localStorage === 'undefined') return;
248
+ localStorage.setItem(this.refreshTokenKey, token);
249
+ }
250
+
251
+ clear(): void {
252
+ if (typeof localStorage === 'undefined') return;
253
+ localStorage.removeItem(this.accessTokenKey);
254
+ localStorage.removeItem(this.refreshTokenKey);
255
+ }
256
+ }
257
+
258
+ class SessionStorageAdapter implements TokenStorage {
259
+ private accessTokenKey = 'uniauth_access_token';
260
+ private refreshTokenKey = 'uniauth_refresh_token';
261
+
262
+ getAccessToken(): string | null {
263
+ if (typeof sessionStorage === 'undefined') return null;
264
+ return sessionStorage.getItem(this.accessTokenKey);
265
+ }
266
+
267
+ setAccessToken(token: string): void {
268
+ if (typeof sessionStorage === 'undefined') return;
269
+ sessionStorage.setItem(this.accessTokenKey, token);
270
+ }
271
+
272
+ getRefreshToken(): string | null {
273
+ if (typeof sessionStorage === 'undefined') return null;
274
+ return sessionStorage.getItem(this.refreshTokenKey);
275
+ }
276
+
277
+ setRefreshToken(token: string): void {
278
+ if (typeof sessionStorage === 'undefined') return;
279
+ sessionStorage.setItem(this.refreshTokenKey, token);
280
+ }
281
+
282
+ clear(): void {
283
+ if (typeof sessionStorage === 'undefined') return;
284
+ sessionStorage.removeItem(this.accessTokenKey);
285
+ sessionStorage.removeItem(this.refreshTokenKey);
286
+ }
287
+ }
288
+
289
+ class MemoryStorageAdapter implements TokenStorage {
290
+ private accessToken: string | null = null;
291
+ private refreshToken: string | null = null;
292
+
293
+ getAccessToken(): string | null {
294
+ return this.accessToken;
295
+ }
296
+
297
+ setAccessToken(token: string): void {
298
+ this.accessToken = token;
299
+ }
300
+
301
+ getRefreshToken(): string | null {
302
+ return this.refreshToken;
303
+ }
304
+
305
+ setRefreshToken(token: string): void {
306
+ this.refreshToken = token;
307
+ }
308
+
309
+ clear(): void {
310
+ this.accessToken = null;
311
+ this.refreshToken = null;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * UniAuth Client
317
+ * 统一认证客户端
318
+ */
319
+ export class UniAuthClient {
320
+ private config: UniAuthConfig;
321
+ private storage: TokenStorage;
322
+ private refreshPromise: Promise<boolean> | null = null;
323
+
324
+ constructor(config: UniAuthConfig) {
325
+ this.config = {
326
+ enableRetry: true,
327
+ timeout: 30000,
328
+ ...config,
329
+ };
330
+
331
+ // Initialize storage adapter
332
+ switch (config.storage) {
333
+ case 'sessionStorage':
334
+ this.storage = new SessionStorageAdapter();
335
+ break;
336
+ case 'memory':
337
+ this.storage = new MemoryStorageAdapter();
338
+ break;
339
+ default:
340
+ this.storage = new LocalStorageAdapter();
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Send verification code to phone number
346
+ * 发送验证码到手机号
347
+ */
348
+ async sendCode(
349
+ phone: string,
350
+ type: 'login' | 'register' | 'reset' = 'login'
351
+ ): Promise<SendCodeResult> {
352
+ const response = await this.request<SendCodeResult>('/api/v1/auth/send-code', {
353
+ method: 'POST',
354
+ body: JSON.stringify({ phone, type }),
355
+ });
356
+
357
+ if (!response.success || !response.data) {
358
+ throw this.createError(response.error?.code || 'SEND_CODE_FAILED', response.error?.message || 'Failed to send code');
359
+ }
360
+
361
+ return response.data;
362
+ }
363
+
364
+ /**
365
+ * Send verification code to email
366
+ * 发送验证码到邮箱
367
+ */
368
+ async sendEmailCode(
369
+ email: string,
370
+ type: 'login' | 'register' | 'reset' | 'email_verify' = 'login'
371
+ ): Promise<SendCodeResult> {
372
+ const response = await this.request<SendCodeResult>('/api/v1/auth/send-code', {
373
+ method: 'POST',
374
+ body: JSON.stringify({ email, type }),
375
+ });
376
+
377
+ if (!response.success || !response.data) {
378
+ throw this.createError(response.error?.code || 'SEND_CODE_FAILED', response.error?.message || 'Failed to send code');
379
+ }
380
+
381
+ return response.data;
382
+ }
383
+
384
+ /**
385
+ * Login with phone verification code
386
+ * 使用手机验证码登录
387
+ */
388
+ async loginWithCode(phone: string, code: string): Promise<LoginResult> {
389
+ const response = await this.request<LoginResult>('/api/v1/auth/phone/verify', {
390
+ method: 'POST',
391
+ body: JSON.stringify({ phone, code }),
392
+ });
393
+
394
+ if (!response.success || !response.data) {
395
+ throw this.createError(response.error?.code || 'VERIFY_FAILED', response.error?.message || 'Failed to verify code');
396
+ }
397
+
398
+ // Store tokens if not MFA required
399
+ if (!response.data.mfa_required) {
400
+ this.storage.setAccessToken(response.data.access_token);
401
+ this.storage.setRefreshToken(response.data.refresh_token);
402
+ this.notifyAuthStateChange(response.data.user);
403
+ }
404
+
405
+ return response.data;
406
+ }
407
+
408
+ /**
409
+ * Login with email verification code
410
+ * 使用邮箱验证码登录
411
+ */
412
+ async loginWithEmailCode(email: string, code: string): Promise<LoginResult> {
413
+ const response = await this.request<LoginResult>('/api/v1/auth/email/verify', {
414
+ method: 'POST',
415
+ body: JSON.stringify({ email, code }),
416
+ });
417
+
418
+ if (!response.success || !response.data) {
419
+ throw this.createError(response.error?.code || 'VERIFY_FAILED', response.error?.message || 'Failed to verify code');
420
+ }
421
+
422
+ // Store tokens if not MFA required
423
+ if (!response.data.mfa_required) {
424
+ this.storage.setAccessToken(response.data.access_token);
425
+ this.storage.setRefreshToken(response.data.refresh_token);
426
+ this.notifyAuthStateChange(response.data.user);
427
+ }
428
+
429
+ return response.data;
430
+ }
431
+
432
+ /**
433
+ * Login with email and password
434
+ * 使用邮箱密码登录
435
+ */
436
+ async loginWithEmail(email: string, password: string): Promise<LoginResult> {
437
+ const response = await this.request<LoginResult>('/api/v1/auth/email/login', {
438
+ method: 'POST',
439
+ body: JSON.stringify({ email, password }),
440
+ });
441
+
442
+ if (!response.success || !response.data) {
443
+ throw this.createError(response.error?.code || 'LOGIN_FAILED', response.error?.message || 'Failed to login');
444
+ }
445
+
446
+ // Store tokens if not MFA required
447
+ if (!response.data.mfa_required) {
448
+ this.storage.setAccessToken(response.data.access_token);
449
+ this.storage.setRefreshToken(response.data.refresh_token);
450
+ this.notifyAuthStateChange(response.data.user);
451
+ }
452
+
453
+ return response.data;
454
+ }
455
+
456
+ /**
457
+ * Handle OAuth callback (for social login)
458
+ * 处理 OAuth 回调(社交登录)
459
+ */
460
+ async handleOAuthCallback(provider: string, code: string): Promise<LoginResult> {
461
+ const response = await this.request<LoginResult>('/api/v1/auth/oauth/callback', {
462
+ method: 'POST',
463
+ body: JSON.stringify({ provider, code }),
464
+ });
465
+
466
+ if (!response.success || !response.data) {
467
+ throw this.createError(response.error?.code || 'OAUTH_FAILED', response.error?.message || 'OAuth callback failed');
468
+ }
469
+
470
+ // Store tokens (if not MFA required)
471
+ if (!response.data.mfa_required) {
472
+ this.storage.setAccessToken(response.data.access_token);
473
+ this.storage.setRefreshToken(response.data.refresh_token);
474
+ this.notifyAuthStateChange(response.data.user);
475
+ }
476
+
477
+ return response.data;
478
+ }
479
+
480
+ // ============================================
481
+ // Email Registration / 邮箱注册
482
+ // ============================================
483
+
484
+ /**
485
+ * Register with email and password
486
+ * 使用邮箱密码注册
487
+ */
488
+ async registerWithEmail(email: string, password: string, nickname?: string): Promise<LoginResult> {
489
+ const response = await this.request<LoginResult>('/api/v1/auth/email/register', {
490
+ method: 'POST',
491
+ body: JSON.stringify({ email, password, nickname }),
492
+ });
493
+
494
+ if (!response.success || !response.data) {
495
+ throw this.createError(response.error?.code || 'REGISTER_FAILED', response.error?.message || 'Failed to register');
496
+ }
497
+
498
+ // Store tokens
499
+ this.storage.setAccessToken(response.data.access_token);
500
+ this.storage.setRefreshToken(response.data.refresh_token);
501
+ this.notifyAuthStateChange(response.data.user);
502
+
503
+ return response.data;
504
+ }
505
+
506
+ // ============================================
507
+ // MFA (Multi-Factor Authentication) / 多因素认证
508
+ // ============================================
509
+
510
+ /**
511
+ * Verify MFA code to complete login
512
+ * 验证 MFA 验证码完成登录
513
+ *
514
+ * Call this after login returns mfa_required: true
515
+ * 当登录返回 mfa_required: true 时调用此方法
516
+ *
517
+ * @example
518
+ * ```typescript
519
+ * const result = await auth.loginWithCode(phone, code);
520
+ * if (result.mfa_required) {
521
+ * const mfaCode = prompt('Enter MFA code:');
522
+ * const finalResult = await auth.verifyMFA(result.mfa_token!, mfaCode);
523
+ * }
524
+ * ```
525
+ */
526
+ async verifyMFA(mfaToken: string, code: string): Promise<LoginResult> {
527
+ const response = await this.request<LoginResult>('/api/v1/auth/mfa/verify-login', {
528
+ method: 'POST',
529
+ body: JSON.stringify({ mfa_token: mfaToken, code }),
530
+ });
531
+
532
+ if (!response.success || !response.data) {
533
+ throw this.createError(response.error?.code || 'MFA_FAILED', response.error?.message || 'MFA verification failed');
534
+ }
535
+
536
+ // Store tokens
537
+ this.storage.setAccessToken(response.data.access_token);
538
+ this.storage.setRefreshToken(response.data.refresh_token);
539
+ this.notifyAuthStateChange(response.data.user);
540
+
541
+ return response.data;
542
+ }
543
+
544
+ // ============================================
545
+ // Social Login / 社交登录
546
+ // ============================================
547
+
548
+ /**
549
+ * Get available OAuth providers
550
+ * 获取可用的 OAuth 提供商列表
551
+ */
552
+ async getOAuthProviders(): Promise<OAuthProvider[]> {
553
+ const response = await this.request<{ providers: OAuthProvider[] }>('/api/v1/auth/oauth/providers', {
554
+ method: 'GET',
555
+ });
556
+
557
+ if (!response.success || !response.data) {
558
+ return [];
559
+ }
560
+
561
+ return response.data.providers || [];
562
+ }
563
+
564
+ /**
565
+ * Start social login (redirect to OAuth provider)
566
+ * 开始社交登录(重定向到 OAuth 提供商)
567
+ *
568
+ * @param provider - OAuth provider ID (e.g., 'google', 'github', 'wechat')
569
+ * @param redirectUri - Where to redirect after OAuth (optional, uses default)
570
+ *
571
+ * @example
572
+ * ```typescript
573
+ * // Redirect user to Google login
574
+ * auth.startSocialLogin('google');
575
+ * ```
576
+ */
577
+ startSocialLogin(provider: string, redirectUri?: string): void {
578
+ const params = new URLSearchParams();
579
+ if (redirectUri) {
580
+ params.set('redirect_uri', redirectUri);
581
+ }
582
+
583
+ const query = params.toString();
584
+ const url = `${this.config.baseUrl}/api/v1/auth/oauth/${provider}/authorize${query ? '?' + query : ''}`;
585
+
586
+ if (typeof window !== 'undefined') {
587
+ window.location.href = url;
588
+ }
589
+ }
590
+
591
+ // ============================================
592
+ // Auth State Management / 认证状态管理
593
+ // ============================================
594
+
595
+ private authStateCallbacks: AuthStateChangeCallback[] = [];
596
+ private currentUser: UserInfo | null = null;
597
+
598
+ /**
599
+ * Subscribe to auth state changes
600
+ * 订阅认证状态变更
601
+ *
602
+ * @returns Unsubscribe function
603
+ *
604
+ * @example
605
+ * ```typescript
606
+ * const unsubscribe = auth.onAuthStateChange((user, isAuthenticated) => {
607
+ * if (isAuthenticated) {
608
+ * console.log('User logged in:', user);
609
+ * } else {
610
+ * console.log('User logged out');
611
+ * }
612
+ * });
613
+ *
614
+ * // Later, to unsubscribe:
615
+ * unsubscribe();
616
+ * ```
617
+ */
618
+ onAuthStateChange(callback: AuthStateChangeCallback): () => void {
619
+ this.authStateCallbacks.push(callback);
620
+
621
+ // Return unsubscribe function
622
+ return () => {
623
+ const index = this.authStateCallbacks.indexOf(callback);
624
+ if (index !== -1) {
625
+ this.authStateCallbacks.splice(index, 1);
626
+ }
627
+ };
628
+ }
629
+
630
+ /**
631
+ * Notify all subscribers of auth state change
632
+ * 通知所有订阅者认证状态变更
633
+ */
634
+ private notifyAuthStateChange(user: UserInfo | null): void {
635
+ this.currentUser = user;
636
+ const isAuthenticated = this.isAuthenticated();
637
+
638
+ for (const callback of this.authStateCallbacks) {
639
+ try {
640
+ callback(user, isAuthenticated);
641
+ } catch (error) {
642
+ console.error('Auth state callback error:', error);
643
+ }
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Get cached current user (sync, may be stale)
649
+ * 获取缓存的当前用户(同步,可能过时)
650
+ */
651
+ getCachedUser(): UserInfo | null {
652
+ return this.currentUser;
653
+ }
654
+
655
+ /**
656
+ * Get access token synchronously (without refresh check)
657
+ * 同步获取访问令牌(不检查刷新)
658
+ */
659
+ getAccessTokenSync(): string | null {
660
+ return this.storage.getAccessToken();
661
+ }
662
+
663
+ /**
664
+ * Check if current token is valid (not expired)
665
+ * 检查当前令牌是否有效(未过期)
666
+ */
667
+ isTokenValid(): boolean {
668
+ const token = this.storage.getAccessToken();
669
+ if (!token) return false;
670
+
671
+ try {
672
+ const payload = JSON.parse(atob(token.split('.')[1]));
673
+ const exp = payload.exp * 1000;
674
+ return Date.now() < exp;
675
+ } catch {
676
+ return false;
677
+ }
678
+ }
679
+
680
+
681
+ /**
682
+ * Get current user info
683
+ * 获取当前用户信息
684
+ */
685
+ async getCurrentUser(): Promise<UserInfo | null> {
686
+ if (!this.isAuthenticated()) {
687
+ return null;
688
+ }
689
+
690
+ try {
691
+ const response = await this.authenticatedRequest<UserInfo>('/api/v1/user/me', {
692
+ method: 'GET',
693
+ });
694
+
695
+ if (!response.success || !response.data) {
696
+ return null;
697
+ }
698
+
699
+ return response.data;
700
+ } catch {
701
+ return null;
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Update user profile
707
+ * 更新用户资料
708
+ */
709
+ async updateProfile(updates: Partial<Pick<UserInfo, 'nickname' | 'avatar_url'>>): Promise<UserInfo> {
710
+ const response = await this.authenticatedRequest<UserInfo>('/api/v1/user/me', {
711
+ method: 'PATCH',
712
+ body: JSON.stringify(updates),
713
+ });
714
+
715
+ if (!response.success || !response.data) {
716
+ throw this.createError(response.error?.code || 'UPDATE_FAILED', response.error?.message || 'Failed to update profile');
717
+ }
718
+
719
+ return response.data;
720
+ }
721
+
722
+ /**
723
+ * Get access token (auto-refresh if needed)
724
+ * 获取访问令牌(如需要则自动刷新)
725
+ */
726
+ async getAccessToken(): Promise<string | null> {
727
+ const token = this.storage.getAccessToken();
728
+
729
+ if (!token) {
730
+ return null;
731
+ }
732
+
733
+ // Check if token is expired (simple check by trying to parse JWT)
734
+ try {
735
+ const payload = JSON.parse(atob(token.split('.')[1]));
736
+ const exp = payload.exp * 1000;
737
+
738
+ // If token expires in less than 5 minutes, refresh it
739
+ if (Date.now() > exp - 5 * 60 * 1000) {
740
+ await this.refreshTokens();
741
+ return this.storage.getAccessToken();
742
+ }
743
+ } catch {
744
+ // If parsing fails, try to use the token anyway
745
+ }
746
+
747
+ return token;
748
+ }
749
+
750
+ /**
751
+ * Check if user is authenticated
752
+ * 检查用户是否已认证
753
+ */
754
+ isAuthenticated(): boolean {
755
+ return !!this.storage.getAccessToken();
756
+ }
757
+
758
+ /**
759
+ * Logout current session
760
+ * 登出当前会话
761
+ */
762
+ async logout(): Promise<void> {
763
+ const refreshToken = this.storage.getRefreshToken();
764
+
765
+ try {
766
+ await this.authenticatedRequest('/api/v1/auth/logout', {
767
+ method: 'POST',
768
+ body: JSON.stringify({ refresh_token: refreshToken }),
769
+ });
770
+ } finally {
771
+ this.storage.clear();
772
+ this.notifyAuthStateChange(null);
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Logout from all devices
778
+ * 从所有设备登出
779
+ */
780
+ async logoutAll(): Promise<void> {
781
+ try {
782
+ await this.authenticatedRequest('/api/v1/auth/logout-all', {
783
+ method: 'POST',
784
+ });
785
+ } finally {
786
+ this.storage.clear();
787
+ this.notifyAuthStateChange(null);
788
+ }
789
+ }
790
+
791
+ // ============================================
792
+ // OAuth2 Client Methods (for integrating with other OAuth providers using UniAuth)
793
+ // OAuth2 客户端方法
794
+ // ============================================
795
+
796
+ /**
797
+ * Start OAuth2 authorization flow
798
+ * 开始 OAuth2 授权流程
799
+ */
800
+ async startOAuth2Flow(options: OAuth2AuthorizeOptions): Promise<string> {
801
+ if (!this.config.clientId) {
802
+ throw this.createError('CONFIG_ERROR', 'clientId is required for OAuth2 flow');
803
+ }
804
+
805
+ const params = new URLSearchParams({
806
+ client_id: this.config.clientId,
807
+ redirect_uri: options.redirectUri,
808
+ response_type: 'code',
809
+ });
810
+
811
+ if (options.scope) {
812
+ params.set('scope', options.scope);
813
+ }
814
+
815
+ if (options.state) {
816
+ params.set('state', options.state);
817
+ }
818
+
819
+ // PKCE support
820
+ if (options.usePKCE) {
821
+ const verifier = generateCodeVerifier();
822
+ const challenge = await generateCodeChallenge(verifier);
823
+
824
+ storeCodeVerifier(verifier);
825
+ params.set('code_challenge', challenge);
826
+ params.set('code_challenge_method', 'S256');
827
+ }
828
+
829
+ return `${this.config.baseUrl}/api/v1/oauth2/authorize?${params.toString()}`;
830
+ }
831
+
832
+ /**
833
+ * Exchange authorization code for tokens (OAuth2 client flow)
834
+ * 使用授权码换取令牌
835
+ */
836
+ async exchangeOAuth2Code(
837
+ code: string,
838
+ redirectUri: string,
839
+ clientSecret?: string
840
+ ): Promise<OAuth2TokenResult> {
841
+ if (!this.config.clientId) {
842
+ throw this.createError('CONFIG_ERROR', 'clientId is required for OAuth2 flow');
843
+ }
844
+
845
+ const body: Record<string, string> = {
846
+ grant_type: 'authorization_code',
847
+ client_id: this.config.clientId,
848
+ code,
849
+ redirect_uri: redirectUri,
850
+ };
851
+
852
+ if (clientSecret) {
853
+ body.client_secret = clientSecret;
854
+ }
855
+
856
+ // Check for PKCE code_verifier
857
+ const codeVerifier = getAndClearCodeVerifier();
858
+ if (codeVerifier) {
859
+ body.code_verifier = codeVerifier;
860
+ }
861
+
862
+ const response = await fetchWithRetry(`${this.config.baseUrl}/api/v1/oauth2/token`, {
863
+ method: 'POST',
864
+ headers: {
865
+ 'Content-Type': 'application/json',
866
+ },
867
+ body: JSON.stringify(body),
868
+ maxRetries: this.config.enableRetry ? 3 : 0,
869
+ timeout: this.config.timeout,
870
+ });
871
+
872
+ const data = await response.json();
873
+
874
+ if (data.error) {
875
+ throw this.createError(data.error, data.error_description || 'Token exchange failed');
876
+ }
877
+
878
+ return data as OAuth2TokenResult;
879
+ }
880
+
881
+ // ============================================
882
+ // SSO Methods (Cross-Domain Single Sign-On)
883
+ // SSO 方法(跨域单点登录)
884
+ // ============================================
885
+
886
+ private ssoConfig: SSOConfig | null = null;
887
+
888
+ /**
889
+ * Configure SSO settings
890
+ * 配置 SSO 设置
891
+ *
892
+ * @example
893
+ * ```typescript
894
+ * auth.configureSso({
895
+ * ssoUrl: 'https://sso.55387.xyz',
896
+ * clientId: 'my-app',
897
+ * redirectUri: 'https://my-app.com/auth/callback',
898
+ * });
899
+ * ```
900
+ */
901
+ configureSso(config: SSOConfig): void {
902
+ this.ssoConfig = {
903
+ scope: 'openid profile email',
904
+ ...config,
905
+ };
906
+ }
907
+
908
+ /**
909
+ * Start SSO login flow
910
+ * 开始 SSO 登录流程
911
+ *
912
+ * This will redirect the user to the SSO service.
913
+ * If the user already has an SSO session, they'll be automatically logged in (silent auth).
914
+ *
915
+ * @example
916
+ * ```typescript
917
+ * // Simple usage - redirects to SSO
918
+ * auth.loginWithSSO();
919
+ *
920
+ * // With options
921
+ * auth.loginWithSSO({ usePKCE: true });
922
+ * ```
923
+ */
924
+ loginWithSSO(options: SSOLoginOptions = {}): void {
925
+ if (!this.ssoConfig) {
926
+ throw this.createError('SSO_NOT_CONFIGURED', 'SSO is not configured. Call configureSso() first.');
927
+ }
928
+
929
+ const { usePKCE = true, state } = options;
930
+
931
+ // Generate state for CSRF protection if not provided
932
+ const stateValue = state || this.generateRandomState();
933
+ this.storeState(stateValue);
934
+
935
+ // Build authorize URL
936
+ const params = new URLSearchParams({
937
+ client_id: this.ssoConfig.clientId,
938
+ redirect_uri: this.ssoConfig.redirectUri,
939
+ response_type: 'code',
940
+ scope: this.ssoConfig.scope || 'openid profile email',
941
+ state: stateValue,
942
+ });
943
+
944
+ // PKCE support
945
+ if (usePKCE) {
946
+ const verifier = generateCodeVerifier();
947
+ storeCodeVerifier(verifier);
948
+ generateCodeChallenge(verifier).then(challenge => {
949
+ params.set('code_challenge', challenge);
950
+ params.set('code_challenge_method', 'S256');
951
+
952
+ // Redirect to SSO
953
+ window.location.href = `${this.ssoConfig!.ssoUrl}/api/v1/oauth2/authorize?${params.toString()}`;
954
+ });
955
+ } else {
956
+ // Redirect to SSO without PKCE
957
+ window.location.href = `${this.ssoConfig.ssoUrl}/api/v1/oauth2/authorize?${params.toString()}`;
958
+ }
959
+ }
960
+
961
+ /**
962
+ * Check if current URL is an SSO callback
963
+ * 检查当前 URL 是否是 SSO 回调
964
+ *
965
+ * @example
966
+ * ```typescript
967
+ * if (auth.isSSOCallback()) {
968
+ * await auth.handleSSOCallback();
969
+ * }
970
+ * ```
971
+ */
972
+ isSSOCallback(): boolean {
973
+ if (typeof window === 'undefined') return false;
974
+ const params = new URLSearchParams(window.location.search);
975
+ return !!(params.get('code') && params.get('state'));
976
+ }
977
+
978
+ /**
979
+ * Handle SSO callback and exchange code for tokens
980
+ * 处理 SSO 回调并交换授权码获取令牌
981
+ *
982
+ * Call this on your callback page after SSO redirects back.
983
+ *
984
+ * @returns LoginResult or null if callback handling failed
985
+ *
986
+ * @example
987
+ * ```typescript
988
+ * // In your callback page component
989
+ * useEffect(() => {
990
+ * if (auth.isSSOCallback()) {
991
+ * auth.handleSSOCallback()
992
+ * .then(result => {
993
+ * if (result) {
994
+ * navigate('/dashboard');
995
+ * }
996
+ * })
997
+ * .catch(err => console.error('SSO login failed:', err));
998
+ * }
999
+ * }, []);
1000
+ * ```
1001
+ */
1002
+ async handleSSOCallback(): Promise<LoginResult | null> {
1003
+ if (!this.ssoConfig) {
1004
+ throw this.createError('SSO_NOT_CONFIGURED', 'SSO is not configured. Call configureSso() first.');
1005
+ }
1006
+
1007
+ if (typeof window === 'undefined') {
1008
+ return null;
1009
+ }
1010
+
1011
+ const params = new URLSearchParams(window.location.search);
1012
+ const code = params.get('code');
1013
+ const state = params.get('state');
1014
+ const error = params.get('error');
1015
+ const errorDescription = params.get('error_description');
1016
+
1017
+ // Handle OAuth error
1018
+ if (error) {
1019
+ throw this.createError(error, errorDescription || 'SSO login failed');
1020
+ }
1021
+
1022
+ // Validate state
1023
+ const savedState = this.getAndClearState();
1024
+ if (state && savedState && state !== savedState) {
1025
+ throw this.createError('INVALID_STATE', 'Invalid state parameter. Please try logging in again.');
1026
+ }
1027
+
1028
+ if (!code) {
1029
+ throw this.createError('NO_CODE', 'No authorization code received.');
1030
+ }
1031
+
1032
+ // Exchange code for tokens
1033
+ const tokenResult = await this.exchangeSSOCode(code, this.ssoConfig.redirectUri);
1034
+
1035
+ // Store tokens
1036
+ this.storage.setAccessToken(tokenResult.access_token);
1037
+ if (tokenResult.refresh_token) {
1038
+ this.storage.setRefreshToken(tokenResult.refresh_token);
1039
+ }
1040
+
1041
+ // Get user info
1042
+ const user = await this.getCurrentUser();
1043
+
1044
+ // Clean up URL (remove code and state from URL)
1045
+ if (typeof window !== 'undefined' && window.history) {
1046
+ const cleanUrl = window.location.pathname;
1047
+ window.history.replaceState({}, document.title, cleanUrl);
1048
+ }
1049
+
1050
+ return {
1051
+ user: user || { id: '', phone: null, email: null, nickname: null, avatar_url: null },
1052
+ access_token: tokenResult.access_token,
1053
+ refresh_token: tokenResult.refresh_token || '',
1054
+ expires_in: tokenResult.expires_in,
1055
+ is_new_user: false,
1056
+ };
1057
+ }
1058
+
1059
+ /**
1060
+ * Check if user can be silently authenticated via SSO
1061
+ * 检查用户是否可以通过 SSO 静默登录
1062
+ *
1063
+ * This starts a silent SSO flow using an iframe to check if user has an active SSO session.
1064
+ *
1065
+ * @returns Promise that resolves to true if silent auth succeeded
1066
+ */
1067
+ async checkSSOSession(): Promise<boolean> {
1068
+ if (!this.ssoConfig) {
1069
+ return false;
1070
+ }
1071
+
1072
+ // If already authenticated, no need to check
1073
+ if (this.isAuthenticated()) {
1074
+ return true;
1075
+ }
1076
+
1077
+ // For now, we can't do true silent auth without iframe/popup
1078
+ // The simplest approach is to redirect and let SSO handle it
1079
+ return false;
1080
+ }
1081
+
1082
+ // Helper methods for SSO
1083
+ private generateRandomState(): string {
1084
+ return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
1085
+ }
1086
+
1087
+ private storeState(state: string): void {
1088
+ if (typeof localStorage !== 'undefined') {
1089
+ localStorage.setItem('uniauth_sso_state', state);
1090
+ }
1091
+ }
1092
+
1093
+ private getAndClearState(): string | null {
1094
+ if (typeof localStorage === 'undefined') return null;
1095
+ const state = localStorage.getItem('uniauth_sso_state');
1096
+ localStorage.removeItem('uniauth_sso_state');
1097
+ return state;
1098
+ }
1099
+
1100
+ /**
1101
+ * Exchange SSO authorization code for tokens
1102
+ * This is a private method used internally by handleSSOCallback
1103
+ */
1104
+ private async exchangeSSOCode(
1105
+ code: string,
1106
+ redirectUri: string
1107
+ ): Promise<OAuth2TokenResult> {
1108
+ const baseUrl = this.ssoConfig?.ssoUrl || this.config.baseUrl;
1109
+ const clientId = this.ssoConfig?.clientId || this.config.clientId;
1110
+
1111
+ if (!clientId) {
1112
+ throw this.createError('CONFIG_ERROR', 'clientId is required for OAuth2 flow');
1113
+ }
1114
+
1115
+ const body: Record<string, string> = {
1116
+ grant_type: 'authorization_code',
1117
+ client_id: clientId,
1118
+ code,
1119
+ redirect_uri: redirectUri,
1120
+ };
1121
+
1122
+ // Check for PKCE code_verifier
1123
+ const codeVerifier = getAndClearCodeVerifier();
1124
+ if (codeVerifier) {
1125
+ body.code_verifier = codeVerifier;
1126
+ }
1127
+
1128
+ const response = await fetchWithRetry(`${baseUrl}/api/v1/oauth2/token`, {
1129
+ method: 'POST',
1130
+ headers: {
1131
+ 'Content-Type': 'application/json',
1132
+ },
1133
+ body: JSON.stringify(body),
1134
+ maxRetries: this.config.enableRetry ? 3 : 0,
1135
+ timeout: this.config.timeout,
1136
+ });
1137
+
1138
+ const data = await response.json();
1139
+
1140
+ if (data.error) {
1141
+ throw this.createError(data.error, data.error_description || 'Token exchange failed');
1142
+ }
1143
+
1144
+ return data as OAuth2TokenResult;
1145
+ }
1146
+
1147
+ // ============================================
1148
+ // Private Methods
1149
+ // ============================================
1150
+
1151
+ /**
1152
+ * Refresh tokens
1153
+ * 刷新令牌
1154
+ */
1155
+ private async refreshTokens(): Promise<boolean> {
1156
+ // Prevent multiple simultaneous refresh requests
1157
+ if (this.refreshPromise) {
1158
+ return this.refreshPromise;
1159
+ }
1160
+
1161
+ this.refreshPromise = this.doRefreshTokens();
1162
+
1163
+ try {
1164
+ return await this.refreshPromise;
1165
+ } finally {
1166
+ this.refreshPromise = null;
1167
+ }
1168
+ }
1169
+
1170
+ private async doRefreshTokens(): Promise<boolean> {
1171
+ const refreshToken = this.storage.getRefreshToken();
1172
+
1173
+ if (!refreshToken) {
1174
+ return false;
1175
+ }
1176
+
1177
+ try {
1178
+ const response = await this.request<TokenPair>('/api/v1/auth/refresh', {
1179
+ method: 'POST',
1180
+ body: JSON.stringify({ refresh_token: refreshToken }),
1181
+ });
1182
+
1183
+ if (!response.success || !response.data) {
1184
+ this.storage.clear();
1185
+ this.config.onAuthError?.({
1186
+ code: 'REFRESH_FAILED',
1187
+ message: response.error?.message || 'Failed to refresh token',
1188
+ });
1189
+ return false;
1190
+ }
1191
+
1192
+ this.storage.setAccessToken(response.data.access_token);
1193
+ this.storage.setRefreshToken(response.data.refresh_token);
1194
+
1195
+ this.config.onTokenRefresh?.(response.data);
1196
+
1197
+ return true;
1198
+ } catch (error) {
1199
+ this.storage.clear();
1200
+ this.config.onAuthError?.({
1201
+ code: 'REFRESH_ERROR',
1202
+ message: error instanceof Error ? error.message : 'Unknown error',
1203
+ });
1204
+ return false;
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * Make an authenticated request
1210
+ * 发起已认证的请求
1211
+ */
1212
+ private async authenticatedRequest<T>(
1213
+ path: string,
1214
+ options: RequestInit = {}
1215
+ ): Promise<ApiResponse<T>> {
1216
+ const token = await this.getAccessToken();
1217
+
1218
+ if (!token) {
1219
+ throw this.createError('NOT_AUTHENTICATED', 'Not authenticated');
1220
+ }
1221
+
1222
+ return this.request<T>(path, {
1223
+ ...options,
1224
+ headers: {
1225
+ ...options.headers,
1226
+ Authorization: `Bearer ${token}`,
1227
+ },
1228
+ });
1229
+ }
1230
+
1231
+ /**
1232
+ * Make a request to the API with retry support
1233
+ * 向 API 发起请求(支持重试)
1234
+ */
1235
+ private async request<T>(
1236
+ path: string,
1237
+ options: RequestInit = {}
1238
+ ): Promise<ApiResponse<T>> {
1239
+ const url = `${this.config.baseUrl}${path}`;
1240
+
1241
+ const fetchOptions: FetchWithRetryOptions = {
1242
+ ...options,
1243
+ headers: {
1244
+ 'Content-Type': 'application/json',
1245
+ ...(this.config.appKey && { 'X-App-Key': this.config.appKey }),
1246
+ ...options.headers,
1247
+ },
1248
+ maxRetries: this.config.enableRetry ? 3 : 0,
1249
+ timeout: this.config.timeout,
1250
+ };
1251
+
1252
+ const response = await fetchWithRetry(url, fetchOptions);
1253
+ const data = await response.json();
1254
+
1255
+ return data as ApiResponse<T>;
1256
+ }
1257
+
1258
+ /**
1259
+ * Create an error object
1260
+ * 创建错误对象
1261
+ */
1262
+ private createError(code: string, message: string, statusCode?: number): UniAuthError {
1263
+ return new UniAuthError(code, message, statusCode);
1264
+ }
1265
+ }
1266
+
1267
+ // Re-export HTTP utilities for advanced usage
1268
+ export {
1269
+ fetchWithRetry,
1270
+ generateCodeVerifier,
1271
+ generateCodeChallenge,
1272
+ storeCodeVerifier,
1273
+ getAndClearCodeVerifier,
1274
+ } from './http.js';
1275
+
1276
+ // Default export
1277
+ export default UniAuthClient;
1278
+