All files / services/core entra-client-credential-service.ts

100% Statements 23/23
90.9% Branches 10/11
100% Functions 3/3
100% Lines 23/23

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122                                                        3x                               3x                     25x 25x   25x 4x     21x 21x   21x             21x               21x     4x 4x 2x   2x     4x     17x 17x   17x         17x                 2x 2x             14x      
import { EntraClientAuthCredential } from './credential';
 
/**
 * Token response from the Microsoft Entra ID token endpoint.
 */
interface TokenResponse {
	access_token: string;
	token_type: string;
	expires_in: number;
	ext_expires_in?: number;
}
 
/**
 * Error response from the Microsoft Entra ID token endpoint.
 */
interface TokenErrorResponse {
	error: string;
	error_description: string;
	error_codes?: number[];
	timestamp?: string;
	trace_id?: string;
	correlation_id?: string;
}
 
/**
 * A buffer time (in milliseconds) before the token's actual expiration to trigger a refresh.
 * Tokens are refreshed 5 minutes before they expire.
 */
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
 
/**
 * Service for acquiring OAuth 2.0 access tokens using the Client Credentials Flow
 * against Microsoft Entra ID (Azure AD).
 * 
 * This flow is used for service-to-service authentication where no user interaction
 * is required. The application authenticates using its own identity (client ID + secret)
 * rather than on behalf of a user.
 * 
 * Token endpoint: `https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token`
 */
export class EntraClientCredentialService {
	/**
	 * In-memory token cache keyed by `{tenantId}:{clientId}`.
	 */
	private static _tokenCache = new Map<string, { accessToken: string; expiresAt: number }>();
 
	/**
	 * Acquires an access token using the Client Credentials Grant.
	 * Returns a cached token if it is still valid; otherwise, requests a new one.
	 * 
	 * @param credential The Entra Client Credential configuration.
	 * @returns A promise that resolves to an access token string.
	 * @throws An error if the token request fails.
	 */
	async acquireToken(credential: EntraClientAuthCredential): Promise<string> {
		const cacheKey = `${credential.tenantId}:${credential.clientId}`;
		const cached = EntraClientCredentialService._tokenCache.get(cacheKey);
 
		if (cached && cached.expiresAt > Date.now() + TOKEN_EXPIRY_BUFFER_MS) {
			return cached.accessToken;
		}
 
		const tokenEndpoint = `https://login.microsoftonline.com/${credential.tenantId}/oauth2/v2.0/token`;
		const scope = credential.scopes.join(' ');
 
		const body = new URLSearchParams({
			grant_type: 'client_credentials',
			client_id: credential.clientId,
			client_secret: credential.clientSecret,
			scope: scope
		});
 
		const response = await fetch(tokenEndpoint, {
			method: 'POST',
			headers: {
				'Content-Type': 'application/x-www-form-urlencoded'
			},
			body: body.toString()
		});
 
		if (!response.ok) {
			let errorMessage: string;
 
			try {
				const errorBody = await response.json() as TokenErrorResponse;
				errorMessage = errorBody.error_description || errorBody.error || response.statusText;
			} catch {
				errorMessage = await response.text() || response.statusText;
			}
 
			throw new Error(`Failed to acquire token from Entra ID: ${errorMessage}`);
		}
 
		const tokenResponse = await response.json() as TokenResponse;
		const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
 
		EntraClientCredentialService._tokenCache.set(cacheKey, {
			accessToken: tokenResponse.access_token,
			expiresAt
		});
 
		return tokenResponse.access_token;
	}
 
	/**
	 * Clears the cached token for the given credential.
	 * 
	 * @param credential The Entra Client Credential configuration.
	 */
	clearCache(credential: EntraClientAuthCredential): void {
		const cacheKey = `${credential.tenantId}:${credential.clientId}`;
		EntraClientCredentialService._tokenCache.delete(cacheKey);
	}
 
	/**
	 * Clears all cached tokens.
	 */
	clearAllCaches(): void {
		EntraClientCredentialService._tokenCache.clear();
	}
}