UNPKG

@citrineos/util

Version:

The OCPP util module which supplies helpful utilities like cache and queue connectors, etc.

231 lines 10.8 kB
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache-2.0 import { HttpMethod, HttpStatus, HUBJECT_DEFAULT_BASEURL, HUBJECT_DEFAULT_CLIENTID, HUBJECT_DEFAULT_CLIENTSECRET, HUBJECT_DEFAULT_TOKENURL, } from '@citrineos/base'; import { Logger } from 'tslog'; import { createPemBlock } from '../CertificateUtil.js'; export class Hubject { _baseUrl; _tokenUrl; _clientId; _clientSecret; _logger; _cache; static AUTH_TOKEN_CACHE_KEY = 'HUBJECT_AUTH_TOKEN'; static AUTH_TOKEN_CACHE_NAMESPACE = 'hubject'; constructor(config, cache, logger) { const hubjectConfig = config.util.certificateAuthority.v2gCA.hubject; if (!hubjectConfig) { throw new Error('Missing Hubject configuration'); } this._baseUrl = hubjectConfig.baseUrl; this._tokenUrl = hubjectConfig.tokenUrl; this._clientId = hubjectConfig.clientId; this._clientSecret = hubjectConfig.clientSecret; this._cache = cache; this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) : new Logger({ name: this.constructor.name }); } /** * Retrieves a signed certificate based on the provided CSR. * DOC: https://hubject.stoplight.io/docs/open-plugncharge/486f0b8b3ded4-simple-enroll-iso-15118-2-and-iso-15118-20 * * @param {string} csrString - The certificate signing request from SignCertificateRequest. * @return {Promise<string>} The signed certificate without header and footer. */ async getSignedCertificate(csrString) { return this._makeAuthenticatedRequest(async () => { const url = `${this._baseUrl}/.well-known/cpo/simpleenroll`; return fetch(url, { method: 'POST', headers: { Accept: 'application/pkcs10', Authorization: await this._getAuthorizationToken(), 'Content-Type': 'application/pkcs10', }, body: csrString, }); }, 'Get signed certificate response is unexpected'); } /** * Retrieves the CA certificates including sub CAs and root CA. * DOC: https://hubject.stoplight.io/docs/open-plugncharge/e246aa213bc22-obtaining-ca-certificates-iso-15118-2-and-iso-15118-20 * * @return {Promise<string>} The CA certificates. */ async getCACertificates() { return this._makeAuthenticatedRequest(async () => { const url = `${this._baseUrl}/.well-known/cpo/cacerts`; return fetch(url, { method: 'GET', headers: { Accept: 'application/pkcs10, application/pkcs7', Authorization: await this._getAuthorizationToken(), 'Content-Transfer-Encoding': 'application/pkcs10', }, }); }, 'Get CA certificates response is unexpected'); } async getSignedContractData(xsdMsgDefNamespace, certificateInstallationReq) { const responseText = await this._makeAuthenticatedRequest(async () => { const url = `${this._baseUrl}/v1/ccp/signedContractData`; return fetch(url, { method: 'POST', headers: { Accept: 'application/json', Authorization: await this._getAuthorizationToken(), 'Content-Type': 'application/json', }, body: JSON.stringify({ certificateInstallationReq: certificateInstallationReq, xsdMsgDefNamespace: xsdMsgDefNamespace, }), }); }, 'Get signed contract data response is unexpected'); const contractData = JSON.parse(responseText); let certificateInstallationRes; if (contractData.CCPResponse.emaidContent && contractData.CCPResponse.emaidContent.length > 0) { for (const emaidContent of contractData.CCPResponse.emaidContent) { if (emaidContent.messageDef && emaidContent.messageDef.certificateInstallationRes) { certificateInstallationRes = emaidContent.messageDef.certificateInstallationRes; } } } if (!certificateInstallationRes) { throw new Error('Failed to find CertificateInstallationRes in response.'); } return certificateInstallationRes; } /** * Retrieves all root certificates from Hubject. * Refer to https://hubject.stoplight.io/docs/open-plugncharge/fdc9bdfdd4fb2-get-all-root-certificates * * @return {Promise<string[]>} Array of root certificate. */ async getRootCertificates() { const responseText = await this._makeAuthenticatedRequest(async () => { const url = `${this._baseUrl}/v1/root/rootCerts`; return fetch(url, { method: 'GET', headers: { Accept: 'application/json', Authorization: await this._getAuthorizationToken(), }, }); }, 'Get root certificates response is unexpected'); const certificates = []; const rootCertificatesResponse = JSON.parse(responseText); for (const root of rootCertificatesResponse.RootCertificateCollection.rootCertificates) { certificates.push(createPemBlock(root.caCertificate)); } return certificates; } async _getDefaultToken(tokenUrl) { const response = await fetch(tokenUrl, { method: 'GET' }); if (!response.ok && response.status !== 304) { throw new Error(`Get token response is unexpected: ${response.status}: ${await response.text()}`); } const token = (await response.json()).data; let tokenValue = token.split('Bearer ')[1]; tokenValue = tokenValue.split('\n')[0]; return 'Bearer ' + tokenValue; } async _getAuthorizationToken(retryCount = 0) { if (this._baseUrl === HUBJECT_DEFAULT_BASEURL && this._tokenUrl === HUBJECT_DEFAULT_TOKENURL && this._clientId === HUBJECT_DEFAULT_CLIENTID && this._clientSecret === HUBJECT_DEFAULT_CLIENTSECRET) { this._logger.warn('Using default Hubject credentials. Please set them in the configuration if needed.'); return await this._getDefaultToken(this._tokenUrl); } const MAX_RETRIES = 10; if (retryCount >= MAX_RETRIES) { throw new Error(`Max retries (${MAX_RETRIES}) exceeded while waiting for auth token. ` + `Another instance may be holding the lock or experiencing issues.`); } const cachedToken = await this._cache.get(Hubject.AUTH_TOKEN_CACHE_KEY, Hubject.AUTH_TOKEN_CACHE_NAMESPACE); if (cachedToken) { return cachedToken; } // Try to acquire lock const lockKey = `${Hubject.AUTH_TOKEN_CACHE_KEY}_LOCK`; const lockAcquired = await this._cache.setIfNotExist(lockKey, 'locked', Hubject.AUTH_TOKEN_CACHE_NAMESPACE, 30); if (!lockAcquired) { // Another instance is fetching, wait for it const waitMs = 1000 + retryCount * 500; // 1s, 1.5s, 2s, 2.5s... this._logger.debug(`Lock not acquired, waiting ${waitMs}ms (retry ${retryCount}/${MAX_RETRIES})`); await new Promise((resolve) => setTimeout(resolve, waitMs)); return this._getAuthorizationToken(retryCount + 1); // Recursive retry } try { // Double-check cache in case another instance populated it // between initial check and lock acquisition const tokenAfterLock = await this._cache.get(Hubject.AUTH_TOKEN_CACHE_KEY, Hubject.AUTH_TOKEN_CACHE_NAMESPACE); if (tokenAfterLock) { return tokenAfterLock; } // Fetch and cache token const token = await this._fetchNewToken(); return token; } finally { // Always release lock const removed = await this._cache.remove(lockKey, Hubject.AUTH_TOKEN_CACHE_NAMESPACE); if (!removed) { this._logger.warn('Failed to remove lock, it may have already expired'); } } } async _fetchNewToken() { const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: this._clientId, client_secret: this._clientSecret, audience: this._baseUrl, }); const response = await fetch(this._tokenUrl, { method: HttpMethod.Post, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: body.toString(), }); if (!response.ok) { throw new Error(`Get token response is unexpected: ${response.status}: ${await response.text()}`); } const tokenResponse = await response.json(); if (!tokenResponse.access_token) { this._logger.error('Error fetching token - no access_token in response'); throw new Error('Error while making call for hubject auth token'); } const token = `Bearer ${tokenResponse.access_token}`; // Cache with expiration buffer const expiresIn = tokenResponse.expires_in || 3600; await this._cache.set(Hubject.AUTH_TOKEN_CACHE_KEY, token, Hubject.AUTH_TOKEN_CACHE_NAMESPACE, expiresIn - 60); return token; } async _makeAuthenticatedRequest(requestFn, errorPrefix) { try { let response = await requestFn(); // If 401/403, clear cache and retry once if (response.status === HttpStatus.FORBIDDEN || response.status === HttpStatus.UNAUTHORIZED) { this._logger.warn(`Received ${response.status}, clearing auth token cache and retrying...`); const removed = await this._cache.remove(Hubject.AUTH_TOKEN_CACHE_KEY, Hubject.AUTH_TOKEN_CACHE_NAMESPACE); this._logger.debug(`Cache ${Hubject.AUTH_TOKEN_CACHE_KEY} removed: ${removed}`); response = await requestFn(); } if (response.status !== HttpStatus.OK) { const msg = `${errorPrefix}: ${response.status}: ${await response.text()}`; this._logger.error(msg); throw new Error(msg); } return await response.text(); } catch (error) { this._logger.error('Request failed:', error); throw error; } } } //# sourceMappingURL=hubject.js.map