Skip to main content

ocpi_kit/client/
registration.rs

1//! The credentials handshake, as a typestate.
2//!
3//! The registration flow is short and almost every integration gets some of it wrong. The
4//! specification describes it as:
5//!
6//! > *The Receiver Platform must create a unique credentials token: `CREDENTIALS_TOKEN_A` that has
7//! > to be used to authorize the Sender until the credentials exchange is finished. … The Sender
8//! > starts the registration process, retrieves the version information and details (using
9//! > `CREDENTIALS_TOKEN_A`). The Sender generates a unique credentials token
10//! > `CREDENTIALS_TOKEN_B`, sends it to the Receiver in a POST request … The Receiver generates a
11//! > unique credentials token `CREDENTIALS_TOKEN_C` and returns it … After the credentials
12//! > exchange has finished, the Sender SHALL use `CREDENTIALS_TOKEN_C` in future OCPI requests.
13//! > The `CREDENTIALS_TOKEN_A` can then be thrown away, it MAY no longer be used.*
14//!
15//! Each of those sentences is a state transition, so each is a type here:
16//!
17//! ```text
18//! Registration ──discover()──▶ Discovered ──select()──▶ Selected ──register()──▶ Peer
19//!  (has TOKEN_A)               (has versions)          (has endpoints)      (has TOKEN_C)
20//! ```
21//!
22//! What that buys: `CREDENTIALS_TOKEN_A` is consumed by `register()` and never handed back, so it
23//! cannot be used afterwards. `register()` exists only on `Selected`, so the POST cannot be sent
24//! before the endpoints have been checked — which the specification requires:
25//!
26//! > *In case the Sender … cannot find the endpoints it expects, it is expected NOT to send the
27//! > POST request with credentials to the Receiver.*
28//!
29//! Spec: 2.3.0 §credentials_registration
30
31use http::Method;
32
33use crate::convert::wire::ObjectKind;
34use crate::transport::{CredentialsToken, OcpiError, OcpiRequest, Quirks};
35use crate::types::{PartyRef, Url};
36use crate::v2_3_0::credentials::Credentials;
37use crate::v2_3_0::versions::{Version, VersionDetails};
38use crate::{InterfaceRole, ModuleId, VersionNumber};
39
40use super::http::Transport;
41use super::peer::Peer;
42
43/// Step 0: what was agreed out of band.
44///
45/// > *This credentials token along with the versions endpoint SHOULD be sent to the Sender in a
46/// > secure way that is outside the scope of this protocol.*
47#[derive(Debug)]
48pub struct Registration {
49    versions_url: Url,
50    token_a: CredentialsToken,
51    /// Set only when the caller overrode the profile; otherwise it follows the version once that
52    /// is known.
53    quirks_override: Option<Quirks>,
54}
55
56impl Registration {
57    /// Starts a registration with the versions URL and `CREDENTIALS_TOKEN_A`.
58    #[must_use]
59    pub fn new(versions_url: Url, token_a: CredentialsToken) -> Self {
60        Self { versions_url, token_a, quirks_override: None }
61    }
62
63    /// Uses a specific interoperability profile while talking to this peer.
64    ///
65    /// Useful when the peer is known to be a 2.1.1 implementation before its version is
66    /// discovered, since those do not Base64-encode the token.
67    #[must_use]
68    pub fn with_quirks(mut self, quirks: Quirks) -> Self {
69        self.quirks_override = Some(quirks);
70        self
71    }
72
73    /// `GET {versions_url}` with `CREDENTIALS_TOKEN_A`.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`OcpiError`] when the peer cannot be reached or answers with an error.
78    pub async fn discover(self, transport: &Transport) -> Result<Discovered, OcpiError> {
79        // The version is not known yet, so the bootstrap GET uses the caller's profile if they
80        // gave one and the conformant defaults otherwise.
81        let quirks = self.quirks_override.clone().unwrap_or_default();
82        let request = OcpiRequest::new(Method::GET, self.versions_url.clone(), ModuleId::Versions);
83        let versions: Vec<Version> = transport.send(&request, &self.token_a, &quirks).await?;
84        Ok(Discovered {
85            versions_url: self.versions_url,
86            token_a: self.token_a,
87            quirks_override: self.quirks_override,
88            versions,
89        })
90    }
91}
92
93/// Step 1: the peer's supported versions are known.
94#[derive(Debug)]
95pub struct Discovered {
96    versions_url: Url,
97    token_a: CredentialsToken,
98    quirks_override: Option<Quirks>,
99    versions: Vec<Version>,
100}
101
102impl Discovered {
103    /// Every version the peer advertised, including ones this crate cannot speak.
104    #[must_use]
105    pub fn versions(&self) -> &[Version] {
106        &self.versions
107    }
108
109    /// The newest version both sides can speak.
110    #[must_use]
111    pub fn best_common_version(&self) -> Option<&Version> {
112        self.versions
113            .iter()
114            .filter(|v| v.version.is_supported())
115            .max_by(|a, b| a.version.cmp_by_release(&b.version))
116    }
117
118    /// `GET` the details of the newest version both sides support.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`OcpiError::Remote`] with `3002 Unsupported version` when there is no version in
123    /// common — which is exactly the code the specification reserves for it.
124    pub async fn select_best(self, transport: &Transport) -> Result<Selected, OcpiError> {
125        let chosen =
126            self.best_common_version().map(|v| v.version.clone()).ok_or_else(|| OcpiError::Remote {
127                status_code: crate::transport::StatusCode::UNSUPPORTED_VERSION,
128                status_message: Some(format!(
129                    "peer supports {}, this build supports {}",
130                    self.versions.iter().map(|v| v.version.to_string()).collect::<Vec<_>>().join(", "),
131                    VersionNumber::supported().iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
132                )),
133            })?;
134        self.select(transport, &chosen).await
135    }
136
137    /// `GET` the details of a specific version.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`OcpiError::Remote`] with `3002` when the peer does not offer that version.
142    pub async fn select(self, transport: &Transport, version: &VersionNumber) -> Result<Selected, OcpiError> {
143        let entry =
144            self.versions.iter().find(|v| v.version == *version).ok_or_else(|| OcpiError::Remote {
145                status_code: crate::transport::StatusCode::UNSUPPORTED_VERSION,
146                status_message: Some(format!("peer does not offer OCPI {version}")),
147            })?;
148        // Now that the version is known, the profile follows it unless the caller overrode it.
149        let quirks = self.quirks_override.clone().unwrap_or_else(|| Quirks::for_version(version));
150        let request = OcpiRequest::new(Method::GET, entry.url.clone(), ModuleId::Versions);
151        let details: VersionDetails = transport.send(&request, &self.token_a, &quirks).await?;
152        Ok(Selected {
153            versions_url: self.versions_url,
154            token_a: self.token_a,
155            quirks,
156            version: version.clone(),
157            details,
158        })
159    }
160}
161
162/// Step 2: the peer's endpoints for the chosen version are known, and can be checked before
163/// anything is sent.
164#[derive(Debug)]
165pub struct Selected {
166    versions_url: Url,
167    token_a: CredentialsToken,
168    quirks: Quirks,
169    version: VersionNumber,
170    details: VersionDetails,
171}
172
173impl Selected {
174    /// The version that was selected.
175    #[must_use]
176    pub const fn version(&self) -> &VersionNumber {
177        &self.version
178    }
179
180    /// The endpoints the peer advertised for it.
181    #[must_use]
182    pub const fn details(&self) -> &VersionDetails {
183        &self.details
184    }
185
186    /// Checks that the peer implements everything this party needs.
187    ///
188    /// > *In case the Sender (starting the credentials exchange process) cannot find the endpoints
189    /// > it expects, it is expected NOT to send the POST request with credentials to the Receiver.
190    /// > Log a message/notify the administrator.*
191    ///
192    /// # Errors
193    ///
194    /// Returns [`OcpiError::Remote`] with `3003 No matching endpoints` naming what is missing.
195    /// Call this before [`Selected::register`]; nothing has been sent to the peer yet, so
196    /// stopping here is exactly what the specification asks for.
197    pub fn require(&self, required: &[(ModuleId, InterfaceRole)]) -> Result<(), OcpiError> {
198        let missing = self.details.missing(required);
199        if missing.is_empty() {
200            return Ok(());
201        }
202        Err(OcpiError::Remote {
203            status_code: crate::transport::StatusCode::NO_MATCHING_ENDPOINTS,
204            status_message: Some(format!(
205                "peer does not implement {}",
206                missing.iter().map(|(m, r)| format!("{m}/{r}")).collect::<Vec<_>>().join(", ")
207            )),
208        })
209    }
210
211    /// `POST {credentials}` with `CREDENTIALS_TOKEN_B`, receiving `CREDENTIALS_TOKEN_C`.
212    ///
213    /// Consumes `self`, so `CREDENTIALS_TOKEN_A` is gone afterwards: *"it MAY no longer be
214    /// used."*
215    ///
216    /// `credentials` must carry **`CREDENTIALS_TOKEN_B`** — the token the peer will use to call
217    /// *this* party — and the versions URL of *this* party. The token in the response is
218    /// `CREDENTIALS_TOKEN_C`, which is what the returned [`Peer`] authenticates with.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`OcpiError::MethodNotAllowed`] when the peer says this party is already
223    /// registered — *"This method MUST return a HTTP status code 405: method not allowed if the
224    /// client has already been registered before"* — and [`OcpiError::Remote`] with `3001` when
225    /// the peer could not call back.
226    pub async fn register(self, transport: &Transport, credentials: &Credentials) -> Result<Peer, OcpiError> {
227        super::http::check_outgoing(credentials, transport.config())?;
228        let url = self.details.credentials_url().cloned().ok_or_else(|| OcpiError::Remote {
229            status_code: crate::transport::StatusCode::NO_MATCHING_ENDPOINTS,
230            status_message: Some(
231                "peer advertised no credentials endpoint, which every implementation must have".to_owned(),
232            ),
233        })?;
234        let request = self.credentials_request(Method::POST, url, credentials)?;
235        let theirs = self.their_credentials(transport, &request, &self.token_a).await?;
236        Ok(peer_from(self.version, self.quirks, self.versions_url, &self.details, &theirs))
237    }
238
239    /// `PUT {credentials}`, for a peer this party is already registered with.
240    ///
241    /// > *A `PUT` will switch to the version that contains this credentials endpoint if it's
242    /// > different from the current version. The server must fetch the client's endpoints again,
243    /// > even if the version has not changed.*
244    ///
245    /// # Errors
246    ///
247    /// Returns [`OcpiError::MethodNotAllowed`] when the peer says this party is **not** registered
248    /// — the mirror image of [`Selected::register`].
249    pub async fn update(
250        self,
251        transport: &Transport,
252        current_token: &CredentialsToken,
253        credentials: &Credentials,
254    ) -> Result<Peer, OcpiError> {
255        super::http::check_outgoing(credentials, transport.config())?;
256        let url = self.details.credentials_url().cloned().ok_or_else(|| OcpiError::Remote {
257            status_code: crate::transport::StatusCode::NO_MATCHING_ENDPOINTS,
258            status_message: Some("peer advertised no credentials endpoint".to_owned()),
259        })?;
260        let request = self.credentials_request(Method::PUT, url, credentials)?;
261        let theirs = self.their_credentials(transport, &request, current_token).await?;
262        Ok(peer_from(self.version, self.quirks, self.versions_url, &self.details, &theirs))
263    }
264
265    /// A credentials request whose body is written in the version that was negotiated.
266    ///
267    /// The handshake is the one exchange that happens *before* there is a [`Peer`] to ask, so the
268    /// version comes from [`Discovered::select`] instead. It matters: a 2.2.1 `Credentials` has no
269    /// `hub_party_id`, and its `Role` enum still has `HUB`, which the 2.3.0 one does not — so
270    /// registering with a 2.2.1 hub without translating fails to decode the answer.
271    fn credentials_request(
272        &self,
273        method: Method,
274        url: Url,
275        credentials: &Credentials,
276    ) -> Result<OcpiRequest, OcpiError> {
277        let request = OcpiRequest::new(method, url, ModuleId::Credentials);
278        match self.bridge_out(credentials)? {
279            Some(value) => request.with_body(&value),
280            None => request.with_body(credentials),
281        }
282    }
283
284    fn bridge_out(&self, credentials: &Credentials) -> Result<Option<serde_json::Value>, OcpiError> {
285        if self.version == crate::CANONICAL_VERSION {
286            return Ok(None);
287        }
288        let value = serde_json::to_value(credentials)
289            .map_err(|e| OcpiError::Decode { path: "/".to_owned(), message: e.to_string() })?;
290        let converted = ObjectKind::Credentials
291            .bridge(&crate::CANONICAL_VERSION, &self.version, value)
292            .map_err(|e| OcpiError::Unsupported(e.to_string()))?;
293        if let Some(note) = converted.lossy.to_status_message() {
294            tracing::warn!(ocpi.peer_version = %self.version, "{note}");
295        }
296        Ok(Some(converted.value))
297    }
298
299    async fn their_credentials(
300        &self,
301        transport: &Transport,
302        request: &OcpiRequest,
303        token: &CredentialsToken,
304    ) -> Result<Credentials, OcpiError> {
305        if self.version == crate::CANONICAL_VERSION {
306            return transport.send(request, token, &self.quirks).await;
307        }
308        let value: serde_json::Value = transport.send(request, token, &self.quirks).await?;
309        let converted = ObjectKind::Credentials
310            .bridge(&self.version, &crate::CANONICAL_VERSION, value)
311            .map_err(|e| OcpiError::Unsupported(e.to_string()))?;
312        serde_json::from_value(converted.value)
313            .map_err(|e| OcpiError::Decode { path: "/".to_owned(), message: e.to_string() })
314    }
315}
316
317fn peer_from(
318    version: VersionNumber,
319    quirks: Quirks,
320    versions_url: Url,
321    details: &VersionDetails,
322    theirs: &Credentials,
323) -> Peer {
324    let mut builder = Peer::builder(version, CredentialsToken::new_lenient(theirs.token.as_str()))
325        .versions_url(versions_url)
326        .endpoints_from(details)
327        .quirks(quirks);
328    for party in theirs.parties() {
329        builder = builder.party(party);
330    }
331    if let Some(hub) = theirs.hub_party() {
332        builder = builder.hub(hub);
333    }
334    builder.build()
335}
336
337/// Where a connection with one peer stands.
338///
339/// The transitions between these are the ones the specification defines; storing this enum is how
340/// a process remembers a registration across restarts.
341///
342/// Spec: 2.3.0 §credentials_use_cases
343#[derive(Clone, Debug, PartialEq)]
344#[non_exhaustive]
345pub enum PeerState {
346    /// `CREDENTIALS_TOKEN_A` has been exchanged out of band; nothing else has happened.
347    ///
348    /// The token may only be used on the `credentials` and `versions` modules.
349    Bootstrapped {
350        /// The peer's versions endpoint.
351        versions_url: Url,
352        /// `CREDENTIALS_TOKEN_A`.
353        token_a: CredentialsToken,
354    },
355    /// The handshake completed.
356    Registered {
357        /// The version in use.
358        version: VersionNumber,
359        /// The token this party uses to call the peer (`CREDENTIALS_TOKEN_C` for the Sender).
360        our_token_for_them: CredentialsToken,
361        /// The token the peer uses to call this party (`CREDENTIALS_TOKEN_B` for the Sender).
362        their_token_for_us: CredentialsToken,
363        /// The parties the peer speaks for.
364        parties: Vec<PartyRef>,
365    },
366    /// A `DELETE` on the credentials module ended the connection.
367    ///
368    /// > *Both parties must end any automated communication.*
369    Unregistered,
370}
371
372impl PeerState {
373    /// Whether requests to functional modules are allowed in this state.
374    ///
375    /// Only a registered peer may be called for anything other than `credentials` and `versions`.
376    #[must_use]
377    pub const fn may_use_functional_modules(&self) -> bool {
378        matches!(self, Self::Registered { .. })
379    }
380
381    /// Whether a credentials `POST` is the right method in this state.
382    ///
383    /// > *This method MUST return a HTTP status code 405: method not allowed if the client has
384    /// > already been registered before.*
385    #[must_use]
386    pub const fn accepts_credentials_post(&self) -> bool {
387        matches!(self, Self::Bootstrapped { .. } | Self::Unregistered)
388    }
389
390    /// Whether a credentials `PUT` or `DELETE` is the right method in this state.
391    ///
392    /// > *This method MUST return a HTTP status code 405: method not allowed if the client has not
393    /// > been registered yet.*
394    #[must_use]
395    pub const fn accepts_credentials_put_or_delete(&self) -> bool {
396        matches!(self, Self::Registered { .. })
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn token(v: &str) -> CredentialsToken {
405        CredentialsToken::new(v).unwrap()
406    }
407
408    #[test]
409    fn credentials_methods_are_gated_on_the_registration_state() {
410        let bootstrapped = PeerState::Bootstrapped {
411            versions_url: Url::new("https://e.com/versions").unwrap(),
412            token_a: token("A"),
413        };
414        assert!(bootstrapped.accepts_credentials_post());
415        assert!(!bootstrapped.accepts_credentials_put_or_delete(), "405 until registered");
416        assert!(!bootstrapped.may_use_functional_modules());
417
418        let registered = PeerState::Registered {
419            version: VersionNumber::V2_3_0,
420            our_token_for_them: token("C"),
421            their_token_for_us: token("B"),
422            parties: vec![PartyRef::new("NL", "TNM").unwrap()],
423        };
424        assert!(!registered.accepts_credentials_post(), "405 once registered");
425        assert!(registered.accepts_credentials_put_or_delete());
426        assert!(registered.may_use_functional_modules());
427
428        assert!(PeerState::Unregistered.accepts_credentials_post());
429        assert!(!PeerState::Unregistered.may_use_functional_modules());
430    }
431
432    #[test]
433    fn the_newest_common_version_is_selected() {
434        let versions = vec![
435            Version::new(VersionNumber::V2_1_1, Url::new("https://e.com/2.1.1").unwrap()),
436            Version::new(VersionNumber::V2_2_1, Url::new("https://e.com/2.2.1").unwrap()),
437            Version::new("3.0".into(), Url::new("https://e.com/3.0").unwrap()),
438        ];
439        let discovered = Discovered {
440            versions_url: Url::new("https://e.com/versions").unwrap(),
441            token_a: token("A"),
442            quirks_override: None,
443            versions,
444        };
445        // 3.0 is advertised but this crate cannot speak it, so 2.2.1 wins.
446        assert_eq!(discovered.best_common_version().map(|v| v.version.clone()), Some(VersionNumber::V2_2_1));
447    }
448
449    #[test]
450    fn missing_required_endpoints_stop_the_handshake_before_anything_is_sent() {
451        use crate::v2_3_0::versions::Endpoint;
452        let details = VersionDetails::new(
453            VersionNumber::V2_3_0,
454            vec![Endpoint::new(
455                ModuleId::Credentials,
456                InterfaceRole::Sender,
457                Url::new("https://e.com/credentials").unwrap(),
458            )],
459        );
460        let selected = Selected {
461            versions_url: Url::new("https://e.com/versions").unwrap(),
462            token_a: token("A"),
463            quirks: Quirks::default(),
464            version: VersionNumber::V2_3_0,
465            details,
466        };
467        assert!(selected.require(&[(ModuleId::Credentials, InterfaceRole::Sender)]).is_ok());
468        let err = selected.require(&[(ModuleId::Cdrs, InterfaceRole::Receiver)]).unwrap_err();
469        assert_eq!(err.status_code(), crate::transport::StatusCode::NO_MATCHING_ENDPOINTS);
470        assert!(err.to_string().contains("cdrs"), "{err}");
471    }
472}