@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/README.md +178 -0
- package/dist/index.cjs +1020 -0
- package/dist/index.d.cts +500 -0
- package/dist/index.d.ts +500 -0
- package/dist/index.js +986 -0
- package/package.json +40 -0
- package/src/client.test.ts +476 -0
- package/src/http.ts +224 -0
- package/src/index.ts +1278 -0
- package/tsconfig.json +19 -0
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
|
+
|