Skip to content

Commit 4294dc8

Browse files
committed
Ensure client metadata hashing is stable
This is done by using the indexmap crate to preserve insertion order for localized fields.
1 parent 5c13757 commit 4294dc8

File tree

6 files changed

+47
-22
lines changed

6 files changed

+47
-22
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ version = "0.27.5"
188188
features = ["http1", "http2"]
189189
default-features = false
190190

191+
# HashMap which preserves insertion order
192+
[workspace.dependencies.indexmap]
193+
version = "2.8.0"
194+
features = ["serde"]
195+
191196
# Snapshot testing
192197
[workspace.dependencies.insta]
193198
version = "1.42.2"

crates/handlers/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ chrono.workspace = true
7474
elliptic-curve.workspace = true
7575
hex.workspace = true
7676
governor.workspace = true
77-
indexmap = "2.8.0"
77+
indexmap.workspace = true
7878
pkcs8.workspace = true
7979
psl = "2.1.96"
8080
sha2.workspace = true

crates/oauth2-types/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ serde_with = { version = "3.12.0", features = ["chrono"] }
2121
chrono.workspace = true
2222
sha2.workspace = true
2323
thiserror.workspace = true
24+
indexmap.workspace = true
2425

2526
mas-iana.workspace = true
2627
mas-jose.workspace = true
2728

2829
[dev-dependencies]
2930
assert_matches = "1.5.0"
31+
insta.workspace = true

crates/oauth2-types/src/registration/client_metadata_serde.rs

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
// SPDX-License-Identifier: AGPL-3.0-only
55
// Please see LICENSE in the repository root for full details.
66

7-
use std::{borrow::Cow, collections::HashMap};
7+
use std::borrow::Cow;
88

99
use chrono::Duration;
10+
use indexmap::IndexMap;
1011
use language_tags::LanguageTag;
1112
use mas_iana::{
1213
jose::{JsonWebEncryptionAlg, JsonWebEncryptionEnc, JsonWebSignatureAlg},
@@ -45,18 +46,18 @@ impl<T> Localized<T> {
4546
}
4647

4748
fn deserialize(
48-
map: &mut HashMap<String, HashMap<Option<LanguageTag>, Value>>,
49+
map: &mut IndexMap<String, IndexMap<Option<LanguageTag>, Value>>,
4950
field_name: &'static str,
5051
) -> Result<Option<Self>, serde_json::Error>
5152
where
5253
T: DeserializeOwned,
5354
{
54-
let Some(map) = map.remove(field_name) else {
55+
let Some(map) = map.shift_remove(field_name) else {
5556
return Ok(None);
5657
};
5758

5859
let mut non_localized = None;
59-
let mut localized = HashMap::with_capacity(map.len() - 1);
60+
let mut localized = IndexMap::with_capacity(map.len() - 1);
6061

6162
for (k, v) in map {
6263
let value = serde_json::from_value(v)?;
@@ -350,8 +351,8 @@ impl<'de> Deserialize<'de> for ClientMetadataLocalizedFields {
350351
where
351352
D: serde::Deserializer<'de>,
352353
{
353-
let map = HashMap::<Cow<'de, str>, Value>::deserialize(deserializer)?;
354-
let mut new_map: HashMap<String, HashMap<Option<LanguageTag>, Value>> = HashMap::new();
354+
let map = IndexMap::<Cow<'de, str>, Value>::deserialize(deserializer)?;
355+
let mut new_map: IndexMap<String, IndexMap<Option<LanguageTag>, Value>> = IndexMap::new();
355356

356357
for (k, v) in map {
357358
let (prefix, lang) = if let Some((prefix, lang)) = k.split_once('#') {
@@ -392,6 +393,8 @@ impl<'de> Deserialize<'de> for ClientMetadataLocalizedFields {
392393

393394
#[cfg(test)]
394395
mod tests {
396+
use insta::assert_yaml_snapshot;
397+
395398
use super::*;
396399

397400
#[test]
@@ -464,16 +467,28 @@ mod tests {
464467
.validate()
465468
.unwrap();
466469

467-
assert_eq!(
468-
serde_json::to_value(metadata).unwrap(),
469-
serde_json::json!({
470-
"redirect_uris": ["http://localhost/oidc"],
471-
"client_name": "Postbox",
472-
"client_name#fr": "Boîte à lettres",
473-
"client_uri": "https://localhost/",
474-
"client_uri#fr": "https://localhost/fr",
475-
"client_uri#de": "https://localhost/de",
476-
})
477-
);
470+
assert_yaml_snapshot!(metadata, @r###"
471+
redirect_uris:
472+
- "http://localhost/oidc"
473+
client_name: Postbox
474+
"client_name#fr": Boîte à lettres
475+
client_uri: "https://localhost/"
476+
"client_uri#fr": "https://localhost/fr"
477+
"client_uri#de": "https://localhost/de"
478+
"###);
479+
480+
// Do a roundtrip, we should get the same metadata back with the same order
481+
let metadata: ClientMetadata =
482+
serde_json::from_value(serde_json::to_value(metadata).unwrap()).unwrap();
483+
let metadata = metadata.validate().unwrap();
484+
assert_yaml_snapshot!(metadata, @r###"
485+
redirect_uris:
486+
- "http://localhost/oidc"
487+
client_name: Postbox
488+
"client_name#fr": Boîte à lettres
489+
client_uri: "https://localhost/"
490+
"client_uri#fr": "https://localhost/fr"
491+
"client_uri#de": "https://localhost/de"
492+
"###);
478493
}
479494
}

crates/oauth2-types/src/registration/mod.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
//!
99
//! [Dynamic Client Registration]: https://openid.net/specs/openid-connect-registration-1_0.html
1010
11-
use std::{collections::HashMap, ops::Deref};
11+
use std::ops::Deref;
1212

1313
use chrono::{DateTime, Duration, Utc};
14+
use indexmap::IndexMap;
1415
use language_tags::LanguageTag;
1516
use mas_iana::{
1617
jose::{JsonWebEncryptionAlg, JsonWebEncryptionEnc, JsonWebSignatureAlg},
@@ -58,7 +59,7 @@ pub const DEFAULT_ENCRYPTION_ENC_ALGORITHM: &JsonWebEncryptionEnc =
5859
#[derive(Debug, Clone, PartialEq, Eq)]
5960
pub struct Localized<T> {
6061
non_localized: T,
61-
localized: HashMap<LanguageTag, T>,
62+
localized: IndexMap<LanguageTag, T>,
6263
}
6364

6465
impl<T> Localized<T> {
@@ -104,8 +105,8 @@ impl<T> Localized<T> {
104105
}
105106
}
106107

107-
impl<T> From<(T, HashMap<LanguageTag, T>)> for Localized<T> {
108-
fn from(t: (T, HashMap<LanguageTag, T>)) -> Self {
108+
impl<T> From<(T, IndexMap<LanguageTag, T>)> for Localized<T> {
109+
fn from(t: (T, IndexMap<LanguageTag, T>)) -> Self {
109110
Localized {
110111
non_localized: t.0,
111112
localized: t.1,

0 commit comments

Comments
 (0)