Skip to main content

ocpi_kit/v2_3_0/
tokens.rs

1//! The *Tokens* module of OCPI 2.3.0: which drivers may charge, and real-time authorization.
2//!
3//! *Module Identifier: `tokens`* — Data owner: eMSP.
4//!
5//! Spec: 2.3.0 §mod_tokens_tokens_module
6
7use bon::Builder;
8use serde::{Deserialize, Serialize};
9
10use crate::ocpi_enum;
11use crate::ocpi_open_enum;
12use crate::types::validate_fields;
13use crate::types::{
14    CiString, ContractId, CountryCode, DateTime, DisplayText, Extensions, OcpiString, PartyId, PartyRef,
15    Validate, Validator, ViolationCode,
16};
17
18use super::sessions::ProfileType;
19
20/// A token an EV driver uses to authorize charging.
21///
22/// > *The combination of `uid` and `type` should be unique for every token within the eMSP's
23/// > system.*
24///
25/// Spec: 2.3.0 §mod_tokens_token_object
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[builder(on(_, into))]
29pub struct Token {
30    /// ISO-3166 alpha-2 country code of the MSP that 'owns' this Token.
31    pub country_code: CountryCode,
32    /// ID of the eMSP that 'owns' this Token.
33    pub party_id: PartyId,
34    /// Unique ID by which this Token, combined with its type, can be identified.
35    ///
36    /// > *This is the field used by CPO system (RFID reader on the Charge Point) to identify
37    /// > this token. … This field is named `uid` instead of `id` to prevent confusion with
38    /// > `contract_id`.*
39    pub uid: CiString<36>,
40    /// Type of the token.
41    #[serde(rename = "type")]
42    pub token_type: TokenType,
43    /// Uniquely identifies the EV driver contract token within the eMSP's platform.
44    pub contract_id: ContractId,
45    /// Visual readable number/identification as printed on the Token.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub visual_number: Option<OcpiString<64>>,
48    /// Issuing company, most of the time the name printed on the token.
49    pub issuer: OcpiString<64>,
50    /// Groups a couple of tokens so a session started with one can be stopped with another.
51    ///
52    /// > *Beware that OCPP 1.5/1.6 only support group_ids (parentId in OCPP 1.5/1.6) with a
53    /// > maximum length of 20.*
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub group_id: Option<CiString<36>>,
56    /// Whether this Token is valid.
57    pub valid: bool,
58    /// What type of white-listing is allowed.
59    ///
60    /// > *NOTE: The eMSP is RECOMMENDED to push Tokens with type `AD_HOC_USER` or `APP_USER`
61    /// > with `whitelist` set to `NEVER`. Whitelists are very useful for RFID type Tokens, but
62    /// > the `AD_HOC_USER`/`APP_USER` Tokens are used to start Sessions from an App etc. so
63    /// > whitelisting them has no advantages.*
64    ///
65    /// That is a recommendation, not a rule — the specification's own `APP_USER` example uses
66    /// `ALLOWED` — so [`Validate`] does not report it. Ask
67    /// [`Token::follows_whitelist_recommendation`] when you want to check it.
68    pub whitelist: WhitelistType,
69    /// Language Code ISO 639-1: the Token owner's preferred interface language.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub language: Option<OcpiString<2>>,
72    /// The default Charging Preference profile type.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub default_profile_type: Option<ProfileType>,
75    /// The driver's own energy supplier/contract, where the Charge Point supports using it.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub energy_contract: Option<EnergyContract>,
78    /// Timestamp when this Token was last updated (or created).
79    pub last_updated: DateTime,
80    /// Undocumented JSON fields, preserved verbatim.
81    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
82    #[builder(default)]
83    pub extensions: Extensions,
84}
85
86impl Token {
87    /// The eMSP that owns this Token.
88    #[must_use]
89    pub fn owner_party(&self) -> PartyRef {
90        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
91    }
92
93    /// Whether this Token follows the specification's advice on whitelisting.
94    ///
95    /// > *The eMSP is RECOMMENDED to push Tokens with type `AD_HOC_USER` or `APP_USER` with
96    /// > `whitelist` set to `NEVER`.*
97    ///
98    /// A recommendation, not a rule: the spec's own `APP_USER` example does not follow it, so
99    /// this is a query rather than a [`Validate`] violation.
100    ///
101    /// Spec: 2.3.0 §mod_tokens_tokentype_enum
102    #[must_use]
103    pub fn follows_whitelist_recommendation(&self) -> bool {
104        !matches!(self.token_type, TokenType::AdHocUser | TokenType::AppUser)
105            || self.whitelist == WhitelistType::Never
106    }
107
108    /// What a CPO should do with this Token when a driver presents it.
109    ///
110    /// This turns the `whitelist` field plus the CPO's current connectivity into the one decision
111    /// a charging backend actually has to make. See [`AuthorizationDecision`].
112    ///
113    /// > *The validity of a Token has no influence on this. If a Token is `valid = false`, when
114    /// > the `whitelist` field requires real-time authorization, the CPO SHALL do a real-time
115    /// > authorization, the state of the Token might have changed.*
116    ///
117    /// Spec: 2.3.0 §mod_tokens_whitelisttype_enum
118    #[must_use]
119    pub fn authorization_decision(&self, online: bool) -> AuthorizationDecision {
120        match self.whitelist {
121            // "CPO shall always allow any use of this Token."
122            WhitelistType::Always => AuthorizationDecision::AllowFromCache,
123            // "The CPO may choose which version of authorization to use."
124            WhitelistType::Allowed => {
125                if online {
126                    AuthorizationDecision::AuthorizeRealtime
127                } else if self.valid {
128                    AuthorizationDecision::AllowFromCache
129                } else {
130                    AuthorizationDecision::Deny
131                }
132            }
133            // "In normal situations realtime authorization shall be used. But when the CPO cannot
134            //  get a response from the eMSP … the CPO shall allow this Token to be used."
135            WhitelistType::AllowedOffline => {
136                if online {
137                    AuthorizationDecision::AuthorizeRealtime
138                } else {
139                    AuthorizationDecision::AllowFromCache
140                }
141            }
142            // "Whitelisting is forbidden, only realtime authorization is allowed."
143            WhitelistType::Never => {
144                if online {
145                    AuthorizationDecision::AuthorizeRealtime
146                } else {
147                    AuthorizationDecision::Deny
148                }
149            }
150        }
151    }
152}
153
154impl Validate for Token {
155    fn validate_in(&self, v: &mut Validator) {
156        validate_fields!(
157            self, v, country_code, party_id, uid, token_type as "type", contract_id,
158            visual_number, issuer, group_id, whitelist, language, default_profile_type,
159            energy_contract, last_updated,
160        );
161        if self.group_id.as_ref().is_some_and(|g| g.len() > 20) {
162            v.report_at(
163                "group_id",
164                ViolationCode::Inconsistent,
165                "OCPP 1.5/1.6 only supports group IDs up to 20 characters; the spec advises \
166                 staying within that as long as drivers may charge at such a Charge Point",
167            );
168        }
169    }
170}
171
172/// What a CPO should do with a Token, given its whitelist setting and the current connectivity.
173///
174/// See [`Token::authorization_decision`].
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
176pub enum AuthorizationDecision {
177    /// Authorize from the locally cached Token, without contacting the eMSP.
178    AllowFromCache,
179    /// Perform a real-time authorization against the eMSP's Tokens Sender interface.
180    AuthorizeRealtime,
181    /// Refuse: whitelisting is forbidden for this Token and the eMSP cannot be reached.
182    Deny,
183}
184
185/// The response to a real-time authorization request.
186///
187/// Spec: 2.3.0 §mod_tokens_authorizationinfo_object
188#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[builder(on(_, into))]
191pub struct AuthorizationInfo {
192    /// Status of the Token, and whether charging is allowed at the optionally given location.
193    pub allowed: AllowedType,
194    /// The complete Token object for which this authorization was requested.
195    pub token: Token,
196    /// The location, if it was in the request and the driver may charge there.
197    ///
198    /// > *Only the EVSEs the EV driver is allowed to charge at are returned.*
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub location: Option<LocationReferences>,
201    /// Reference to the authorization, echoed later in the relevant Session and CDR.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub authorization_reference: Option<CiString<36>>,
204    /// Additional information to display to the EV driver.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub info: Option<DisplayText>,
207    /// Undocumented JSON fields, preserved verbatim.
208    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
209    #[builder(default)]
210    pub extensions: Extensions,
211}
212
213impl Validate for AuthorizationInfo {
214    fn validate_in(&self, v: &mut Validator) {
215        validate_fields!(self, v, allowed, token, location, authorization_reference, info);
216        if self.allowed != AllowedType::Allowed && self.location.is_some() {
217            v.report_at(
218                "location",
219                ViolationCode::Inconsistent,
220                "a location is only returned when the driver is allowed to charge there",
221            );
222        }
223    }
224}
225
226/// References to a location and the EVSEs within it.
227///
228/// Spec: 2.3.0 §mod_tokens_locationreferences_class
229#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
230#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
231pub struct LocationReferences {
232    /// Unique identifier for the location.
233    pub location_id: CiString<36>,
234    /// Unique identifiers for EVSEs within the given location.
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub evse_uids: Vec<CiString<36>>,
237    /// Undocumented JSON fields, preserved verbatim.
238    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
239    pub extensions: Extensions,
240}
241
242impl Validate for LocationReferences {
243    fn validate_in(&self, v: &mut Validator) {
244        validate_fields!(self, v, location_id, evse_uids);
245    }
246}
247
248/// A driver's own energy contract, for Charge Points that support using it.
249///
250/// > *NOTE: In a lot of countries it is currently not allowed/possible to use a driver's own
251/// > energy supplier/contract at a Charge Point.*
252///
253/// Spec: 2.3.0 §mod_tokens_energy_contract
254#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
256pub struct EnergyContract {
257    /// Name of the energy supplier for this token.
258    pub supplier_name: OcpiString<64>,
259    /// Contract ID at the energy supplier, belonging to the owner of this token.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub contract_id: Option<OcpiString<64>>,
262    /// Undocumented JSON fields, preserved verbatim.
263    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
264    pub extensions: Extensions,
265}
266
267impl Validate for EnergyContract {
268    fn validate_in(&self, v: &mut Validator) {
269        validate_fields!(self, v, supplier_name, contract_id);
270    }
271}
272
273ocpi_enum! {
274    /// The outcome of a real-time authorization.
275    ///
276    /// Spec: 2.3.0 §mod_tokens_allowed_enum
277    pub enum AllowedType {
278        /// This Token is allowed to charge (at this location).
279        Allowed = "ALLOWED",
280        /// This Token is blocked.
281        Blocked = "BLOCKED",
282        /// This Token has expired.
283        Expired = "EXPIRED",
284        /// The account has not enough credits to charge (at the given location).
285        NoCredit = "NO_CREDIT",
286        /// Token is valid, but is not allowed to charge at the given location.
287        NotAllowed = "NOT_ALLOWED",
288    }
289}
290
291ocpi_open_enum! {
292    /// The type of a Token.
293    ///
294    /// Became an `OpenEnum` in OCPI 2.3.0, which also added `EMAID` for ISO 15118 Plug & Charge.
295    ///
296    /// > *NOTE: The eMSP is RECOMMENDED to not push Tokens with type `EMAID` at all. Exchanging
297    /// > Token objects for EMAID Tokens is not necessary because the CPO already learns which
298    /// > Party issued the Token from the Charging Station.*
299    ///
300    /// Spec: 2.3.0 §mod_tokens_tokentype_enum
301    pub enum TokenType {
302        /// One-time-use Token ID generated by a server or app.
303        AdHocUser = "AD_HOC_USER",
304        /// Token ID generated by a server or app to identify a user of an app.
305        AppUser = "APP_USER",
306        /// An EMAID, used when the Charging Station and vehicle speak ISO 15118.
307        Emaid = "EMAID",
308        /// Other type of token.
309        Other = "OTHER",
310        /// RFID Token.
311        Rfid = "RFID",
312    }
313}
314
315ocpi_enum! {
316    /// When authorization of a Token by the CPO is allowed without asking the eMSP.
317    ///
318    /// Spec: 2.3.0 §mod_tokens_whitelisttype_enum
319    pub enum WhitelistType {
320        /// Token always has to be whitelisted; real-time authorization is not possible.
321        Always = "ALWAYS",
322        /// Whitelisting is allowed and so is real-time authorization; the CPO chooses.
323        Allowed = "ALLOWED",
324        /// Real-time authorization normally, whitelist only when the eMSP cannot be reached.
325        AllowedOffline = "ALLOWED_OFFLINE",
326        /// Whitelisting is forbidden; only real-time authorization is allowed.
327        Never = "NEVER",
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn token(whitelist: WhitelistType, valid: bool) -> Token {
336        Token::builder()
337            .country_code("NL")
338            .party_id("TNM")
339            .uid("012345678")
340            .token_type(TokenType::Rfid)
341            .contract_id("NL-TNM-C12345678-X")
342            .issuer("TheNewMotion")
343            .valid(valid)
344            .whitelist(whitelist)
345            .last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
346            .build()
347    }
348
349    #[test]
350    fn whitelist_semantics_become_one_decision() {
351        use AuthorizationDecision::{AllowFromCache, AuthorizeRealtime, Deny};
352        // ALWAYS: "CPO shall always allow any use of this Token", online or not, valid or not.
353        for online in [true, false] {
354            assert_eq!(token(WhitelistType::Always, false).authorization_decision(online), AllowFromCache);
355        }
356        // NEVER: only real-time; offline means no charging.
357        assert_eq!(token(WhitelistType::Never, true).authorization_decision(true), AuthorizeRealtime);
358        assert_eq!(token(WhitelistType::Never, true).authorization_decision(false), Deny);
359        // ALLOWED_OFFLINE: real-time when possible, cache when not.
360        assert_eq!(token(WhitelistType::AllowedOffline, false).authorization_decision(false), AllowFromCache);
361        // ALLOWED: the CPO chooses; offline it falls back to the cached validity.
362        assert_eq!(token(WhitelistType::Allowed, false).authorization_decision(false), Deny);
363        assert_eq!(token(WhitelistType::Allowed, true).authorization_decision(false), AllowFromCache);
364    }
365
366    #[test]
367    fn the_whitelist_recommendation_is_a_query_not_a_violation() {
368        let mut t = token(WhitelistType::Allowed, true);
369        t.token_type = TokenType::AppUser;
370        assert!(!t.follows_whitelist_recommendation());
371        // The spec's own APP_USER example uses ALLOWED, so this must not be a violation.
372        assert!(t.validate().is_ok());
373        t.whitelist = WhitelistType::Never;
374        assert!(t.follows_whitelist_recommendation());
375        assert!(token(WhitelistType::Always, true).follows_whitelist_recommendation());
376    }
377
378    #[test]
379    fn long_group_ids_are_flagged_for_ocpp_compatibility() {
380        let mut t = token(WhitelistType::Allowed, true);
381        t.group_id = Some(CiString::new("G".repeat(21)).unwrap());
382        assert!(t.validate().unwrap_err().as_slice().iter().any(|x| x.pointer == "/group_id"));
383        t.group_id = Some(CiString::new("G".repeat(20)).unwrap());
384        assert!(t.validate().is_ok());
385    }
386
387    #[test]
388    fn round_trips_with_the_spec_field_names() {
389        let json = r#"{"country_code":"NL","party_id":"TNM","uid":"012345678","type":"RFID","contract_id":"NL-TNM-C12345678-X","issuer":"TheNewMotion","valid":true,"whitelist":"ALWAYS","last_updated":"2018-12-10T17:16:15Z"}"#;
390        let t: Token = serde_json::from_str(json).unwrap();
391        assert_eq!(t.token_type, TokenType::Rfid);
392        assert_eq!(serde_json::to_string(&t).unwrap(), json);
393    }
394}