use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{Error, OAuthError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EveJwtClaims {
pub iss: String,
pub sub: String,
pub aud: Vec<String>,
pub jti: String,
pub kid: String,
pub tenant: String,
pub region: String,
#[serde(with = "jwt_timestamp_format")]
pub exp: DateTime<Utc>,
#[serde(with = "jwt_timestamp_format")]
pub iat: DateTime<Utc>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_scp")]
pub scp: Vec<String>,
pub name: String,
pub owner: String,
pub azp: String,
}
impl EveJwtClaims {
pub fn character_id(&self) -> Result<i64, Error> {
let segments = self.sub.split(':').collect::<Vec<&str>>();
let segments_len = segments.len();
if segments_len != 3 {
let message = format!(
"The `sub` field segment length is {} but the expected length is 2",
segments_len,
);
error!(message);
return Err(Error::OAuthError(OAuthError::CharacterIdParseError(
message,
)));
}
match segments[2].parse::<i64>() {
Ok(character_id) => Ok(character_id),
Err(err) => {
let message = format!("Failed to parse `sub` field to i64 due to error: {}", err);
error!(message);
Err(Error::OAuthError(OAuthError::CharacterIdParseError(
message,
)))
}
}
}
pub fn is_expired(&self) -> bool {
let character_id = self.character_id().unwrap_or(0);
let now = Utc::now();
let token_expiration = self.exp;
if now < token_expiration {
let time_remaining = self.exp - now;
debug!(
"Checked token for expiration, token for character ID {} is not yet expired, expiration in {}s",
character_id,
time_remaining.num_seconds()
);
return false;
}
let time_remaining = now - self.exp;
debug!(
"Checked token for expiration, token for character ID {} is expired, expired {}s ago",
character_id,
time_remaining.num_seconds()
);
true
}
pub fn has_scopes(&self, scopes: &Vec<String>) -> bool {
let character_id = self.character_id().unwrap_or(0);
for expected_scope in scopes {
if !self.scp.iter().any(|scope| scope == expected_scope) {
debug!(
"Token for character ID {} is missing scope: {}",
character_id, expected_scope
);
return false;
}
}
debug!(
"Token for character ID {} has all expected scopes",
character_id
);
true
}
}
fn deserialize_scp<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
use serde_json::Value;
let opt_value = Option::<Value>::deserialize(deserializer)?;
match opt_value {
None => Ok(Vec::new()),
Some(value) => match value {
Value::String(s) => Ok(vec![s]),
Value::Array(arr) => {
let mut scopes = Vec::with_capacity(arr.len());
for item in arr {
if let Value::String(s) = item {
scopes.push(s);
} else {
return Err(Error::custom("Expected string array for scopes"));
}
}
Ok(scopes)
}
_ => Err(Error::custom(
"Expected null, string, or string array for `scp` field",
)),
},
}
}
mod jwt_timestamp_format {
use chrono::{DateTime, TimeZone, Utc};
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_i64(date.timestamp())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
let timestamp = i64::deserialize(deserializer)?;
Utc.timestamp_opt(timestamp, 0)
.single()
.ok_or_else(|| serde::de::Error::custom("Invalid timestamp"))
}
}
#[cfg(test)]
mod claims_character_id_tests {
use crate::{tests::util::create_mock_jwt_claims, Error, OAuthError};
#[test]
fn test_claims_character_id_success() {
let mut mock_claims = create_mock_jwt_claims();
mock_claims.sub = "CHARACTER:EVE:123456789".to_string();
let result = mock_claims.character_id();
assert!(result.is_ok());
let character_id = result.unwrap();
assert_eq!(character_id, 123456789);
}
#[test]
fn test_claims_character_id_segment_error() {
let mut mock_claims = create_mock_jwt_claims();
mock_claims.sub = "not_segmented".to_string();
let result = mock_claims.character_id();
assert!(result.is_err());
assert!(matches!(
result,
Err(Error::OAuthError(OAuthError::CharacterIdParseError(_)))
))
}
#[test]
fn test_claims_character_id_i64_parse_error() {
let mut mock_claims = create_mock_jwt_claims();
mock_claims.sub = "CHARACTER:EVE:not_a_number".to_string();
let result = mock_claims.character_id();
assert!(result.is_err());
assert!(matches!(
result,
Err(Error::OAuthError(OAuthError::CharacterIdParseError(_)))
))
}
}
#[cfg(test)]
mod is_expired_tests {
use chrono::{Duration, Utc};
use crate::tests::util::create_mock_jwt_claims;
#[tokio::test]
pub async fn test_is_expired_false() {
let mock_claims = create_mock_jwt_claims();
let result = mock_claims.is_expired();
assert_eq!(result, false);
}
#[tokio::test]
async fn test_is_expired_true() {
let mut mock_claims = create_mock_jwt_claims();
mock_claims.exp = Utc::now() - Duration::seconds(60); mock_claims.iat = Utc::now() - Duration::seconds(960);
let result = mock_claims.is_expired();
assert_eq!(result, true);
}
}
#[cfg(test)]
mod has_scopes_tests {
use crate::tests::util::create_mock_jwt_claims;
#[test]
fn test_has_scopes_true() {
let mut mock_claims = create_mock_jwt_claims();
mock_claims.scp = vec!["publicData".to_string()];
let expected_scopes = vec!["publicData".to_string()];
let result = mock_claims.has_scopes(&expected_scopes);
assert_eq!(result, true);
}
#[test]
fn test_has_scopes_false() {
let mut mock_claims = create_mock_jwt_claims();
mock_claims.scp = vec!["".to_string()];
let expected_scopes = vec!["publicData".to_string()];
let result = mock_claims.has_scopes(&expected_scopes);
assert_eq!(result, false);
}
}
#[cfg(test)]
mod deserialize_scp_tests {
use std::time::Duration;
use chrono::Utc;
use serde_json::Value;
use super::EveJwtClaims;
fn create_mock_json<T>(scp: T) -> serde_json::Value
where
T: serde::Serialize,
{
let expires_in_fifteen_minutes = Utc::now() + Duration::from_secs(900);
let created_now = Utc::now();
serde_json::json!({
"iss": "https://login.eveonline.com",
"sub": "CHARACTER:EVE:123456789",
"aud": ["client_id".to_string(), "EVE Online"],
"jti": "abc123def456",
"kid": "JWT-Signature-Key-1",
"tenant": "tranquility",
"region": "world",
"exp": expires_in_fifteen_minutes.timestamp(),
"iat": created_now.timestamp(),
"scp": scp,
"name": "Test Character",
"owner": "123456789",
"azp": "client_id"
})
}
#[test]
fn test_deserialize_scp_null() {
let json_data = create_mock_json(Value::Null);
let claims: EveJwtClaims =
serde_json::from_value(json_data).expect("Failed to deserialize claims");
assert!(claims.scp.is_empty());
}
#[test]
fn test_deserialize_scp_single_string() {
let json_data = create_mock_json("publicData");
let claims: EveJwtClaims =
serde_json::from_value(json_data).expect("Failed to deserialize claims");
assert_eq!(claims.scp.len(), 1);
assert_eq!(claims.scp[0], "publicData");
}
#[test]
fn test_deserialize_scp_string_array() {
let json_data =
create_mock_json(vec!["publicData", "esi-characters.read_agents_research.v1"]);
let claims: EveJwtClaims =
serde_json::from_value(json_data).expect("Failed to deserialize claims");
assert_eq!(claims.scp.len(), 2);
assert_eq!(claims.scp[0], "publicData");
assert_eq!(claims.scp[1], "esi-characters.read_agents_research.v1");
}
#[test]
fn test_deserialize_scp_not_string() {
let json_data = create_mock_json(488);
let result = serde_json::from_value::<EveJwtClaims>(json_data);
assert!(result.is_err());
assert!(matches!(result,
Err(err) if err.to_string().contains("Expected null, string, or string array for `scp` field")
));
}
#[test]
fn test_deserialize_scp_not_string_array() {
let json_data = create_mock_json(vec![9, 9, 9]);
let result = serde_json::from_value::<EveJwtClaims>(json_data);
assert!(result.is_err());
assert!(matches!(result,
Err(err) if err.to_string().contains("Expected string array for scopes")
));
}
}
#[cfg(test)]
mod test_jwt_timestamp_format {
use super::EveJwtClaims;
use chrono::{Duration, Utc};
fn create_test_jwt_json(exp_value: impl Into<serde_json::Value>) -> serde_json::Value {
let created_now = Utc::now();
serde_json::json!({
"iss": "https://login.eveonline.com",
"sub": "CHARACTER:EVE:123456789",
"aud": ["client_id".to_string(), "EVE Online"],
"jti": "abc123def456",
"kid": "JWT-Signature-Key-1",
"tenant": "tranquility",
"region": "world",
"exp": exp_value.into(),
"iat": created_now.timestamp(),
"scp": [],
"name": "Test Character",
"owner": "123456789",
"azp": "client_id"
})
}
#[test]
fn test_jwt_timestamp_format_success() {
let exp = Utc::now() + Duration::seconds(1200);
let json_data = create_test_jwt_json(exp.timestamp());
let result = serde_json::from_value::<EveJwtClaims>(json_data);
assert!(result.is_ok());
}
#[test]
fn test_jwt_timestamp_format_invalid_timestamp() {
let json_data = create_test_jwt_json(i64::MAX);
let result = serde_json::from_value::<EveJwtClaims>(json_data);
assert!(result.is_err());
assert!(matches!(result,
Err(err) if err.to_string().contains("Invalid timestamp")
));
}
}