* 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
delete connectorStatus.transactionStart
delete connectorStatus.transactionId
delete connectorStatus.transactionIdTag
+ delete connectorStatus.transactionGroupIdToken
connectorStatus.transactionEnergyActiveImportRegisterValue = 0
delete connectorStatus.transactionBeginMeterValue
delete connectorStatus.transactionSeqNo
generateUUID,
logger,
sleep,
+ truncateId,
validateUUID,
} from '../../../utils/index.js'
import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
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
}
): Promise<OCPP20RequestStartTransactionResponse> {
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
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 {
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(),
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 {
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(),
connectorStatus.transactionStarted = true
connectorStatus.transactionId = transactionId
connectorStatus.transactionIdTag = idToken.idToken
+ connectorStatus.transactionGroupIdToken = groupIdToken?.idToken
connectorStatus.transactionStart = new Date()
connectorStatus.transactionEnergyActiveImportRegisterValue = 0
connectorStatus.remoteStartId = remoteStartId
}
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 {
)
}
+ 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.
*/
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 {
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`
)
}
}
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
} 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'
)
}
}
+ // 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(
OCPP20GetTransactionStatusResponse,
OCPP20GetVariablesRequest,
OCPP20GetVariablesResponse,
+ OCPP20IdTokenType,
OCPP20InstallCertificateRequest,
OCPP20InstallCertificateResponse,
OCPP20RequestStartTransactionRequest,
chargingStation: ChargingStation,
commandPayload: OCPP20UpdateFirmwareRequest
) => OCPP20UpdateFirmwareResponse
+
+ isAuthorizedToStopTransaction: (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ presentedIdToken: OCPP20IdTokenType,
+ presentedGroupIdToken?: OCPP20IdTokenType
+ ) => boolean
}
/**
handleRequestTriggerMessage: serviceImpl.handleRequestTriggerMessage.bind(service),
handleRequestUnlockConnector: serviceImpl.handleRequestUnlockConnector.bind(service),
handleRequestUpdateFirmware: serviceImpl.handleRequestUpdateFirmware.bind(service),
+ isAuthorizedToStopTransaction: serviceImpl.isAuthorizedToStopTransaction.bind(service),
}
}
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
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
}
// 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
}
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
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
// 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})`
)
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
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
}
// 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
}
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
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
// 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)
}
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)
}
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)
}
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
}
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
}
// 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
}
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
*/
}
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'}`
)
}
this.lruOrder.clear()
this.rateLimits.clear()
- logger.info(`InMemoryAuthCache: Cleared ${String(entriesCleared)} entries`)
+ logger.info(`${moduleName}: Cleared ${String(entriesCleared)} entries`)
}
public dispose (): void {
// 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
}
}
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
}
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
}
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)}`)
}
}
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
}
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)}`
)
}
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)}...`
}
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'
// ============================================================================
+import type { OCPP20IdTokenInfoType } from '../../../../types/ocpp/2.0/Transaction.js'
import type { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js'
import type {
AuthConfiguration,
AuthRequest,
UnifiedIdentifier,
} from '../types/AuthTypes.js'
+import type { IdentifierType } from '../types/AuthTypes.js'
/**
* Authorization cache interface
*/
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
+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'
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 {
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<OCPPVersion, OCPP16AuthAdapter | OCPP20AuthAdapter>
private readonly chargingStation: ChargingStation
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
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
}
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 {
} 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
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 {
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 {
} 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
}
* 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`
+ )
}
}
*/
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
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`
)
}
}
}
} catch (error) {
logger.debug(
- `${this.chargingStation.logPrefix()} Local authorization check failed: ${getErrorMessage(error)}`
+ `${this.chargingStation.logPrefix()} ${moduleName}.isLocallyAuthorized: Local authorization check failed: ${getErrorMessage(error)}`
)
}
}
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
// Apply validated configuration
this.config = newConfig
- logger.info(`${this.chargingStation.logPrefix()} Authentication configuration updated`)
+ logger.info(
+ `${this.chargingStation.logPrefix()} ${moduleName}.updateConfiguration: Authentication configuration updated`
+ )
}
/**
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`
)
}
}
})
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'}`
)
}
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
/**
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,
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',
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<string, unknown> {
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
}
}
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
)
}
} 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',
} 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,
AuthorizationStatus,
} from '../types/AuthTypes.js'
+const moduleName = 'LocalAuthStrategy'
+
/**
* Local Authentication Strategy
*
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)
}
}
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)
}
}
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,
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
}
}
* Cleanup strategy resources
*/
public cleanup (): void {
- logger.info('LocalAuthStrategy: Cleaning up...')
+ logger.info(`${moduleName}: Cleaning up...`)
// Reset internal state
this.isInitialized = false
totalRequests: 0,
}
- logger.info('LocalAuthStrategy: Cleanup completed')
+ logger.info(`${moduleName}: Cleanup completed`)
}
/**
*/
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,
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
}
}
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
}
}
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,
// 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,
}
} 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,
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) {
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
+ }
}
IdentifierType,
} from '../types/AuthTypes.js'
+const moduleName = 'RemoteAuthStrategy'
+
/**
* Remote Authentication Strategy
*
*/
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`)
}
/**
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
}
// 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
}
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)
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(
}
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) {
}
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
* Cleanup strategy resources
*/
public cleanup (): void {
- logger.info('RemoteAuthStrategy: Cleaning up...')
+ logger.info(`${moduleName}: Cleaning up...`)
// Reset internal state
this.isInitialized = false
totalResponseTimeMs: 0,
}
- logger.info('RemoteAuthStrategy: Cleanup completed')
+ logger.info(`${moduleName}: Cleanup completed`)
}
/**
*/
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
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,
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
}
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
}
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
}
}
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
}
}
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) {
import { OCPP16AuthorizationStatus } from '../../../../types/ocpp/1.6/Transaction.js'
import {
+ OCPP20AuthorizationStatusEnumType,
OCPP20IdTokenEnumType,
RequestStartStopStatusEnumType,
} from '../../../../types/ocpp/2.0/Transaction.js'
/** 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
}
}
+/**
+ * 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
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
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.`
)
}
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(', ')}`)
}
}
validateOfflineConfig(config)
checkAuthMethodsEnabled(config)
- logger.debug('AuthConfigValidator: Configuration validated successfully')
+ logger.debug(`${moduleName}: Configuration validated successfully`)
}
/**
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.`
)
}
}
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.`
)
}
}
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.`
)
}
config.unknownIdAuthorization
) {
logger.debug(
- `AuthConfigValidator: Offline mode enabled with unknownIdAuthorization=${config.unknownIdAuthorization}`
+ `${moduleName}: Offline mode enabled with unknownIdAuthorization=${config.unknownIdAuthorization}`
)
}
}
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.`
)
}
}
* that occurs after the EV has connected.
*/
transactionEvseSent?: boolean
+ transactionGroupIdToken?: string
transactionId?: number | string
transactionIdTag?: string
/**
throw error
})
}
+
+export const truncateId = (identifier: string, maxLen = 8): string => {
+ if (identifier.length <= maxLen) {
+ return identifier
+ }
+ return `${identifier.slice(0, maxLen)}...`
+}
roundTo,
secureRandom,
sleep,
+ truncateId,
validateIdentifierString,
validateUUID,
} from './Utils.js'
--- /dev/null
+/**
+ * @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<typeof createTestableIncomingRequestService>
+
+ 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<string> {
+ 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)
+ })
+})
--- /dev/null
+/**
+ * @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<typeof createTestableIncomingRequestService>
+
+ 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)
+ })
+})
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)
--- /dev/null
+/**
+ * @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')
+ })
+})
connectorStatus.transactionStarted = false
connectorStatus.transactionId = undefined
connectorStatus.transactionIdTag = undefined
+ connectorStatus.transactionGroupIdToken = undefined
connectorStatus.transactionStart = undefined
connectorStatus.transactionEnergyActiveImportRegisterValue = 0
connectorStatus.remoteStartId = undefined
connectorStatus.transactionStarted = false
connectorStatus.transactionId = undefined
connectorStatus.transactionIdTag = undefined
+ connectorStatus.transactionGroupIdToken = undefined
connectorStatus.transactionStart = undefined
connectorStatus.transactionEnergyActiveImportRegisterValue = 0
connectorStatus.remoteStartId = undefined
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'
--- /dev/null
+/**
+ * @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)
+ })
+ })
+})