dcb-user-service/src/auth/services/token.service.ts

270 lines
9.1 KiB
TypeScript

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<string> {
// 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<string> {
const keycloakConfig = this.configService.get<KeycloakConfig>('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<KeycloakTokenResponse>(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<number>('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<KeycloakTokenResponse> {
const keycloakConfig = this.configService.get<KeycloakConfig>('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<KeycloakTokenResponse>(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<KeycloakTokenResponse> {
const keycloakConfig = this.configService.get<KeycloakConfig>('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<KeycloakTokenResponse>(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<void> {
const keycloakConfig = this.configService.get<KeycloakConfig>('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<boolean> {
const keycloakConfig = this.configService.get<KeycloakConfig>('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<boolean> {
const mode = this.configService.get<string>('keycloak.validationMode') || 'online';
if (mode === 'offline') {
return this.validateOffline(token);
} else {
return this.validateOnline(token);
}
}
private async validateOnline(token: string): Promise<boolean> {
const keycloakConfig = this.configService.get<KeycloakConfig>('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<string | null> {
// 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'
};
}
}