@jmlq/auth 0.0.1-alpha.14 → 0.0.1-alpha.16
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/dist/application/dtos/response/login.response.d.ts +1 -1
- package/dist/application/use-cases/refresh-token.use-case.js +9 -1
- package/dist/domain/entities/credential.entity.d.ts +15 -5
- package/dist/domain/entities/credential.entity.js +15 -3
- package/dist/domain/ports/repository/credential.repository.d.ts +15 -0
- package/dist/domain/props/entities/credential.props.d.ts +8 -1
- package/dist/in-memory/in-memory-credential.repository.d.ts +1 -0
- package/dist/in-memory/in-memory-credential.repository.js +9 -4
- package/dist/infrastructure/services/token-session.service.d.ts +100 -9
- package/dist/infrastructure/services/token-session.service.js +253 -22
- package/package.json +1 -1
|
@@ -10,13 +10,21 @@ class RefreshTokenUseCase {
|
|
|
10
10
|
try {
|
|
11
11
|
// Refrescar la sesión
|
|
12
12
|
const credential = await this.tokenSession.refreshSession(request.refreshToken);
|
|
13
|
+
// Rotación obligatoria: si no hay refreshToken nuevo, el refresh falló.
|
|
14
|
+
const newRefreshToken = credential.refreshToken;
|
|
15
|
+
if (!newRefreshToken || !newRefreshToken.trim()) {
|
|
16
|
+
// Mensaje genérico por seguridad
|
|
17
|
+
throw new errors_1.TokenExpiredError();
|
|
18
|
+
}
|
|
13
19
|
return {
|
|
14
20
|
sessionId: credential.sessionId.getValue(),
|
|
15
21
|
accessToken: credential.accessToken,
|
|
16
|
-
refreshToken:
|
|
22
|
+
refreshToken: newRefreshToken,
|
|
17
23
|
};
|
|
18
24
|
}
|
|
19
25
|
catch (error) {
|
|
26
|
+
// Si quieres, aquí puedes mapear SOLO expiración e invalid-token a TokenExpiredError
|
|
27
|
+
// y relanzar el resto. Mantengo tu comportamiento actual para no cambiar semántica.
|
|
20
28
|
throw new errors_1.TokenExpiredError();
|
|
21
29
|
}
|
|
22
30
|
}
|
|
@@ -19,7 +19,7 @@ export declare class Credential {
|
|
|
19
19
|
/**
|
|
20
20
|
* Token de refresco asociado
|
|
21
21
|
*/
|
|
22
|
-
private readonly _refreshToken
|
|
22
|
+
private readonly _refreshToken?;
|
|
23
23
|
/**
|
|
24
24
|
* Fecha de expiración del token de acceso
|
|
25
25
|
*/
|
|
@@ -48,7 +48,7 @@ export declare class Credential {
|
|
|
48
48
|
/**
|
|
49
49
|
* Obtiene el token de refresco
|
|
50
50
|
*/
|
|
51
|
-
get refreshToken(): string;
|
|
51
|
+
get refreshToken(): string | undefined;
|
|
52
52
|
/**
|
|
53
53
|
* Obtiene la fecha de expiración del token de acceso
|
|
54
54
|
*/
|
|
@@ -73,9 +73,19 @@ export declare class Credential {
|
|
|
73
73
|
static create(sessionId: Id, userId: Id, accessToken: string, refreshToken: string, expirationDate: Date): Credential;
|
|
74
74
|
/**
|
|
75
75
|
*Reconstitution method for repository
|
|
76
|
-
*
|
|
77
|
-
* @param props Propiedades de las credenciales
|
|
78
|
-
* @returns Nueva instancia de Credential
|
|
76
|
+
*refreshToken puede venir undefined si en DB solo existe hash.
|
|
79
77
|
*/
|
|
80
78
|
static reconstitute(props: ICredentialProps): Credential;
|
|
79
|
+
/**
|
|
80
|
+
* Crea una nueva credencial como resultado de una rotación de refresh token.
|
|
81
|
+
* Impone que refreshToken exista (rotación obligatoria).
|
|
82
|
+
*/
|
|
83
|
+
static rotate(props: {
|
|
84
|
+
sessionId: Id;
|
|
85
|
+
userId: Id;
|
|
86
|
+
accessToken: string;
|
|
87
|
+
refreshToken: string;
|
|
88
|
+
expiresAt: Date;
|
|
89
|
+
createdAt?: Date;
|
|
90
|
+
}): Credential;
|
|
81
91
|
}
|
|
@@ -83,12 +83,24 @@ class Credential {
|
|
|
83
83
|
}
|
|
84
84
|
/**
|
|
85
85
|
*Reconstitution method for repository
|
|
86
|
-
*
|
|
87
|
-
* @param props Propiedades de las credenciales
|
|
88
|
-
* @returns Nueva instancia de Credential
|
|
86
|
+
*refreshToken puede venir undefined si en DB solo existe hash.
|
|
89
87
|
*/
|
|
90
88
|
static reconstitute(props) {
|
|
91
89
|
return new Credential(props);
|
|
92
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Crea una nueva credencial como resultado de una rotación de refresh token.
|
|
93
|
+
* Impone que refreshToken exista (rotación obligatoria).
|
|
94
|
+
*/
|
|
95
|
+
static rotate(props) {
|
|
96
|
+
return new Credential({
|
|
97
|
+
sessionId: props.sessionId,
|
|
98
|
+
userId: props.userId,
|
|
99
|
+
accessToken: props.accessToken,
|
|
100
|
+
refreshToken: props.refreshToken,
|
|
101
|
+
expiresAt: props.expiresAt,
|
|
102
|
+
createdAt: props.createdAt ?? new Date(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
93
105
|
}
|
|
94
106
|
exports.Credential = Credential;
|
|
@@ -45,4 +45,19 @@ export interface ICredentialRepositoryPort {
|
|
|
45
45
|
* Elimina una sesión por refresh token (logout basado en refresh).
|
|
46
46
|
*/
|
|
47
47
|
deleteByRefreshToken(refreshToken: string): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Rotación atómica de refresh token (single-use).
|
|
50
|
+
*
|
|
51
|
+
* Debe:
|
|
52
|
+
* - “consumir” el refreshToken actual (el entrante) de forma atómica,
|
|
53
|
+
* - y persistir la credencial nueva para la MISMA sessionId.
|
|
54
|
+
*
|
|
55
|
+
* Retorna true si la rotación ocurrió (1 fila afectada),
|
|
56
|
+
* false si el refresh token ya fue usado / revocado / no existe.
|
|
57
|
+
*
|
|
58
|
+
* Nota:
|
|
59
|
+
* - El core NO sabe de hashes.
|
|
60
|
+
* - La implementación infra puede usar refreshTokenHash internamente.
|
|
61
|
+
*/
|
|
62
|
+
rotateByRefreshToken(currentRefreshToken: string, nextCredential: Credential): Promise<boolean>;
|
|
48
63
|
}
|
|
@@ -3,7 +3,14 @@ export interface ICredentialProps {
|
|
|
3
3
|
sessionId: Id;
|
|
4
4
|
userId: Id;
|
|
5
5
|
accessToken: string;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Token de refresco (secreto).
|
|
8
|
+
* Nota de seguridad:
|
|
9
|
+
* - En persistencia NO debe guardarse en claro.
|
|
10
|
+
* - En reconstitución desde DB puede ser undefined (solo existe el hash).
|
|
11
|
+
* - En flujos que emiten tokens (login/refresh) debe existir.
|
|
12
|
+
*/
|
|
13
|
+
refreshToken?: string;
|
|
7
14
|
expiresAt: Date;
|
|
8
15
|
createdAt: Date;
|
|
9
16
|
}
|
|
@@ -5,6 +5,7 @@ import { Credential, ICredentialRepositoryPort, Id } from "../domain";
|
|
|
5
5
|
* Soporta múltiples sesiones por usuario (multi-dispositivo) usando `sessionId`.
|
|
6
6
|
*/
|
|
7
7
|
export declare class InMemoryCredentialRepository implements ICredentialRepositoryPort {
|
|
8
|
+
rotateByRefreshToken(currentRefreshToken: string, nextCredential: Credential): Promise<boolean>;
|
|
8
9
|
/**
|
|
9
10
|
* Fuente de verdad: credenciales indexadas por sessionId.
|
|
10
11
|
*/
|
|
@@ -21,6 +21,9 @@ class InMemoryCredentialRepository {
|
|
|
21
21
|
*/
|
|
22
22
|
this.refreshTokenIndex = new Map();
|
|
23
23
|
}
|
|
24
|
+
rotateByRefreshToken(currentRefreshToken, nextCredential) {
|
|
25
|
+
throw new Error("Method not implemented.");
|
|
26
|
+
}
|
|
24
27
|
/**
|
|
25
28
|
* Guarda (upsert) una credencial por (userId, sessionId).
|
|
26
29
|
* - Si existía una credencial para ese sessionId, limpia el índice de refreshToken viejo.
|
|
@@ -31,13 +34,14 @@ class InMemoryCredentialRepository {
|
|
|
31
34
|
const userKey = credential.userId.getValue();
|
|
32
35
|
// Si existía una credencial para esa sesión, limpiar refresh index anterior
|
|
33
36
|
const old = this.credentialsBySessionId.get(sessionKey);
|
|
34
|
-
if (old) {
|
|
37
|
+
if (old?.refreshToken) {
|
|
35
38
|
this.refreshTokenIndex.delete(old.refreshToken);
|
|
36
39
|
}
|
|
37
40
|
// Guardar credencial por sesión
|
|
38
41
|
this.credentialsBySessionId.set(sessionKey, credential);
|
|
39
42
|
// Indexar refresh -> session
|
|
40
|
-
|
|
43
|
+
if (credential.refreshToken)
|
|
44
|
+
this.refreshTokenIndex.set(credential.refreshToken, sessionKey);
|
|
41
45
|
// Indexar user -> sessions
|
|
42
46
|
const sessions = this.sessionsByUserId.get(userKey) ?? new Set();
|
|
43
47
|
sessions.add(sessionKey);
|
|
@@ -97,7 +101,7 @@ class InMemoryCredentialRepository {
|
|
|
97
101
|
return;
|
|
98
102
|
for (const sid of sessionIds) {
|
|
99
103
|
const c = this.credentialsBySessionId.get(sid);
|
|
100
|
-
if (c) {
|
|
104
|
+
if (c?.refreshToken) {
|
|
101
105
|
this.refreshTokenIndex.delete(c.refreshToken);
|
|
102
106
|
}
|
|
103
107
|
this.credentialsBySessionId.delete(sid);
|
|
@@ -113,7 +117,8 @@ class InMemoryCredentialRepository {
|
|
|
113
117
|
if (!c)
|
|
114
118
|
return;
|
|
115
119
|
// limpiar refresh index
|
|
116
|
-
|
|
120
|
+
if (c.refreshToken)
|
|
121
|
+
this.refreshTokenIndex.delete(c.refreshToken);
|
|
117
122
|
// limpiar relación user -> sessions
|
|
118
123
|
const userKey = c.userId.getValue();
|
|
119
124
|
const sessions = this.sessionsByUserId.get(userKey);
|
|
@@ -2,23 +2,45 @@ import { Credential, ICredentialRepositoryPort, Id, ITokenServicePort, ITokenSes
|
|
|
2
2
|
/**
|
|
3
3
|
* Servicio de sesiones de usuario (rotación de refresh token) con soporte multi-dispositivo.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* - Persistir credenciales por sessionId (dispositivo)
|
|
8
|
-
* - Recuperar usuario
|
|
5
|
+
* Capa:
|
|
6
|
+
* - Infraestructura (orquesta ports y persistencia, no contiene reglas de negocio de Auth)
|
|
9
7
|
*
|
|
10
|
-
*
|
|
8
|
+
* Responsabilidades:
|
|
9
|
+
* - Generar/verificar tokens usando ITokenServicePort (plugin: jose / etc.)
|
|
10
|
+
* - Persistir credenciales por sessionId (1 fila = 1 sesión/dispositivo)
|
|
11
|
+
* - Recuperar usuario desde IUserRepositoryPort
|
|
12
|
+
*
|
|
13
|
+
* Reglas clave (multi-dispositivo):
|
|
11
14
|
* - createSession: crea sessionId nuevo (nuevo dispositivo/sesión)
|
|
12
15
|
* - refreshSession: rota tokens manteniendo el MISMO sessionId (misma sesión)
|
|
13
|
-
* - validateSession: valida JWT
|
|
16
|
+
* - validateSession: valida JWT + valida que la sesión exista (revocable) y no expirada
|
|
14
17
|
*/
|
|
15
18
|
export declare class TokenSessionService implements ITokenSessionPort {
|
|
19
|
+
/**
|
|
20
|
+
* Puerto para operaciones JWT (generar/verificar/expiración).
|
|
21
|
+
* Implementación típica: jose (via @jmlq/auth-plugin-jose).
|
|
22
|
+
*/
|
|
16
23
|
private readonly tokenService;
|
|
24
|
+
/**
|
|
25
|
+
* Puerto de usuarios (fuente de verdad del usuario).
|
|
26
|
+
* Se usa para:
|
|
27
|
+
* - encontrar usuario por id (payload.sub)
|
|
28
|
+
* - validar canLogin()
|
|
29
|
+
*/
|
|
17
30
|
private readonly userRepository;
|
|
31
|
+
/**
|
|
32
|
+
* Puerto de credenciales/sesiones (persistencia revocable).
|
|
33
|
+
* Se usa para:
|
|
34
|
+
* - save / findBySessionId / findByRefreshToken
|
|
35
|
+
* - deleteByRefreshToken / deleteBySessionId
|
|
36
|
+
*/
|
|
18
37
|
private readonly credentialRepository;
|
|
19
38
|
/**
|
|
20
39
|
* Expiración del access token en formato humano.
|
|
21
40
|
* @example "15m"
|
|
41
|
+
*
|
|
42
|
+
* Nota:
|
|
43
|
+
* - La interpretación la hace el tokenService (o un parser interno).
|
|
22
44
|
*/
|
|
23
45
|
private readonly accessTokenExpiration;
|
|
24
46
|
/**
|
|
@@ -26,10 +48,32 @@ export declare class TokenSessionService implements ITokenSessionPort {
|
|
|
26
48
|
* @example "7d"
|
|
27
49
|
*/
|
|
28
50
|
private readonly refreshTokenExpiration;
|
|
29
|
-
constructor(
|
|
51
|
+
constructor(
|
|
52
|
+
/**
|
|
53
|
+
* Puerto para operaciones JWT (generar/verificar/expiración).
|
|
54
|
+
* Implementación típica: jose (via @jmlq/auth-plugin-jose).
|
|
55
|
+
*/
|
|
56
|
+
tokenService: ITokenServicePort,
|
|
57
|
+
/**
|
|
58
|
+
* Puerto de usuarios (fuente de verdad del usuario).
|
|
59
|
+
* Se usa para:
|
|
60
|
+
* - encontrar usuario por id (payload.sub)
|
|
61
|
+
* - validar canLogin()
|
|
62
|
+
*/
|
|
63
|
+
userRepository: IUserRepositoryPort,
|
|
64
|
+
/**
|
|
65
|
+
* Puerto de credenciales/sesiones (persistencia revocable).
|
|
66
|
+
* Se usa para:
|
|
67
|
+
* - save / findBySessionId / findByRefreshToken
|
|
68
|
+
* - deleteByRefreshToken / deleteBySessionId
|
|
69
|
+
*/
|
|
70
|
+
credentialRepository: ICredentialRepositoryPort,
|
|
30
71
|
/**
|
|
31
72
|
* Expiración del access token en formato humano.
|
|
32
73
|
* @example "15m"
|
|
74
|
+
*
|
|
75
|
+
* Nota:
|
|
76
|
+
* - La interpretación la hace el tokenService (o un parser interno).
|
|
33
77
|
*/
|
|
34
78
|
accessTokenExpiration?: string,
|
|
35
79
|
/**
|
|
@@ -40,6 +84,19 @@ export declare class TokenSessionService implements ITokenSessionPort {
|
|
|
40
84
|
/**
|
|
41
85
|
* Emite access/refresh tokens para un usuario y una sesión dada.
|
|
42
86
|
*
|
|
87
|
+
* Este método es el “núcleo” de emisión de tokens y su claim policy.
|
|
88
|
+
*
|
|
89
|
+
* Qué hace:
|
|
90
|
+
* 1) Construye claims del usuario (id/email/roles)
|
|
91
|
+
* 2) Deriva permissions efectivas desde roles (RBAC)
|
|
92
|
+
* 3) Construye customClaims.permissions para que el API pueda autorizar (requirePermissions)
|
|
93
|
+
* 4) Genera access + refresh
|
|
94
|
+
* 5) Obtiene expiresAt desde el accessToken (fuente robusta: tokenService)
|
|
95
|
+
*
|
|
96
|
+
* Importante:
|
|
97
|
+
* - permissions vienen de roles.getValuePublic().permissions
|
|
98
|
+
* (en el host se “pegan” desde AccessSnapshotResolver)
|
|
99
|
+
*
|
|
43
100
|
* @param user Usuario autenticado.
|
|
44
101
|
* @param sessionId Identificador de sesión/dispositivo.
|
|
45
102
|
*/
|
|
@@ -47,6 +104,15 @@ export declare class TokenSessionService implements ITokenSessionPort {
|
|
|
47
104
|
/**
|
|
48
105
|
* Crea una nueva sesión (nuevo dispositivo).
|
|
49
106
|
*
|
|
107
|
+
* Flujo:
|
|
108
|
+
* 1) Genera un sessionId nuevo
|
|
109
|
+
* 2) Emite tokens atados a ese sessionId
|
|
110
|
+
* 3) Construye Credential (entidad de dominio de sesión)
|
|
111
|
+
* 4) Persiste credencial
|
|
112
|
+
*
|
|
113
|
+
* Efecto:
|
|
114
|
+
* - multi-dispositivo: un usuario puede tener N credenciales activas (N sessionId)
|
|
115
|
+
*
|
|
50
116
|
* @param user Usuario autenticado.
|
|
51
117
|
* @returns Credencial persistida para la nueva sesión.
|
|
52
118
|
*/
|
|
@@ -54,6 +120,17 @@ export declare class TokenSessionService implements ITokenSessionPort {
|
|
|
54
120
|
/**
|
|
55
121
|
* Rota refresh token manteniendo el MISMO sessionId (misma sesión/dispositivo).
|
|
56
122
|
*
|
|
123
|
+
* Flujo:
|
|
124
|
+
* 1) Busca credencial por refreshToken (revocable)
|
|
125
|
+
* 2) Verifica refreshToken (firma/exp)
|
|
126
|
+
* 3) Recupera usuario (payload.sub) y valida canLogin()
|
|
127
|
+
* 4) Re-emite tokens con el MISMO sessionId
|
|
128
|
+
* 5) Reemplaza credencial persistida (delete + save)
|
|
129
|
+
*
|
|
130
|
+
* Seguridad:
|
|
131
|
+
* - Si el refreshToken no existe en repositorio => sesión revocada o token inválido => TokenExpiredError
|
|
132
|
+
* - Verificación JWT también puede lanzar => TokenExpiredError
|
|
133
|
+
*
|
|
57
134
|
* @param refreshToken Refresh token actual.
|
|
58
135
|
* @returns Nueva credencial rotada para la misma sesión.
|
|
59
136
|
*/
|
|
@@ -61,11 +138,15 @@ export declare class TokenSessionService implements ITokenSessionPort {
|
|
|
61
138
|
/**
|
|
62
139
|
* Valida una sesión a partir de access token.
|
|
63
140
|
*
|
|
141
|
+
* Este método soporta “revocación” (token válido criptográficamente pero sesión eliminada en DB).
|
|
142
|
+
*
|
|
64
143
|
* Reglas:
|
|
65
144
|
* - JWT debe ser válido (firma/exp/issuer/aud según plugin)
|
|
66
145
|
* - Debe existir `sid` en el payload
|
|
67
|
-
* - La sesión (sid) debe existir en el repositorio
|
|
68
|
-
* - (
|
|
146
|
+
* - La sesión (sid) debe existir en el repositorio
|
|
147
|
+
* - (recomendado) accessToken debe coincidir con el almacenado (solo el último token es válido)
|
|
148
|
+
* - La credencial no debe estar expirada (según su estado interno)
|
|
149
|
+
* - El usuario debe existir y poder loguearse
|
|
69
150
|
*
|
|
70
151
|
* @param accessToken Access token recibido.
|
|
71
152
|
* @returns Usuario si sesión es válida, o null si no lo es.
|
|
@@ -74,12 +155,22 @@ export declare class TokenSessionService implements ITokenSessionPort {
|
|
|
74
155
|
/**
|
|
75
156
|
* Revoca una sesión usando refresh token.
|
|
76
157
|
*
|
|
158
|
+
* Uso típico:
|
|
159
|
+
* - logout donde el cliente envía refreshToken (cookie/body)
|
|
160
|
+
*
|
|
161
|
+
* Efecto:
|
|
162
|
+
* - la sesión deja de poder refrescar tokens
|
|
163
|
+
* - si validateSession compara accessToken, también invalida el access actual cuando se borra la credencial
|
|
164
|
+
*
|
|
77
165
|
* @param refreshToken Refresh token actual.
|
|
78
166
|
*/
|
|
79
167
|
revokeSession(refreshToken: string): Promise<void>;
|
|
80
168
|
/**
|
|
81
169
|
* Revoca una sesión por sessionId (logout por dispositivo).
|
|
82
170
|
*
|
|
171
|
+
* Uso típico:
|
|
172
|
+
* - logout selectivo (cerrar solo este dispositivo)
|
|
173
|
+
*
|
|
83
174
|
* @param sessionId Identificador de sesión/dispositivo.
|
|
84
175
|
*/
|
|
85
176
|
revokeSessionById(sessionId: Id): Promise<void>;
|
|
@@ -1,8 +1,48 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
// src/infrastructure/services/token-session.service.ts
|
|
2
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
4
|
exports.TokenSessionService = void 0;
|
|
4
|
-
// src/infrastructure/services/token-session.service.ts
|
|
5
5
|
const domain_1 = require("../../domain");
|
|
6
|
+
/**
|
|
7
|
+
* Type guard para validar arrays de strings en runtime.
|
|
8
|
+
*
|
|
9
|
+
* Se usa para:
|
|
10
|
+
* - evitar suposiciones sobre shapes provenientes de getValuePublic()
|
|
11
|
+
* - garantizar que "permissions" sea string[] antes de usarlo en claims
|
|
12
|
+
*/
|
|
13
|
+
function isStringArray(value) {
|
|
14
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Devuelve valores únicos y ordenados (estable y determinista).
|
|
18
|
+
*
|
|
19
|
+
* Por qué importa:
|
|
20
|
+
* - Evita duplicados en permissions (p.ej. permisos compartidos entre roles)
|
|
21
|
+
* - Orden estable => tokens más previsibles y facilita testing/debug
|
|
22
|
+
*/
|
|
23
|
+
function uniqSorted(values) {
|
|
24
|
+
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Extrae permissions[] desde el resultado de getValuePublic() de los roles.
|
|
28
|
+
*
|
|
29
|
+
* Diseño:
|
|
30
|
+
* - No asume que permissions existe ni que tiene el tipo correcto.
|
|
31
|
+
* - Solo agrega cuando es string[].
|
|
32
|
+
* - Normaliza con uniqSorted().
|
|
33
|
+
*
|
|
34
|
+
* Resultado:
|
|
35
|
+
* - permissions finales efectivas para el usuario en ese instante.
|
|
36
|
+
*/
|
|
37
|
+
function extractPermissionsFromRolesPublic(rolesPublic) {
|
|
38
|
+
const perms = [];
|
|
39
|
+
for (const rp of rolesPublic) {
|
|
40
|
+
if (isStringArray(rp.permissions)) {
|
|
41
|
+
perms.push(...rp.permissions);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return uniqSorted(perms);
|
|
45
|
+
}
|
|
6
46
|
/**
|
|
7
47
|
* Extrae el `sid` (sessionId) del payload de forma segura.
|
|
8
48
|
*
|
|
@@ -10,30 +50,61 @@ const domain_1 = require("../../domain");
|
|
|
10
50
|
* - En la mayoría de implementaciones, `sid` viene como claim plano.
|
|
11
51
|
* - Si tu `ITokenServicePort` lo entrega de otra forma, ajusta aquí (único lugar).
|
|
12
52
|
*
|
|
53
|
+
* Ventaja:
|
|
54
|
+
* - Centraliza la dependencia en el formato del payload (single source of truth).
|
|
55
|
+
*
|
|
13
56
|
* @param payload Payload verificado del JWT.
|
|
14
|
-
* @returns sessionId (sid)
|
|
57
|
+
* @returns sessionId (sid).
|
|
15
58
|
*/
|
|
16
59
|
function getSessionIdFromPayload(payload) {
|
|
60
|
+
// Aquí se asume que IJWTPayload expone "sid" como string.
|
|
61
|
+
// Si llegara a ser opcional en el tipo, este método debería retornar string | undefined
|
|
62
|
+
// y el resto del flujo ya está preparado para manejar "falsy".
|
|
17
63
|
return payload.sid;
|
|
18
64
|
}
|
|
19
65
|
/**
|
|
20
66
|
* Servicio de sesiones de usuario (rotación de refresh token) con soporte multi-dispositivo.
|
|
21
67
|
*
|
|
22
|
-
*
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
25
|
-
*
|
|
68
|
+
* Capa:
|
|
69
|
+
* - Infraestructura (orquesta ports y persistencia, no contiene reglas de negocio de Auth)
|
|
70
|
+
*
|
|
71
|
+
* Responsabilidades:
|
|
72
|
+
* - Generar/verificar tokens usando ITokenServicePort (plugin: jose / etc.)
|
|
73
|
+
* - Persistir credenciales por sessionId (1 fila = 1 sesión/dispositivo)
|
|
74
|
+
* - Recuperar usuario desde IUserRepositoryPort
|
|
26
75
|
*
|
|
27
|
-
* Reglas:
|
|
76
|
+
* Reglas clave (multi-dispositivo):
|
|
28
77
|
* - createSession: crea sessionId nuevo (nuevo dispositivo/sesión)
|
|
29
78
|
* - refreshSession: rota tokens manteniendo el MISMO sessionId (misma sesión)
|
|
30
|
-
* - validateSession: valida JWT
|
|
79
|
+
* - validateSession: valida JWT + valida que la sesión exista (revocable) y no expirada
|
|
31
80
|
*/
|
|
32
81
|
class TokenSessionService {
|
|
33
|
-
constructor(
|
|
82
|
+
constructor(
|
|
83
|
+
/**
|
|
84
|
+
* Puerto para operaciones JWT (generar/verificar/expiración).
|
|
85
|
+
* Implementación típica: jose (via @jmlq/auth-plugin-jose).
|
|
86
|
+
*/
|
|
87
|
+
tokenService,
|
|
88
|
+
/**
|
|
89
|
+
* Puerto de usuarios (fuente de verdad del usuario).
|
|
90
|
+
* Se usa para:
|
|
91
|
+
* - encontrar usuario por id (payload.sub)
|
|
92
|
+
* - validar canLogin()
|
|
93
|
+
*/
|
|
94
|
+
userRepository,
|
|
95
|
+
/**
|
|
96
|
+
* Puerto de credenciales/sesiones (persistencia revocable).
|
|
97
|
+
* Se usa para:
|
|
98
|
+
* - save / findBySessionId / findByRefreshToken
|
|
99
|
+
* - deleteByRefreshToken / deleteBySessionId
|
|
100
|
+
*/
|
|
101
|
+
credentialRepository,
|
|
34
102
|
/**
|
|
35
103
|
* Expiración del access token en formato humano.
|
|
36
104
|
* @example "15m"
|
|
105
|
+
*
|
|
106
|
+
* Nota:
|
|
107
|
+
* - La interpretación la hace el tokenService (o un parser interno).
|
|
37
108
|
*/
|
|
38
109
|
accessTokenExpiration = "15m",
|
|
39
110
|
/**
|
|
@@ -50,38 +121,119 @@ class TokenSessionService {
|
|
|
50
121
|
/**
|
|
51
122
|
* Emite access/refresh tokens para un usuario y una sesión dada.
|
|
52
123
|
*
|
|
124
|
+
* Este método es el “núcleo” de emisión de tokens y su claim policy.
|
|
125
|
+
*
|
|
126
|
+
* Qué hace:
|
|
127
|
+
* 1) Construye claims del usuario (id/email/roles)
|
|
128
|
+
* 2) Deriva permissions efectivas desde roles (RBAC)
|
|
129
|
+
* 3) Construye customClaims.permissions para que el API pueda autorizar (requirePermissions)
|
|
130
|
+
* 4) Genera access + refresh
|
|
131
|
+
* 5) Obtiene expiresAt desde el accessToken (fuente robusta: tokenService)
|
|
132
|
+
*
|
|
133
|
+
* Importante:
|
|
134
|
+
* - permissions vienen de roles.getValuePublic().permissions
|
|
135
|
+
* (en el host se “pegan” desde AccessSnapshotResolver)
|
|
136
|
+
*
|
|
53
137
|
* @param user Usuario autenticado.
|
|
54
138
|
* @param sessionId Identificador de sesión/dispositivo.
|
|
55
139
|
*/
|
|
56
140
|
async issueTokens(user, sessionId) {
|
|
141
|
+
/**
|
|
142
|
+
* rolesPublic:
|
|
143
|
+
* - getValuePublic() expone un shape serializable del Role
|
|
144
|
+
* - se tipa como RolePublic[] vía cast (controlado) para extraer permissions en runtime
|
|
145
|
+
*
|
|
146
|
+
* Nota:
|
|
147
|
+
* - Este cast está contenido aquí.
|
|
148
|
+
* - Las comprobaciones reales se hacen con isStringArray().
|
|
149
|
+
*/
|
|
150
|
+
const rolesPublic = user.roles.map((r) => r.getValuePublic());
|
|
151
|
+
/**
|
|
152
|
+
* permissions vienen “pegadas” a los roles por el host (API REST):
|
|
153
|
+
* - AccessSnapshotResolver resuelve roles + permissions actuales
|
|
154
|
+
* - mapRolesFromSnapshot crea Role(role, Permission[]) y getValuePublic expone permissions
|
|
155
|
+
*/
|
|
156
|
+
const permissions = extractPermissionsFromRolesPublic(rolesPublic);
|
|
157
|
+
/**
|
|
158
|
+
* Custom claims mínimos que necesita el API para requirePermissions().
|
|
159
|
+
*
|
|
160
|
+
* Decisión:
|
|
161
|
+
* - Si no hay permissions => customClaims vacío (JWT más limpio).
|
|
162
|
+
* - Si hay permissions => { permissions: string[] }.
|
|
163
|
+
*
|
|
164
|
+
* Esto hace que requirePermissions() pueda operar solo con JWT,
|
|
165
|
+
* sin requerir llamadas extra al resolver en cada request.
|
|
166
|
+
*/
|
|
167
|
+
const customClaims = permissions.length > 0 ? { permissions } : {};
|
|
168
|
+
/**
|
|
169
|
+
* userClaims:
|
|
170
|
+
* - Shape mínimo de usuario que el tokenService necesita
|
|
171
|
+
* - Evita pasar la entidad User completa (no serializable y con lógica)
|
|
172
|
+
*/
|
|
57
173
|
const userClaims = {
|
|
58
174
|
id: user.id.getValue(),
|
|
59
175
|
email: user.email.getValue(),
|
|
60
176
|
roles: user.roles.map((r) => r.getValuePublic()),
|
|
61
177
|
};
|
|
178
|
+
/**
|
|
179
|
+
* Generar access token:
|
|
180
|
+
* - sessionId se coloca en "sid"
|
|
181
|
+
* - expiresIn aplica política de expiración configurada
|
|
182
|
+
* - customClaims adjunta permissions para autorización en el API
|
|
183
|
+
*/
|
|
62
184
|
const accessToken = await this.tokenService.generateAccessToken({
|
|
63
185
|
user: userClaims,
|
|
64
186
|
expiresIn: this.accessTokenExpiration,
|
|
65
187
|
sessionId: sessionId.getValue(),
|
|
188
|
+
customClaims,
|
|
66
189
|
});
|
|
190
|
+
/**
|
|
191
|
+
* Generar refresh token:
|
|
192
|
+
* - Mantiene mismo sid
|
|
193
|
+
* - Suele tener expiración mayor
|
|
194
|
+
* - También transporta customClaims (depende de tu política; aquí se incluye por consistencia)
|
|
195
|
+
*/
|
|
67
196
|
const refreshToken = await this.tokenService.generateRefreshToken({
|
|
68
197
|
user: userClaims,
|
|
69
198
|
expiresIn: this.refreshTokenExpiration,
|
|
70
199
|
sessionId: sessionId.getValue(),
|
|
200
|
+
customClaims,
|
|
71
201
|
});
|
|
72
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Expiración del access token:
|
|
204
|
+
* - Se deriva desde el token (tokenService), evitando parseos propios
|
|
205
|
+
* - Reduce riesgo de inconsistencias entre "exp" y cálculos internos
|
|
206
|
+
*/
|
|
73
207
|
const expiresAt = await this.tokenService.getTokenExpiration(accessToken);
|
|
74
208
|
return { accessToken, refreshToken, expiresAt };
|
|
75
209
|
}
|
|
76
210
|
/**
|
|
77
211
|
* Crea una nueva sesión (nuevo dispositivo).
|
|
78
212
|
*
|
|
213
|
+
* Flujo:
|
|
214
|
+
* 1) Genera un sessionId nuevo
|
|
215
|
+
* 2) Emite tokens atados a ese sessionId
|
|
216
|
+
* 3) Construye Credential (entidad de dominio de sesión)
|
|
217
|
+
* 4) Persiste credencial
|
|
218
|
+
*
|
|
219
|
+
* Efecto:
|
|
220
|
+
* - multi-dispositivo: un usuario puede tener N credenciales activas (N sessionId)
|
|
221
|
+
*
|
|
79
222
|
* @param user Usuario autenticado.
|
|
80
223
|
* @returns Credencial persistida para la nueva sesión.
|
|
81
224
|
*/
|
|
82
225
|
async createSession(user) {
|
|
83
226
|
const sessionId = domain_1.Id.generate();
|
|
84
227
|
const { accessToken, refreshToken, expiresAt } = await this.issueTokens(user, sessionId);
|
|
228
|
+
/**
|
|
229
|
+
* Credential representa la sesión activa.
|
|
230
|
+
* Suele incluir:
|
|
231
|
+
* - sessionId
|
|
232
|
+
* - userId
|
|
233
|
+
* - accessToken actual
|
|
234
|
+
* - refreshToken actual
|
|
235
|
+
* - expiresAt (del access)
|
|
236
|
+
*/
|
|
85
237
|
const credential = domain_1.Credential.create(sessionId, user.id, accessToken, refreshToken, expiresAt);
|
|
86
238
|
await this.credentialRepository.save(credential);
|
|
87
239
|
return credential;
|
|
@@ -89,75 +241,151 @@ class TokenSessionService {
|
|
|
89
241
|
/**
|
|
90
242
|
* Rota refresh token manteniendo el MISMO sessionId (misma sesión/dispositivo).
|
|
91
243
|
*
|
|
244
|
+
* Flujo:
|
|
245
|
+
* 1) Busca credencial por refreshToken (revocable)
|
|
246
|
+
* 2) Verifica refreshToken (firma/exp)
|
|
247
|
+
* 3) Recupera usuario (payload.sub) y valida canLogin()
|
|
248
|
+
* 4) Re-emite tokens con el MISMO sessionId
|
|
249
|
+
* 5) Reemplaza credencial persistida (delete + save)
|
|
250
|
+
*
|
|
251
|
+
* Seguridad:
|
|
252
|
+
* - Si el refreshToken no existe en repositorio => sesión revocada o token inválido => TokenExpiredError
|
|
253
|
+
* - Verificación JWT también puede lanzar => TokenExpiredError
|
|
254
|
+
*
|
|
92
255
|
* @param refreshToken Refresh token actual.
|
|
93
256
|
* @returns Nueva credencial rotada para la misma sesión.
|
|
94
257
|
*/
|
|
95
258
|
async refreshSession(refreshToken) {
|
|
96
259
|
const existing = await this.credentialRepository.findByRefreshToken(refreshToken);
|
|
260
|
+
// Si no existe el refreshToken en DB, se considera expirado/revocado
|
|
97
261
|
if (!existing)
|
|
98
262
|
throw new domain_1.TokenExpiredError();
|
|
263
|
+
/**
|
|
264
|
+
* Verificación criptográfica y claims:
|
|
265
|
+
* - Si la firma o exp fallan => TokenExpiredError
|
|
266
|
+
*
|
|
267
|
+
* Nota:
|
|
268
|
+
* - Se oculta el error real por seguridad (no filtrar detalles al cliente).
|
|
269
|
+
*/
|
|
99
270
|
const payload = await this.tokenService
|
|
100
271
|
.verifyRefreshToken(refreshToken)
|
|
101
272
|
.catch(() => {
|
|
102
273
|
throw new domain_1.TokenExpiredError();
|
|
103
274
|
});
|
|
275
|
+
/**
|
|
276
|
+
* El sub del payload identifica al usuario.
|
|
277
|
+
* Se re-carga para:
|
|
278
|
+
* - validar existencia
|
|
279
|
+
* - validar estado (canLogin)
|
|
280
|
+
* - obtener roles/permisos actuales (si el repo resuelve RBAC en cada fetch)
|
|
281
|
+
*/
|
|
104
282
|
const user = await this.userRepository.findById(new domain_1.Id(payload.sub));
|
|
105
283
|
if (!user)
|
|
106
284
|
throw new domain_1.UserNotFoundError();
|
|
107
285
|
if (!user.canLogin())
|
|
108
286
|
throw new domain_1.UserDisabledError();
|
|
109
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Mantener identidad de sesión:
|
|
289
|
+
* - NO se crea un sessionId nuevo
|
|
290
|
+
* - Se rota token, pero el dispositivo/sesión sigue siendo el mismo
|
|
291
|
+
*/
|
|
110
292
|
const sessionId = existing.sessionId;
|
|
111
293
|
const { accessToken, refreshToken: newRefreshToken, expiresAt, } = await this.issueTokens(user, sessionId);
|
|
112
294
|
const rotated = domain_1.Credential.create(sessionId, user.id, accessToken, newRefreshToken, expiresAt);
|
|
113
295
|
/**
|
|
114
|
-
*
|
|
115
|
-
* -
|
|
116
|
-
* -
|
|
117
|
-
*
|
|
118
|
-
* Alternativa: credentialRepository.update(rotated)
|
|
119
|
-
* (si tu implementación hace UPSERT, `save` ya cubre el caso)
|
|
296
|
+
* Rotación atómica (single-use):
|
|
297
|
+
* - Si otro request ya consumió este refreshToken, esta operación debe fallar.
|
|
298
|
+
* - Evita que el mismo refreshToken se use dos veces bajo concurrencia.
|
|
120
299
|
*/
|
|
121
|
-
await this.credentialRepository.
|
|
122
|
-
|
|
300
|
+
const rotatedOk = await this.credentialRepository.rotateByRefreshToken(refreshToken, rotated);
|
|
301
|
+
if (!rotatedOk) {
|
|
302
|
+
// Token ya consumido/revocado/no existe (no filtramos detalle)
|
|
303
|
+
throw new domain_1.TokenExpiredError();
|
|
304
|
+
}
|
|
123
305
|
return rotated;
|
|
124
306
|
}
|
|
125
307
|
/**
|
|
126
308
|
* Valida una sesión a partir de access token.
|
|
127
309
|
*
|
|
310
|
+
* Este método soporta “revocación” (token válido criptográficamente pero sesión eliminada en DB).
|
|
311
|
+
*
|
|
128
312
|
* Reglas:
|
|
129
313
|
* - JWT debe ser válido (firma/exp/issuer/aud según plugin)
|
|
130
314
|
* - Debe existir `sid` en el payload
|
|
131
|
-
* - La sesión (sid) debe existir en el repositorio
|
|
132
|
-
* - (
|
|
315
|
+
* - La sesión (sid) debe existir en el repositorio
|
|
316
|
+
* - (recomendado) accessToken debe coincidir con el almacenado (solo el último token es válido)
|
|
317
|
+
* - La credencial no debe estar expirada (según su estado interno)
|
|
318
|
+
* - El usuario debe existir y poder loguearse
|
|
133
319
|
*
|
|
134
320
|
* @param accessToken Access token recibido.
|
|
135
321
|
* @returns Usuario si sesión es válida, o null si no lo es.
|
|
136
322
|
*/
|
|
137
323
|
async validateSession(accessToken) {
|
|
138
324
|
try {
|
|
325
|
+
/**
|
|
326
|
+
* verifyAccessToken:
|
|
327
|
+
* - valida firma y exp y claims críticos según configuración del tokenService
|
|
328
|
+
* - retorna payload tipado como IJWTPayload
|
|
329
|
+
*/
|
|
139
330
|
const payload = await this.tokenService.verifyAccessToken(accessToken);
|
|
331
|
+
/**
|
|
332
|
+
* sid:
|
|
333
|
+
* - identifica la sesión/dispositivo
|
|
334
|
+
* - si no existe, no podemos validar sesión revocable => null
|
|
335
|
+
*/
|
|
140
336
|
const sid = getSessionIdFromPayload(payload);
|
|
141
337
|
if (!sid)
|
|
142
338
|
return null;
|
|
339
|
+
/**
|
|
340
|
+
* Validación de sesión en storage:
|
|
341
|
+
* - si no existe => sesión revocada => null
|
|
342
|
+
*/
|
|
143
343
|
const credential = await this.credentialRepository.findBySessionId(new domain_1.Id(sid));
|
|
144
344
|
if (!credential)
|
|
145
345
|
return null;
|
|
146
|
-
|
|
346
|
+
/**
|
|
347
|
+
* Validación recomendada:
|
|
348
|
+
* - asegura que solo el último accessToken emitido para esa sesión sea aceptado
|
|
349
|
+
* - previene replay de accessTokens antiguos dentro de la misma sesión
|
|
350
|
+
*
|
|
351
|
+
* Nota:
|
|
352
|
+
* - Esto exige que el repo persista accessToken actual.
|
|
353
|
+
*/
|
|
147
354
|
if (credential.accessToken !== accessToken)
|
|
148
355
|
return null;
|
|
356
|
+
/**
|
|
357
|
+
* Validación de expiración a nivel de credencial:
|
|
358
|
+
* - respaldo adicional (además de "exp" del JWT)
|
|
359
|
+
* - útil si tu sistema modela expiración o revocación interna
|
|
360
|
+
*/
|
|
149
361
|
if (credential.isExpired())
|
|
150
362
|
return null;
|
|
363
|
+
/**
|
|
364
|
+
* sub del payload identifica al usuario.
|
|
365
|
+
* Se valida existencia y estado.
|
|
366
|
+
*/
|
|
151
367
|
const user = await this.userRepository.findById(new domain_1.Id(payload.sub));
|
|
152
368
|
return user && user.canLogin() ? user : null;
|
|
153
369
|
}
|
|
154
370
|
catch (e) {
|
|
371
|
+
/**
|
|
372
|
+
* SessionAuthError:
|
|
373
|
+
* - encapsula el error interno (logging/observabilidad)
|
|
374
|
+
* - evita filtrar detalles sensibles al exterior
|
|
375
|
+
*/
|
|
155
376
|
throw new domain_1.SessionAuthError("Session Authentication failed", e);
|
|
156
377
|
}
|
|
157
378
|
}
|
|
158
379
|
/**
|
|
159
380
|
* Revoca una sesión usando refresh token.
|
|
160
381
|
*
|
|
382
|
+
* Uso típico:
|
|
383
|
+
* - logout donde el cliente envía refreshToken (cookie/body)
|
|
384
|
+
*
|
|
385
|
+
* Efecto:
|
|
386
|
+
* - la sesión deja de poder refrescar tokens
|
|
387
|
+
* - si validateSession compara accessToken, también invalida el access actual cuando se borra la credencial
|
|
388
|
+
*
|
|
161
389
|
* @param refreshToken Refresh token actual.
|
|
162
390
|
*/
|
|
163
391
|
async revokeSession(refreshToken) {
|
|
@@ -166,6 +394,9 @@ class TokenSessionService {
|
|
|
166
394
|
/**
|
|
167
395
|
* Revoca una sesión por sessionId (logout por dispositivo).
|
|
168
396
|
*
|
|
397
|
+
* Uso típico:
|
|
398
|
+
* - logout selectivo (cerrar solo este dispositivo)
|
|
399
|
+
*
|
|
169
400
|
* @param sessionId Identificador de sesión/dispositivo.
|
|
170
401
|
*/
|
|
171
402
|
async revokeSessionById(sessionId) {
|