@hemia/auth-sdk 0.0.1

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 ADDED
@@ -0,0 +1,86 @@
1
+ # Hemia Auth SDK
2
+
3
+ SDK para gestionar autenticación SSO con PKCE flow, manejo de sesiones y refresh tokens automático.
4
+
5
+ ---
6
+
7
+ ## 📦 Instalación
8
+
9
+ ```bash
10
+ npm install @hemia/auth-sdk
11
+ bun add @hemia/auth-sdk
12
+ ```
13
+
14
+ ```bash
15
+ bun add @hemia/auth-sdk
16
+ ```
17
+
18
+ ---
19
+
20
+ ## 🚀 Uso Básico
21
+
22
+ ### Configuración
23
+
24
+ ```typescript
25
+ import { AuthService, AuthCacheService } from '@hemia/auth-sdk';
26
+
27
+ const config = {
28
+ clientId: 'your-client-id',
29
+ clientSecret: 'your-client-secret',
30
+ redirectUri: 'http://localhost:3000/callback',
31
+ ssoBaseUrl: 'https://sso.hemia.com',
32
+ ssoAuthUrl: '/oauth/authorize',
33
+ ssoTokenEndpoint: '/oauth/token',
34
+ ssoLogoutEndpoint: '/oauth/logout',
35
+ uiBaseUrl: 'http://localhost:3000'
36
+ };
37
+
38
+ const storage = new AuthCacheService(redisClient);
39
+ const jwtManager = new JwtManager();
40
+ const authService = new AuthService(config, storage, jwtManager);
41
+ ```
42
+
43
+ ---
44
+
45
+ ## 🎯 Controller Abstracto
46
+
47
+ El SDK incluye un `AbstractAuthController` con endpoints listos:
48
+
49
+ - `GET /login` - Inicia el flujo de autenticación
50
+ - `GET /callback` - Procesa el callback del SSO
51
+ - `GET /me` - Obtiene datos del usuario autenticado
52
+ - `POST /logout` - Cierra la sesión
53
+
54
+ ```typescript
55
+ import { AbstractAuthController } from '@hemia/auth-sdk';
56
+
57
+ export class AuthController extends AbstractAuthController {
58
+ constructor(authService: AuthService) {
59
+ super(authService);
60
+ }
61
+ }
62
+ ```
63
+
64
+ ---
65
+
66
+ ## 🔒 Manejo de Errores
67
+
68
+ Todos los errores de sesión extienden `SessionError` e incluyen:
69
+ - `code`: Código del error
70
+ - `message`: Mensaje descriptivo
71
+ - `redirectTo`: URL de redirección sugerida
72
+
73
+ ---
74
+
75
+ ## 🛠️ Scripts Disponibles
76
+
77
+ | Script | Descripción |
78
+ |--------------|----------------------------------|
79
+ | `npm run build` | Compila el paquete con Rollup |
80
+ | `npm run test` | Ejecuta pruebas con Jest |
81
+ | `npm run clean` | Limpia la carpeta `dist/` |
82
+
83
+ ---
84
+
85
+ ## ✨ Generado con Hemia CLI
86
+
@@ -0,0 +1,496 @@
1
+ import 'reflect-metadata';
2
+ import { Get, Req, Res, Post, HttpError, BadRequestError, CustomHttpError, InternalServerError } from '@hemia/common';
3
+ import { HMNetworkServices } from '@hemia/network-services';
4
+ import { randomBytes, createHash } from 'crypto';
5
+ import { injectable } from 'inversify';
6
+ import { CacheService } from '@hemia/cache-manager';
7
+
8
+ /******************************************************************************
9
+ Copyright (c) Microsoft Corporation.
10
+
11
+ Permission to use, copy, modify, and/or distribute this software for any
12
+ purpose with or without fee is hereby granted.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
15
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
16
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
17
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
18
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
19
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
20
+ PERFORMANCE OF THIS SOFTWARE.
21
+ ***************************************************************************** */
22
+ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
23
+
24
+
25
+ function __decorate(decorators, target, key, desc) {
26
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
27
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
28
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
29
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
30
+ }
31
+
32
+ function __param(paramIndex, decorator) {
33
+ return function (target, key) { decorator(target, key, paramIndex); }
34
+ }
35
+
36
+ function __metadata(metadataKey, metadataValue) {
37
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(metadataKey, metadataValue);
38
+ }
39
+
40
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
41
+ var e = new Error(message);
42
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
43
+ };
44
+
45
+ class SessionError extends Error {
46
+ constructor(message, code, redirectTo) {
47
+ super(message);
48
+ this.code = code;
49
+ this.redirectTo = redirectTo;
50
+ this.name = this.constructor.name;
51
+ Error.captureStackTrace(this, this.constructor);
52
+ }
53
+ }
54
+ class SessionNotFoundError extends SessionError {
55
+ constructor(message = 'Sesión no encontrada') {
56
+ super(message, 'SESSION_NOT_FOUND', '/?session=required');
57
+ }
58
+ }
59
+ class SessionExpiredError extends SessionError {
60
+ constructor(message = 'Sesión expirada') {
61
+ super(message, 'SESSION_EXPIRED', '/session-expired?session=expired');
62
+ }
63
+ }
64
+ class SessionInvalidError extends SessionError {
65
+ constructor(message = 'Sesión inválida') {
66
+ super(message, 'SESSION_INVALID', '/?session=invalid');
67
+ }
68
+ }
69
+ class TokenRefreshFailedError extends SessionError {
70
+ constructor(message = 'Error al renovar tokens') {
71
+ super(message, 'TOKEN_REFRESH_FAILED', '/session-expired?session=expired');
72
+ }
73
+ }
74
+ class InvalidTokenFormatError extends SessionError {
75
+ constructor(message = 'Formato de token inválido') {
76
+ super(message, 'INVALID_TOKEN_FORMAT', '/?session=invalid');
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Controller Abstracto Reutilizable
82
+ * Gestiona automáticamente Login, Callback, Me y Logout.
83
+ */
84
+ class AbstractAuthController {
85
+ constructor(authService) {
86
+ this.authService = authService;
87
+ }
88
+ async login(req, res) {
89
+ try {
90
+ const autoParam = typeof req.query.auto === 'string' ? req.query.auto : 'false';
91
+ const { loginUrl, tempState } = this.authService.generateLoginParams(autoParam);
92
+ res.cookie('auth_flow', JSON.stringify(tempState), {
93
+ httpOnly: true,
94
+ secure: process.env.NODE_ENV === 'production',
95
+ maxAge: 300000 // 5 min
96
+ });
97
+ res.redirect(loginUrl);
98
+ }
99
+ catch (error) {
100
+ console.error('Login Error:', error);
101
+ res.status(500).send('Login initialization failed');
102
+ }
103
+ }
104
+ async callback(req, res) {
105
+ try {
106
+ const { code, state } = req.query;
107
+ const authFlowCookie = req.cookies['auth_flow'];
108
+ if (!authFlowCookie) {
109
+ res.status(400).send('Missing auth flow cookie');
110
+ return;
111
+ }
112
+ const storedState = JSON.parse(authFlowCookie);
113
+ const result = await this.authService.handleCallback(code, state, storedState);
114
+ res.cookie('x-session', result.sessionId, {
115
+ httpOnly: true,
116
+ secure: process.env.NODE_ENV === 'production',
117
+ sameSite: 'lax',
118
+ maxAge: result.expiresIn * 1000,
119
+ path: '/'
120
+ });
121
+ res.clearCookie('auth_flow');
122
+ res.redirect(result.redirectUrl);
123
+ }
124
+ catch (error) {
125
+ console.error('Callback Error:', error);
126
+ if (error instanceof HttpError) {
127
+ res.status(error.statusCode).json({
128
+ success: false,
129
+ message: error.message,
130
+ error: error.error
131
+ });
132
+ return;
133
+ }
134
+ res.status(500).json({
135
+ success: false,
136
+ message: 'Failed to complete authentication',
137
+ error: error.message
138
+ });
139
+ }
140
+ }
141
+ async me(req, res) {
142
+ const sessionId = req.cookies['x-session'];
143
+ if (!sessionId) {
144
+ return res.status(401).json({
145
+ success: false,
146
+ message: 'No session found',
147
+ data: {
148
+ redirect_to: '/?session=required'
149
+ },
150
+ error: {
151
+ message: 'Session is missing',
152
+ code: 'SESSION_MISSING'
153
+ }
154
+ });
155
+ }
156
+ try {
157
+ const result = await this.authService.getSessionUser(sessionId);
158
+ return res.status(200).json({
159
+ success: true,
160
+ data: result
161
+ });
162
+ }
163
+ catch (error) {
164
+ res.clearCookie('x-session', {
165
+ httpOnly: true,
166
+ secure: process.env.NODE_ENV === 'production',
167
+ sameSite: 'lax',
168
+ });
169
+ if (error instanceof SessionError) {
170
+ return res.status(401).json({
171
+ success: false,
172
+ message: error.message,
173
+ data: {
174
+ redirect_to: error.redirectTo || '/login'
175
+ },
176
+ error: {
177
+ message: error.message,
178
+ code: error.code
179
+ }
180
+ });
181
+ }
182
+ else {
183
+ return res.status(500).json({
184
+ success: false,
185
+ message: 'Failed to retrieve session user',
186
+ error: error.message
187
+ });
188
+ }
189
+ }
190
+ }
191
+ async logout(req, res) {
192
+ const sessionId = req.cookies['x-session'];
193
+ if (sessionId) {
194
+ await this.authService.logout(sessionId);
195
+ }
196
+ res.clearCookie('x-session', {
197
+ httpOnly: true,
198
+ secure: process.env.NODE_ENV === 'production',
199
+ sameSite: 'lax',
200
+ });
201
+ return res.status(200).json({ success: true });
202
+ }
203
+ }
204
+ __decorate([
205
+ Get('/login'),
206
+ __param(0, Req()),
207
+ __param(1, Res()),
208
+ __metadata("design:type", Function),
209
+ __metadata("design:paramtypes", [Object, Object]),
210
+ __metadata("design:returntype", Promise)
211
+ ], AbstractAuthController.prototype, "login", null);
212
+ __decorate([
213
+ Get('/callback'),
214
+ __metadata("design:type", Function),
215
+ __metadata("design:paramtypes", [Object, Object]),
216
+ __metadata("design:returntype", Promise)
217
+ ], AbstractAuthController.prototype, "callback", null);
218
+ __decorate([
219
+ Get('/me'),
220
+ __param(0, Req()),
221
+ __param(1, Res()),
222
+ __metadata("design:type", Function),
223
+ __metadata("design:paramtypes", [Object, Object]),
224
+ __metadata("design:returntype", Promise)
225
+ ], AbstractAuthController.prototype, "me", null);
226
+ __decorate([
227
+ Post('/logout'),
228
+ __param(0, Req()),
229
+ __param(1, Res()),
230
+ __metadata("design:type", Function),
231
+ __metadata("design:paramtypes", [Object, Object]),
232
+ __metadata("design:returntype", Promise)
233
+ ], AbstractAuthController.prototype, "logout", null);
234
+
235
+ /**
236
+ * Utilidades para manejo de PKCE y codificación Base64URL
237
+ */
238
+ class Generators {
239
+ /**
240
+ * Base64URL Encoding
241
+ * Reemplaza la implementación manual con Buffer de Node.js
242
+ */
243
+ static base64urlEncode(strOrBuffer) {
244
+ const buffer = Buffer.isBuffer(strOrBuffer)
245
+ ? strOrBuffer
246
+ : Buffer.from(strOrBuffer);
247
+ return buffer
248
+ .toString('base64')
249
+ .replace(/\+/g, '-')
250
+ .replace(/\//g, '_')
251
+ .replace(/=+$/, '');
252
+ }
253
+ /**
254
+ * Genera un Code Verifier para el flujo PKCE (OAuth 2.0).
255
+ * Crea una cadena aleatoria de 43 a 128 caracteres, codificada en Base64URL.
256
+ */
257
+ static generateCodeVerifier() {
258
+ const buffer = randomBytes(32);
259
+ return this.base64urlEncode(buffer);
260
+ }
261
+ /**
262
+ * Generate Code Challenge (SHA-256)
263
+ */
264
+ static generateCodeChallenge(verifier) {
265
+ const hash = createHash('sha256')
266
+ .update(verifier)
267
+ .digest();
268
+ return this.base64urlEncode(hash);
269
+ }
270
+ /**
271
+ * Genera un estado aleatorio para la protección CSRF.
272
+ */
273
+ static state() {
274
+ const buffer = randomBytes(16);
275
+ return this.base64urlEncode(buffer);
276
+ }
277
+ }
278
+
279
+ let AuthCacheService = class AuthCacheService {
280
+ constructor(cacheClient) {
281
+ this.cacheClient = cacheClient;
282
+ }
283
+ async set(key, value, ttlSeconds) {
284
+ await this.cacheClient.setObject(key, value, ttlSeconds);
285
+ }
286
+ async get(key) {
287
+ return await this.cacheClient.getObject(key);
288
+ }
289
+ async delete(key) {
290
+ await this.cacheClient.deleteKey(key);
291
+ }
292
+ };
293
+ AuthCacheService = __decorate([
294
+ injectable(),
295
+ __metadata("design:paramtypes", [CacheService])
296
+ ], AuthCacheService);
297
+
298
+ let AuthService = class AuthService {
299
+ constructor(config, storage, jwtManager) {
300
+ this.config = config;
301
+ this.storage = storage;
302
+ this.jwtManager = jwtManager;
303
+ this.networkServices = new HMNetworkServices(this.config.ssoBaseUrl);
304
+ }
305
+ /**
306
+ * Genera los parámetros necesarios para iniciar el login SSO
307
+ * @param auto
308
+ * @returns
309
+ */
310
+ generateLoginParams(auto = 'false') {
311
+ const codeVerifier = Generators.generateCodeVerifier();
312
+ const codeChallenge = Generators.generateCodeChallenge(codeVerifier);
313
+ const state = Generators.state();
314
+ const params = new URLSearchParams({
315
+ response_type: 'code',
316
+ client_id: this.config.clientId,
317
+ redirect_uri: this.config.redirectUri,
318
+ state: state,
319
+ code_challenge: codeChallenge,
320
+ code_challenge_method: 'S256',
321
+ auto: auto
322
+ });
323
+ const loginUrl = `${this.config.ssoBaseUrl}${this.config.ssoAuthUrl}?${params.toString()}`;
324
+ const tempState = {
325
+ state,
326
+ codeVerifier
327
+ };
328
+ return {
329
+ loginUrl,
330
+ tempState
331
+ };
332
+ }
333
+ /**
334
+ * Maneja el callback del SSO, intercambiando el código por tokens y creando la sesión.
335
+ * @param code Código de autorización recibido del SSO
336
+ * @param incomingState Estado recibido del SSO
337
+ * @param storedState Estado temporal almacenado antes de redirigir al SSO
338
+ * @returns Información de la sesión creada
339
+ */
340
+ async handleCallback(code, incomingState, storedState) {
341
+ if (incomingState !== storedState.state) {
342
+ throw new BadRequestError('Invalid state parameter', 'invalid_state');
343
+ }
344
+ try {
345
+ const response = await this.networkServices.post(this.config.ssoTokenEndpoint, {
346
+ grantType: 'authorization_code',
347
+ clientId: this.config.clientId,
348
+ clientSecret: this.config.clientSecret,
349
+ code,
350
+ redirectUri: this.config.redirectUri,
351
+ codeVerifier: storedState.codeVerifier
352
+ });
353
+ if (response.status !== 200) {
354
+ throw new CustomHttpError('Token exchange failed', response.status, 'token_exchange_failed');
355
+ }
356
+ if (!response.data.access_token) {
357
+ throw new InternalServerError('No access token received from SSO', 'invalid_token_response');
358
+ }
359
+ const { access_token, refresh_token, id_token, expires_in, session_id } = response.data;
360
+ const sessionId = randomBytes(16).toString('hex');
361
+ const sessionData = {
362
+ accessToken: access_token,
363
+ refreshToken: refresh_token,
364
+ idToken: id_token,
365
+ expiresAt: Date.now() + (expires_in * 1000),
366
+ createdAt: new Date().toISOString(),
367
+ ssoSessionId: session_id
368
+ };
369
+ await this.storage.set(`x-session:${sessionId}`, sessionData, expires_in);
370
+ return {
371
+ sessionId,
372
+ expiresIn: expires_in,
373
+ redirectUrl: `${this.config.uiBaseUrl}`
374
+ };
375
+ }
376
+ catch (error) {
377
+ console.error('Token Exchange Error:', error);
378
+ throw error;
379
+ }
380
+ }
381
+ /**
382
+ * Obtiene y valida la sesión del usuario a partir del sessionId.
383
+ * Si la sesión está cerca de expirar, intenta refrescar los tokens.
384
+ * @param sessionId Identificador de la sesión
385
+ * @returns Información del usuario o error si la sesión no es válida
386
+ */
387
+ async getSessionUser(sessionId) {
388
+ const key = `x-session:${sessionId}`;
389
+ let session = await this.storage.get(key);
390
+ if (!session) {
391
+ throw new SessionNotFoundError();
392
+ }
393
+ if (session.expiresAt < Date.now()) {
394
+ throw new SessionExpiredError();
395
+ }
396
+ const timeUntilExpiry = session.expiresAt - Date.now();
397
+ if (timeUntilExpiry < 2 * 60 * 1000) {
398
+ try {
399
+ session = await this.refreshTokens(session, sessionId);
400
+ }
401
+ catch (error) {
402
+ throw new TokenRefreshFailedError();
403
+ }
404
+ }
405
+ try {
406
+ const userData = await this.decodeIdToken(session.idToken);
407
+ if (!userData) {
408
+ throw new SessionInvalidError();
409
+ }
410
+ return userData;
411
+ }
412
+ catch (e) {
413
+ throw new InvalidTokenFormatError();
414
+ }
415
+ }
416
+ /**
417
+ * Cierra la sesión del usuario tanto en el SSO como localmente.
418
+ * @param sessionId Identificador de la sesión
419
+ */
420
+ async logout(sessionId) {
421
+ const key = `x-session:${sessionId}`;
422
+ const session = await this.storage.get(key);
423
+ if (session) {
424
+ try {
425
+ await this.networkServices.post(this.config.ssoLogoutEndpoint, {
426
+ ssoSessionId: session.sessionId
427
+ });
428
+ }
429
+ catch (e) { /* Silent error */ }
430
+ await this.storage.delete(key);
431
+ }
432
+ }
433
+ /**
434
+ * Decodifica el ID token para extraer la información del usuario.
435
+ * @param idToken Token de identificación JWT
436
+ * @returns Información del usuario o null si no se puede decodificar
437
+ */
438
+ async decodeIdToken(idToken) {
439
+ try {
440
+ const decode = this.jwtManager.decode(idToken);
441
+ if (!decode) {
442
+ return null;
443
+ }
444
+ const data = {
445
+ email: decode.email || '',
446
+ name: decode.name || '',
447
+ given_name: decode.given_name,
448
+ family_name: decode.family_name,
449
+ picture: decode.picture,
450
+ ...decode
451
+ };
452
+ return data;
453
+ }
454
+ catch (error) {
455
+ throw new Error('Failed to decode ID token');
456
+ }
457
+ }
458
+ /**
459
+ * Refresca los tokens de la sesión utilizando el refresh token.
460
+ * @param session Datos actuales de la sesión
461
+ * @param sessionId Identificador de la sesión
462
+ * @returns Datos actualizados de la sesión
463
+ */
464
+ async refreshTokens(session, sessionId) {
465
+ const response = await this.networkServices.post(this.config.ssoTokenEndpoint, {
466
+ grantType: 'refresh_token',
467
+ clientId: this.config.clientId,
468
+ clientSecret: this.config.clientSecret,
469
+ refreshToken: session.refreshToken,
470
+ sessionId: session.ssoSessionId
471
+ });
472
+ const { access_token, refresh_token, id_token, expires_in } = response.data;
473
+ const updatedSession = {
474
+ accessToken: access_token,
475
+ refreshToken: refresh_token || session.refreshToken,
476
+ idToken: id_token || session.idToken,
477
+ expiresAt: Date.now() + (expires_in * 1000),
478
+ sessionId: response.data.session_id || '',
479
+ createdAt: Date.now().toString()
480
+ };
481
+ await this.storage.set(`x-session:${sessionId}`, updatedSession, expires_in);
482
+ return updatedSession;
483
+ }
484
+ };
485
+ AuthService = __decorate([
486
+ injectable(),
487
+ __metadata("design:paramtypes", [Object, AuthCacheService, Object])
488
+ ], AuthService);
489
+
490
+ const registerAuthSdk = (container, config, cacheService, jwtManager) => {
491
+ container.bind(AuthService).toDynamicValue(() => {
492
+ return new AuthService(config, cacheService, jwtManager);
493
+ }).inSingletonScope();
494
+ };
495
+
496
+ export { AbstractAuthController, AuthCacheService, AuthService, InvalidTokenFormatError, SessionError, SessionExpiredError, SessionInvalidError, SessionNotFoundError, TokenRefreshFailedError, registerAuthSdk };