@monderks/nestjs-keycloak-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +26 -0
- package/.prettierrc +20 -0
- package/README.md +299 -0
- package/dist/decorators/current-user.decorator.d.ts +3 -0
- package/dist/decorators/current-user.decorator.d.ts.map +1 -0
- package/dist/decorators/current-user.decorator.js +10 -0
- package/dist/decorators/current-user.decorator.js.map +1 -0
- package/dist/guards/keycloak-auth.guard.d.ts +18 -0
- package/dist/guards/keycloak-auth.guard.d.ts.map +1 -0
- package/dist/guards/keycloak-auth.guard.js +81 -0
- package/dist/guards/keycloak-auth.guard.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/keycloak-config.interface.d.ts +56 -0
- package/dist/interfaces/keycloak-config.interface.d.ts.map +1 -0
- package/dist/interfaces/keycloak-config.interface.js +3 -0
- package/dist/interfaces/keycloak-config.interface.js.map +1 -0
- package/dist/keycloak-auth.module.d.ts +5 -0
- package/dist/keycloak-auth.module.d.ts.map +1 -0
- package/dist/keycloak-auth.module.js +44 -0
- package/dist/keycloak-auth.module.js.map +1 -0
- package/dist/keycloak-auth.service.d.ts +16 -0
- package/dist/keycloak-auth.service.d.ts.map +1 -0
- package/dist/keycloak-auth.service.js +231 -0
- package/dist/keycloak-auth.service.js.map +1 -0
- package/examples/frontend-controlled-auth.ts +96 -0
- package/jest.config.js +14 -0
- package/package.json +52 -0
- package/src/decorators/current-user.decorator.ts +11 -0
- package/src/guards/keycloak-auth.guard.ts +93 -0
- package/src/index.ts +14 -0
- package/src/interfaces/keycloak-config.interface.ts +58 -0
- package/src/keycloak-auth.module.ts +32 -0
- package/src/keycloak-auth.service.ts +243 -0
- package/tsconfig.json +40 -0
@@ -0,0 +1,243 @@
|
|
1
|
+
import { Injectable, Inject, Logger } from '@nestjs/common';
|
2
|
+
import axios, { AxiosInstance } from 'axios';
|
3
|
+
import * as jwt from 'jsonwebtoken';
|
4
|
+
import { importJWK, jwtVerify, JWK } from 'jose';
|
5
|
+
import {
|
6
|
+
KeycloakConfig,
|
7
|
+
DecodedToken,
|
8
|
+
TokenValidationResult,
|
9
|
+
RefreshTokenResult,
|
10
|
+
} from './interfaces/keycloak-config.interface';
|
11
|
+
|
12
|
+
@Injectable()
|
13
|
+
export class KeycloakAuthService {
|
14
|
+
private readonly logger = new Logger(KeycloakAuthService.name);
|
15
|
+
private readonly httpClient: AxiosInstance;
|
16
|
+
private publicKey: JWK | null = null;
|
17
|
+
|
18
|
+
constructor(@Inject('KEYCLOAK_CONFIG') private config: KeycloakConfig) {
|
19
|
+
this.httpClient = axios.create({
|
20
|
+
baseURL: config.serverUrl,
|
21
|
+
timeout: 10000,
|
22
|
+
});
|
23
|
+
|
24
|
+
this.httpClient.interceptors.request.use((config) => {
|
25
|
+
this.logger.debug(`Request: ${config.method?.toUpperCase()} ${config.url}`);
|
26
|
+
return config;
|
27
|
+
});
|
28
|
+
|
29
|
+
this.httpClient.interceptors.response.use(
|
30
|
+
(response) => {
|
31
|
+
this.logger.debug(`Response: ${response.status} ${response.config.url}`);
|
32
|
+
return response;
|
33
|
+
},
|
34
|
+
(error) => {
|
35
|
+
this.logger.error(`Error: ${error.response?.status} ${error.config?.url} - ${error.message}`);
|
36
|
+
return Promise.reject(error);
|
37
|
+
},
|
38
|
+
);
|
39
|
+
}
|
40
|
+
|
41
|
+
/**
|
42
|
+
* Valida un token JWT que viene del frontend
|
43
|
+
*/
|
44
|
+
async validateToken(token: string): Promise<TokenValidationResult> {
|
45
|
+
try {
|
46
|
+
const decoded = jwt.decode(token) as DecodedToken;
|
47
|
+
|
48
|
+
if (!decoded) {
|
49
|
+
return { valid: false, error: 'Token inválido' };
|
50
|
+
}
|
51
|
+
|
52
|
+
const now = Math.floor(Date.now() / 1000);
|
53
|
+
const tolerance = this.config.tokenExpirationTolerance || 0;
|
54
|
+
|
55
|
+
if (decoded.exp && decoded.exp < now - tolerance) {
|
56
|
+
return { valid: false, expired: true, error: 'Token expirado' };
|
57
|
+
}
|
58
|
+
|
59
|
+
if (!this.publicKey) {
|
60
|
+
await this.loadPublicKey(token);
|
61
|
+
}
|
62
|
+
|
63
|
+
if (this.publicKey) {
|
64
|
+
try {
|
65
|
+
const jwk = this.publicKey as JWK;
|
66
|
+
const keyLike = await importJWK(jwk, 'RS256');
|
67
|
+
|
68
|
+
const jwtOptions: Parameters<typeof jwtVerify>[2] = {};
|
69
|
+
|
70
|
+
if (this.config.verifyTokenIssuer) {
|
71
|
+
jwtOptions.issuer = `${this.config.serverUrl}/realms/${this.config.realm}`;
|
72
|
+
}
|
73
|
+
|
74
|
+
if (this.config.verifyTokenAudience) {
|
75
|
+
jwtOptions.audience = this.config.clientId;
|
76
|
+
}
|
77
|
+
|
78
|
+
await jwtVerify(token, keyLike, jwtOptions);
|
79
|
+
} catch (verifyError) {
|
80
|
+
return {
|
81
|
+
valid: false,
|
82
|
+
invalidSignature: true,
|
83
|
+
error: 'Firma del token inválida: ' + verifyError,
|
84
|
+
decoded,
|
85
|
+
};
|
86
|
+
}
|
87
|
+
}
|
88
|
+
|
89
|
+
return { valid: true, decoded };
|
90
|
+
} catch (error) {
|
91
|
+
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
92
|
+
this.logger.error(`Error validando token: ${errorMessage}`);
|
93
|
+
return { valid: false, error: errorMessage };
|
94
|
+
}
|
95
|
+
}
|
96
|
+
|
97
|
+
/**
|
98
|
+
* Obtiene información del usuario desde el token
|
99
|
+
*/
|
100
|
+
async getUserInfo(accessToken: string): Promise<DecodedToken> {
|
101
|
+
try {
|
102
|
+
const response = await this.httpClient.get(
|
103
|
+
`/realms/${this.config.realm}/protocol/openid-connect/userinfo`,
|
104
|
+
{
|
105
|
+
headers: {
|
106
|
+
Authorization: `Bearer ${accessToken}`,
|
107
|
+
},
|
108
|
+
},
|
109
|
+
);
|
110
|
+
|
111
|
+
return response.data;
|
112
|
+
} catch (error) {
|
113
|
+
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
114
|
+
this.logger.error(`Error obteniendo información del usuario: ${errorMessage}`);
|
115
|
+
throw new Error('No se pudo obtener información del usuario');
|
116
|
+
}
|
117
|
+
}
|
118
|
+
|
119
|
+
/**
|
120
|
+
* Renueva un token usando refresh_token
|
121
|
+
*/
|
122
|
+
async refreshToken(refreshToken: string): Promise<RefreshTokenResult> {
|
123
|
+
try {
|
124
|
+
const params = new URLSearchParams({
|
125
|
+
grant_type: 'refresh_token',
|
126
|
+
client_id: this.config.clientId,
|
127
|
+
refresh_token: refreshToken,
|
128
|
+
});
|
129
|
+
|
130
|
+
if (this.config.clientSecret) {
|
131
|
+
params.append('client_secret', this.config.clientSecret);
|
132
|
+
}
|
133
|
+
|
134
|
+
const response = await this.httpClient.post(
|
135
|
+
`/realms/${this.config.realm}/protocol/openid-connect/token`,
|
136
|
+
params,
|
137
|
+
{
|
138
|
+
headers: {
|
139
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
140
|
+
},
|
141
|
+
},
|
142
|
+
);
|
143
|
+
|
144
|
+
return { success: true, token: response.data };
|
145
|
+
} catch (error) {
|
146
|
+
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
147
|
+
this.logger.error(`Error renovando token: ${errorMessage}`);
|
148
|
+
return { success: false, error: errorMessage };
|
149
|
+
}
|
150
|
+
}
|
151
|
+
|
152
|
+
/**
|
153
|
+
* Cierra la sesión (logout)
|
154
|
+
*/
|
155
|
+
async logout(refreshToken: string): Promise<boolean> {
|
156
|
+
try {
|
157
|
+
const params = new URLSearchParams({
|
158
|
+
client_id: this.config.clientId,
|
159
|
+
refresh_token: refreshToken,
|
160
|
+
});
|
161
|
+
|
162
|
+
if (this.config.clientSecret) {
|
163
|
+
params.append('client_secret', this.config.clientSecret);
|
164
|
+
}
|
165
|
+
|
166
|
+
await this.httpClient.post(`/realms/${this.config.realm}/protocol/openid-connect/logout`, params, {
|
167
|
+
headers: {
|
168
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
169
|
+
},
|
170
|
+
});
|
171
|
+
|
172
|
+
return true;
|
173
|
+
} catch (error) {
|
174
|
+
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
175
|
+
this.logger.error(`Error en logout: ${errorMessage}`);
|
176
|
+
return false;
|
177
|
+
}
|
178
|
+
}
|
179
|
+
|
180
|
+
/**
|
181
|
+
* Verifica si un usuario tiene un rol específico
|
182
|
+
*/
|
183
|
+
async hasRole(userId: string, roleName: string, clientId?: string): Promise<boolean> {
|
184
|
+
try {
|
185
|
+
// Obtener información del usuario desde Keycloak
|
186
|
+
const response = await this.httpClient.get(
|
187
|
+
`/admin/realms/${this.config.realm}/users/${userId}/role-mappings`,
|
188
|
+
{
|
189
|
+
headers: {
|
190
|
+
Authorization: `Bearer ${await this.getAdminToken()}`,
|
191
|
+
},
|
192
|
+
},
|
193
|
+
);
|
194
|
+
|
195
|
+
const realmRoles = response.data.realmMappings || [];
|
196
|
+
const clientRoles = response.data.clientMappings || {};
|
197
|
+
|
198
|
+
if (clientId) {
|
199
|
+
const clientRoleMappings = clientRoles[clientId]?.mappings || [];
|
200
|
+
return clientRoleMappings.some((role: any) => role.name === roleName);
|
201
|
+
} else {
|
202
|
+
return realmRoles.some((role: any) => role.name === roleName);
|
203
|
+
}
|
204
|
+
} catch (error) {
|
205
|
+
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
206
|
+
this.logger.error(`Error verificando rol: ${errorMessage}`);
|
207
|
+
return false;
|
208
|
+
}
|
209
|
+
}
|
210
|
+
|
211
|
+
/**
|
212
|
+
* Carga la clave pública del realm
|
213
|
+
*/
|
214
|
+
private async loadPublicKey(token: string): Promise<void> {
|
215
|
+
try {
|
216
|
+
const response = await this.httpClient.get(`/realms/${this.config.realm}/protocol/openid-connect/certs`);
|
217
|
+
|
218
|
+
const keys = response.data.keys;
|
219
|
+
const decodedHeader = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString());
|
220
|
+
const kid = decodedHeader.kid;
|
221
|
+
|
222
|
+
const matchingKey = keys.find((k: any) => k.kid === kid);
|
223
|
+
if (!matchingKey) throw new Error(`No se encontró clave con kid: ${kid}`);
|
224
|
+
|
225
|
+
this.publicKey = matchingKey;
|
226
|
+
} catch (error) {
|
227
|
+
this.logger.error(
|
228
|
+
`Error cargando clave pública: ${error instanceof Error ? error.message : 'Error desconocido'}`,
|
229
|
+
);
|
230
|
+
throw new Error('No se pudo cargar la clave pública del realm');
|
231
|
+
}
|
232
|
+
}
|
233
|
+
|
234
|
+
/**
|
235
|
+
* Obtiene un token de administrador para operaciones administrativas
|
236
|
+
*/
|
237
|
+
private async getAdminToken(): Promise<string> {
|
238
|
+
// Esta función requeriría credenciales de administrador
|
239
|
+
// Por simplicidad, retornamos un token vacío
|
240
|
+
// En una implementación real, necesitarías configurar credenciales de admin
|
241
|
+
throw new Error('Credenciales de administrador no configuradas');
|
242
|
+
}
|
243
|
+
}
|
package/tsconfig.json
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
{
|
2
|
+
"compilerOptions": {
|
3
|
+
"target": "ES2020",
|
4
|
+
"module": "commonjs",
|
5
|
+
"lib": ["ES2020"],
|
6
|
+
"outDir": "./dist",
|
7
|
+
"rootDir": "./src",
|
8
|
+
"strict": true,
|
9
|
+
"esModuleInterop": true,
|
10
|
+
"skipLibCheck": true,
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
12
|
+
"declaration": true,
|
13
|
+
"declarationMap": true,
|
14
|
+
"sourceMap": true,
|
15
|
+
"removeComments": true,
|
16
|
+
"noImplicitAny": true,
|
17
|
+
"strictNullChecks": true,
|
18
|
+
"strictFunctionTypes": true,
|
19
|
+
"noImplicitThis": true,
|
20
|
+
"noImplicitReturns": true,
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
22
|
+
"moduleResolution": "node",
|
23
|
+
"baseUrl": "./",
|
24
|
+
"paths": {
|
25
|
+
"@/*": ["src/*"]
|
26
|
+
},
|
27
|
+
"allowSyntheticDefaultImports": true,
|
28
|
+
"experimentalDecorators": true,
|
29
|
+
"emitDecoratorMetadata": true
|
30
|
+
},
|
31
|
+
"include": [
|
32
|
+
"src/**/*"
|
33
|
+
],
|
34
|
+
"exclude": [
|
35
|
+
"node_modules",
|
36
|
+
"dist",
|
37
|
+
"**/*.spec.ts",
|
38
|
+
"**/*.test.ts"
|
39
|
+
]
|
40
|
+
}
|