@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.
Files changed (37) hide show
  1. package/.eslintrc.js +26 -0
  2. package/.prettierrc +20 -0
  3. package/README.md +299 -0
  4. package/dist/decorators/current-user.decorator.d.ts +3 -0
  5. package/dist/decorators/current-user.decorator.d.ts.map +1 -0
  6. package/dist/decorators/current-user.decorator.js +10 -0
  7. package/dist/decorators/current-user.decorator.js.map +1 -0
  8. package/dist/guards/keycloak-auth.guard.d.ts +18 -0
  9. package/dist/guards/keycloak-auth.guard.d.ts.map +1 -0
  10. package/dist/guards/keycloak-auth.guard.js +81 -0
  11. package/dist/guards/keycloak-auth.guard.js.map +1 -0
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +28 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/interfaces/keycloak-config.interface.d.ts +56 -0
  17. package/dist/interfaces/keycloak-config.interface.d.ts.map +1 -0
  18. package/dist/interfaces/keycloak-config.interface.js +3 -0
  19. package/dist/interfaces/keycloak-config.interface.js.map +1 -0
  20. package/dist/keycloak-auth.module.d.ts +5 -0
  21. package/dist/keycloak-auth.module.d.ts.map +1 -0
  22. package/dist/keycloak-auth.module.js +44 -0
  23. package/dist/keycloak-auth.module.js.map +1 -0
  24. package/dist/keycloak-auth.service.d.ts +16 -0
  25. package/dist/keycloak-auth.service.d.ts.map +1 -0
  26. package/dist/keycloak-auth.service.js +231 -0
  27. package/dist/keycloak-auth.service.js.map +1 -0
  28. package/examples/frontend-controlled-auth.ts +96 -0
  29. package/jest.config.js +14 -0
  30. package/package.json +52 -0
  31. package/src/decorators/current-user.decorator.ts +11 -0
  32. package/src/guards/keycloak-auth.guard.ts +93 -0
  33. package/src/index.ts +14 -0
  34. package/src/interfaces/keycloak-config.interface.ts +58 -0
  35. package/src/keycloak-auth.module.ts +32 -0
  36. package/src/keycloak-auth.service.ts +243 -0
  37. 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
+ }