@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.
- package/index.d.ts +177 -0
- package/index.js +845 -0
- package/index.mjs +845 -0
- 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;
|