ocpi_kit/transport/auth.rs
1//! Credentials tokens: the `Authorization: Token <base64>` header, done carefully.
2
3use core::fmt;
4
5use subtle::ConstantTimeEq;
6use zeroize::{Zeroize, ZeroizeOnDrop};
7
8use crate::types::{OcpiString, Validate, Validator, ViolationCode};
9
10/// The `Authorization` header value prefix OCPI uses.
11///
12/// > *The literal 'Token' indicates that the token-based authentication mechanism is used.*
13pub const TOKEN_PREFIX: &str = "Token ";
14
15/// A credentials token: the shared secret two platforms authenticate each other with.
16///
17/// > *`token`: The credentials token for the other party to authenticate in your system. It
18/// > should only contain printable non-whitespace ASCII characters, that is, characters with
19/// > Unicode code points from the range of U+0021 up to and including U+007E.*
20///
21/// This type exists so that a credentials token is hard to leak:
22///
23/// * [`Debug`] and [`Display`](fmt::Display) print `Token(****ab12)`, never the secret. A token
24/// that ends up in a `tracing` span, a panic message or a serialised error is therefore not a
25/// disclosure.
26/// * [`PartialEq`] compares in **constant time**, so a server that looks a token up by comparing
27/// against known tokens does not leak its contents through timing.
28/// * The buffer is zeroised when the token is dropped.
29/// * There is no `Serialize`: a token reaches the wire only through
30/// [`CredentialsToken::to_header_value`], or as the
31/// [`Credentials.token`](crate::v2_3_0::credentials::Credentials::token) field of a
32/// credentials object, which is the one place the protocol puts it in a body.
33///
34/// ```
35/// use ocpi_kit::transport::CredentialsToken;
36///
37/// let token = CredentialsToken::new("example-token").unwrap();
38/// assert_eq!(token.to_header_value(), "Token ZXhhbXBsZS10b2tlbg==");
39/// assert_eq!(format!("{token:?}"), "Token(****oken)");
40/// ```
41///
42/// Spec: 2.3.0 §transport_and_format_authorization_header, §credentials_credentials_object
43#[derive(Clone, Zeroize, ZeroizeOnDrop)]
44pub struct CredentialsToken(String);
45
46impl CredentialsToken {
47 /// The maximum length the spec gives: `string(64)`.
48 pub const MAX_LEN: usize = 64;
49
50 /// Creates a token, enforcing the character set and length the spec gives.
51 ///
52 /// # Errors
53 ///
54 /// Returns [`InvalidToken`] if the value is empty, longer than 64 characters, or contains a
55 /// character outside U+0021..=U+007E — which notably excludes the space.
56 pub fn new(value: impl Into<String>) -> Result<Self, InvalidToken> {
57 let value = value.into();
58 if value.is_empty() {
59 return Err(InvalidToken("a credentials token cannot be empty".to_owned()));
60 }
61 if value.chars().count() > Self::MAX_LEN {
62 return Err(InvalidToken(format!(
63 "a credentials token is string(64); this one has {} characters",
64 value.chars().count()
65 )));
66 }
67 if let Some(bad) = value.chars().find(|c| !matches!(c, '!'..='~')) {
68 return Err(InvalidToken(format!(
69 "a credentials token may only contain U+0021..U+007E; found U+{:04X}",
70 bad as u32
71 )));
72 }
73 Ok(Self(value))
74 }
75
76 /// Creates a token without enforcing anything, for values read off the wire.
77 pub fn new_lenient(value: impl Into<String>) -> Self {
78 Self(value.into())
79 }
80
81 /// Generates a fresh random token.
82 ///
83 /// Produces a hyphenated UUID v4, which is what the specification's own examples use and
84 /// what the vast majority of implementations do.
85 #[must_use]
86 pub fn generate() -> Self {
87 Self(uuid::Uuid::new_v4().to_string())
88 }
89
90 /// The token in cleartext.
91 ///
92 /// Named to be conspicuous at a call site: everything else about this type is designed to
93 /// stop the secret escaping by accident.
94 #[must_use]
95 pub fn expose_secret(&self) -> &str {
96 &self.0
97 }
98
99 /// The token as the `string(64)` that goes into a `Credentials` object body.
100 #[must_use]
101 pub fn to_credentials_field(&self) -> OcpiString<64> {
102 OcpiString::new_lenient(self.0.clone())
103 }
104
105 /// The full `Authorization` header value, Base64-encoded as the spec requires.
106 ///
107 /// > *After the literal 'Token', there SHALL be one space, followed by the 'encoded token'.
108 /// > The encoded token is obtained by encoding the credentials token to an octet sequence
109 /// > with UTF-8 and then encoding that octet sequence with Base64 according to RFC 4648.*
110 #[must_use]
111 pub fn to_header_value(&self) -> String {
112 use base64::Engine as _;
113 format!("{TOKEN_PREFIX}{}", base64::engine::general_purpose::STANDARD.encode(&self.0))
114 }
115
116 /// The `Authorization` header value **without** Base64, for pre-2.2-d2 peers.
117 ///
118 /// > *NOTE: Many OCPI 2.1.1 and 2.2 implementations do not Base64 encode the credentials
119 /// > token when including it in the 'Authorization' header. … Implementations that wish to be
120 /// > compatible with non-encoding 2.1.1 and 2.2 implementations have to choose the right way
121 /// > to parse and write authorization headers by either trial and error or configuration
122 /// > flags.*
123 ///
124 /// This crate chooses configuration flags: see
125 /// [`Quirks::send_unencoded_token`](super::Quirks::send_unencoded_token).
126 #[must_use]
127 pub fn to_header_value_unencoded(&self) -> String {
128 format!("{TOKEN_PREFIX}{}", self.0)
129 }
130
131 /// Parses an `Authorization` header value.
132 ///
133 /// Both encodings are accepted: the value is Base64-decoded when that yields a valid token,
134 /// and otherwise taken literally. `accept_unencoded` gates the fallback — leave it off for a
135 /// peer that is known to encode properly, so that a mangled header is an error rather than a
136 /// token nobody recognises.
137 ///
138 /// # Errors
139 ///
140 /// Returns [`InvalidToken`] if the value does not start with `Token `, or if what follows is
141 /// neither valid Base64 of a token nor (when allowed) a bare token.
142 pub fn parse_header(value: &str, accept_unencoded: bool) -> Result<Self, InvalidToken> {
143 use base64::Engine as _;
144
145 let rest = strip_token_prefix(value)
146 .ok_or_else(|| InvalidToken("Authorization header does not start with \"Token \"".into()))?;
147 if rest.is_empty() {
148 return Err(InvalidToken("Authorization header has no token".into()));
149 }
150
151 // The spec mandates RFC 4648 §4 with padding; tolerate the unpadded form on input.
152 let decoded = base64::engine::general_purpose::STANDARD
153 .decode(rest)
154 .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(rest));
155
156 if let Ok(bytes) = decoded
157 && let Ok(text) = String::from_utf8(bytes)
158 {
159 // A token that decodes to something outside the charset is more likely a token
160 // that merely *looked* like Base64; fall through to the literal reading.
161 if !text.is_empty() && text.chars().all(|c| matches!(c, '!'..='~')) {
162 return Ok(Self(text));
163 }
164 }
165
166 if accept_unencoded {
167 return Self::new(rest);
168 }
169 Err(InvalidToken(
170 "Authorization header is not Base64-encoded as OCPI 2.2-d2 and later require; \
171 set Quirks::accept_unencoded_token for peers that predate that"
172 .into(),
173 ))
174 }
175
176 /// Whether this value satisfies the character set and length the spec gives.
177 #[must_use]
178 pub fn is_conformant(&self) -> bool {
179 !self.0.is_empty()
180 && self.0.chars().count() <= Self::MAX_LEN
181 && self.0.chars().all(|c| matches!(c, '!'..='~'))
182 }
183
184 /// A stable, non-reversible fingerprint, for logging and correlating without disclosure.
185 ///
186 /// This is the last four characters of the token, which is what the redacted `Debug` shows.
187 /// It is a debugging aid, not a secret-safe identifier for a short token.
188 #[must_use]
189 pub fn hint(&self) -> String {
190 let n = self.0.chars().count();
191 let tail: String = self.0.chars().skip(n.saturating_sub(4)).collect();
192 format!("****{tail}")
193 }
194}
195
196fn strip_token_prefix(value: &str) -> Option<&str> {
197 // "NOTE: HTTP header names are case-insensitive" — the scheme name conventionally is too.
198 let (scheme, rest) = value.split_once(' ')?;
199 if scheme.eq_ignore_ascii_case("Token") { Some(rest.trim_start()) } else { None }
200}
201
202impl PartialEq for CredentialsToken {
203 /// Constant-time comparison: a server resolving a token must not leak it through timing.
204 fn eq(&self, other: &Self) -> bool {
205 let a = self.0.as_bytes();
206 let b = other.0.as_bytes();
207 // `ct_eq` requires equal lengths; comparing the lengths first leaks only the length,
208 // which the Base64 in the header already reveals.
209 a.len() == b.len() && bool::from(a.ct_eq(b))
210 }
211}
212
213impl Eq for CredentialsToken {}
214
215impl fmt::Debug for CredentialsToken {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 write!(f, "Token({})", self.hint())
218 }
219}
220
221impl fmt::Display for CredentialsToken {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 write!(f, "Token({})", self.hint())
224 }
225}
226
227impl core::str::FromStr for CredentialsToken {
228 type Err = InvalidToken;
229 fn from_str(s: &str) -> Result<Self, Self::Err> {
230 Self::new(s)
231 }
232}
233
234impl Validate for CredentialsToken {
235 fn validate_in(&self, v: &mut Validator) {
236 if !self.is_conformant() {
237 v.report(ViolationCode::IllegalCharacter, "a credentials token is string(64) of U+0021..U+007E");
238 }
239 }
240}
241
242/// Why a string is not a usable credentials token.
243#[derive(Clone, Debug, PartialEq, Eq)]
244pub struct InvalidToken(String);
245
246impl fmt::Display for InvalidToken {
247 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248 write!(f, "invalid credentials token: {}", self.0)
249 }
250}
251impl std::error::Error for InvalidToken {}
252
253/// Which token of the registration handshake a value is.
254///
255/// > *the Receiver Platform must create a unique credentials token: `CREDENTIALS_TOKEN_A` … The
256/// > Sender generates a unique credentials token: `CREDENTIALS_TOKEN_B` … The Receiver generates
257/// > a unique credentials token: `CREDENTIALS_TOKEN_C`.*
258///
259/// The distinction matters at runtime because Token A is scoped:
260///
261/// > *When a server receives a request with a valid `CREDENTIALS_TOKEN_A`, on another module
262/// > than `credentials` or `versions`, the server SHALL respond with an HTTP `401 -
263/// > Unauthorized` status code.*
264///
265/// Spec: 2.3.0 §credentials_registration, §transport_and_format_authorization_header
266#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
267pub enum TokenRole {
268 /// `CREDENTIALS_TOKEN_A`: the bootstrap token, valid only for `credentials` and `versions`.
269 A,
270 /// `CREDENTIALS_TOKEN_B`: the token the Sender gives the Receiver in the POST.
271 B,
272 /// `CREDENTIALS_TOKEN_C`: the token the Receiver returns, used for everything afterwards.
273 C,
274}
275
276impl TokenRole {
277 /// Whether a request authenticated with this token may address `module`.
278 ///
279 /// Spec: 2.3.0 §transport_and_format_authorization_header
280 #[must_use]
281 pub fn may_access(self, module: &crate::ModuleId) -> bool {
282 use crate::ModuleId;
283 match self {
284 Self::A => matches!(module, ModuleId::Credentials | ModuleId::Versions),
285 Self::B | Self::C => true,
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::ModuleId;
294
295 #[test]
296 fn header_encoding_matches_the_spec_example() {
297 // The spec's own example: credentials token 'example-token'.
298 let token = CredentialsToken::new("example-token").unwrap();
299 assert_eq!(token.to_header_value(), "Token ZXhhbXBsZS10b2tlbg==");
300 assert_eq!(token.to_header_value_unencoded(), "Token example-token");
301 }
302
303 #[test]
304 fn header_parsing_accepts_both_encodings_under_the_flag() {
305 let encoded = "Token ZXhhbXBsZS10b2tlbg==";
306 let parsed = CredentialsToken::parse_header(encoded, false).unwrap();
307 assert_eq!(parsed.expose_secret(), "example-token");
308
309 // A bare token is not valid Base64 of a token, so it needs the quirk.
310 let bare = "Token 12345678-1234-1234-1234-123456789012";
311 assert!(CredentialsToken::parse_header(bare, false).is_err());
312 assert_eq!(
313 CredentialsToken::parse_header(bare, true).unwrap().expose_secret(),
314 "12345678-1234-1234-1234-123456789012"
315 );
316 }
317
318 #[test]
319 fn header_parsing_is_case_insensitive_on_the_scheme_and_rejects_junk() {
320 assert!(CredentialsToken::parse_header("token ZXhhbXBsZS10b2tlbg==", false).is_ok());
321 assert!(CredentialsToken::parse_header("Bearer abc", true).is_err());
322 assert!(CredentialsToken::parse_header("Token ", true).is_err());
323 assert!(CredentialsToken::parse_header("", true).is_err());
324 }
325
326 #[test]
327 fn the_secret_never_appears_in_debug_or_display() {
328 let token = CredentialsToken::new("super-secret-value").unwrap();
329 let debug = format!("{token:?}");
330 let display = format!("{token}");
331 for rendering in [&debug, &display] {
332 assert!(!rendering.contains("super-secret"), "{rendering}");
333 assert!(rendering.contains("****alue"), "{rendering}");
334 }
335 }
336
337 #[test]
338 fn the_charset_excludes_whitespace() {
339 assert!(CredentialsToken::new("has space").is_err());
340 assert!(CredentialsToken::new("").is_err());
341 assert!(CredentialsToken::new("a".repeat(65)).is_err());
342 assert!(CredentialsToken::new("a".repeat(64)).is_ok());
343 assert!(CredentialsToken::new("~!@#$%^&*()_+").is_ok());
344 }
345
346 #[test]
347 fn equality_is_value_based_and_generated_tokens_differ() {
348 let a = CredentialsToken::new("same").unwrap();
349 let b = CredentialsToken::new("same").unwrap();
350 let c = CredentialsToken::new("other").unwrap();
351 assert_eq!(a, b);
352 assert_ne!(a, c);
353 assert_ne!(CredentialsToken::generate(), CredentialsToken::generate());
354 assert!(CredentialsToken::generate().is_conformant());
355 }
356
357 #[test]
358 fn token_a_is_scoped_to_credentials_and_versions() {
359 assert!(TokenRole::A.may_access(&ModuleId::Credentials));
360 assert!(TokenRole::A.may_access(&ModuleId::Versions));
361 assert!(!TokenRole::A.may_access(&ModuleId::Locations));
362 assert!(!TokenRole::A.may_access(&ModuleId::Cdrs));
363 assert!(TokenRole::C.may_access(&ModuleId::Locations));
364 }
365}