From cde7fa6344684107572c35d6de64737bdd1a4ad2 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 18 Mar 2026 11:44:58 +0100 Subject: [PATCH] feat(ocpp2): fix authorization conformance gaps (C10, C12, C01, C09) (#1735) * feat(ocpp2): auto-update auth cache on TransactionEventResponse (C10.FR.01/04/05, C12.FR.06) * feat(ocpp2): check MasterPassGroupId in start transaction (C12.FR.09) * feat(ocpp2): add groupId-based stop transaction authorization (C01.FR.03, C09.FR.03/07) * fix(ocpp2): address PR review findings - C12.FR.09: compare groupIdToken not idToken - C09.FR.03: remove inner isIdTokenAuthorized check - Guard updateCacheEntry on authorizationCacheEnabled - Fix MasterPass test to mock VariableManager * style(tests): align test naming with style guide * fix: merge duplicate AuthTypes.js imports in auth barrel * fix(tests): add required JSDoc to GroupIdStop test helper * style(ocpp2): harmonize log levels, remove spec refs from log messages, use truncateId * style(auth): harmonize log prefixes, levels, and identifier truncation * refactor(auth): use moduleName constant for log prefixes in OCPPAuthServiceImpl * refactor(auth): use moduleName constant across all auth files * fix(tests): skip flaky RequestStopTransaction test on all Node 22 platforms * refactor(auth): harmonize log prefixes in auth adapters * refactor: move truncateId to utils and truncate identifiers in OCPP20 logs * style(ocpp2): remove spec ref from statusInfo additionalInfo * style(ocpp2): truncate idToken in statusInfo additionalInfo * test(ocpp2): improve test coverage and remove duplicates in auth conformance tests --- src/charging-station/Helpers.ts | 1 + .../ocpp/2.0/OCPP20IncomingRequestService.ts | 98 ++++++-- .../ocpp/2.0/OCPP20ResponseService.ts | 18 ++ .../ocpp/2.0/__testable__/index.ts | 9 + .../ocpp/auth/adapters/OCPP16AuthAdapter.ts | 10 +- .../ocpp/auth/adapters/OCPP20AuthAdapter.ts | 26 +-- .../ocpp/auth/cache/InMemoryAuthCache.ts | 43 +--- src/charging-station/ocpp/auth/index.ts | 5 +- .../ocpp/auth/interfaces/OCPPAuthService.ts | 14 ++ .../ocpp/auth/services/OCPPAuthServiceImpl.ts | 119 ++++++++-- .../strategies/CertificateAuthStrategy.ts | 23 +- .../ocpp/auth/strategies/LocalAuthStrategy.ts | 91 +++++--- .../auth/strategies/RemoteAuthStrategy.ts | 50 ++-- .../ocpp/auth/types/AuthTypes.ts | 49 ++++ .../ocpp/auth/utils/ConfigValidator.ts | 24 +- src/types/ConnectorStatus.ts | 1 + src/utils/Utils.ts | 7 + src/utils/index.ts | 1 + ...IncomingRequestService-GroupIdStop.test.ts | 179 +++++++++++++++ ...0IncomingRequestService-MasterPass.test.ts | 215 ++++++++++++++++++ ...uestService-RequestStopTransaction.test.ts | 2 +- .../OCPP20ResponseService-CacheUpdate.test.ts | 183 +++++++++++++++ .../ocpp/2.0/OCPP20TestUtils.ts | 2 + .../ocpp/auth/cache/InMemoryAuthCache.test.ts | 6 +- ...lAuthStrategy-DisablePostAuthorize.test.ts | 160 +++++++++++++ 25 files changed, 1164 insertions(+), 172 deletions(-) create mode 100644 tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-MasterPass.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts create mode 100644 tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index 761d976a..e86e738b 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -530,6 +530,7 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine delete connectorStatus.transactionStart delete connectorStatus.transactionId delete connectorStatus.transactionIdTag + delete connectorStatus.transactionGroupIdToken connectorStatus.transactionEnergyActiveImportRegisterValue = 0 delete connectorStatus.transactionBeginMeterValue delete connectorStatus.transactionSeqNo diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index a06a56d0..00c7ee1e 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -133,6 +133,7 @@ import { generateUUID, logger, sleep, + truncateId, validateUUID, } from '../../../utils/index.js' import { getConfigurationKey } from '../../ConfigurationKeyUtils.js' @@ -704,7 +705,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { const config = authService.getConfiguration() if (!config.authorizationCacheEnabled) { logger.info( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearCache: Authorization cache disabled, returning Rejected (C11.FR.04)` + `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearCache: Authorization cache disabled, returning Rejected` ) return OCPP20Constants.OCPP_RESPONSE_REJECTED } @@ -2277,7 +2278,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ): Promise { const { chargingProfile, evseId, groupIdToken, idToken, remoteStartId } = commandPayload logger.info( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction request received on EVSE ${evseId?.toString() ?? 'undefined'} with idToken ${idToken.idToken} and remoteStartId ${remoteStartId.toString()}` + `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction request received on EVSE ${evseId?.toString() ?? 'undefined'} with idToken ${truncateId(idToken.idToken)} and remoteStartId ${remoteStartId.toString()}` ) let resolvedEvseId = evseId @@ -2357,11 +2358,38 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { let isAuthorized = true if (shouldAuthorizeRemoteStart) { + // C12.FR.09: Check MasterPassGroupId before authorization + const masterPassGroupIdResults = variableManager.getVariables(chargingStation, [ + { + attributeType: AttributeEnumType.Actual, + component: { name: OCPP20ComponentName.AuthCtrlr }, + variable: { name: 'MasterPassGroupId' }, + }, + ]) + const masterPassGroupId = masterPassGroupIdResults[0]?.attributeValue + if ( + masterPassGroupId != null && + masterPassGroupId.length > 0 && + groupIdToken?.idToken === masterPassGroupId + ) { + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: IdToken with MasterPassGroupId group cannot start a transaction` + ) + return { + status: RequestStartStopStatusEnumType.Rejected, + statusInfo: { + additionalInfo: 'MasterPassGroupId tokens cannot start transactions', + reasonCode: ReasonCodeEnumType.InvalidIdToken, + }, + transactionId: generateUUID(), + } + } + try { isAuthorized = this.isIdTokenAuthorized(chargingStation, idToken) } catch (error) { logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Authorization error for ${idToken.idToken}:`, + `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Authorization error for ${truncateId(idToken.idToken)}:`, error ) return { @@ -2381,12 +2409,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { if (!isAuthorized) { logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: IdToken ${idToken.idToken} is not authorized` + `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: IdToken ${truncateId(idToken.idToken)} is not authorized` ) return { status: RequestStartStopStatusEnumType.Rejected, statusInfo: { - additionalInfo: `IdToken ${idToken.idToken} is not authorized`, + additionalInfo: `IdToken ${truncateId(idToken.idToken)} is not authorized`, reasonCode: ReasonCodeEnumType.InvalidIdToken, }, transactionId: generateUUID(), @@ -2399,7 +2427,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { isGroupAuthorized = this.isIdTokenAuthorized(chargingStation, groupIdToken) } catch (error) { logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Group authorization error for ${groupIdToken.idToken}:`, + `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Group authorization error for ${truncateId(groupIdToken.idToken)}:`, error ) return { @@ -2414,12 +2442,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { if (!isGroupAuthorized) { logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: GroupIdToken ${groupIdToken.idToken} is not authorized` + `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: GroupIdToken ${truncateId(groupIdToken.idToken)} is not authorized` ) return { status: RequestStartStopStatusEnumType.Rejected, statusInfo: { - additionalInfo: `GroupIdToken ${groupIdToken.idToken} is not authorized`, + additionalInfo: `GroupIdToken ${truncateId(groupIdToken.idToken)} is not authorized`, reasonCode: ReasonCodeEnumType.InvalidIdToken, }, transactionId: generateUUID(), @@ -2504,6 +2532,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { connectorStatus.transactionStarted = true connectorStatus.transactionId = transactionId connectorStatus.transactionIdTag = idToken.idToken + connectorStatus.transactionGroupIdToken = groupIdToken?.idToken connectorStatus.transactionStart = new Date() connectorStatus.transactionEnergyActiveImportRegisterValue = 0 connectorStatus.remoteStartId = remoteStartId @@ -2530,7 +2559,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } logger.info( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction ACCEPTED on #${connectorId.toString()} for idToken '${idToken.idToken}'` + `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction ACCEPTED on #${connectorId.toString()} for idToken '${truncateId(idToken.idToken)}'` ) return { @@ -2885,6 +2914,47 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ) } + private isAuthorizedToStopTransaction ( + chargingStation: ChargingStation, + connectorId: number, + presentedIdToken: OCPP20IdTokenType, + presentedGroupIdToken?: OCPP20IdTokenType + ): boolean { + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + if (connectorStatus?.transactionStarted !== true) { + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: No active transaction on connector ${connectorId.toString()}` + ) + return false + } + + // C01.FR.03(a): Same idToken as the one used to start the transaction + if (presentedIdToken.idToken === connectorStatus.transactionIdTag) { + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: Same idToken as start token - authorized locally` + ) + return true + } + + // C01.FR.03(b) / C09.FR.03 / C09.FR.07: + // Different valid idToken with same GroupIdToken as start → authorize locally + if ( + connectorStatus.transactionGroupIdToken != null && + presentedGroupIdToken?.idToken != null && + presentedGroupIdToken.idToken === connectorStatus.transactionGroupIdToken + ) { + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: Same GroupIdToken as start token - authorized locally without AuthorizationRequest` + ) + return true + } + + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: IdToken ${truncateId(presentedIdToken.idToken)} not authorized to stop transaction on connector ${connectorId.toString()}` + ) + return false + } + /** * Checks if charging station is idle per OCPP 2.0.1 Errata definition. * Idle means: no active transactions, no firmware update in progress, no pending reservations. @@ -2931,7 +3001,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { */ logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Validating idToken ${idToken.idToken} of type ${idToken.type}` + `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Validating idToken ${truncateId(idToken.idToken)} of type ${idToken.type}` ) try { @@ -2949,12 +3019,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { const isLocalAuthorized = this.isIdTokenLocalAuthorized(chargingStation, idToken.idToken) if (isLocalAuthorized) { logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${idToken.idToken} authorized via local auth list` + `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${truncateId(idToken.idToken)} authorized via local auth list` ) return true } logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${idToken.idToken} not found in local auth list` + `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${truncateId(idToken.idToken)} not found in local auth list` ) } @@ -2967,12 +3037,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${idToken.idToken} authorization failed - not found in local list and remote auth not configured` + `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${truncateId(idToken.idToken)} authorization failed - not found in local list and remote auth not configured` ) return false } catch (error) { logger.error( - `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Error during authorization validation for ${idToken.idToken}:`, + `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Error during authorization validation for ${truncateId(idToken.idToken)}:`, error ) return false diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index 118d79d3..2b86919c 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -26,6 +26,7 @@ import { } from '../../../types/index.js' import { OCPP20AuthorizationStatusEnumType } from '../../../types/ocpp/2.0/Transaction.js' import { convertToDate, logger } from '../../../utils/index.js' +import { mapOCPP20TokenType, OCPPAuthServiceFactory } from '../auth/index.js' import { OCPPResponseService } from '../OCPPResponseService.js' import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js' @@ -340,6 +341,23 @@ export class OCPP20ResponseService extends OCPPResponseService { ) } } + // C10.FR.01/04/05: Update auth cache with idTokenInfo from response + if (requestPayload.idToken != null) { + const idTokenValue = requestPayload.idToken.idToken + const idTokenInfo = payload.idTokenInfo + const identifierType = mapOCPP20TokenType(requestPayload.idToken.type) + OCPPAuthServiceFactory.getInstance(chargingStation) + .then(authService => { + authService.updateCacheEntry(idTokenValue, idTokenInfo, identifierType) + return undefined + }) + .catch((error: unknown) => { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error updating auth cache:`, + error + ) + }) + } } if (payload.updatedPersonalMessage != null) { logger.info( diff --git a/src/charging-station/ocpp/2.0/__testable__/index.ts b/src/charging-station/ocpp/2.0/__testable__/index.ts index 5bbda90c..36a3d048 100644 --- a/src/charging-station/ocpp/2.0/__testable__/index.ts +++ b/src/charging-station/ocpp/2.0/__testable__/index.ts @@ -37,6 +37,7 @@ import type { OCPP20GetTransactionStatusResponse, OCPP20GetVariablesRequest, OCPP20GetVariablesResponse, + OCPP20IdTokenType, OCPP20InstallCertificateRequest, OCPP20InstallCertificateResponse, OCPP20RequestStartTransactionRequest, @@ -239,6 +240,13 @@ export interface TestableOCPP20IncomingRequestService { chargingStation: ChargingStation, commandPayload: OCPP20UpdateFirmwareRequest ) => OCPP20UpdateFirmwareResponse + + isAuthorizedToStopTransaction: ( + chargingStation: ChargingStation, + connectorId: number, + presentedIdToken: OCPP20IdTokenType, + presentedGroupIdToken?: OCPP20IdTokenType + ) => boolean } /** @@ -286,6 +294,7 @@ export function createTestableIncomingRequestService ( handleRequestTriggerMessage: serviceImpl.handleRequestTriggerMessage.bind(service), handleRequestUnlockConnector: serviceImpl.handleRequestUnlockConnector.bind(service), handleRequestUpdateFirmware: serviceImpl.handleRequestUpdateFirmware.bind(service), + isAuthorizedToStopTransaction: serviceImpl.isAuthorizedToStopTransaction.bind(service), } } diff --git a/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts b/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts index b4c68f21..0b34715c 100644 --- a/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts +++ b/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts @@ -284,7 +284,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter { return remoteAuthEnabled && isOnline } catch (error) { logger.warn( - `${this.chargingStation.logPrefix()} Error checking remote authorization availability`, + `${this.chargingStation.logPrefix()} ${moduleName}.isRemoteAvailable: Error checking remote authorization availability`, error ) return false @@ -331,7 +331,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter { if (!hasLocalAuth && !hasRemoteAuth) { logger.warn( - `${this.chargingStation.logPrefix()} OCPP 1.6 adapter: No authorization methods enabled` + `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: No authorization methods enabled` ) return false } @@ -339,7 +339,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter { // Validate timeout values if (config.authorizationTimeout < 1) { logger.warn( - `${this.chargingStation.logPrefix()} OCPP 1.6 adapter: Invalid authorization timeout` + `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: Invalid authorization timeout` ) return false } @@ -347,7 +347,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter { return true } catch (error) { logger.error( - `${this.chargingStation.logPrefix()} OCPP 1.6 adapter configuration validation failed`, + `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: Configuration validation failed`, error ) return false @@ -367,7 +367,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter { return configKey?.value === 'true' } catch (error) { logger.warn( - `${this.chargingStation.logPrefix()} Error getting offline transaction config`, + `${this.chargingStation.logPrefix()} ${moduleName}.getOfflineTransactionConfig: Error getting offline transaction config`, error ) return false diff --git a/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts b/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts index 75fac487..d481738e 100644 --- a/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts +++ b/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts @@ -168,7 +168,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { // Map OCPP 2.0 authorization status to unified status const unifiedStatus = this.mapOCPP20AuthStatus(authStatus) - logger.info( + logger.debug( `${this.chargingStation.logPrefix()} ${moduleName}.${methodName}: Authorization result for ${idToken.idToken}: ${authStatus} (unified: ${unifiedStatus})` ) @@ -453,7 +453,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { return isOnline && remoteStartEnabled } catch (error) { logger.warn( - `${this.chargingStation.logPrefix()} Error checking remote authorization availability`, + `${this.chargingStation.logPrefix()} ${moduleName}.isRemoteAvailable: Error checking remote authorization availability`, error ) return false @@ -506,7 +506,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { if (!hasRemoteAuth && !hasLocalAuth && !hasCertAuth) { logger.warn( - `${this.chargingStation.logPrefix()} OCPP 2.0 adapter: No authorization methods enabled` + `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: No authorization methods enabled` ) return false } @@ -514,7 +514,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { // Validate timeout values if (config.authorizationTimeout < 1) { logger.warn( - `${this.chargingStation.logPrefix()} OCPP 2.0 adapter: Invalid authorization timeout` + `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: Invalid authorization timeout` ) return false } @@ -522,7 +522,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { return true } catch (error) { logger.error( - `${this.chargingStation.logPrefix()} OCPP 2.0 adapter configuration validation failed`, + `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: Configuration validation failed`, error ) return false @@ -577,7 +577,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { return true } catch (error) { logger.warn( - `${this.chargingStation.logPrefix()} Error getting offline authorization config`, + `${this.chargingStation.logPrefix()} ${moduleName}.getOfflineAuthorizationConfig: Error getting offline authorization config`, error ) return false @@ -609,7 +609,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { // Check if variable was successfully retrieved if (results.length === 0) { logger.debug( - `${this.chargingStation.logPrefix()} Variable ${component}.${variable} not found in registry` + `${this.chargingStation.logPrefix()} ${moduleName}.getVariableValue: Variable ${component}.${variable} not found in registry` ) return this.getDefaultVariableValue(component, variable, useDefaultFallback) } @@ -622,7 +622,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { result.attributeValue == null ) { logger.debug( - `${this.chargingStation.logPrefix()} Variable ${component}.${variable} not available: ${result.attributeStatus}` + `${this.chargingStation.logPrefix()} ${moduleName}.getVariableValue: Variable ${component}.${variable} not available: ${result.attributeStatus}` ) return this.getDefaultVariableValue(component, variable, useDefaultFallback) } @@ -630,7 +630,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { return result.attributeValue } catch (error) { logger.warn( - `${this.chargingStation.logPrefix()} Error getting variable ${component}.${variable}`, + `${this.chargingStation.logPrefix()} ${moduleName}.getVariableValue: Error getting variable ${component}.${variable}`, error ) return this.getDefaultVariableValue(component, variable, useDefaultFallback) @@ -749,7 +749,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { } logger.warn( - `${this.chargingStation.logPrefix()} Invalid boolean value '${value}', using default: ${defaultValue.toString()}` + `${this.chargingStation.logPrefix()} ${moduleName}.parseBooleanVariable: Invalid boolean value '${value}', using default: ${defaultValue.toString()}` ) return defaultValue } @@ -776,7 +776,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { if (isNaN(parsed)) { logger.warn( - `${this.chargingStation.logPrefix()} Invalid integer value '${value}', using default: ${defaultValue.toString()}` + `${this.chargingStation.logPrefix()} ${moduleName}.parseIntegerVariable: Invalid integer value '${value}', using default: ${defaultValue.toString()}` ) return defaultValue } @@ -784,14 +784,14 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { // Validate range if (min != null && parsed < min) { logger.warn( - `${this.chargingStation.logPrefix()} Integer value ${parsed.toString()} below minimum ${min.toString()}, using minimum` + `${this.chargingStation.logPrefix()} ${moduleName}.parseIntegerVariable: Integer value ${parsed.toString()} below minimum ${min.toString()}, using minimum` ) return min } if (max != null && parsed > max) { logger.warn( - `${this.chargingStation.logPrefix()} Integer value ${parsed.toString()} above maximum ${max.toString()}, using maximum` + `${this.chargingStation.logPrefix()} ${moduleName}.parseIntegerVariable: Integer value ${parsed.toString()} above maximum ${max.toString()}, using maximum` ) return max } diff --git a/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts b/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts index 44f3a2da..b875570a 100644 --- a/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts +++ b/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts @@ -1,9 +1,11 @@ import type { AuthCache, CacheStats } from '../interfaces/OCPPAuthService.js' import type { AuthorizationResult } from '../types/AuthTypes.js' -import { logger } from '../../../../utils/index.js' +import { logger, truncateId } from '../../../../utils/index.js' import { AuthorizationStatus } from '../types/AuthTypes.js' +const moduleName = 'InMemoryAuthCache' + /** * Cached authorization entry with expiration */ @@ -137,7 +139,7 @@ export class InMemoryAuthCache implements AuthCache { } logger.info( - `InMemoryAuthCache: Initialized with maxEntries=${String(this.maxEntries)}, defaultTtl=${String(this.defaultTtl)}s, rateLimit=${this.rateLimit.enabled ? `${String(this.rateLimit.maxRequests)} req/${String(this.rateLimit.windowMs)}ms` : 'disabled'}` + `${moduleName}: Initialized with maxEntries=${String(this.maxEntries)}, defaultTtl=${String(this.defaultTtl)}s, rateLimit=${this.rateLimit.enabled ? `${String(this.rateLimit.maxRequests)} req/${String(this.rateLimit.windowMs)}ms` : 'disabled'}` ) } @@ -150,7 +152,7 @@ export class InMemoryAuthCache implements AuthCache { this.lruOrder.clear() this.rateLimits.clear() - logger.info(`InMemoryAuthCache: Cleared ${String(entriesCleared)} entries`) + logger.info(`${moduleName}: Cleared ${String(entriesCleared)} entries`) } public dispose (): void { @@ -169,9 +171,7 @@ export class InMemoryAuthCache implements AuthCache { // Check rate limiting first if (!this.checkRateLimit(identifier)) { this.stats.rateLimitBlocked++ - logger.warn( - `InMemoryAuthCache: Rate limit exceeded for identifier: ${this.truncateId(identifier)}` - ) + logger.warn(`${moduleName}: Rate limit exceeded for identifier: ${truncateId(identifier)}`) return undefined } @@ -198,7 +198,7 @@ export class InMemoryAuthCache implements AuthCache { } this.lruOrder.set(identifier, now) logger.debug( - `InMemoryAuthCache: Expired entry transitioned to EXPIRED for identifier: ${this.truncateId(identifier)}` + `${moduleName}: Expired entry transitioned to EXPIRED for identifier: ${truncateId(identifier)}` ) return entry.result } @@ -213,7 +213,7 @@ export class InMemoryAuthCache implements AuthCache { entry.expiresAt = now + this.defaultTtl * 1000 } - logger.debug(`InMemoryAuthCache: Cache hit for identifier: ${this.truncateId(identifier)}`) + logger.debug(`${moduleName}: Cache hit for identifier: ${truncateId(identifier)}`) return entry.result } @@ -261,9 +261,7 @@ export class InMemoryAuthCache implements AuthCache { this.lruOrder.delete(identifier) if (deleted) { - logger.debug( - `InMemoryAuthCache: Removed entry for identifier: ${this.truncateId(identifier)}` - ) + logger.debug(`${moduleName}: Removed entry for identifier: ${truncateId(identifier)}`) } } @@ -317,7 +315,7 @@ export class InMemoryAuthCache implements AuthCache { if (!this.checkRateLimit(identifier)) { this.stats.rateLimitBlocked++ logger.warn( - `InMemoryAuthCache: Rate limit exceeded, not caching identifier: ${this.truncateId(identifier)}` + `${moduleName}: Rate limit exceeded, not caching identifier: ${truncateId(identifier)}` ) return } @@ -343,7 +341,7 @@ export class InMemoryAuthCache implements AuthCache { this.stats.sets++ logger.debug( - `InMemoryAuthCache: Cached result for identifier: ${this.truncateId(identifier)}, ttl=${String(clampedTtl)}s, entries=${String(this.cache.size)}/${String(this.maxEntries)}` + `${moduleName}: Cached result for identifier: ${truncateId(identifier)}, ttl=${String(clampedTtl)}s, entries=${String(this.cache.size)}/${String(this.maxEntries)}` ) } @@ -448,24 +446,7 @@ export class InMemoryAuthCache implements AuthCache { this.cache.delete(candidateIdentifier) this.lruOrder.delete(candidateIdentifier) this.stats.evictions++ - logger.debug(`InMemoryAuthCache: Evicted LRU entry: ${this.truncateId(candidateIdentifier)}`) + logger.debug(`${moduleName}: Evicted LRU entry: ${truncateId(candidateIdentifier)}`) } } - - private truncateId (identifier: string, maxLen = 8): string { - return truncateId(identifier, maxLen) - } -} - -/** - * Truncate identifier for safe log output. - * @param identifier - Full identifier string - * @param maxLen - Maximum length before truncation (default: 8) - * @returns Truncated identifier with '...' suffix, or original if within limit - */ -export function truncateId (identifier: string, maxLen = 8): string { - if (identifier.length <= maxLen) { - return identifier - } - return `${identifier.slice(0, maxLen)}...` } diff --git a/src/charging-station/ocpp/auth/index.ts b/src/charging-station/ocpp/auth/index.ts index bd323c8a..57a79faa 100644 --- a/src/charging-station/ocpp/auth/index.ts +++ b/src/charging-station/ocpp/auth/index.ts @@ -68,18 +68,17 @@ export { type AuthRequest, type CertificateHashData, IdentifierType, - type UnifiedIdentifier, -} from './types/AuthTypes.js' -export { isCertificateBased, isOCPP16Type, isOCPP20Type, mapOCPP16Status, + mapOCPP20AuthorizationStatus, mapOCPP20TokenType, mapToOCPP16Status, mapToOCPP20Status, mapToOCPP20TokenType, requiresAdditionalInfo, + type UnifiedIdentifier, } from './types/AuthTypes.js' // ============================================================================ diff --git a/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts b/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts index ffe608f1..6fc794be 100644 --- a/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts +++ b/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts @@ -1,3 +1,4 @@ +import type { OCPP20IdTokenInfoType } from '../../../../types/ocpp/2.0/Transaction.js' import type { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js' import type { AuthConfiguration, @@ -5,6 +6,7 @@ import type { AuthRequest, UnifiedIdentifier, } from '../types/AuthTypes.js' +import type { IdentifierType } from '../types/AuthTypes.js' /** * Authorization cache interface @@ -425,6 +427,18 @@ export interface OCPPAuthService { */ testConnectivity(): boolean + /** + * Update a cache entry from TransactionEventResponse idTokenInfo (C10.FR.01/04/05) + * @param identifier - The idToken string to cache + * @param idTokenInfo - The idTokenInfo from the CSMS response + * @param identifierType - Optional identifier type for cache skip logic (C02.FR.03/C03.FR.02) + */ + updateCacheEntry( + identifier: string, + idTokenInfo: OCPP20IdTokenInfoType, + identifierType?: IdentifierType + ): void + /** * Update authentication configuration * @param config - New configuration to apply diff --git a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts index 452d8dc2..36a06059 100644 --- a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts +++ b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts @@ -1,3 +1,4 @@ +import type { OCPP20IdTokenInfoType } from '../../../../types/ocpp/2.0/Transaction.js' import type { OCPP16AuthAdapter } from '../adapters/OCPP16AuthAdapter.js' import type { OCPP20AuthAdapter } from '../adapters/OCPP20AuthAdapter.js' import type { LocalAuthStrategy } from '../strategies/LocalAuthStrategy.js' @@ -5,7 +6,13 @@ import type { LocalAuthStrategy } from '../strategies/LocalAuthStrategy.js' import { OCPPError } from '../../../../exception/OCPPError.js' import { ErrorType } from '../../../../types/index.js' import { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js' -import { ensureError, getErrorMessage, logger } from '../../../../utils/index.js' +import { + convertToDate, + ensureError, + getErrorMessage, + logger, + truncateId, +} from '../../../../utils/index.js' import { type ChargingStation } from '../../../ChargingStation.js' import { AuthComponentFactory } from '../factories/AuthComponentFactory.js' import { @@ -21,10 +28,13 @@ import { AuthorizationStatus, type AuthRequest, IdentifierType, + mapOCPP20AuthorizationStatus, type UnifiedIdentifier, } from '../types/AuthTypes.js' import { AuthConfigValidator } from '../utils/ConfigValidator.js' +const moduleName = 'OCPPAuthServiceImpl' + export class OCPPAuthServiceImpl implements OCPPAuthService { private readonly adapters: Map private readonly chargingStation: ChargingStation @@ -82,7 +92,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { this.metrics.totalRequests++ logger.debug( - `${this.chargingStation.logPrefix()} Starting authentication for identifier: ${JSON.stringify(request.identifier)}` + `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Starting authentication for identifier: ${JSON.stringify(request.identifier)}` ) // Try each strategy in priority order @@ -91,28 +101,28 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { if (!strategy) { logger.debug( - `${this.chargingStation.logPrefix()} Strategy '${strategyName}' not available, skipping` + `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Strategy '${strategyName}' not available, skipping` ) continue } if (!strategy.canHandle(request, this.config)) { logger.debug( - `${this.chargingStation.logPrefix()} Strategy '${strategyName}' cannot handle request, skipping` + `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Strategy '${strategyName}' cannot handle request, skipping` ) continue } try { logger.debug( - `${this.chargingStation.logPrefix()} Trying authentication strategy: ${strategyName}` + `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Trying authentication strategy: ${strategyName}` ) const result = await strategy.authenticate(request, this.config) if (!result) { logger.debug( - `${this.chargingStation.logPrefix()} Strategy '${strategyName}' returned no result, continuing to next strategy` + `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Strategy '${strategyName}' returned no result, continuing to next strategy` ) continue } @@ -123,7 +133,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { this.updateMetricsForResult(result, strategyName, duration) logger.info( - `${this.chargingStation.logPrefix()} Authentication successful using ${strategyName} strategy (${String(duration)}ms): ${result.status}` + `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Authentication successful using ${strategyName} strategy (${String(duration)}ms): ${result.status}` ) return { @@ -146,7 +156,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { } catch (error) { lastError = ensureError(error) logger.debug( - `${this.chargingStation.logPrefix()} Strategy '${strategyName}' failed: ${getErrorMessage(error)}` + `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Strategy '${strategyName}' failed: ${getErrorMessage(error)}` ) // Continue to next strategy unless it's a critical error @@ -165,7 +175,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { this.metrics.totalResponseTime += duration logger.error( - `${this.chargingStation.logPrefix()} Authentication failed for all strategies (${String(duration)}ms): ${errorMessage}` + `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Authentication failed for all strategies (${String(duration)}ms): ${errorMessage}` ) return { @@ -238,7 +248,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { const duration = Date.now() - startTime logger.info( - `${this.chargingStation.logPrefix()} Direct authentication with ${strategyName} successful (${String(duration)}ms): ${result.status}` + `${this.chargingStation.logPrefix()} ${moduleName}.authorizeWithStrategy: Direct authentication with ${strategyName} successful (${String(duration)}ms): ${result.status}` ) return { @@ -258,7 +268,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { } catch (error) { const duration = Date.now() - startTime logger.error( - `${this.chargingStation.logPrefix()} Direct authentication with ${strategyName} failed (${String(duration)}ms): ${getErrorMessage(error)}` + `${this.chargingStation.logPrefix()} ${moduleName}.authorizeWithStrategy: Direct authentication with ${strategyName} failed (${String(duration)}ms): ${getErrorMessage(error)}` ) throw error } @@ -268,16 +278,22 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { * Clear all cached authorizations */ public clearCache (): void { - logger.debug(`${this.chargingStation.logPrefix()} Clearing all cached authorizations`) + logger.debug( + `${this.chargingStation.logPrefix()} ${moduleName}.clearCache: Clearing all cached authorizations` + ) // Clear cache in local strategy const localStrategy = this.strategies.get('local') as LocalAuthStrategy | undefined const localAuthCache = localStrategy?.getAuthCache() if (localAuthCache) { localAuthCache.clear() - logger.info(`${this.chargingStation.logPrefix()} Authorization cache cleared`) + logger.info( + `${this.chargingStation.logPrefix()} ${moduleName}.clearCache: Authorization cache cleared` + ) } else { - logger.debug(`${this.chargingStation.logPrefix()} No authorization cache available to clear`) + logger.debug( + `${this.chargingStation.logPrefix()} ${moduleName}.clearCache: No authorization cache available to clear` + ) } } @@ -415,7 +431,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { */ public invalidateCache (identifier: UnifiedIdentifier): void { logger.debug( - `${this.chargingStation.logPrefix()} Invalidating cache for identifier: ${identifier.value}` + `${this.chargingStation.logPrefix()} ${moduleName}.invalidateCache: Invalidating cache for identifier: ${identifier.value}` ) // Invalidate in local strategy @@ -423,11 +439,11 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { if (localStrategy) { localStrategy.invalidateCache(identifier.value) logger.info( - `${this.chargingStation.logPrefix()} Cache invalidated for identifier: ${identifier.value}` + `${this.chargingStation.logPrefix()} ${moduleName}.invalidateCache: Cache invalidated for identifier: ${identifier.value}` ) } else { logger.debug( - `${this.chargingStation.logPrefix()} No local strategy available for cache invalidation` + `${this.chargingStation.logPrefix()} ${moduleName}.invalidateCache: No local strategy available for cache invalidation` ) } } @@ -461,7 +477,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { } } catch (error) { logger.debug( - `${this.chargingStation.logPrefix()} Local authorization check failed: ${getErrorMessage(error)}` + `${this.chargingStation.logPrefix()} ${moduleName}.isLocallyAuthorized: Local authorization check failed: ${getErrorMessage(error)}` ) } } @@ -504,6 +520,63 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { return true } + public updateCacheEntry ( + identifier: string, + idTokenInfo: OCPP20IdTokenInfoType, + identifierType?: IdentifierType + ): void { + if (!this.config.authorizationCacheEnabled) { + return + } + + if ( + identifierType === IdentifierType.NO_AUTHORIZATION || + identifierType === IdentifierType.CENTRAL + ) { + logger.debug( + `${this.chargingStation.logPrefix()} ${moduleName}.updateCacheEntry: Skipping cache for ${identifierType} identifier type` + ) + return + } + + const localStrategy = this.strategies.get('local') as LocalAuthStrategy | undefined + const authCache = localStrategy?.getAuthCache() + if (authCache == null) { + logger.debug( + `${this.chargingStation.logPrefix()} ${moduleName}.updateCacheEntry: No auth cache available` + ) + return + } + + const unifiedStatus = mapOCPP20AuthorizationStatus(idTokenInfo.status) + + const result: AuthorizationResult = { + isOffline: false, + method: AuthenticationMethod.REMOTE_AUTHORIZATION, + status: unifiedStatus, + timestamp: new Date(), + } + + let ttl: number | undefined + if (idTokenInfo.cacheExpiryDateTime != null) { + const expiryDate = convertToDate(idTokenInfo.cacheExpiryDateTime) + if (expiryDate != null) { + const expiryMs = expiryDate.getTime() + const ttlSeconds = Math.ceil((expiryMs - Date.now()) / 1000) + if (ttlSeconds > 0) { + ttl = ttlSeconds + } + } + } + ttl ??= this.config.authorizationCacheLifetime + + authCache.set(identifier, result, ttl) + + logger.debug( + `${this.chargingStation.logPrefix()} ${moduleName}.updateCacheEntry: Updated cache for ${truncateId(identifier)} status=${unifiedStatus}, ttl=${ttl != null ? ttl.toString() : 'default'}s` + ) + } + /** * Update authentication configuration * @param config - Partial configuration object with values to update @@ -519,7 +592,9 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { // Apply validated configuration this.config = newConfig - logger.info(`${this.chargingStation.logPrefix()} Authentication configuration updated`) + logger.info( + `${this.chargingStation.logPrefix()} ${moduleName}.updateConfiguration: Authentication configuration updated` + ) } /** @@ -551,11 +626,11 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { if (isConfigurable(strategy)) { strategy.configure(config) logger.info( - `${this.chargingStation.logPrefix()} Updated configuration for strategy: ${strategyName}` + `${this.chargingStation.logPrefix()} ${moduleName}.updateStrategyConfiguration: Updated configuration for strategy: ${strategyName}` ) } else { logger.warn( - `${this.chargingStation.logPrefix()} Strategy '${strategyName}' does not support runtime configuration updates` + `${this.chargingStation.logPrefix()} ${moduleName}.updateStrategyConfiguration: Strategy '${strategyName}' does not support runtime configuration updates` ) } } @@ -633,7 +708,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { }) logger.info( - `${this.chargingStation.logPrefix()} Initialized ${String(this.strategies.size)} authentication strategies for OCPP ${ocppVersion ?? 'unknown'}` + `${this.chargingStation.logPrefix()} ${moduleName}.initializeStrategies: Initialized ${String(this.strategies.size)} authentication strategies for OCPP ${ocppVersion ?? 'unknown'}` ) } diff --git a/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts index 650f1915..118a977a 100644 --- a/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts @@ -11,6 +11,8 @@ import { OCPPVersion } from '../../../../types/index.js' import { isNotEmptyString, logger, sleep } from '../../../../utils/index.js' import { AuthenticationMethod, AuthorizationStatus, IdentifierType } from '../types/AuthTypes.js' +const moduleName = 'CertificateAuthStrategy' + const CERTIFICATE_VERIFY_DELAY_MS = 100 /** @@ -61,7 +63,7 @@ export class CertificateAuthStrategy implements AuthStrategy { const certValidation = this.validateCertificateData(request.identifier) if (!certValidation.isValid) { logger.warn( - `${this.chargingStation.logPrefix()} Certificate validation failed: ${String(certValidation.reason)}` + `${moduleName}: Certificate validation failed: ${String(certValidation.reason)}` ) return this.createFailureResult( AuthorizationStatus.INVALID, @@ -97,7 +99,7 @@ export class CertificateAuthStrategy implements AuthStrategy { startTime ) } catch (error) { - logger.error(`${this.chargingStation.logPrefix()} Certificate authorization error:`, error) + logger.error(`${moduleName}: Certificate authorization error:`, error) return this.createFailureResult( AuthorizationStatus.INVALID, 'Certificate authorization failed', @@ -138,9 +140,7 @@ export class CertificateAuthStrategy implements AuthStrategy { cleanup (): void { this.isInitialized = false - logger.debug( - `${this.chargingStation.logPrefix()} Certificate authentication strategy cleaned up` - ) + logger.debug(`${moduleName}: Certificate authentication strategy cleaned up`) } getStats (): Record { @@ -152,13 +152,11 @@ export class CertificateAuthStrategy implements AuthStrategy { initialize (config: AuthConfiguration): void { if (!config.certificateAuthEnabled) { - logger.info(`${this.chargingStation.logPrefix()} Certificate authentication disabled`) + logger.info(`${moduleName}: Certificate authentication disabled`) return } - logger.info( - `${this.chargingStation.logPrefix()} Certificate authentication strategy initialized` - ) + logger.info(`${moduleName}: Certificate authentication strategy initialized`) this.isInitialized = true } @@ -392,7 +390,7 @@ export class CertificateAuthStrategy implements AuthStrategy { } logger.info( - `${this.chargingStation.logPrefix()} Certificate authorization successful for certificate ${request.identifier.certificateHashData?.serialNumber ?? 'unknown'}` + `${moduleName}: Certificate authorization successful for certificate ${request.identifier.certificateHashData?.serialNumber ?? 'unknown'}` ) return successResult @@ -405,10 +403,7 @@ export class CertificateAuthStrategy implements AuthStrategy { ) } } catch (error) { - logger.error( - `${this.chargingStation.logPrefix()} OCPP 2.0 certificate validation error:`, - error - ) + logger.error(`${moduleName}: OCPP 2.0 certificate validation error:`, error) return this.createFailureResult( AuthorizationStatus.INVALID, 'Certificate validation error', diff --git a/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts index b151b8e5..181b95aa 100644 --- a/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts @@ -5,7 +5,7 @@ import type { } from '../interfaces/OCPPAuthService.js' import type { AuthConfiguration, AuthorizationResult, AuthRequest } from '../types/AuthTypes.js' -import { ensureError, getErrorMessage, logger } from '../../../../utils/index.js' +import { ensureError, getErrorMessage, logger, truncateId } from '../../../../utils/index.js' import { AuthContext, AuthenticationError, @@ -14,6 +14,8 @@ import { AuthorizationStatus, } from '../types/AuthTypes.js' +const moduleName = 'LocalAuthStrategy' + /** * Local Authentication Strategy * @@ -68,15 +70,22 @@ export class LocalAuthStrategy implements AuthStrategy { try { logger.debug( - `LocalAuthStrategy: Authenticating ${request.identifier.value} for ${request.context}` + `${moduleName}: Authenticating ${request.identifier.value} for ${request.context}` ) // 1. Try local authorization list first (highest priority) if (config.localAuthListEnabled && this.localAuthListManager) { const localResult = await this.checkLocalAuthList(request, config) if (localResult) { - logger.debug(`LocalAuthStrategy: Found in local auth list: ${localResult.status}`) + logger.debug(`${moduleName}: Found in local auth list: ${localResult.status}`) this.stats.localListHits++ + // C14.FR.03: non-Accepted local list tokens trigger re-auth unless DisablePostAuthorize + if (this.shouldTriggerPostAuthorize(localResult, config)) { + logger.debug( + `${moduleName}: Local list token non-Accepted (${localResult.status}), deferring to remote auth` + ) + return undefined + } return this.enhanceResult(localResult, AuthenticationMethod.LOCAL_LIST, startTime) } } @@ -85,8 +94,15 @@ export class LocalAuthStrategy implements AuthStrategy { if (config.authorizationCacheEnabled && this.authCache) { const cacheResult = this.checkAuthCache(request, config) if (cacheResult) { - logger.debug(`LocalAuthStrategy: Found in cache: ${cacheResult.status}`) + logger.debug(`${moduleName}: Found in cache: ${cacheResult.status}`) this.stats.cacheHits++ + // C10.FR.03, C12.FR.05: non-Accepted cached tokens trigger re-auth unless DisablePostAuthorize + if (this.shouldTriggerPostAuthorize(cacheResult, config)) { + logger.debug( + `${moduleName}: Cached token non-Accepted (${cacheResult.status}), deferring to remote auth` + ) + return undefined + } return this.enhanceResult(cacheResult, AuthenticationMethod.CACHE, startTime) } } @@ -95,19 +111,17 @@ export class LocalAuthStrategy implements AuthStrategy { if (config.offlineAuthorizationEnabled && request.allowOffline) { const offlineResult = this.handleOfflineFallback(request, config) if (offlineResult) { - logger.debug(`LocalAuthStrategy: Offline fallback: ${offlineResult.status}`) + logger.debug(`${moduleName}: Offline fallback: ${offlineResult.status}`) this.stats.offlineDecisions++ return this.enhanceResult(offlineResult, AuthenticationMethod.OFFLINE_FALLBACK, startTime) } } - logger.debug( - `LocalAuthStrategy: No local authorization found for ${request.identifier.value}` - ) + logger.debug(`${moduleName}: No local authorization found for ${request.identifier.value}`) return undefined } catch (error) { const errorMessage = getErrorMessage(error) - logger.error(`LocalAuthStrategy: Authentication error: ${errorMessage}`) + logger.error(`${moduleName}: Authentication error: ${errorMessage}`) throw new AuthenticationError( `Local authentication failed: ${errorMessage}`, AuthErrorCode.STRATEGY_ERROR, @@ -135,10 +149,10 @@ export class LocalAuthStrategy implements AuthStrategy { try { this.authCache.set(identifier, result, ttl) - logger.debug(`LocalAuthStrategy: Cached result for ${identifier}`) + logger.debug(`${moduleName}: Cached result for ${truncateId(identifier)}`) } catch (error) { const errorMessage = getErrorMessage(error) - logger.error(`LocalAuthStrategy: Failed to cache result: ${errorMessage}`) + logger.error(`${moduleName}: Failed to cache result: ${errorMessage}`) // Don't throw - caching is not critical } } @@ -162,7 +176,7 @@ export class LocalAuthStrategy implements AuthStrategy { * Cleanup strategy resources */ public cleanup (): void { - logger.info('LocalAuthStrategy: Cleaning up...') + logger.info(`${moduleName}: Cleaning up...`) // Reset internal state this.isInitialized = false @@ -174,7 +188,7 @@ export class LocalAuthStrategy implements AuthStrategy { totalRequests: 0, } - logger.info('LocalAuthStrategy: Cleanup completed') + logger.info(`${moduleName}: Cleanup completed`) } /** @@ -217,30 +231,30 @@ export class LocalAuthStrategy implements AuthStrategy { */ public initialize (config: AuthConfiguration): void { try { - logger.info('LocalAuthStrategy: Initializing...') + logger.info(`${moduleName}: Initializing...`) if (config.localAuthListEnabled && !this.localAuthListManager) { - logger.warn('LocalAuthStrategy: Local auth list enabled but no manager provided') + logger.warn(`${moduleName}: Local auth list enabled but no manager provided`) } if (config.authorizationCacheEnabled && !this.authCache) { - logger.warn('LocalAuthStrategy: Authorization cache enabled but no cache provided') + logger.warn(`${moduleName}: Authorization cache enabled but no cache provided`) } // Initialize components if available if (this.localAuthListManager) { - logger.debug('LocalAuthStrategy: Local auth list manager available') + logger.debug(`${moduleName}: Local auth list manager available`) } if (this.authCache) { - logger.debug('LocalAuthStrategy: Authorization cache available') + logger.debug(`${moduleName}: Authorization cache available`) } this.isInitialized = true - logger.info('LocalAuthStrategy: Initialized successfully') + logger.info(`${moduleName}: Initialized successfully`) } catch (error) { const errorMessage = getErrorMessage(error) - logger.error(`LocalAuthStrategy: Initialization failed: ${errorMessage}`) + logger.error(`${moduleName}: Initialization failed: ${errorMessage}`) throw new AuthenticationError( `Local auth strategy initialization failed: ${errorMessage}`, AuthErrorCode.CONFIGURATION_ERROR, @@ -260,10 +274,10 @@ export class LocalAuthStrategy implements AuthStrategy { try { this.authCache.remove(identifier) - logger.debug(`LocalAuthStrategy: Invalidated cache for ${identifier}`) + logger.debug(`${moduleName}: Invalidated cache for ${truncateId(identifier)}`) } catch (error) { const errorMessage = getErrorMessage(error) - logger.error(`LocalAuthStrategy: Failed to invalidate cache: ${errorMessage}`) + logger.error(`${moduleName}: Failed to invalidate cache: ${errorMessage}`) // Don't throw - cache invalidation errors are not critical } } @@ -283,7 +297,7 @@ export class LocalAuthStrategy implements AuthStrategy { return !!entry } catch (error) { const errorMessage = getErrorMessage(error) - logger.error(`LocalAuthStrategy: Error checking local list: ${errorMessage}`) + logger.error(`${moduleName}: Error checking local list: ${errorMessage}`) return false } } @@ -324,11 +338,11 @@ export class LocalAuthStrategy implements AuthStrategy { return undefined } - logger.debug(`LocalAuthStrategy: Cache hit for ${request.identifier.value}`) + logger.debug(`${moduleName}: Cache hit for ${truncateId(request.identifier.value)}`) return cachedResult } catch (error) { const errorMessage = getErrorMessage(error) - logger.error(`LocalAuthStrategy: Cache check failed: ${errorMessage}`) + logger.error(`${moduleName}: Cache check failed: ${errorMessage}`) throw new AuthenticationError( `Authorization cache check failed: ${errorMessage}`, AuthErrorCode.CACHE_ERROR, @@ -362,7 +376,7 @@ export class LocalAuthStrategy implements AuthStrategy { // Check if entry is expired if (entry.expiryDate && entry.expiryDate < new Date()) { - logger.debug(`LocalAuthStrategy: Entry ${request.identifier.value} expired`) + logger.debug(`${moduleName}: Entry ${truncateId(request.identifier.value)} expired`) return { expiryDate: entry.expiryDate, isOffline: false, @@ -386,7 +400,7 @@ export class LocalAuthStrategy implements AuthStrategy { } } catch (error) { const errorMessage = getErrorMessage(error) - logger.error(`LocalAuthStrategy: Local auth list check failed: ${errorMessage}`) + logger.error(`${moduleName}: Local auth list check failed: ${errorMessage}`) throw new AuthenticationError( `Local auth list check failed: ${errorMessage}`, AuthErrorCode.LOCAL_LIST_ERROR, @@ -434,7 +448,9 @@ export class LocalAuthStrategy implements AuthStrategy { request: AuthRequest, config: AuthConfiguration ): AuthorizationResult | undefined { - logger.debug(`LocalAuthStrategy: Applying offline fallback for ${request.identifier.value}`) + logger.debug( + `${moduleName}: Applying offline fallback for ${truncateId(request.identifier.value)}` + ) // For transaction stops, always allow (safety requirement) if (request.context === AuthContext.TRANSACTION_STOP) { @@ -493,8 +509,25 @@ export class LocalAuthStrategy implements AuthStrategy { case 'unauthorized': return AuthorizationStatus.INVALID default: - logger.warn(`LocalAuthStrategy: Unknown entry status: ${status}, defaulting to INVALID`) + logger.warn(`${moduleName}: Unknown entry status: ${status}, defaulting to INVALID`) return AuthorizationStatus.INVALID } } + + /** + * Check whether a non-Accepted result should trigger post-authorize (remote re-auth). + * + * Per C10.FR.03, C12.FR.05, and C14.FR.03: when DisablePostAuthorize is explicitly false, + * non-Accepted tokens from cache or local list should be deferred to remote authorization. + * When DisablePostAuthorize is true or not configured, local results are returned as-is. + * @param result - Authorization result from cache or local list + * @param config - Authentication configuration with disablePostAuthorize setting + * @returns True if the result should be discarded to trigger remote re-authorization + */ + private shouldTriggerPostAuthorize ( + result: AuthorizationResult, + config: AuthConfiguration + ): boolean { + return result.status !== AuthorizationStatus.ACCEPTED && config.disablePostAuthorize === false + } } diff --git a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts index b80b06f9..3e406e95 100644 --- a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts @@ -15,6 +15,8 @@ import { IdentifierType, } from '../types/AuthTypes.js' +const moduleName = 'RemoteAuthStrategy' + /** * Remote Authentication Strategy * @@ -65,7 +67,7 @@ export class RemoteAuthStrategy implements AuthStrategy { */ public addAdapter (version: OCPPVersion, adapter: OCPPAuthAdapter): void { this.adapters.set(version, adapter) - logger.debug(`RemoteAuthStrategy: Added OCPP ${version} adapter`) + logger.debug(`${moduleName}: Added OCPP ${version} adapter`) } /** @@ -91,14 +93,14 @@ export class RemoteAuthStrategy implements AuthStrategy { try { logger.debug( - `RemoteAuthStrategy: Authenticating ${request.identifier.value.substring(0, 8)}... via CSMS for ${request.context}` + `${moduleName}: Authenticating ${request.identifier.value.substring(0, 8)}... via CSMS for ${request.context}` ) // Get appropriate adapter for OCPP version const adapter = this.adapters.get(request.identifier.ocppVersion) if (!adapter) { logger.warn( - `RemoteAuthStrategy: No adapter available for OCPP version ${request.identifier.ocppVersion}` + `${moduleName}: No adapter available for OCPP version ${request.identifier.ocppVersion}` ) return undefined } @@ -106,7 +108,7 @@ export class RemoteAuthStrategy implements AuthStrategy { // Check if remote service is available const isAvailable = await this.checkRemoteAvailability(adapter, config) if (!isAvailable) { - logger.debug('RemoteAuthStrategy: Remote service unavailable') + logger.debug(`${moduleName}: Remote service unavailable`) return undefined } @@ -114,7 +116,7 @@ export class RemoteAuthStrategy implements AuthStrategy { const result = await this.performRemoteAuthorization(request, adapter, config, startTime) if (result) { - logger.debug(`RemoteAuthStrategy: Remote authorization: ${result.status}`) + logger.debug(`${moduleName}: Remote authorization: ${result.status}`) this.stats.successfulRemoteAuth++ // Check if identifier is in Local Auth List — do not cache (OCPP 1.6 §3.5.3) @@ -124,7 +126,7 @@ export class RemoteAuthStrategy implements AuthStrategy { const isInLocalList = await this.localAuthListManager.getEntry(request.identifier.value) if (isInLocalList) { logger.debug( - `RemoteAuthStrategy: Skipping cache for local list identifier: ${request.identifier.value.substring(0, 8)}...` + `${moduleName}: Skipping cache for local list identifier: ${request.identifier.value.substring(0, 8)}...` ) } else { this.cacheResult( @@ -147,7 +149,7 @@ export class RemoteAuthStrategy implements AuthStrategy { } logger.debug( - `RemoteAuthStrategy: No remote authorization result for ${request.identifier.value.substring(0, 8)}...` + `${moduleName}: No remote authorization result for ${request.identifier.value.substring(0, 8)}...` ) return undefined } catch (error) { @@ -163,7 +165,7 @@ export class RemoteAuthStrategy implements AuthStrategy { } const errorMessage = getErrorMessage(error) - logger.error(`RemoteAuthStrategy: Authentication error: ${errorMessage}`) + logger.error(`${moduleName}: Authentication error: ${errorMessage}`) // Don't rethrow - allow other strategies to handle return undefined @@ -193,7 +195,7 @@ export class RemoteAuthStrategy implements AuthStrategy { * Cleanup strategy resources */ public cleanup (): void { - logger.info('RemoteAuthStrategy: Cleaning up...') + logger.info(`${moduleName}: Cleaning up...`) // Reset internal state this.isInitialized = false @@ -208,7 +210,7 @@ export class RemoteAuthStrategy implements AuthStrategy { totalResponseTimeMs: 0, } - logger.info('RemoteAuthStrategy: Cleanup completed') + logger.info(`${moduleName}: Cleanup completed`) } /** @@ -257,11 +259,11 @@ export class RemoteAuthStrategy implements AuthStrategy { */ public initialize (config: AuthConfiguration): void { try { - logger.info('RemoteAuthStrategy: Initializing...') + logger.info(`${moduleName}: Initializing...`) // Validate that we have at least one adapter if (this.adapters.size === 0) { - logger.warn('RemoteAuthStrategy: No OCPP adapters provided') + logger.warn(`${moduleName}: No OCPP adapters provided`) } // Validate adapter configurations @@ -269,27 +271,27 @@ export class RemoteAuthStrategy implements AuthStrategy { try { const isValid = adapter.validateConfiguration(config) if (!isValid) { - logger.warn(`RemoteAuthStrategy: Invalid configuration for OCPP ${version}`) + logger.warn(`${moduleName}: Invalid configuration for OCPP ${version}`) } else { - logger.debug(`RemoteAuthStrategy: OCPP ${version} adapter configured`) + logger.debug(`${moduleName}: OCPP ${version} adapter configured`) } } catch (error) { const errorMessage = getErrorMessage(error) logger.error( - `RemoteAuthStrategy: Configuration validation failed for OCPP ${version}: ${errorMessage}` + `${moduleName}: Configuration validation failed for OCPP ${version}: ${errorMessage}` ) } } if (this.authCache) { - logger.debug('RemoteAuthStrategy: Authorization cache available for result caching') + logger.debug(`${moduleName}: Authorization cache available for result caching`) } this.isInitialized = true - logger.info('RemoteAuthStrategy: Initialized successfully') + logger.info(`${moduleName}: Initialized successfully`) } catch (error) { const errorMessage = getErrorMessage(error) - logger.error(`RemoteAuthStrategy: Initialization failed: ${errorMessage}`) + logger.error(`${moduleName}: Initialization failed: ${errorMessage}`) throw new AuthenticationError( `Remote auth strategy initialization failed: ${errorMessage}`, AuthErrorCode.CONFIGURATION_ERROR, @@ -306,7 +308,7 @@ export class RemoteAuthStrategy implements AuthStrategy { public removeAdapter (version: OCPPVersion): boolean { const removed = this.adapters.delete(version) if (removed) { - logger.debug(`RemoteAuthStrategy: Removed OCPP ${version} adapter`) + logger.debug(`${moduleName}: Removed OCPP ${version} adapter`) } return removed } @@ -374,7 +376,7 @@ export class RemoteAuthStrategy implements AuthStrategy { identifierType === IdentifierType.NO_AUTHORIZATION || identifierType === IdentifierType.CENTRAL ) { - logger.debug(`RemoteAuthStrategy: Skipping cache for ${identifierType} identifier type`) + logger.debug(`${moduleName}: Skipping cache for ${identifierType} identifier type`) return } @@ -383,11 +385,11 @@ export class RemoteAuthStrategy implements AuthStrategy { const cacheTtl = ttl ?? result.cacheTtl ?? 300 // Default 5 minutes this.authCache.set(identifier, result, cacheTtl) logger.debug( - `RemoteAuthStrategy: Cached result for ${identifier.substring(0, 8)}... (TTL: ${String(cacheTtl)}s)` + `${moduleName}: Cached result for ${identifier.substring(0, 8)}... (TTL: ${String(cacheTtl)}s)` ) } catch (error) { const errorMessage = getErrorMessage(error) - logger.error(`RemoteAuthStrategy: Failed to cache result: ${errorMessage}`) + logger.error(`${moduleName}: Failed to cache result: ${errorMessage}`) // Don't throw - caching is not critical for authentication } } @@ -419,7 +421,7 @@ export class RemoteAuthStrategy implements AuthStrategy { return result } catch (error) { const errorMessage = getErrorMessage(error) - logger.debug(`RemoteAuthStrategy: Remote availability check failed: ${errorMessage}`) + logger.debug(`${moduleName}: Remote availability check failed: ${errorMessage}`) return false } } @@ -491,7 +493,7 @@ export class RemoteAuthStrategy implements AuthStrategy { clearTimeout(timeoutHandle) logger.debug( - `RemoteAuthStrategy: Remote authorization completed in ${String(Date.now() - startTime)}ms` + `${moduleName}: Remote authorization completed in ${String(Date.now() - startTime)}ms` ) return result } catch (error) { diff --git a/src/charging-station/ocpp/auth/types/AuthTypes.ts b/src/charging-station/ocpp/auth/types/AuthTypes.ts index 355c4eb2..1fba4f64 100644 --- a/src/charging-station/ocpp/auth/types/AuthTypes.ts +++ b/src/charging-station/ocpp/auth/types/AuthTypes.ts @@ -3,6 +3,7 @@ import type { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js' import { OCPP16AuthorizationStatus } from '../../../../types/ocpp/1.6/Transaction.js' import { + OCPP20AuthorizationStatusEnumType, OCPP20IdTokenEnumType, RequestStartStopStatusEnumType, } from '../../../../types/ocpp/2.0/Transaction.js' @@ -119,6 +120,15 @@ export interface AuthConfiguration extends JsonObject { /** Enable strict certificate validation (default: false) */ certificateValidationStrict?: boolean + /** + * Disable post-authorize behavior for non-Accepted cached/local list tokens (OCPP 2.0). + * When true, non-Accepted tokens from cache or local list are accepted locally without + * triggering a remote AuthorizationRequest (C10.FR.03, C12.FR.05, C14.FR.03). + * When false, non-Accepted cached/local tokens trigger re-authorization via remote. + * When undefined, existing behavior is preserved (no filtering). + */ + disablePostAuthorize?: boolean + /** Enable local authorization list */ localAuthListEnabled: boolean @@ -375,6 +385,45 @@ export const mapOCPP16Status = (status: OCPP16AuthorizationStatus): Authorizatio } } +/** + * Maps OCPP 2.0 authorization status enum to unified authorization status + * @param status - OCPP 2.0 authorization status + * @returns Unified authorization status + * @example + * ```typescript + * const unifiedStatus = mapOCPP20AuthorizationStatus(OCPP20AuthorizationStatusEnumType.Accepted) + * // Returns: AuthorizationStatus.ACCEPTED + * ``` + */ +export const mapOCPP20AuthorizationStatus = ( + status: OCPP20AuthorizationStatusEnumType +): AuthorizationStatus => { + switch (status) { + case OCPP20AuthorizationStatusEnumType.Accepted: + return AuthorizationStatus.ACCEPTED + case OCPP20AuthorizationStatusEnumType.Blocked: + return AuthorizationStatus.BLOCKED + case OCPP20AuthorizationStatusEnumType.ConcurrentTx: + return AuthorizationStatus.CONCURRENT_TX + case OCPP20AuthorizationStatusEnumType.Expired: + return AuthorizationStatus.EXPIRED + case OCPP20AuthorizationStatusEnumType.Invalid: + return AuthorizationStatus.INVALID + case OCPP20AuthorizationStatusEnumType.NoCredit: + return AuthorizationStatus.NO_CREDIT + case OCPP20AuthorizationStatusEnumType.NotAllowedTypeEVSE: + return AuthorizationStatus.NOT_ALLOWED_TYPE_EVSE + case OCPP20AuthorizationStatusEnumType.NotAtThisLocation: + return AuthorizationStatus.NOT_AT_THIS_LOCATION + case OCPP20AuthorizationStatusEnumType.NotAtThisTime: + return AuthorizationStatus.NOT_AT_THIS_TIME + case OCPP20AuthorizationStatusEnumType.Unknown: + return AuthorizationStatus.UNKNOWN + default: + return AuthorizationStatus.INVALID + } +} + /** * Maps OCPP 2.0 token type to unified identifier type * @param type - OCPP 2.0 token type diff --git a/src/charging-station/ocpp/auth/utils/ConfigValidator.ts b/src/charging-station/ocpp/auth/utils/ConfigValidator.ts index fa6607ea..7f9e5bff 100644 --- a/src/charging-station/ocpp/auth/utils/ConfigValidator.ts +++ b/src/charging-station/ocpp/auth/utils/ConfigValidator.ts @@ -1,6 +1,8 @@ import { logger } from '../../../../utils/index.js' import { type AuthConfiguration, AuthenticationError, AuthErrorCode } from '../types/AuthTypes.js' +const moduleName = 'AuthConfigValidator' + /** * Warn if no authentication method is enabled in the configuration. * @param config - Authentication configuration to check @@ -14,7 +16,7 @@ function checkAuthMethodsEnabled (config: AuthConfiguration): void { if (!hasLocalList && !hasCache && !hasRemote && !hasCertificate && !hasOffline) { logger.warn( - 'AuthConfigValidator: No authentication method is enabled. All authorization requests will fail unless at least one method is enabled.' + `${moduleName}: No authentication method is enabled. All authorization requests will fail unless at least one method is enabled.` ) } @@ -26,9 +28,7 @@ function checkAuthMethodsEnabled (config: AuthConfiguration): void { if (hasOffline) enabledMethods.push('offline') if (enabledMethods.length > 0) { - logger.debug( - `AuthConfigValidator: Enabled authentication methods: ${enabledMethods.join(', ')}` - ) + logger.debug(`${moduleName}: Enabled authentication methods: ${enabledMethods.join(', ')}`) } } @@ -46,7 +46,7 @@ function validate (config: AuthConfiguration): void { validateOfflineConfig(config) checkAuthMethodsEnabled(config) - logger.debug('AuthConfigValidator: Configuration validated successfully') + logger.debug(`${moduleName}: Configuration validated successfully`) } /** @@ -71,13 +71,13 @@ function validateCacheConfig (config: AuthConfiguration): void { if (config.authorizationCacheLifetime < 60) { logger.warn( - `AuthConfigValidator: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least 60s for efficiency.` + `${moduleName}: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least 60s for efficiency.` ) } if (config.authorizationCacheLifetime > 86400) { logger.warn( - `AuthConfigValidator: authorizationCacheLifetime is very long (${String(config.authorizationCacheLifetime)}s). This may lead to stale authorizations.` + `${moduleName}: authorizationCacheLifetime is very long (${String(config.authorizationCacheLifetime)}s). This may lead to stale authorizations.` ) } } @@ -99,7 +99,7 @@ function validateCacheConfig (config: AuthConfiguration): void { if (config.maxCacheEntries < 10) { logger.warn( - `AuthConfigValidator: maxCacheEntries is very small (${String(config.maxCacheEntries)}). Cache may be ineffective with frequent evictions.` + `${moduleName}: maxCacheEntries is very small (${String(config.maxCacheEntries)}). Cache may be ineffective with frequent evictions.` ) } } @@ -112,7 +112,7 @@ function validateCacheConfig (config: AuthConfiguration): void { function validateOfflineConfig (config: AuthConfiguration): void { if (config.allowOfflineTxForUnknownId && !config.offlineAuthorizationEnabled) { logger.warn( - 'AuthConfigValidator: allowOfflineTxForUnknownId is true but offlineAuthorizationEnabled is false. Unknown IDs will not be authorized.' + `${moduleName}: allowOfflineTxForUnknownId is true but offlineAuthorizationEnabled is false. Unknown IDs will not be authorized.` ) } @@ -122,7 +122,7 @@ function validateOfflineConfig (config: AuthConfiguration): void { config.unknownIdAuthorization ) { logger.debug( - `AuthConfigValidator: Offline mode enabled with unknownIdAuthorization=${config.unknownIdAuthorization}` + `${moduleName}: Offline mode enabled with unknownIdAuthorization=${config.unknownIdAuthorization}` ) } } @@ -148,13 +148,13 @@ function validateTimeout (config: AuthConfiguration): void { if (config.authorizationTimeout < 5) { logger.warn( - `AuthConfigValidator: authorizationTimeout is very short (${String(config.authorizationTimeout)}s). This may cause premature timeouts.` + `${moduleName}: authorizationTimeout is very short (${String(config.authorizationTimeout)}s). This may cause premature timeouts.` ) } if (config.authorizationTimeout > 60) { logger.warn( - `AuthConfigValidator: authorizationTimeout is very long (${String(config.authorizationTimeout)}s). Users may experience long waits.` + `${moduleName}: authorizationTimeout is very long (${String(config.authorizationTimeout)}s). Users may experience long waits.` ) } } diff --git a/src/types/ConnectorStatus.ts b/src/types/ConnectorStatus.ts index 4fdeae1c..7b272de2 100644 --- a/src/types/ConnectorStatus.ts +++ b/src/types/ConnectorStatus.ts @@ -34,6 +34,7 @@ export interface ConnectorStatus { * that occurs after the EV has connected. */ transactionEvseSent?: boolean + transactionGroupIdToken?: string transactionId?: number | string transactionIdTag?: string /** diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index bc723324..876da768 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -482,3 +482,10 @@ export const queueMicrotaskErrorThrowing = (error: Error): void => { throw error }) } + +export const truncateId = (identifier: string, maxLen = 8): string => { + if (identifier.length <= maxLen) { + return identifier + } + return `${identifier.slice(0, maxLen)}...` +} diff --git a/src/utils/index.ts b/src/utils/index.ts index e708405b..a36d9451 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -58,6 +58,7 @@ export { roundTo, secureRandom, sleep, + truncateId, validateIdentifierString, validateUUID, } from './Utils.js' diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts new file mode 100644 index 00000000..7631280b --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts @@ -0,0 +1,179 @@ +/** + * @file Tests for OCPP 2.0 GroupId-based stop transaction authorization + * @description Unit tests for C01.FR.03, C09.FR.03, C09.FR.07 conformance + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { OCPP20RequestStartTransactionRequest } from '../../../../src/types/index.js' + +import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js' +import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' +import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js' +import { RequestStartStopStatusEnumType } from '../../../../src/types/index.js' +import { OCPP20IdTokenEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' +import { createMockAuthService } from '../auth/helpers/MockFactories.js' +import { + createOCPP20ListenerStation, + resetConnectorTransactionState, + resetLimits, + resetReportingValueSize, +} from './OCPP20TestUtils.js' + +const GROUP_ID_TOKEN = 'GROUP_ALPHA' +const DIFFERENT_GROUP_ID_TOKEN = 'GROUP_BETA' +const START_TOKEN = 'START_USER_001' +const STOP_TOKEN_SAME_GROUP = 'STOP_USER_002' +const STOP_TOKEN_DIFFERENT_GROUP = 'STOP_USER_003' + +await describe('C09 - GroupId-based Stop Transaction Authorization', async () => { + let mockStation: ChargingStation + let incomingRequestService: OCPP20IncomingRequestService + let testableService: ReturnType + + beforeEach(() => { + const { station } = createOCPP20ListenerStation(TEST_CHARGING_STATION_BASE_NAME) + mockStation = station + incomingRequestService = new OCPP20IncomingRequestService() + testableService = createTestableIncomingRequestService(incomingRequestService) + const stationId = mockStation.stationInfo?.chargingStationId ?? 'unknown' + OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService()) + resetLimits(mockStation) + resetReportingValueSize(mockStation) + resetConnectorTransactionState(mockStation) + }) + + afterEach(() => { + standardCleanup() + OCPPAuthServiceFactory.clearAllInstances() + }) + + /** + * Starts a transaction with an optional group ID token on the given EVSE. + * @param station - charging station instance + * @param evseId - EVSE identifier + * @param remoteStartId - remote start identifier + * @param idToken - token used to start + * @param groupIdToken - optional group token + * @returns transaction ID + */ + async function startTransactionWithGroup ( + station: ChargingStation, + evseId = 1, + remoteStartId = 1, + idToken = START_TOKEN, + groupIdToken?: string + ): Promise { + const startRequest: OCPP20RequestStartTransactionRequest = { + evseId, + groupIdToken: + groupIdToken != null + ? { idToken: groupIdToken, type: OCPP20IdTokenEnumType.Central } + : undefined, + idToken: { + idToken, + type: OCPP20IdTokenEnumType.ISO14443, + }, + remoteStartId, + } + + const startResponse = await testableService.handleRequestStartTransaction(station, startRequest) + + assert.strictEqual(startResponse.status, RequestStartStopStatusEnumType.Accepted) + assert.notStrictEqual(startResponse.transactionId, undefined) + return startResponse.transactionId as string + } + + await it('C09.FR.03/C09.FR.07 - should authorize stop by same GroupIdToken without AuthorizationRequest', async () => { + await startTransactionWithGroup(mockStation, 1, 100, START_TOKEN, GROUP_ID_TOKEN) + + const connectorStatus = mockStation.getConnectorStatus(1) + assert.notStrictEqual(connectorStatus, undefined) + if (connectorStatus == null) { + assert.fail('Expected connectorStatus to be defined') + } + assert.strictEqual(connectorStatus.transactionGroupIdToken, GROUP_ID_TOKEN) + assert.strictEqual(connectorStatus.transactionStarted, true) + + const isAuthorized = testableService.isAuthorizedToStopTransaction( + mockStation, + 1, + { idToken: STOP_TOKEN_SAME_GROUP, type: OCPP20IdTokenEnumType.ISO14443 }, + { idToken: GROUP_ID_TOKEN, type: OCPP20IdTokenEnumType.Central } + ) + + assert.strictEqual(isAuthorized, true) + }) + + await it('should NOT authorize stop by different GroupIdToken', async () => { + await startTransactionWithGroup(mockStation, 1, 300, START_TOKEN, GROUP_ID_TOKEN) + + const isAuthorized = testableService.isAuthorizedToStopTransaction( + mockStation, + 1, + { idToken: STOP_TOKEN_DIFFERENT_GROUP, type: OCPP20IdTokenEnumType.ISO14443 }, + { idToken: DIFFERENT_GROUP_ID_TOKEN, type: OCPP20IdTokenEnumType.Central } + ) + + assert.strictEqual(isAuthorized, false) + }) + + // FR: C01.FR.03(a) + await it('should authorize stop by same idToken as start without GroupIdToken', async () => { + await startTransactionWithGroup(mockStation, 1, 400, START_TOKEN) + + const isAuthorized = testableService.isAuthorizedToStopTransaction(mockStation, 1, { + idToken: START_TOKEN, + type: OCPP20IdTokenEnumType.ISO14443, + }) + + assert.strictEqual(isAuthorized, true) + }) + + await it('should NOT authorize stop when no active transaction on connector', () => { + const isAuthorized = testableService.isAuthorizedToStopTransaction( + mockStation, + 1, + { idToken: START_TOKEN, type: OCPP20IdTokenEnumType.ISO14443 }, + { idToken: GROUP_ID_TOKEN, type: OCPP20IdTokenEnumType.Central } + ) + + assert.strictEqual(isAuthorized, false) + }) + + await it('should NOT authorize stop when presented GroupIdToken is undefined but start had one', async () => { + await startTransactionWithGroup(mockStation, 1, 500, START_TOKEN, GROUP_ID_TOKEN) + + const isAuthorized = testableService.isAuthorizedToStopTransaction(mockStation, 1, { + idToken: STOP_TOKEN_SAME_GROUP, + type: OCPP20IdTokenEnumType.ISO14443, + }) + + assert.strictEqual(isAuthorized, false) + }) + + await it('should store transactionGroupIdToken on connector during start', async () => { + await startTransactionWithGroup(mockStation, 1, 600, START_TOKEN, GROUP_ID_TOKEN) + + const connectorStatus = mockStation.getConnectorStatus(1) + if (connectorStatus == null) { + assert.fail('Expected connectorStatus to be defined') + } + assert.strictEqual(connectorStatus.transactionGroupIdToken, GROUP_ID_TOKEN) + assert.strictEqual(connectorStatus.transactionIdTag, START_TOKEN) + }) + + await it('should not store transactionGroupIdToken when start has no groupIdToken', async () => { + await startTransactionWithGroup(mockStation, 1, 700, START_TOKEN) + + const connectorStatus = mockStation.getConnectorStatus(1) + if (connectorStatus == null) { + assert.fail('Expected connectorStatus to be defined') + } + assert.strictEqual(connectorStatus.transactionGroupIdToken, undefined) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-MasterPass.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-MasterPass.test.ts new file mode 100644 index 00000000..3e0ad1c7 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-MasterPass.test.ts @@ -0,0 +1,215 @@ +/** + * @file Tests for OCPP20IncomingRequestService MasterPassGroupId check + * @description Unit tests for OCPP 2.0 MasterPassGroupId authorization (C12.FR.09) + */ +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { OCPP20RequestStartTransactionRequest } from '../../../../src/types/index.js' +import type { OCPP20GetVariableDataType } from '../../../../src/types/index.js' + +import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js' +import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' +import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js' +import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js' +import { + GetVariableStatusEnumType, + OCPPVersion, + RequestStartStopStatusEnumType, +} from '../../../../src/types/index.js' +import { OCPP20IdTokenEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js' +import { Constants } from '../../../../src/utils/index.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' +import { createMockAuthService } from '../auth/helpers/MockFactories.js' +import { + resetConnectorTransactionState, + resetLimits, + resetReportingValueSize, +} from './OCPP20TestUtils.js' + +await describe('C12.FR.09 - MasterPassGroupId Check', async () => { + let mockStation: ChargingStation + let testableService: ReturnType + + beforeEach(() => { + const { station } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 3, + evseConfiguration: { evsesCount: 3 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + ocppRequestService: { + requestHandler: async () => Promise.resolve({}), + }, + stationInfo: { + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, + }) + mockStation = station + const incomingRequestService = new OCPP20IncomingRequestService() + testableService = createTestableIncomingRequestService(incomingRequestService) + const stationId = mockStation.stationInfo?.chargingStationId ?? 'unknown' + OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService()) + resetConnectorTransactionState(mockStation) + resetLimits(mockStation) + resetReportingValueSize(mockStation) + }) + + afterEach(() => { + OCPP20VariableManager.getInstance().resetRuntimeOverrides() + OCPP20VariableManager.getInstance().invalidateMappingsCache() + standardCleanup() + OCPPAuthServiceFactory.clearAllInstances() + }) + + await it('C12.FR.09 - should reject start transaction when groupIdToken matches MasterPassGroupId', async () => { + const masterPassGroupId = 'MASTER_GROUP_1' + + const originalGetVariables = OCPP20VariableManager.getInstance().getVariables.bind( + OCPP20VariableManager.getInstance() + ) + mock.method( + OCPP20VariableManager.getInstance(), + 'getVariables', + (station: ChargingStation, requests: OCPP20GetVariableDataType[]) => { + const results = originalGetVariables(station, requests) + for (let i = 0; i < (requests as { variable?: { name?: string } }[]).length; i++) { + const req = (requests as { variable?: { name?: string } }[])[i] + if (req.variable?.name === 'MasterPassGroupId') { + results[i] = { + ...results[i], + attributeStatus: GetVariableStatusEnumType.Accepted, + attributeValue: masterPassGroupId, + } + } + } + return results + } + ) + + const request: OCPP20RequestStartTransactionRequest = { + evseId: 1, + groupIdToken: { + idToken: masterPassGroupId, + type: OCPP20IdTokenEnumType.Central, + }, + idToken: { + idToken: 'SOME_USER_TOKEN', + type: OCPP20IdTokenEnumType.ISO14443, + }, + remoteStartId: 1, + } + + const response = await testableService.handleRequestStartTransaction(mockStation, request) + + assert.notStrictEqual(response, undefined) + assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected) + }) + + await it('C12.FR.09 - should be no-op when MasterPassGroupId not configured', async () => { + const request: OCPP20RequestStartTransactionRequest = { + evseId: 1, + idToken: { + idToken: 'VALID_TOKEN_123', + type: OCPP20IdTokenEnumType.ISO14443, + }, + remoteStartId: 1, + } + + const response = await testableService.handleRequestStartTransaction(mockStation, request) + + assert.notStrictEqual(response, undefined) + assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted) + assert.notStrictEqual(response.transactionId, undefined) + }) + + await it('C12.FR.09 - should allow start when groupIdToken does not match MasterPassGroupId', async () => { + const masterPassGroupId = 'MASTER_GROUP_1' + + const originalGetVariables = OCPP20VariableManager.getInstance().getVariables.bind( + OCPP20VariableManager.getInstance() + ) + mock.method( + OCPP20VariableManager.getInstance(), + 'getVariables', + (station: ChargingStation, requests: OCPP20GetVariableDataType[]) => { + const results = originalGetVariables(station, requests) + for (let i = 0; i < (requests as { variable?: { name?: string } }[]).length; i++) { + const req = (requests as { variable?: { name?: string } }[])[i] + if (req.variable?.name === 'MasterPassGroupId') { + results[i] = { + ...results[i], + attributeStatus: GetVariableStatusEnumType.Accepted, + attributeValue: masterPassGroupId, + } + } + } + return results + } + ) + + const request: OCPP20RequestStartTransactionRequest = { + evseId: 1, + groupIdToken: { + idToken: 'DIFFERENT_GROUP', + type: OCPP20IdTokenEnumType.Central, + }, + idToken: { + idToken: 'SOME_USER_TOKEN', + type: OCPP20IdTokenEnumType.ISO14443, + }, + remoteStartId: 1, + } + + const response = await testableService.handleRequestStartTransaction(mockStation, request) + + assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted) + }) + + await it('C12.FR.09 - should not reject when idToken matches MasterPassGroupId but groupIdToken does not', async () => { + const masterPassGroupId = 'MASTER_GROUP_1' + + const originalGetVariables = OCPP20VariableManager.getInstance().getVariables.bind( + OCPP20VariableManager.getInstance() + ) + mock.method( + OCPP20VariableManager.getInstance(), + 'getVariables', + (station: ChargingStation, requests: OCPP20GetVariableDataType[]) => { + const results = originalGetVariables(station, requests) + for (let i = 0; i < (requests as { variable?: { name?: string } }[]).length; i++) { + const req = (requests as { variable?: { name?: string } }[])[i] + if (req.variable?.name === 'MasterPassGroupId') { + results[i] = { + ...results[i], + attributeStatus: GetVariableStatusEnumType.Accepted, + attributeValue: masterPassGroupId, + } + } + } + return results + } + ) + + const request: OCPP20RequestStartTransactionRequest = { + evseId: 1, + groupIdToken: { + idToken: 'DIFFERENT_GROUP', + type: OCPP20IdTokenEnumType.Central, + }, + idToken: { + idToken: masterPassGroupId, + type: OCPP20IdTokenEnumType.ISO14443, + }, + remoteStartId: 1, + } + + const response = await testableService.handleRequestStartTransaction(mockStation, request) + + assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts index 70c99756..b3212a8b 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts @@ -249,7 +249,7 @@ await describe('F03 - Remote Stop Transaction', async () => { await it( 'should call requestStopTransaction when response is Accepted', { - skip: process.platform === 'darwin' && process.versions.node.startsWith('22.'), + skip: process.versions.node.startsWith('22.'), }, async () => { const transactionId = await startTransaction(listenerStation, 1, 100) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts new file mode 100644 index 00000000..a3cd6f2e --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts @@ -0,0 +1,183 @@ +/** + * @file Tests for OCPP20ResponseService cache update on TransactionEventResponse + * @description Unit tests for auth cache auto-update from TransactionEventResponse idTokenInfo + * per OCPP 2.0.1 C10.FR.01/04/05, C12.FR.06, C02.FR.03, C03.FR.02 + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { AuthCache } from '../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js' +import type { LocalAuthStrategy } from '../../../../src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.js' + +import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js' +import { OCPPAuthServiceImpl } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.js' +import { + AuthorizationStatus, + IdentifierType, +} from '../../../../src/charging-station/ocpp/auth/types/AuthTypes.js' +import { OCPP20AuthorizationStatusEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js' +import { OCPPVersion } from '../../../../src/types/ocpp/OCPPVersion.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' + +const TEST_IDENTIFIER = 'TEST_RFID_TOKEN_001' +const TEST_STATION_ID = 'CS_CACHE_UPDATE_TEST' + +await describe('C10 - TransactionEventResponse Cache Update', async () => { + let station: ChargingStation + let authService: OCPPAuthServiceImpl + let authCache: AuthCache + + beforeEach(async () => { + const { station: mockStation } = createMockChargingStation({ + baseName: TEST_STATION_ID, + connectorsCount: 1, + stationInfo: { + chargingStationId: TEST_STATION_ID, + ocppVersion: OCPPVersion.VERSION_201, + }, + }) + station = mockStation + + authService = new OCPPAuthServiceImpl(station) + await authService.initialize() + + const localStrategy = authService.getStrategy('local') as LocalAuthStrategy | undefined + const cache = localStrategy?.getAuthCache() + assert.ok(cache != null, 'Auth cache must be available after initialization') + authCache = cache + }) + + afterEach(() => { + OCPPAuthServiceFactory.clearAllInstances() + standardCleanup() + }) + + await it('C10.FR.05 - should update cache on TransactionEventResponse with Accepted idTokenInfo', () => { + // Arrange + const idTokenInfo = { + status: OCPP20AuthorizationStatusEnumType.Accepted, + } + + // Act + authService.updateCacheEntry(TEST_IDENTIFIER, idTokenInfo, IdentifierType.ISO14443) + + // Assert + const cached = authCache.get(TEST_IDENTIFIER) + assert.ok(cached != null, 'Cache entry should exist') + assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED) + }) + + await it('C10.FR.09 - should use cacheExpiryDateTime as TTL when present in idTokenInfo', () => { + // Arrange — expiry 600 seconds from now + const futureDate = new Date(Date.now() + 600_000) + const idTokenInfo = { + cacheExpiryDateTime: futureDate, + status: OCPP20AuthorizationStatusEnumType.Accepted, + } + + // Act + authService.updateCacheEntry(TEST_IDENTIFIER, idTokenInfo, IdentifierType.ISO14443) + + // Assert — entry is cached (TTL is explicit, checked via presence) + const cached = authCache.get(TEST_IDENTIFIER) + assert.ok(cached != null, 'Cache entry should exist with explicit TTL') + assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED) + }) + + await it('C10.FR.08 - should use AuthCacheLifeTime as TTL when cacheExpiryDateTime absent', () => { + // Arrange — no cacheExpiryDateTime + const idTokenInfo = { + status: OCPP20AuthorizationStatusEnumType.Accepted, + } + + // Act + authService.updateCacheEntry(TEST_IDENTIFIER, idTokenInfo, IdentifierType.ISO14443) + + // Assert — entry is cached (uses config.authorizationCacheLifetime as default TTL) + const cached = authCache.get(TEST_IDENTIFIER) + assert.ok(cached != null, 'Cache entry should exist with default TTL') + assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED) + }) + + await it('C02.FR.03 - should NOT cache NoAuthorization token type', () => { + // Arrange + const idTokenInfo = { + status: OCPP20AuthorizationStatusEnumType.Accepted, + } + + // Act + authService.updateCacheEntry('', idTokenInfo, IdentifierType.NO_AUTHORIZATION) + + // Assert + const cached = authCache.get('') + assert.strictEqual(cached, undefined, 'NoAuthorization tokens must not be cached') + }) + + await it('C03.FR.02 - should NOT cache Central token type', () => { + // Arrange + const idTokenInfo = { + status: OCPP20AuthorizationStatusEnumType.Accepted, + } + + // Act + authService.updateCacheEntry('CENTRAL_TOKEN_001', idTokenInfo, IdentifierType.CENTRAL) + + // Assert + const cached = authCache.get('CENTRAL_TOKEN_001') + assert.strictEqual(cached, undefined, 'Central tokens must not be cached') + }) + + await it('C10.FR.01 - should cache non-Accepted status (Blocked, Expired, etc.)', () => { + // Arrange — multiple non-Accepted statuses per C10.FR.01: cache ALL statuses + const blockedInfo = { + status: OCPP20AuthorizationStatusEnumType.Blocked, + } + const expiredInfo = { + status: OCPP20AuthorizationStatusEnumType.Expired, + } + + // Act + authService.updateCacheEntry('BLOCKED_TOKEN', blockedInfo, IdentifierType.ISO14443) + authService.updateCacheEntry('EXPIRED_TOKEN', expiredInfo, IdentifierType.ISO14443) + + // Assert + const cachedBlocked = authCache.get('BLOCKED_TOKEN') + assert.ok(cachedBlocked != null, 'Blocked status should be cached') + assert.strictEqual(cachedBlocked.status, AuthorizationStatus.BLOCKED) + + const cachedExpired = authCache.get('EXPIRED_TOKEN') + assert.ok(cachedExpired != null, 'Expired status should be cached') + assert.strictEqual(cachedExpired.status, AuthorizationStatus.EXPIRED) + }) + + await it('should not update cache when authorizationCacheEnabled is false', async () => { + // Arrange — create service with cache disabled + const { station: disabledStation } = createMockChargingStation({ + baseName: 'CS_CACHE_DISABLED', + connectorsCount: 1, + stationInfo: { + chargingStationId: 'CS_CACHE_DISABLED', + ocppVersion: OCPPVersion.VERSION_201, + }, + }) + const disabledService = new OCPPAuthServiceImpl(disabledStation) + await disabledService.initialize() + disabledService.updateConfiguration({ authorizationCacheEnabled: false }) + + const idTokenInfo = { + status: OCPP20AuthorizationStatusEnumType.Accepted, + } + + // Act + disabledService.updateCacheEntry(TEST_IDENTIFIER, idTokenInfo, IdentifierType.ISO14443) + + // Assert — cache should not have been written to + const localStrategy = disabledService.getStrategy('local') as LocalAuthStrategy | undefined + const cache = localStrategy?.getAuthCache() + const cached = cache?.get(TEST_IDENTIFIER) + assert.strictEqual(cached, undefined, 'Cache entry should not exist when cache is disabled') + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts index 16596814..61625ea0 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts @@ -231,6 +231,7 @@ export function resetConnectorTransactionState (chargingStation: ChargingStation connectorStatus.transactionStarted = false connectorStatus.transactionId = undefined connectorStatus.transactionIdTag = undefined + connectorStatus.transactionGroupIdToken = undefined connectorStatus.transactionStart = undefined connectorStatus.transactionEnergyActiveImportRegisterValue = 0 connectorStatus.remoteStartId = undefined @@ -244,6 +245,7 @@ export function resetConnectorTransactionState (chargingStation: ChargingStation connectorStatus.transactionStarted = false connectorStatus.transactionId = undefined connectorStatus.transactionIdTag = undefined + connectorStatus.transactionGroupIdToken = undefined connectorStatus.transactionStart = undefined connectorStatus.transactionEnergyActiveImportRegisterValue = 0 connectorStatus.remoteStartId = undefined diff --git a/tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts b/tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts index 69b44649..c490e184 100644 --- a/tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts +++ b/tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts @@ -7,14 +7,12 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import type { AuthorizationResult } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js' -import { - InMemoryAuthCache, - truncateId, -} from '../../../../../src/charging-station/ocpp/auth/cache/InMemoryAuthCache.js' +import { InMemoryAuthCache } from '../../../../../src/charging-station/ocpp/auth/cache/InMemoryAuthCache.js' import { AuthenticationMethod, AuthorizationStatus, } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js' +import { truncateId } from '../../../../../src/utils/index.js' import { standardCleanup, withMockTimers } from '../../../../helpers/TestLifecycleHelpers.js' import { createMockAuthorizationResult } from '../helpers/MockFactories.js' diff --git a/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts b/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts new file mode 100644 index 00000000..c383a49f --- /dev/null +++ b/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts @@ -0,0 +1,160 @@ +/** + * @file Tests for LocalAuthStrategy DisablePostAuthorize behavior + * @description Tests for C10.FR.03, C12.FR.05, C14.FR.03 conformance + */ +import assert from 'node:assert/strict' +import { afterEach, describe, it } from 'node:test' + +import { LocalAuthStrategy } from '../../../../../src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.js' +import { + AuthenticationMethod, + AuthorizationStatus, + IdentifierType, +} from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js' +import { OCPPVersion } from '../../../../../src/types/ocpp/OCPPVersion.js' +import { standardCleanup } from '../../../../helpers/TestLifecycleHelpers.js' +import { + createMockAuthCache, + createMockAuthorizationResult, + createMockAuthRequest, + createMockIdentifier, + createMockLocalAuthListManager, + createTestAuthConfig, +} from '../helpers/MockFactories.js' + +await describe('LocalAuthStrategy - DisablePostAuthorize', async () => { + let strategy: LocalAuthStrategy + + afterEach(() => { + standardCleanup() + }) + + await describe('C10.FR.03 - cache post-authorize', async () => { + await it('should accept non-Accepted cached token without re-auth when DisablePostAuthorize=true', async () => { + // Arrange + const blockedResult = createMockAuthorizationResult({ + method: AuthenticationMethod.CACHE, + status: AuthorizationStatus.BLOCKED, + }) + const mockAuthCache = createMockAuthCache({ + get: () => blockedResult, + }) + strategy = new LocalAuthStrategy(undefined, mockAuthCache) + const config = createTestAuthConfig({ + authorizationCacheEnabled: true, + disablePostAuthorize: true, + }) + strategy.initialize(config) + const request = createMockAuthRequest({ + identifier: createMockIdentifier( + OCPPVersion.VERSION_20, + 'BLOCKED-TAG', + IdentifierType.ISO14443 + ), + }) + + // Act + const result = await strategy.authenticate(request, config) + + // Assert + assert.notStrictEqual(result, undefined) + assert.strictEqual(result?.status, AuthorizationStatus.BLOCKED) + assert.strictEqual(result.method, AuthenticationMethod.CACHE) + }) + + await it('should trigger re-auth for non-Accepted cached token when DisablePostAuthorize=false', async () => { + // Arrange + const blockedResult = createMockAuthorizationResult({ + method: AuthenticationMethod.CACHE, + status: AuthorizationStatus.BLOCKED, + }) + const mockAuthCache = createMockAuthCache({ + get: () => blockedResult, + }) + strategy = new LocalAuthStrategy(undefined, mockAuthCache) + const config = createTestAuthConfig({ + authorizationCacheEnabled: true, + disablePostAuthorize: false, + }) + strategy.initialize(config) + const request = createMockAuthRequest({ + identifier: createMockIdentifier( + OCPPVersion.VERSION_20, + 'BLOCKED-TAG', + IdentifierType.ISO14443 + ), + }) + + // Act + const result = await strategy.authenticate(request, config) + + // Assert - undefined signals orchestrator should try remote strategy + assert.strictEqual(result, undefined) + }) + }) + + await describe('C14.FR.03 - local list post-authorize', async () => { + await it('should accept non-Accepted local list token without re-auth when DisablePostAuthorize=true', async () => { + const localListManager = createMockLocalAuthListManager({ + getEntry: () => + new Promise(resolve => { + resolve({ + identifier: 'BLOCKED-TAG', + status: 'Blocked', + }) + }), + }) + strategy = new LocalAuthStrategy(localListManager, undefined) + const config = createTestAuthConfig({ + disablePostAuthorize: true, + localAuthListEnabled: true, + }) + strategy.initialize(config) + const request = createMockAuthRequest({ + identifier: createMockIdentifier( + OCPPVersion.VERSION_20, + 'BLOCKED-TAG', + IdentifierType.ISO14443 + ), + }) + + const result = await strategy.authenticate(request, config) + + assert.notStrictEqual(result, undefined) + assert.strictEqual(result?.status, AuthorizationStatus.BLOCKED) + assert.strictEqual(result.method, AuthenticationMethod.LOCAL_LIST) + }) + }) + + await describe('default behavior', async () => { + await it('should be no-op when DisablePostAuthorize not configured (default behavior preserved)', async () => { + // Arrange + const blockedResult = createMockAuthorizationResult({ + method: AuthenticationMethod.CACHE, + status: AuthorizationStatus.BLOCKED, + }) + const mockAuthCache = createMockAuthCache({ + get: () => blockedResult, + }) + strategy = new LocalAuthStrategy(undefined, mockAuthCache) + const config = createTestAuthConfig({ + authorizationCacheEnabled: true, + }) + strategy.initialize(config) + const request = createMockAuthRequest({ + identifier: createMockIdentifier( + OCPPVersion.VERSION_20, + 'BLOCKED-TAG', + IdentifierType.ISO14443 + ), + }) + + // Act + const result = await strategy.authenticate(request, config) + + // Assert - returns cached result as-is (disablePostAuthorize not in config) + assert.notStrictEqual(result, undefined) + assert.strictEqual(result?.status, AuthorizationStatus.BLOCKED) + }) + }) +}) -- 2.53.0