From 964d84bf68006b002d4dc7bd19438584ddf6d1c8 Mon Sep 17 00:00:00 2001 From: "Victor \"multun\" Collod" Date: Wed, 24 Apr 2024 15:11:28 +0200 Subject: [PATCH] Allow updated_at to be an epoch string Allow unix timestamps within a string for updated_at, as follows: "updated_at": "1713964430.299453" This is not allowed by the specification, but was observed in the wild on at least one OpenAM instance. This misbehavior is probably caused by a plugin. This parsing leniancy feature is gated behind "accept-string-epoch". --- Cargo.toml | 1 + src/helpers.rs | 61 ++++++++++++++++++++++++++++++++++++++++++- src/id_token/tests.rs | 32 +++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 2cb0a61..c83952a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ maintenance = { status = "actively-developed" } [features] accept-rfc3339-timestamps = [] accept-string-booleans = [] +accept-string-epoch = [] curl = ["oauth2/curl"] default = ["reqwest", "rustls-tls"] native-tls = ["oauth2/native-tls"] diff --git a/src/helpers.rs b/src/helpers.rs index 76f5457..85a01fd 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -62,6 +62,59 @@ where } } +// Some providers return numbers as strings +#[cfg(feature = "accept-string-epoch")] +pub(crate) mod serde_string_number { + use serde::{de, Deserializer}; + + use std::fmt; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct StringLikeNumberVisitor; + + impl<'de> de::Visitor<'de> for StringLikeNumberVisitor { + type Value = serde_json::Number; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a JSON number") + } + + fn visit_i64(self, v: i64) -> Result + where + E: de::Error, + { + Ok(v.into()) + } + + fn visit_u64(self, v: u64) -> Result + where + E: de::Error, + { + Ok(v.into()) + } + + fn visit_f64(self, v: f64) -> Result + where + E: de::Error, + { + serde_json::Number::from_f64(v) + .ok_or_else(|| de::Error::custom("not a JSON number")) + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + serde_json::from_str(v).map_err(|_| de::Error::custom("not a JSON number")) + } + } + deserializer.deserialize_any(StringLikeNumberVisitor) + } +} + // Some providers return boolean values as strings. Provide support for // parsing using stdlib. #[cfg(feature = "accept-string-booleans")] @@ -331,7 +384,13 @@ impl Display for Boolean { #[derive(Debug, Deserialize, Serialize)] #[serde(untagged)] pub(crate) enum Timestamp { - Seconds(serde_json::Number), + Seconds( + #[cfg_attr( + feature = "accept-string-epoch", + serde(deserialize_with = "crate::helpers::serde_string_number::deserialize") + )] + serde_json::Number, + ), #[cfg(feature = "accept-rfc3339-timestamps")] Rfc3339(String), } diff --git a/src/id_token/tests.rs b/src/id_token/tests.rs index 304ab8c..b558967 100644 --- a/src/id_token/tests.rs +++ b/src/id_token/tests.rs @@ -489,6 +489,38 @@ fn test_accept_rfc3339_timestamp() { ); } +#[test] +#[cfg(feature = "accept-string-epoch")] +fn test_accept_string_updated_at() { + for (updated_at, sec, nsec) in [ + ("1713963222.5", 1713963222, 500_000_000), + ("42.5", 42, 500_000_000), + ("42", 42, 0), + ("-42", -42, 0), + ] { + let payload = format!( + "{{ + \"iss\": \"https://server.example.com\", + \"sub\": \"24400320\", + \"aud\": \"s6BhdRkqt3\", + \"exp\": 1311281970, + \"iat\": 1311280970, + \"updated_at\": \"{updated_at}\" + }}" + ); + let claims: CoreIdTokenClaims = + serde_json::from_str(payload.as_str()).expect("failed to deserialize"); + assert_eq!( + claims.updated_at(), + Some( + Utc.timestamp_opt(sec, nsec) + .single() + .expect("valid timestamp") + ) + ); + } +} + #[test] fn test_unknown_claims_serde() { let expected_serialized_claims = "{\