Ensure client metadata hashing is stable

This is done by using the indexmap crate to preserve insertion order for
localized fields.
This commit is contained in:
Quentin Gliech
2025-03-25 15:00:48 +01:00
parent 8fbd75eb7e
commit 5ae2970d94
6 changed files with 47 additions and 22 deletions

2
Cargo.lock generated
View File

@@ -4009,6 +4009,8 @@ dependencies = [
"assert_matches",
"base64ct",
"chrono",
"indexmap 2.8.0",
"insta",
"language-tags",
"mas-iana",
"mas-jose",

View File

@@ -188,6 +188,11 @@ version = "0.27.5"
features = ["http1", "http2"]
default-features = false
# HashMap which preserves insertion order
[workspace.dependencies.indexmap]
version = "2.8.0"
features = ["serde"]
# Snapshot testing
[workspace.dependencies.insta]
version = "1.42.2"

View File

@@ -74,7 +74,7 @@ chrono.workspace = true
elliptic-curve.workspace = true
hex.workspace = true
governor.workspace = true
indexmap = "2.8.0"
indexmap.workspace = true
pkcs8.workspace = true
psl = "2.1.96"
sha2.workspace = true

View File

@@ -21,9 +21,11 @@ serde_with = { version = "3.12.0", features = ["chrono"] }
chrono.workspace = true
sha2.workspace = true
thiserror.workspace = true
indexmap.workspace = true
mas-iana.workspace = true
mas-jose.workspace = true
[dev-dependencies]
assert_matches = "1.5.0"
insta.workspace = true

View File

@@ -4,9 +4,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::{borrow::Cow, collections::HashMap};
use std::borrow::Cow;
use chrono::Duration;
use indexmap::IndexMap;
use language_tags::LanguageTag;
use mas_iana::{
jose::{JsonWebEncryptionAlg, JsonWebEncryptionEnc, JsonWebSignatureAlg},
@@ -45,18 +46,18 @@ impl<T> Localized<T> {
}
fn deserialize(
map: &mut HashMap<String, HashMap<Option<LanguageTag>, Value>>,
map: &mut IndexMap<String, IndexMap<Option<LanguageTag>, Value>>,
field_name: &'static str,
) -> Result<Option<Self>, serde_json::Error>
where
T: DeserializeOwned,
{
let Some(map) = map.remove(field_name) else {
let Some(map) = map.shift_remove(field_name) else {
return Ok(None);
};
let mut non_localized = None;
let mut localized = HashMap::with_capacity(map.len() - 1);
let mut localized = IndexMap::with_capacity(map.len() - 1);
for (k, v) in map {
let value = serde_json::from_value(v)?;
@@ -350,8 +351,8 @@ impl<'de> Deserialize<'de> for ClientMetadataLocalizedFields {
where
D: serde::Deserializer<'de>,
{
let map = HashMap::<Cow<'de, str>, Value>::deserialize(deserializer)?;
let mut new_map: HashMap<String, HashMap<Option<LanguageTag>, Value>> = HashMap::new();
let map = IndexMap::<Cow<'de, str>, Value>::deserialize(deserializer)?;
let mut new_map: IndexMap<String, IndexMap<Option<LanguageTag>, Value>> = IndexMap::new();
for (k, v) in map {
let (prefix, lang) = if let Some((prefix, lang)) = k.split_once('#') {
@@ -392,6 +393,8 @@ impl<'de> Deserialize<'de> for ClientMetadataLocalizedFields {
#[cfg(test)]
mod tests {
use insta::assert_yaml_snapshot;
use super::*;
#[test]
@@ -464,16 +467,28 @@ mod tests {
.validate()
.unwrap();
assert_eq!(
serde_json::to_value(metadata).unwrap(),
serde_json::json!({
"redirect_uris": ["http://localhost/oidc"],
"client_name": "Postbox",
"client_name#fr": "Boîte à lettres",
"client_uri": "https://localhost/",
"client_uri#fr": "https://localhost/fr",
"client_uri#de": "https://localhost/de",
})
);
assert_yaml_snapshot!(metadata, @r###"
redirect_uris:
- "http://localhost/oidc"
client_name: Postbox
"client_name#fr": Boîte à lettres
client_uri: "https://localhost/"
"client_uri#fr": "https://localhost/fr"
"client_uri#de": "https://localhost/de"
"###);
// Do a roundtrip, we should get the same metadata back with the same order
let metadata: ClientMetadata =
serde_json::from_value(serde_json::to_value(metadata).unwrap()).unwrap();
let metadata = metadata.validate().unwrap();
assert_yaml_snapshot!(metadata, @r###"
redirect_uris:
- "http://localhost/oidc"
client_name: Postbox
"client_name#fr": Boîte à lettres
client_uri: "https://localhost/"
"client_uri#fr": "https://localhost/fr"
"client_uri#de": "https://localhost/de"
"###);
}
}

View File

@@ -8,9 +8,10 @@
//!
//! [Dynamic Client Registration]: https://openid.net/specs/openid-connect-registration-1_0.html
use std::{collections::HashMap, ops::Deref};
use std::ops::Deref;
use chrono::{DateTime, Duration, Utc};
use indexmap::IndexMap;
use language_tags::LanguageTag;
use mas_iana::{
jose::{JsonWebEncryptionAlg, JsonWebEncryptionEnc, JsonWebSignatureAlg},
@@ -58,7 +59,7 @@ pub const DEFAULT_ENCRYPTION_ENC_ALGORITHM: &JsonWebEncryptionEnc =
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Localized<T> {
non_localized: T,
localized: HashMap<LanguageTag, T>,
localized: IndexMap<LanguageTag, T>,
}
impl<T> Localized<T> {
@@ -104,8 +105,8 @@ impl<T> Localized<T> {
}
}
impl<T> From<(T, HashMap<LanguageTag, T>)> for Localized<T> {
fn from(t: (T, HashMap<LanguageTag, T>)) -> Self {
impl<T> From<(T, IndexMap<LanguageTag, T>)> for Localized<T> {
fn from(t: (T, IndexMap<LanguageTag, T>)) -> Self {
Localized {
non_localized: t.0,
localized: t.1,