@citrineos/util
Version:
The OCPP util module which supplies helpful utilities like cache and queue connectors, etc.
231 lines • 10.8 kB
JavaScript
// 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