270 lines
9.1 KiB
TypeScript
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'
|
|
};
|
|
}
|
|
}
|