import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { firstValueFrom } from 'rxjs'; import { KeycloakConfig } from '../../config/keycloak.config'; import * as jwt from 'jsonwebtoken'; export interface KeycloakTokenResponse { access_token: string; refresh_token?: string; expires_in: number; token_type: string; scope?: string; } @Injectable() export class TokenService { private readonly logger = new Logger(TokenService.name); private currentToken: string | null = null; private tokenExpiry: Date | null = null; constructor( private configService: ConfigService, private httpService: HttpService, ) {} // === POUR L'API ADMIN (KeycloakApiService) - Client Credentials === async getToken(): Promise { // Si nous avons un token valide, le retourner if (this.currentToken && this.isTokenValid()) { return this.currentToken; } // Sinon, acquérir un nouveau token en utilisant client_credentials return await this.acquireClientCredentialsToken(); } private async acquireClientCredentialsToken(): Promise { const keycloakConfig = this.configService.get('keycloak'); if (!keycloakConfig) { throw new Error('Keycloak configuration not found'); } const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`; const params = new URLSearchParams(); params.append('grant_type', 'client_credentials'); params.append('client_id', keycloakConfig.adminClientId); // ← Client admin params.append('client_secret', keycloakConfig.adminClientSecret); // ← Secret admin try { const response = await firstValueFrom( this.httpService.post(tokenEndpoint, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }) ); // Stocker le token et sa date d'expiration this.currentToken = response.data.access_token; this.tokenExpiry = new Date(Date.now() + (response.data.expires_in * 1000)); this.logger.log('Successfully acquired client credentials token'); return this.currentToken; } catch (error: any) { this.logger.error('Failed to acquire client token', error.response?.data); throw new Error(error.response?.data?.error_description || 'Failed to acquire client token'); } } private isTokenValid(): boolean { if (!this.currentToken || !this.tokenExpiry) { return false; } // Ajouter un buffer de sécurité (30 secondes par défaut) const bufferSeconds = this.configService.get('keycloak.tokenBufferSeconds') || 30; const bufferMs = bufferSeconds * 1000; return this.tokenExpiry.getTime() > (Date.now() + bufferMs); } // === POUR L'AUTHENTIFICATION UTILISATEUR (AuthController) - Password Grant === async acquireUserToken(username: string, password: string): Promise { const keycloakConfig = this.configService.get('keycloak'); if (!keycloakConfig) { throw new Error('Keycloak configuration not found'); } const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`; const params = new URLSearchParams(); params.append('grant_type', 'password'); params.append('client_id', keycloakConfig.authClientId); // ← Client auth params.append('client_secret', keycloakConfig.authClientSecret); // ← Secret auth params.append('username', username); params.append('password', password); try { const response = await firstValueFrom( this.httpService.post(tokenEndpoint, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }) ); this.logger.log(`User token acquired successfully for: ${username}`); return response.data; } catch (error: any) { this.logger.error('Failed to acquire user token', error.response?.data); throw new Error(error.response?.data?.error_description || 'Authentication failed'); } } async refreshToken(refreshToken: string): Promise { const keycloakConfig = this.configService.get('keycloak'); if (!keycloakConfig) { throw new Error('Keycloak configuration not found'); } const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`; const params = new URLSearchParams(); params.append('grant_type', 'refresh_token'); params.append('client_id', keycloakConfig.authClientId); // ← Utiliser le client auth pour le refresh params.append('client_secret', keycloakConfig.authClientSecret); params.append('refresh_token', refreshToken); try { const response = await firstValueFrom( this.httpService.post(tokenEndpoint, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }) ); return response.data; } catch (error: any) { this.logger.error('Token refresh failed', error.response?.data); throw new Error(error.response?.data?.error_description || 'Token refresh failed'); } } async revokeToken(token: string): Promise { const keycloakConfig = this.configService.get('keycloak'); if (!keycloakConfig) { throw new Error('Keycloak configuration not found'); } const revokeEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/revoke`; const params = new URLSearchParams(); // Utiliser le client auth pour la révocation (car c'est généralement lié aux tokens utilisateur) params.append('client_id', keycloakConfig.authClientId); params.append('client_secret', keycloakConfig.authClientSecret); params.append('token', token); try { await firstValueFrom( this.httpService.post(revokeEndpoint, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }) ); this.logger.log('Token revoked successfully'); } catch (error: any) { this.logger.error('Token revocation failed', error.response?.data); throw new Error('Token revocation failed'); } } async validateOffline(token: string): Promise { const keycloakConfig = this.configService.get('keycloak'); if (!keycloakConfig?.publicKey) { this.logger.error('Missing Keycloak public key for offline validation'); return false; } try { const formattedKey = `-----BEGIN PUBLIC KEY-----\n${keycloakConfig.publicKey}\n-----END PUBLIC KEY-----`; jwt.verify(token, formattedKey, { algorithms: ['RS256'], audience: keycloakConfig.authClientId, }); return true; } catch (err) { this.logger.error('Offline token validation failed:', err.message); return false; } } async validateToken(token: string): Promise { const mode = this.configService.get('keycloak.validationMode') || 'online'; if (mode === 'offline') { return this.validateOffline(token); } else { return this.validateOnline(token); } } private async validateOnline(token: string): Promise { const keycloakConfig = this.configService.get('keycloak'); if (!keycloakConfig) { throw new Error('Keycloak configuration not found'); } const introspectEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token/introspect`; const params = new URLSearchParams(); params.append('client_id', keycloakConfig.authClientId); params.append('client_secret', keycloakConfig.authClientSecret); params.append('token', token); try { const response = await firstValueFrom( this.httpService.post(introspectEndpoint, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }) ); return response.data.active === true; } catch (error: any) { this.logger.error('Online token validation failed', error.response?.data); return false; } } async getStoredRefreshToken(accessToken: string): Promise { // Implémentez votre logique de stockage des refresh tokens ici // Pour l'instant, retournez null ou implémentez selon vos besoins return null; } // === MÉTHODES UTILITAIRES === clearToken(): void { this.currentToken = null; this.tokenExpiry = null; this.logger.log('Admin client token cleared from cache'); } getTokenInfo(): { hasToken: boolean; expiresIn?: number; clientType: string } { if (!this.currentToken || !this.tokenExpiry) { return { hasToken: false, clientType: 'admin' }; } const expiresIn = this.tokenExpiry.getTime() - Date.now(); return { hasToken: true, expiresIn: Math.max(0, Math.floor(expiresIn / 1000)), // en secondes clientType: 'admin' }; } }