Skip to content

Commit 9c46508

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 5a4807c commit 9c46508

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
@@ -184,6 +184,11 @@ version = "0.27.5"
184184
features = ["http1", "http2"]
185185
default-features = false
186186

187+
# HashMap which preserves insertion order
188+
[workspace.dependencies.indexmap]
189+
version = "2.8.0"
190+
features = ["serde"]
191+
187192
# Snapshot testing
188193
[workspace.dependencies.insta]
189194
version = "1.42.2"

crates/handlers/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ camino.workspace = true
7373
chrono.workspace = true
7474
elliptic-curve.workspace = true
7575
governor.workspace = true
76-
indexmap = "2.8.0"
76+
indexmap.workspace = true
7777
pkcs8.workspace = true
7878
psl = "2.1.96"
7979
time = "0.3.41"

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 = "0.10.8"
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)?;
@@ -347,8 +348,8 @@ impl<'de> Deserialize<'de> for ClientMetadataLocalizedFields {
347348
where
348349
D: serde::Deserializer<'de>,
349350
{
350-
let map = HashMap::<Cow<'de, str>, Value>::deserialize(deserializer)?;
351-
let mut new_map: HashMap<String, HashMap<Option<LanguageTag>, Value>> = HashMap::new();
351+
let map = IndexMap::<Cow<'de, str>, Value>::deserialize(deserializer)?;
352+
let mut new_map: IndexMap<String, IndexMap<Option<LanguageTag>, Value>> = IndexMap::new();
352353

353354
for (k, v) in map {
354355
let (prefix, lang) = if let Some((prefix, lang)) = k.split_once('#') {
@@ -389,6 +390,8 @@ impl<'de> Deserialize<'de> for ClientMetadataLocalizedFields {
389390

390391
#[cfg(test)]
391392
mod tests {
393+
use insta::assert_yaml_snapshot;
394+
392395
use super::*;
393396

394397
#[test]
@@ -461,16 +464,28 @@ mod tests {
461464
.validate()
462465
.unwrap();
463466

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

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)