@claudiocc2/oauth2-client 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index.d.ts +177 -0
  2. package/index.js +845 -0
  3. package/index.mjs +845 -0
  4. package/package.json +10 -3
package/index.js ADDED
@@ -0,0 +1,845 @@
1
+ // =====================================================
2
+ // OAuth2 PKCE Client - COMPLETE EDITION
3
+ // Session Flow + PKCE + Zanzibar + Webhooks + Introspection
4
+ // =====================================================
5
+
6
+ /**
7
+ * Cliente OAuth2 completo con flujo de sesión
8
+ * @class OAuth2Client
9
+ */
10
+ class OAuth2Client {
11
+ /**
12
+ * @param {Object} config - Configuración
13
+ * @param {string} config.clientId - Client ID
14
+ * @param {string} config.authServerUrl - URL del servidor OAuth (Go)
15
+ * @param {string} config.backendUrl - URL de tu backend (que intercambia tokens)
16
+ * @param {string} config.loginPageUrl - URL del frontend de login (Go)
17
+ * @param {string} config.redirectUri - URI de callback
18
+ * @param {string[]} config.scopes - Scopes
19
+ * @param {boolean} config.usePKCE - Usar PKCE (default: true)
20
+ */
21
+ constructor(config) {
22
+ this.clientId = config.clientId;
23
+ this.authServerUrl = config.authServerUrl.replace(/\/$/, '');
24
+ this.backendUrl = config.backendUrl?.replace(/\/$/, '') || '';
25
+ this.loginPageUrl = config.loginPageUrl || `${this.authServerUrl}/login`;
26
+ this.redirectUri = config.redirectUri;
27
+ this.scopes = config.scopes || ['read', 'write'];
28
+ this.usePKCE = config.usePKCE !== false;
29
+
30
+ this.STORAGE_PREFIX = 'oauth2_';
31
+ this.CODE_VERIFIER_KEY = this.STORAGE_PREFIX + 'code_verifier';
32
+ this.STATE_KEY = this.STORAGE_PREFIX + 'state';
33
+ this.ACCESS_TOKEN_KEY = this.STORAGE_PREFIX + 'access_token';
34
+ this.REFRESH_TOKEN_KEY = this.STORAGE_PREFIX + 'refresh_token';
35
+ this.EXPIRES_AT_KEY = this.STORAGE_PREFIX + 'expires_at';
36
+ }
37
+
38
+ // =====================================================
39
+ // SESSION CHECK (con cookies del servidor Go)
40
+ // =====================================================
41
+
42
+ /**
43
+ * Verifica si hay sesión en el servidor Go
44
+ * @returns {Promise<Object>} { authenticated: boolean, user?: Object }
45
+ */
46
+ async checkSession() {
47
+ try {
48
+ const response = await fetch(`${this.authServerUrl}/oauth/session`, {
49
+ method: 'GET',
50
+ credentials: 'include', // Envía cookies
51
+ headers: {
52
+ 'Accept': 'application/json'
53
+ }
54
+ });
55
+
56
+ if (!response.ok) {
57
+ return { authenticated: false };
58
+ }
59
+
60
+ return await response.json();
61
+ } catch (err) {
62
+ console.error('Session check failed:', err);
63
+ return { authenticated: false };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Verifica sesión y redirige a login si no está autenticado
69
+ * @param {string} redirectAfterLogin - URL a donde volver después del login
70
+ * @returns {Promise<Object|null>} Session o null si redirige
71
+ */
72
+ async ensureAuthenticated(redirectAfterLogin) {
73
+ const session = await this.checkSession();
74
+
75
+ if (!session.authenticated) {
76
+ const redirect = redirectAfterLogin || window.location.href;
77
+ const loginUrl = `${this.loginPageUrl}?redirect=${encodeURIComponent(redirect)}`;
78
+ window.location.href = loginUrl;
79
+ return null;
80
+ }
81
+
82
+ return session;
83
+ }
84
+
85
+ // =====================================================
86
+ // PKCE UTILITIES
87
+ // =====================================================
88
+
89
+ _generateCodeVerifier() {
90
+ const array = new Uint8Array(32);
91
+ crypto.getRandomValues(array);
92
+ return this._base64URLEncode(array);
93
+ }
94
+
95
+ async _generateCodeChallenge(verifier) {
96
+ const encoder = new TextEncoder();
97
+ const data = encoder.encode(verifier);
98
+ const hash = await crypto.subtle.digest('SHA-256', data);
99
+ return this._base64URLEncode(new Uint8Array(hash));
100
+ }
101
+
102
+ _base64URLEncode(buffer) {
103
+ const base64 = btoa(String.fromCharCode(...buffer));
104
+ return base64
105
+ .replace(/\+/g, '-')
106
+ .replace(/\//g, '_')
107
+ .replace(/=/g, '');
108
+ }
109
+
110
+ _generateState() {
111
+ const array = new Uint8Array(16);
112
+ crypto.getRandomValues(array);
113
+ return this._base64URLEncode(array);
114
+ }
115
+
116
+ // =====================================================
117
+ // AUTHORIZATION FLOW (con sesión)
118
+ // =====================================================
119
+
120
+ /**
121
+ * Solicita código de autorización al servidor Go (POST con sesión)
122
+ * @returns {Promise<string>} Código de autorización
123
+ */
124
+ async requestAuthorizationCode() {
125
+ const state = this._generateState();
126
+ localStorage.setItem(this.STATE_KEY, state);
127
+
128
+ let body = {
129
+ client_id: this.clientId,
130
+ redirect_uri: this.redirectUri,
131
+ scope: this.scopes.join(' '),
132
+ state: state
133
+ };
134
+
135
+ // PKCE
136
+ if (this.usePKCE) {
137
+ const codeVerifier = this._generateCodeVerifier();
138
+ const codeChallenge = await this._generateCodeChallenge(codeVerifier);
139
+ localStorage.setItem(this.CODE_VERIFIER_KEY, codeVerifier);
140
+
141
+ body.code_challenge = codeChallenge;
142
+ body.code_challenge_method = 'S256';
143
+ }
144
+
145
+ // Solicitar código (con cookies de sesión)
146
+ const response = await fetch(`${this.authServerUrl}/oauth/authorize`, {
147
+ method: 'POST',
148
+ credentials: 'include', // Envía cookies de sesión
149
+ headers: {
150
+ 'Content-Type': 'application/json',
151
+ 'Accept': 'application/json'
152
+ },
153
+ body: JSON.stringify(body)
154
+ });
155
+
156
+ if (!response.ok) {
157
+ const error = await response.json();
158
+ throw new Error(`Authorization failed: ${error.error || 'Unknown error'}`);
159
+ }
160
+
161
+ const result = await response.json();
162
+ return result.code;
163
+ }
164
+
165
+ /**
166
+ * Flujo completo: Verifica sesión → Solicita código → Intercambia con backend
167
+ * @returns {Promise<Object>} Tokens
168
+ */
169
+ async authenticate() {
170
+ // 1. Verificar sesión en servidor Go
171
+ const session = await this.ensureAuthenticated();
172
+ if (!session) return null; // Redirigió a login
173
+
174
+ // 2. Solicitar código de autorización
175
+ const code = await this.requestAuthorizationCode();
176
+
177
+ // 3. Enviar código a tu backend para intercambio
178
+ const tokens = await this.exchangeCodeWithBackend(code);
179
+
180
+ return tokens;
181
+ }
182
+
183
+ /**
184
+ * Intercambia código por tokens usando TU backend
185
+ * @param {string} code - Código de autorización
186
+ * @returns {Promise<Object>} Tokens
187
+ */
188
+ async exchangeCodeWithBackend(code) {
189
+ const codeVerifier = localStorage.getItem(this.CODE_VERIFIER_KEY);
190
+ const state = localStorage.getItem(this.STATE_KEY);
191
+
192
+ const response = await fetch(`${this.backendUrl}/api/oauth/exchange`, {
193
+ method: 'POST',
194
+ credentials: 'include', // Para que tu backend cree cookies
195
+ headers: {
196
+ 'Content-Type': 'application/json'
197
+ },
198
+ body: JSON.stringify({
199
+ code: code,
200
+ code_verifier: codeVerifier,
201
+ state: state
202
+ })
203
+ });
204
+
205
+ if (!response.ok) {
206
+ throw new Error('Token exchange failed');
207
+ }
208
+
209
+ const tokens = await response.json();
210
+
211
+ // Guardar en localStorage (backup)
212
+ this._saveTokens(tokens);
213
+
214
+ // Limpiar PKCE
215
+ localStorage.removeItem(this.CODE_VERIFIER_KEY);
216
+ localStorage.removeItem(this.STATE_KEY);
217
+
218
+ return tokens;
219
+ }
220
+
221
+ // =====================================================
222
+ // TOKEN MANAGEMENT (con introspection)
223
+ // =====================================================
224
+
225
+ _saveTokens(tokens) {
226
+ if (tokens.access_token) {
227
+ localStorage.setItem(this.ACCESS_TOKEN_KEY, tokens.access_token);
228
+ }
229
+
230
+ if (tokens.refresh_token) {
231
+ localStorage.setItem(this.REFRESH_TOKEN_KEY, tokens.refresh_token);
232
+ }
233
+
234
+ if (tokens.expires_in) {
235
+ const expiresAt = Date.now() + (tokens.expires_in * 1000);
236
+ localStorage.setItem(this.EXPIRES_AT_KEY, expiresAt.toString());
237
+ }
238
+ }
239
+
240
+ getAccessToken() {
241
+ return localStorage.getItem(this.ACCESS_TOKEN_KEY);
242
+ }
243
+
244
+ getRefreshToken() {
245
+ return localStorage.getItem(this.REFRESH_TOKEN_KEY);
246
+ }
247
+
248
+ isTokenExpired() {
249
+ const expiresAt = localStorage.getItem(this.EXPIRES_AT_KEY);
250
+ if (!expiresAt) return true;
251
+ return Date.now() >= (parseInt(expiresAt) - 30000);
252
+ }
253
+
254
+ isAuthenticated() {
255
+ return !!this.getAccessToken() && !this.isTokenExpired();
256
+ }
257
+
258
+ /**
259
+ * Verifica token usando introspection (en el servidor Go)
260
+ * @param {string} token - Token a verificar
261
+ * @returns {Promise<Object>} { active: boolean, ... }
262
+ */
263
+ async introspectToken(token) {
264
+ const response = await fetch(`${this.authServerUrl}/oauth/introspect`, {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({
268
+ token: token,
269
+ client_id: this.clientId
270
+ })
271
+ });
272
+
273
+ if (!response.ok) {
274
+ throw new Error('Introspection failed');
275
+ }
276
+
277
+ return await response.json();
278
+ }
279
+
280
+ /**
281
+ * Verifica si un token es válido
282
+ * @param {string} token - Token
283
+ * @returns {Promise<boolean>}
284
+ */
285
+ async isTokenValid(token) {
286
+ try {
287
+ const result = await this.introspectToken(token);
288
+ return result.active || false;
289
+ } catch (err) {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Obtiene token válido (verifica con introspection si es necesario)
296
+ * @returns {Promise<string|null>}
297
+ */
298
+ async getValidToken() {
299
+ const token = this.getAccessToken();
300
+ if (!token) return null;
301
+
302
+ // Verificar con introspection
303
+ try {
304
+ const info = await this.introspectToken(token);
305
+ if (info.active) {
306
+ return token;
307
+ }
308
+ } catch (err) {
309
+ console.error('Token validation failed:', err);
310
+ }
311
+
312
+ // Token inválido, intentar refresh
313
+ return await this.refreshTokenWithBackend();
314
+ }
315
+
316
+ /**
317
+ * Refresca token usando tu backend
318
+ * @returns {Promise<string|null>}
319
+ */
320
+ async refreshTokenWithBackend() {
321
+ try {
322
+ const response = await fetch(`${this.backendUrl}/api/oauth/refresh`, {
323
+ method: 'POST',
324
+ credentials: 'include'
325
+ });
326
+
327
+ if (!response.ok) {
328
+ this.logout();
329
+ return null;
330
+ }
331
+
332
+ const tokens = await response.json();
333
+ this._saveTokens(tokens);
334
+
335
+ return tokens.access_token;
336
+ } catch (err) {
337
+ console.error('Token refresh failed:', err);
338
+ this.logout();
339
+ return null;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Refresca tokens (método tradicional, sin backend)
345
+ * @returns {Promise<Object>}
346
+ */
347
+ async refreshToken() {
348
+ const refreshToken = this.getRefreshToken();
349
+ if (!refreshToken) {
350
+ throw new Error('No refresh token available');
351
+ }
352
+
353
+ const response = await fetch(`${this.authServerUrl}/oauth/token`, {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({
357
+ grant_type: 'refresh_token',
358
+ client_id: this.clientId,
359
+ refresh_token: refreshToken
360
+ })
361
+ });
362
+
363
+ if (!response.ok) {
364
+ if (response.status === 401) {
365
+ this.logout();
366
+ }
367
+ throw new Error('Token refresh failed');
368
+ }
369
+
370
+ const tokens = await response.json();
371
+ this._saveTokens(tokens);
372
+ return tokens;
373
+ }
374
+
375
+ logout() {
376
+ localStorage.removeItem(this.ACCESS_TOKEN_KEY);
377
+ localStorage.removeItem(this.REFRESH_TOKEN_KEY);
378
+ localStorage.removeItem(this.EXPIRES_AT_KEY);
379
+ localStorage.removeItem(this.CODE_VERIFIER_KEY);
380
+ localStorage.removeItem(this.STATE_KEY);
381
+ }
382
+
383
+ // =====================================================
384
+ // HTTP CLIENT (auto-refresh con introspection)
385
+ // =====================================================
386
+
387
+ /**
388
+ * Fetch con auto-validación y refresh
389
+ * @param {string} url - URL
390
+ * @param {Object} options - Opciones fetch
391
+ * @returns {Promise<Response>}
392
+ */
393
+ async fetch(url, options = {}) {
394
+ const token = await this.getValidToken();
395
+
396
+ if (!token) {
397
+ throw new Error('No valid token available');
398
+ }
399
+
400
+ const headers = {
401
+ ...options.headers,
402
+ 'Authorization': `Bearer ${token}`
403
+ };
404
+
405
+ const response = await fetch(url, {
406
+ ...options,
407
+ headers,
408
+ credentials: 'include'
409
+ });
410
+
411
+ // Si falla autenticación, intentar refresh una vez
412
+ if (response.status === 401 && !options._retry) {
413
+ const newToken = await this.refreshTokenWithBackend();
414
+ if (newToken) {
415
+ return await this.fetch(url, { ...options, _retry: true });
416
+ }
417
+ }
418
+
419
+ return response;
420
+ }
421
+
422
+ // =====================================================
423
+ // ZANZIBAR (PERMISOS)
424
+ // =====================================================
425
+
426
+ /**
427
+ * Verifica un permiso
428
+ * @param {Object} check - Verificación de permiso
429
+ * @returns {Promise<boolean>}
430
+ */
431
+ async checkPermission(check) {
432
+ try {
433
+ const response = await this.fetch(`${this.authServerUrl}/authz/check`, {
434
+ method: 'POST',
435
+ headers: { 'Content-Type': 'application/json' },
436
+ body: JSON.stringify(check)
437
+ });
438
+
439
+ if (!response.ok) return false;
440
+
441
+ const result = await response.json();
442
+ return result.allowed || false;
443
+ } catch (err) {
444
+ console.error('Permission check failed:', err);
445
+ return false;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Verifica múltiples permisos
451
+ * @param {Array} checks - Array de verificaciones
452
+ * @returns {Promise<Array>}
453
+ */
454
+ async checkPermissions(checks) {
455
+ const response = await this.fetch(`${this.authServerUrl}/authz/check-batch`, {
456
+ method: 'POST',
457
+ headers: { 'Content-Type': 'application/json' },
458
+ body: JSON.stringify({ checks })
459
+ });
460
+
461
+ if (!response.ok) {
462
+ throw new Error('Batch check failed');
463
+ }
464
+
465
+ const result = await response.json();
466
+ return result.results || [];
467
+ }
468
+
469
+ /**
470
+ * Otorga un permiso
471
+ * @param {Object} relation - Relación de permiso
472
+ * @returns {Promise<boolean>}
473
+ */
474
+ async grantPermission(relation) {
475
+ try {
476
+ const response = await this.fetch(`${this.authServerUrl}/authz/write`, {
477
+ method: 'POST',
478
+ headers: { 'Content-Type': 'application/json' },
479
+ body: JSON.stringify(relation)
480
+ });
481
+
482
+ return response.ok;
483
+ } catch (err) {
484
+ console.error('Grant permission failed:', err);
485
+ return false;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Revoca un permiso
491
+ * @param {Object} relation - Relación de permiso
492
+ * @returns {Promise<boolean>}
493
+ */
494
+ async revokePermission(relation) {
495
+ try {
496
+ const response = await this.fetch(`${this.authServerUrl}/authz/delete`, {
497
+ method: 'POST',
498
+ headers: { 'Content-Type': 'application/json' },
499
+ body: JSON.stringify(relation)
500
+ });
501
+
502
+ return response.ok;
503
+ } catch (err) {
504
+ console.error('Revoke permission failed:', err);
505
+ return false;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Lista permisos de un usuario
511
+ * @param {string} namespace - Namespace
512
+ * @param {string} subjectId - Subject ID
513
+ * @returns {Promise<Array>}
514
+ */
515
+ async listPermissions(namespace, subjectId) {
516
+ const query = new URLSearchParams({ namespace, subject_id: subjectId });
517
+
518
+ const response = await this.fetch(`${this.authServerUrl}/authz/list?${query}`, {
519
+ method: 'GET'
520
+ });
521
+
522
+ if (!response.ok) {
523
+ throw new Error('List permissions failed');
524
+ }
525
+
526
+ const result = await response.json();
527
+ return result.permissions || [];
528
+ }
529
+
530
+ // =====================================================
531
+ // WEBHOOKS
532
+ // =====================================================
533
+
534
+ /**
535
+ * Crea un webhook
536
+ * @param {Object} webhook - Configuración del webhook
537
+ * @returns {Promise<Object>}
538
+ */
539
+ async createWebhook(webhook) {
540
+ const response = await this.fetch(`${this.authServerUrl}/api/webhooks`, {
541
+ method: 'POST',
542
+ headers: { 'Content-Type': 'application/json' },
543
+ body: JSON.stringify(webhook)
544
+ });
545
+
546
+ if (!response.ok) {
547
+ throw new Error('Create webhook failed');
548
+ }
549
+
550
+ return await response.json();
551
+ }
552
+
553
+ /**
554
+ * Lista webhooks
555
+ * @param {number} limit - Límite
556
+ * @param {number} offset - Offset
557
+ * @returns {Promise<Object>}
558
+ */
559
+ async listWebhooks(limit = 20, offset = 0) {
560
+ const query = new URLSearchParams({ limit, offset });
561
+
562
+ const response = await this.fetch(`${this.authServerUrl}/api/webhooks?${query}`, {
563
+ method: 'GET'
564
+ });
565
+
566
+ if (!response.ok) {
567
+ throw new Error('List webhooks failed');
568
+ }
569
+
570
+ return await response.json();
571
+ }
572
+
573
+ /**
574
+ * Obtiene un webhook
575
+ * @param {string} webhookId - Webhook ID
576
+ * @returns {Promise<Object>}
577
+ */
578
+ async getWebhook(webhookId) {
579
+ const response = await this.fetch(`${this.authServerUrl}/api/webhooks/${webhookId}`, {
580
+ method: 'GET'
581
+ });
582
+
583
+ if (!response.ok) {
584
+ throw new Error('Get webhook failed');
585
+ }
586
+
587
+ return await response.json();
588
+ }
589
+
590
+ /**
591
+ * Actualiza un webhook
592
+ * @param {string} webhookId - Webhook ID
593
+ * @param {Object} data - Datos a actualizar
594
+ * @returns {Promise<Object>}
595
+ */
596
+ async updateWebhook(webhookId, data) {
597
+ const response = await this.fetch(`${this.authServerUrl}/api/webhooks/${webhookId}`, {
598
+ method: 'PUT',
599
+ headers: { 'Content-Type': 'application/json' },
600
+ body: JSON.stringify(data)
601
+ });
602
+
603
+ if (!response.ok) {
604
+ throw new Error('Update webhook failed');
605
+ }
606
+
607
+ return await response.json();
608
+ }
609
+
610
+ /**
611
+ * Elimina un webhook
612
+ * @param {string} webhookId - Webhook ID
613
+ * @returns {Promise<boolean>}
614
+ */
615
+ async deleteWebhook(webhookId) {
616
+ try {
617
+ const response = await this.fetch(`${this.authServerUrl}/api/webhooks/${webhookId}`, {
618
+ method: 'DELETE'
619
+ });
620
+
621
+ return response.ok;
622
+ } catch (err) {
623
+ console.error('Delete webhook failed:', err);
624
+ return false;
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Lista entregas de un webhook
630
+ * @param {string} webhookId - Webhook ID
631
+ * @param {number} limit - Límite
632
+ * @param {number} offset - Offset
633
+ * @returns {Promise<Object>}
634
+ */
635
+ async listWebhookDeliveries(webhookId, limit = 50, offset = 0) {
636
+ const query = new URLSearchParams({ limit, offset });
637
+
638
+ const response = await this.fetch(`${this.authServerUrl}/api/webhooks/${webhookId}/deliveries?${query}`, {
639
+ method: 'GET'
640
+ });
641
+
642
+ if (!response.ok) {
643
+ throw new Error('List deliveries failed');
644
+ }
645
+
646
+ return await response.json();
647
+ }
648
+
649
+ /**
650
+ * Prueba un webhook
651
+ * @param {string} webhookId - Webhook ID
652
+ * @returns {Promise<boolean>}
653
+ */
654
+ async testWebhook(webhookId) {
655
+ try {
656
+ const response = await this.fetch(`${this.authServerUrl}/api/webhooks/${webhookId}/test`, {
657
+ method: 'POST'
658
+ });
659
+
660
+ return response.ok;
661
+ } catch (err) {
662
+ console.error('Test webhook failed:', err);
663
+ return false;
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Verifica la firma de un webhook entrante
669
+ * @param {string} payload - Payload (JSON string)
670
+ * @param {string} signature - Firma recibida
671
+ * @param {string} secret - Secret del webhook
672
+ * @returns {Promise<boolean>}
673
+ */
674
+ async verifyWebhookSignature(payload, signature, secret) {
675
+ const encoder = new TextEncoder();
676
+ const keyData = encoder.encode(secret);
677
+ const messageData = encoder.encode(payload);
678
+
679
+ const key = await crypto.subtle.importKey(
680
+ 'raw',
681
+ keyData,
682
+ { name: 'HMAC', hash: 'SHA-256' },
683
+ false,
684
+ ['sign']
685
+ );
686
+
687
+ const signatureBuffer = await crypto.subtle.sign('HMAC', key, messageData);
688
+ const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
689
+ .map(b => b.toString(16).padStart(2, '0'))
690
+ .join('');
691
+
692
+ return expectedSignature === signature;
693
+ }
694
+
695
+ /**
696
+ * Lista eventos disponibles
697
+ * @returns {Promise<Array>}
698
+ */
699
+ async listWebhookEvents() {
700
+ const response = await fetch(`${this.authServerUrl}/api/webhooks/events`, {
701
+ method: 'GET'
702
+ });
703
+
704
+ if (!response.ok) {
705
+ throw new Error('List events failed');
706
+ }
707
+
708
+ const result = await response.json();
709
+ return result.events || [];
710
+ }
711
+
712
+ // =====================================================
713
+ // PASSWORD MANAGEMENT
714
+ // =====================================================
715
+
716
+ /**
717
+ * Solicita reset de contraseña
718
+ * @param {string} email - Email del usuario
719
+ * @returns {Promise<boolean>}
720
+ */
721
+ async forgotPassword(email) {
722
+ try {
723
+ const response = await fetch(`${this.authServerUrl}/api/auth/forgot-password`, {
724
+ method: 'POST',
725
+ headers: { 'Content-Type': 'application/json' },
726
+ body: JSON.stringify({ email })
727
+ });
728
+
729
+ return response.ok;
730
+ } catch (err) {
731
+ console.error('Forgot password failed:', err);
732
+ return false;
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Resetea contraseña con token
738
+ * @param {string} token - Token de reset
739
+ * @param {string} newPassword - Nueva contraseña
740
+ * @returns {Promise<boolean>}
741
+ */
742
+ async resetPassword(token, newPassword) {
743
+ try {
744
+ const response = await fetch(`${this.authServerUrl}/api/auth/reset-password`, {
745
+ method: 'POST',
746
+ headers: { 'Content-Type': 'application/json' },
747
+ body: JSON.stringify({
748
+ token: token,
749
+ new_password: newPassword
750
+ })
751
+ });
752
+
753
+ return response.ok;
754
+ } catch (err) {
755
+ console.error('Reset password failed:', err);
756
+ return false;
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Cambia contraseña (autenticado)
762
+ * @param {string} currentPassword - Contraseña actual
763
+ * @param {string} newPassword - Nueva contraseña
764
+ * @returns {Promise<boolean>}
765
+ */
766
+ async changePassword(currentPassword, newPassword) {
767
+ try {
768
+ const response = await this.fetch(`${this.authServerUrl}/api/users/me/change-password`, {
769
+ method: 'POST',
770
+ headers: { 'Content-Type': 'application/json' },
771
+ body: JSON.stringify({
772
+ current_password: currentPassword,
773
+ new_password: newPassword
774
+ })
775
+ });
776
+
777
+ return response.ok;
778
+ } catch (err) {
779
+ console.error('Change password failed:', err);
780
+ return false;
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Cambia username
786
+ * @param {string} newUsername - Nuevo username
787
+ * @param {string} password - Contraseña para confirmar
788
+ * @returns {Promise<Object>}
789
+ */
790
+ async changeUsername(newUsername, password) {
791
+ const response = await this.fetch(`${this.authServerUrl}/api/users/me/username`, {
792
+ method: 'PUT',
793
+ headers: { 'Content-Type': 'application/json' },
794
+ body: JSON.stringify({
795
+ new_username: newUsername,
796
+ password: password
797
+ })
798
+ });
799
+
800
+ if (!response.ok) {
801
+ throw new Error('Change username failed');
802
+ }
803
+
804
+ return await response.json();
805
+ }
806
+ }
807
+
808
+ // =====================================================
809
+ // HELPER PARA INICIALIZACIÓN AUTOMÁTICA
810
+ // =====================================================
811
+
812
+ /**
813
+ * Inicializa automáticamente y verifica sesión
814
+ * @param {Object} config - Configuración
815
+ * @returns {Promise<OAuth2Client>}
816
+ */
817
+ OAuth2Client.autoInit = async function(config) {
818
+ const client = new OAuth2Client(config);
819
+
820
+ // Verificar si hay sesión
821
+ const session = await client.checkSession();
822
+
823
+ if (!session.authenticated) {
824
+ console.log('No session found, redirecting to login...');
825
+ await client.ensureAuthenticated();
826
+ return null;
827
+ }
828
+
829
+ console.log('Session active:', session.user);
830
+ return client;
831
+ };
832
+
833
+ // =====================================================
834
+ // EXPORT
835
+ // =====================================================
836
+
837
+ if (typeof module !== 'undefined' && module.exports) {
838
+ module.exports = OAuth2Client;
839
+ }
840
+
841
+ if (typeof window !== 'undefined') {
842
+ window.OAuth2Client = OAuth2Client;
843
+ }
844
+
845
+ export default OAuth2Client;