use core::fmt;
use subtle::ConstantTimeEq;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::types::{OcpiString, Validate, Validator, ViolationCode};
pub const TOKEN_PREFIX: &str = "Token ";
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct CredentialsToken(String);
impl CredentialsToken {
pub const MAX_LEN: usize = 64;
pub fn new(value: impl Into<String>) -> Result<Self, InvalidToken> {
let value = value.into();
if value.is_empty() {
return Err(InvalidToken("a credentials token cannot be empty".to_owned()));
}
if value.chars().count() > Self::MAX_LEN {
return Err(InvalidToken(format!(
"a credentials token is string(64); this one has {} characters",
value.chars().count()
)));
}
if let Some(bad) = value.chars().find(|c| !matches!(c, '!'..='~')) {
return Err(InvalidToken(format!(
"a credentials token may only contain U+0021..U+007E; found U+{:04X}",
bad as u32
)));
}
Ok(Self(value))
}
pub fn new_lenient(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn generate() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
#[must_use]
pub fn expose_secret(&self) -> &str {
&self.0
}
#[must_use]
pub fn to_credentials_field(&self) -> OcpiString<64> {
OcpiString::new_lenient(self.0.clone())
}
#[must_use]
pub fn to_header_value(&self) -> String {
use base64::Engine as _;
format!("{TOKEN_PREFIX}{}", base64::engine::general_purpose::STANDARD.encode(&self.0))
}
#[must_use]
pub fn to_header_value_unencoded(&self) -> String {
format!("{TOKEN_PREFIX}{}", self.0)
}
pub fn parse_header(value: &str, accept_unencoded: bool) -> Result<Self, InvalidToken> {
use base64::Engine as _;
let rest = strip_token_prefix(value)
.ok_or_else(|| InvalidToken("Authorization header does not start with \"Token \"".into()))?;
if rest.is_empty() {
return Err(InvalidToken("Authorization header has no token".into()));
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(rest)
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(rest));
if let Ok(bytes) = decoded
&& let Ok(text) = String::from_utf8(bytes)
{
if !text.is_empty() && text.chars().all(|c| matches!(c, '!'..='~')) {
return Ok(Self(text));
}
}
if accept_unencoded {
return Self::new(rest);
}
Err(InvalidToken(
"Authorization header is not Base64-encoded as OCPI 2.2-d2 and later require; \
set Quirks::accept_unencoded_token for peers that predate that"
.into(),
))
}
#[must_use]
pub fn is_conformant(&self) -> bool {
!self.0.is_empty()
&& self.0.chars().count() <= Self::MAX_LEN
&& self.0.chars().all(|c| matches!(c, '!'..='~'))
}
#[must_use]
pub fn hint(&self) -> String {
let n = self.0.chars().count();
let tail: String = self.0.chars().skip(n.saturating_sub(4)).collect();
format!("****{tail}")
}
}
fn strip_token_prefix(value: &str) -> Option<&str> {
let (scheme, rest) = value.split_once(' ')?;
if scheme.eq_ignore_ascii_case("Token") { Some(rest.trim_start()) } else { None }
}
impl PartialEq for CredentialsToken {
fn eq(&self, other: &Self) -> bool {
let a = self.0.as_bytes();
let b = other.0.as_bytes();
a.len() == b.len() && bool::from(a.ct_eq(b))
}
}
impl Eq for CredentialsToken {}
impl fmt::Debug for CredentialsToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Token({})", self.hint())
}
}
impl fmt::Display for CredentialsToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Token({})", self.hint())
}
}
impl core::str::FromStr for CredentialsToken {
type Err = InvalidToken;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl Validate for CredentialsToken {
fn validate_in(&self, v: &mut Validator) {
if !self.is_conformant() {
v.report(ViolationCode::IllegalCharacter, "a credentials token is string(64) of U+0021..U+007E");
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InvalidToken(String);
impl fmt::Display for InvalidToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid credentials token: {}", self.0)
}
}
impl std::error::Error for InvalidToken {}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum TokenRole {
A,
B,
C,
}
impl TokenRole {
#[must_use]
pub fn may_access(self, module: &crate::ModuleId) -> bool {
use crate::ModuleId;
match self {
Self::A => matches!(module, ModuleId::Credentials | ModuleId::Versions),
Self::B | Self::C => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ModuleId;
#[test]
fn header_encoding_matches_the_spec_example() {
let token = CredentialsToken::new("example-token").unwrap();
assert_eq!(token.to_header_value(), "Token ZXhhbXBsZS10b2tlbg==");
assert_eq!(token.to_header_value_unencoded(), "Token example-token");
}
#[test]
fn header_parsing_accepts_both_encodings_under_the_flag() {
let encoded = "Token ZXhhbXBsZS10b2tlbg==";
let parsed = CredentialsToken::parse_header(encoded, false).unwrap();
assert_eq!(parsed.expose_secret(), "example-token");
let bare = "Token 12345678-1234-1234-1234-123456789012";
assert!(CredentialsToken::parse_header(bare, false).is_err());
assert_eq!(
CredentialsToken::parse_header(bare, true).unwrap().expose_secret(),
"12345678-1234-1234-1234-123456789012"
);
}
#[test]
fn header_parsing_is_case_insensitive_on_the_scheme_and_rejects_junk() {
assert!(CredentialsToken::parse_header("token ZXhhbXBsZS10b2tlbg==", false).is_ok());
assert!(CredentialsToken::parse_header("Bearer abc", true).is_err());
assert!(CredentialsToken::parse_header("Token ", true).is_err());
assert!(CredentialsToken::parse_header("", true).is_err());
}
#[test]
fn the_secret_never_appears_in_debug_or_display() {
let token = CredentialsToken::new("super-secret-value").unwrap();
let debug = format!("{token:?}");
let display = format!("{token}");
for rendering in [&debug, &display] {
assert!(!rendering.contains("super-secret"), "{rendering}");
assert!(rendering.contains("****alue"), "{rendering}");
}
}
#[test]
fn the_charset_excludes_whitespace() {
assert!(CredentialsToken::new("has space").is_err());
assert!(CredentialsToken::new("").is_err());
assert!(CredentialsToken::new("a".repeat(65)).is_err());
assert!(CredentialsToken::new("a".repeat(64)).is_ok());
assert!(CredentialsToken::new("~!@#$%^&*()_+").is_ok());
}
#[test]
fn equality_is_value_based_and_generated_tokens_differ() {
let a = CredentialsToken::new("same").unwrap();
let b = CredentialsToken::new("same").unwrap();
let c = CredentialsToken::new("other").unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
assert_ne!(CredentialsToken::generate(), CredentialsToken::generate());
assert!(CredentialsToken::generate().is_conformant());
}
#[test]
fn token_a_is_scoped_to_credentials_and_versions() {
assert!(TokenRole::A.may_access(&ModuleId::Credentials));
assert!(TokenRole::A.may_access(&ModuleId::Versions));
assert!(!TokenRole::A.may_access(&ModuleId::Locations));
assert!(!TokenRole::A.may_access(&ModuleId::Cdrs));
assert!(TokenRole::C.may_access(&ModuleId::Locations));
}
}