Skip to main content

ocpi_kit/server/
auth.rs

1//! Who is calling: resolving a credentials token to a party, in constant time.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::sync::RwLock;
6
7use crate::ModuleId;
8use crate::transport::{CredentialsToken, OcpiError, TokenRole};
9use crate::types::PartyRef;
10use crate::{InterfaceRole, VersionNumber};
11
12/// The party a request was authenticated as.
13#[derive(Clone, Debug, PartialEq)]
14pub struct AuthenticatedPeer {
15    /// A stable identifier for the platform, for logging and for looking up its state.
16    pub peer_id: String,
17    /// Which handshake token this is, which decides what it may address.
18    pub role: TokenRole,
19    /// The parties this platform speaks for, from the `roles` of its credentials.
20    pub parties: Vec<PartyRef>,
21    /// The OCPI version the connection was registered with.
22    pub version: VersionNumber,
23}
24
25impl AuthenticatedPeer {
26    /// Whether this platform speaks for `party`.
27    ///
28    /// This is what decides whether a client-owned-object URL is theirs to write to.
29    #[must_use]
30    pub fn owns(&self, party: &PartyRef) -> bool {
31        self.parties.iter().any(|p| p == party)
32    }
33
34    /// Checks that this token may address `module`.
35    ///
36    /// > *When a server receives a request with a valid `CREDENTIALS_TOKEN_A`, on another module
37    /// > than `credentials` or `versions`, the server SHALL respond with an HTTP `401 -
38    /// > Unauthorized` status code.*
39    ///
40    /// # Errors
41    ///
42    /// Returns [`OcpiError::TokenAOutOfScope`], which maps to HTTP 401.
43    ///
44    /// Spec: 2.3.0 §transport_and_format_authorization_header
45    pub fn check_scope(&self, module: &ModuleId) -> Result<(), OcpiError> {
46        if self.role.may_access(module) { Ok(()) } else { Err(OcpiError::TokenAOutOfScope) }
47    }
48
49    /// Checks that this platform may write to a client-owned object under `owner`.
50    ///
51    /// > *When a client tries to access an object with a URL that has a different `country_code`
52    /// > and/or `party_id` than one of the CredentialsRoles given during the credentials
53    /// > handshake, it is allowed to respond with an HTTP `404` status code, this way blocking
54    /// > client access to objects that do not belong to them.*
55    ///
56    /// A 404 rather than a 403 is deliberate: it does not reveal whether the object exists.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`OcpiError::NotFound`], which maps to HTTP 404.
61    ///
62    /// Spec: 2.3.0 §transport_and_format_errors
63    pub fn check_ownership(&self, owner: &PartyRef) -> Result<(), OcpiError> {
64        if self.owns(owner) {
65            return Ok(());
66        }
67        Err(OcpiError::NotFound(format!("{owner} is not a party of the authenticated platform")))
68    }
69}
70
71/// Resolves an incoming credentials token to the platform that holds it.
72///
73/// The implementation decides where registrations live — a database, a config file, an in-memory
74/// map for a test. What it must do is compare tokens in **constant time**, which
75/// [`CredentialsToken`]'s [`PartialEq`] does; a naive `String` comparison leaks the token one
76/// byte at a time to anyone who can measure the response.
77pub trait TokenStore: Send + Sync + 'static {
78    /// Looks up the platform a token belongs to.
79    ///
80    /// Returns `None` for a token this server does not know, which the caller turns into an
81    /// HTTP 401 — *"If the header is missing or the credentials token doesn't match any known
82    /// party then the server SHALL respond with an HTTP `401 - Unauthorized` status code."*
83    fn resolve(&self, token: &CredentialsToken) -> Option<AuthenticatedPeer>;
84}
85
86impl<T: TokenStore> TokenStore for Arc<T> {
87    fn resolve(&self, token: &CredentialsToken) -> Option<AuthenticatedPeer> {
88        T::resolve(self, token)
89    }
90}
91
92/// A [`TokenStore`] held in memory, for tests, small deployments and getting started.
93///
94/// Lookup is a linear scan with a constant-time comparison per entry, which is the right
95/// trade-off up to a few thousand peers; beyond that, index by a keyed hash of the token in your
96/// own store rather than by the token itself.
97#[derive(Debug, Default)]
98pub struct InMemoryTokenStore {
99    entries: RwLock<Vec<(CredentialsToken, AuthenticatedPeer)>>,
100}
101
102impl InMemoryTokenStore {
103    /// An empty store.
104    #[must_use]
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Registers a token.
110    pub fn insert(&self, token: CredentialsToken, peer: AuthenticatedPeer) {
111        let mut entries = self.entries.write().expect("token store lock poisoned");
112        entries.retain(|(existing, _)| existing != &token);
113        entries.push((token, peer));
114    }
115
116    /// Removes a token, as a credentials `DELETE` does.
117    pub fn remove(&self, token: &CredentialsToken) {
118        let mut entries = self.entries.write().expect("token store lock poisoned");
119        entries.retain(|(existing, _)| existing != token);
120    }
121
122    /// Replaces one platform's token, as a credentials `PUT` does.
123    ///
124    /// > *It is advisable to renew the credentials tokens at least once a month.*
125    pub fn rotate(&self, peer_id: &str, new_token: CredentialsToken) -> bool {
126        let mut entries = self.entries.write().expect("token store lock poisoned");
127        let Some(index) = entries.iter().position(|(_, p)| p.peer_id == peer_id) else {
128            return false;
129        };
130        let peer = entries[index].1.clone();
131        entries.remove(index);
132        entries.push((new_token, peer));
133        true
134    }
135
136    /// How many tokens are registered.
137    #[must_use]
138    pub fn len(&self) -> usize {
139        self.entries.read().expect("token store lock poisoned").len()
140    }
141
142    /// Whether the store is empty.
143    #[must_use]
144    pub fn is_empty(&self) -> bool {
145        self.len() == 0
146    }
147}
148
149impl TokenStore for InMemoryTokenStore {
150    fn resolve(&self, token: &CredentialsToken) -> Option<AuthenticatedPeer> {
151        let entries = self.entries.read().expect("token store lock poisoned");
152        // `CredentialsToken: PartialEq` is a constant-time comparison.
153        entries.iter().find(|(known, _)| known == token).map(|(_, peer)| peer.clone())
154    }
155}
156
157/// Which modules and interfaces this server implements, for generating version details.
158#[derive(Clone, Debug, Default)]
159pub struct MountedModules {
160    modules: Vec<(ModuleId, InterfaceRole)>,
161}
162
163impl MountedModules {
164    /// An empty set.
165    #[must_use]
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    /// Records that a module and interface is served.
171    pub fn add(&mut self, module: ModuleId, role: InterfaceRole) {
172        if !self.modules.iter().any(|(m, r)| m == &module && *r == role) {
173            self.modules.push((module, role));
174        }
175    }
176
177    /// Everything mounted, in the order it was mounted.
178    #[must_use]
179    pub fn all(&self) -> &[(ModuleId, InterfaceRole)] {
180        &self.modules
181    }
182
183    /// Whether a module and interface is served.
184    #[must_use]
185    pub fn contains(&self, module: &ModuleId, role: InterfaceRole) -> bool {
186        self.modules.iter().any(|(m, r)| m.matches(module) && *r == role)
187    }
188}
189
190/// A registry of the peers this server has registered, keyed by peer id.
191///
192/// Only the parts the server needs at request time; the full registration state belongs in the
193/// integrator's own storage.
194#[derive(Debug, Default)]
195pub struct PeerRegistry {
196    peers: RwLock<HashMap<String, AuthenticatedPeer>>,
197}
198
199impl PeerRegistry {
200    /// An empty registry.
201    #[must_use]
202    pub fn new() -> Self {
203        Self::default()
204    }
205
206    /// Records or replaces a peer.
207    pub fn upsert(&self, peer: AuthenticatedPeer) {
208        self.peers.write().expect("peer registry lock poisoned").insert(peer.peer_id.clone(), peer);
209    }
210
211    /// Looks a peer up by id.
212    #[must_use]
213    pub fn get(&self, peer_id: &str) -> Option<AuthenticatedPeer> {
214        self.peers.read().expect("peer registry lock poisoned").get(peer_id).cloned()
215    }
216
217    /// Forgets a peer, as a credentials `DELETE` does.
218    pub fn remove(&self, peer_id: &str) -> Option<AuthenticatedPeer> {
219        self.peers.write().expect("peer registry lock poisoned").remove(peer_id)
220    }
221
222    /// Every registered peer.
223    #[must_use]
224    pub fn all(&self) -> Vec<AuthenticatedPeer> {
225        self.peers.read().expect("peer registry lock poisoned").values().cloned().collect()
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn peer(id: &str, role: TokenRole) -> AuthenticatedPeer {
234        AuthenticatedPeer {
235            peer_id: id.to_owned(),
236            role,
237            parties: vec![PartyRef::new("NL", "TNM").unwrap()],
238            version: VersionNumber::V2_3_0,
239        }
240    }
241
242    #[test]
243    fn token_a_may_only_reach_credentials_and_versions() {
244        let bootstrap = peer("p1", TokenRole::A);
245        assert!(bootstrap.check_scope(&ModuleId::Credentials).is_ok());
246        assert!(bootstrap.check_scope(&ModuleId::Versions).is_ok());
247        let err = bootstrap.check_scope(&ModuleId::Locations).unwrap_err();
248        assert_eq!(err.http_status(), 401);
249
250        assert!(peer("p1", TokenRole::C).check_scope(&ModuleId::Locations).is_ok());
251    }
252
253    #[test]
254    fn writing_to_another_partys_object_is_a_404_not_a_403() {
255        let p = peer("p1", TokenRole::C);
256        assert!(p.check_ownership(&PartyRef::new("nl", "tnm").unwrap()).is_ok());
257        let err = p.check_ownership(&PartyRef::new("DE", "ABC").unwrap()).unwrap_err();
258        assert_eq!(err.http_status(), 404, "a 404 does not reveal whether the object exists");
259    }
260
261    #[test]
262    fn the_in_memory_store_resolves_rotates_and_forgets() {
263        let store = InMemoryTokenStore::new();
264        let token = CredentialsToken::new("token-c").unwrap();
265        store.insert(token.clone(), peer("p1", TokenRole::C));
266        assert_eq!(store.resolve(&token).unwrap().peer_id, "p1");
267        assert!(store.resolve(&CredentialsToken::new("other").unwrap()).is_none());
268
269        let rotated = CredentialsToken::new("token-c2").unwrap();
270        assert!(store.rotate("p1", rotated.clone()));
271        assert!(store.resolve(&token).is_none(), "the old token stops working");
272        assert_eq!(store.resolve(&rotated).unwrap().peer_id, "p1");
273        assert_eq!(store.len(), 1);
274
275        store.remove(&rotated);
276        assert!(store.is_empty());
277    }
278
279    #[test]
280    fn mounted_modules_match_the_booking_identifier_either_way() {
281        let mut mounted = MountedModules::new();
282        mounted.add(ModuleId::Booking, InterfaceRole::Sender);
283        mounted.add(ModuleId::Booking, InterfaceRole::Sender);
284        assert_eq!(mounted.all().len(), 1, "mounting twice is idempotent");
285        assert!(mounted.contains(&ModuleId::Custom("bookings".into()), InterfaceRole::Sender));
286        assert!(!mounted.contains(&ModuleId::Booking, InterfaceRole::Receiver));
287    }
288}