Skip to content

Commit 76b9a6d

Browse files
authored
Deduplicate client registrations by hashing the metadata (#4293)
2 parents c24c3ee + 3f66940 commit 76b9a6d

22 files changed

+660
-193
lines changed

Cargo.lock

Lines changed: 4 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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ version = "0.8.0"
148148
[workspace.dependencies.headers]
149149
version = "0.4.0"
150150

151+
# Hex encoding and decoding
152+
[workspace.dependencies.hex]
153+
version = "0.4.3"
154+
151155
# HTTP request/response
152156
[workspace.dependencies.http]
153157
version = "1.3.1"
@@ -184,6 +188,11 @@ version = "0.27.5"
184188
features = ["http1", "http2"]
185189
default-features = false
186190

191+
# HashMap which preserves insertion order
192+
[workspace.dependencies.indexmap]
193+
version = "2.8.0"
194+
features = ["serde"]
195+
187196
# Snapshot testing
188197
[workspace.dependencies.insta]
189198
version = "1.42.2"
@@ -278,6 +287,11 @@ version = "0.5.1"
278287
version = "0.8.22"
279288
features = ["url", "chrono", "preserve_order"]
280289

290+
# SHA-2 cryptographic hash algorithm
291+
[workspace.dependencies.sha2]
292+
version = "0.10.8"
293+
features = ["oid"]
294+
281295
# Query builder
282296
[workspace.dependencies.sea-query]
283297
version = "0.32.3"

crates/data-model/src/oauth2/client.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ pub struct Client {
3535
/// Client identifier
3636
pub client_id: String,
3737

38+
/// Hash of the client metadata
39+
pub metadata_digest: Option<String>,
40+
3841
pub encrypted_client_secret: Option<String>,
3942

4043
pub application_type: Option<ApplicationType>,
@@ -177,6 +180,7 @@ impl Client {
177180
Self {
178181
id: Ulid::from_datetime_with_source(now.into(), rng),
179182
client_id: "client1".to_owned(),
183+
metadata_digest: None,
180184
encrypted_client_secret: None,
181185
application_type: Some(ApplicationType::Web),
182186
redirect_uris: vec![
@@ -202,6 +206,7 @@ impl Client {
202206
Self {
203207
id: Ulid::from_datetime_with_source(now.into(), rng),
204208
client_id: "client2".to_owned(),
209+
metadata_digest: None,
205210
encrypted_client_secret: None,
206211
application_type: Some(ApplicationType::Native),
207212
redirect_uris: vec![Url::parse("https://client2.example.com/redirect").unwrap()],

crates/handlers/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,12 @@ base64ct.workspace = true
7272
camino.workspace = true
7373
chrono.workspace = true
7474
elliptic-curve.workspace = true
75+
hex.workspace = true
7576
governor.workspace = true
76-
indexmap = "2.8.0"
77+
indexmap.workspace = true
7778
pkcs8.workspace = true
7879
psl = "2.1.96"
80+
sha2.workspace = true
7981
time = "0.3.41"
8082
url.workspace = true
8183
mime = "0.3.17"

crates/handlers/src/graphql/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ async fn create_test_client(state: &TestState) -> Client {
3737
vec![],
3838
None,
3939
None,
40+
None,
4041
vec![],
4142
None,
4243
None,

crates/handlers/src/oauth2/registration.rs

Lines changed: 132 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use oauth2_types::{
2222
use psl::Psl;
2323
use rand::distributions::{Alphanumeric, DistString};
2424
use serde::Serialize;
25+
use sha2::Digest as _;
2526
use thiserror::Error;
2627
use tracing::info;
2728
use url::Url;
@@ -50,6 +51,7 @@ impl_from_error_for_route!(mas_storage::RepositoryError);
5051
impl_from_error_for_route!(mas_policy::LoadError);
5152
impl_from_error_for_route!(mas_policy::EvaluationError);
5253
impl_from_error_for_route!(mas_keystore::aead::Error);
54+
impl_from_error_for_route!(serde_json::Error);
5355

5456
impl IntoResponse for RouteError {
5557
fn into_response(self) -> axum::response::Response {
@@ -204,7 +206,13 @@ pub(crate) async fn post(
204206
// Propagate any JSON extraction error
205207
let Json(body) = body?;
206208

207-
info!(?body, "Client registration");
209+
// Sort the properties to ensure a stable serialisation order for hashing
210+
let body = body.sorted();
211+
212+
// We need to serialize the body to compute the hash, and to log it
213+
let body_json = serde_json::to_string(&body)?;
214+
215+
info!(body = body_json, "Client registration");
208216

209217
let user_agent = user_agent.map(|ua| ua.to_string());
210218

@@ -276,34 +284,59 @@ pub(crate) async fn post(
276284
_ => (None, None),
277285
};
278286

279-
let client = repo
280-
.oauth2_client()
281-
.add(
282-
&mut rng,
283-
&clock,
284-
metadata.redirect_uris().to_vec(),
285-
encrypted_client_secret,
286-
metadata.application_type.clone(),
287-
//&metadata.response_types(),
288-
metadata.grant_types().to_vec(),
289-
metadata
290-
.client_name
291-
.clone()
292-
.map(Localized::to_non_localized),
293-
metadata.logo_uri.clone().map(Localized::to_non_localized),
294-
metadata.client_uri.clone().map(Localized::to_non_localized),
295-
metadata.policy_uri.clone().map(Localized::to_non_localized),
296-
metadata.tos_uri.clone().map(Localized::to_non_localized),
297-
metadata.jwks_uri.clone(),
298-
metadata.jwks.clone(),
299-
// XXX: those might not be right, should be function calls
300-
metadata.id_token_signed_response_alg.clone(),
301-
metadata.userinfo_signed_response_alg.clone(),
302-
metadata.token_endpoint_auth_method.clone(),
303-
metadata.token_endpoint_auth_signing_alg.clone(),
304-
metadata.initiate_login_uri.clone(),
305-
)
306-
.await?;
287+
// If the client doesn't have a secret, we may be able to deduplicate it. To
288+
// do so, we hash the client metadata, and look for it in the database
289+
let (digest_hash, existing_client) = if client_secret.is_none() {
290+
// XXX: One interesting caveat is that we hash *before* saving to the database.
291+
// It means it takes into account fields that we don't care about *yet*.
292+
//
293+
// This means that if later we start supporting a particular field, we
294+
// will still serve the 'old' client_id, without updating the client in the
295+
// database
296+
let hash = sha2::Sha256::digest(body_json);
297+
let hash = hex::encode(hash);
298+
let client = repo.oauth2_client().find_by_metadata_digest(&hash).await?;
299+
(Some(hash), client)
300+
} else {
301+
(None, None)
302+
};
303+
304+
let client = if let Some(client) = existing_client {
305+
tracing::info!(%client.id, "Reusing existing client");
306+
client
307+
} else {
308+
let client = repo
309+
.oauth2_client()
310+
.add(
311+
&mut rng,
312+
&clock,
313+
metadata.redirect_uris().to_vec(),
314+
digest_hash,
315+
encrypted_client_secret,
316+
metadata.application_type.clone(),
317+
//&metadata.response_types(),
318+
metadata.grant_types().to_vec(),
319+
metadata
320+
.client_name
321+
.clone()
322+
.map(Localized::to_non_localized),
323+
metadata.logo_uri.clone().map(Localized::to_non_localized),
324+
metadata.client_uri.clone().map(Localized::to_non_localized),
325+
metadata.policy_uri.clone().map(Localized::to_non_localized),
326+
metadata.tos_uri.clone().map(Localized::to_non_localized),
327+
metadata.jwks_uri.clone(),
328+
metadata.jwks.clone(),
329+
// XXX: those might not be right, should be function calls
330+
metadata.id_token_signed_response_alg.clone(),
331+
metadata.userinfo_signed_response_alg.clone(),
332+
metadata.token_endpoint_auth_method.clone(),
333+
metadata.token_endpoint_auth_signing_alg.clone(),
334+
metadata.initiate_login_uri.clone(),
335+
)
336+
.await?;
337+
tracing::info!(%client.id, "Registered new client");
338+
client
339+
};
307340

308341
let response = ClientRegistrationResponse {
309342
client_id: client.client_id.clone(),
@@ -490,4 +523,74 @@ mod tests {
490523
let response: ClientRegistrationResponse = response.json();
491524
assert!(response.client_secret.is_some());
492525
}
526+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
527+
async fn test_registration_dedupe(pool: PgPool) {
528+
setup();
529+
let state = TestState::from_pool(pool).await.unwrap();
530+
531+
// Post a client registration twice, we should get the same client ID
532+
let request =
533+
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
534+
"client_uri": "https://example.com/",
535+
"client_name": "Example",
536+
"client_name#en": "Example",
537+
"client_name#fr": "Exemple",
538+
"client_name#de": "Beispiel",
539+
"redirect_uris": ["https://example.com/", "https://example.com/callback"],
540+
"response_types": ["code"],
541+
"grant_types": ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
542+
"token_endpoint_auth_method": "none",
543+
}));
544+
545+
let response = state.request(request.clone()).await;
546+
response.assert_status(StatusCode::CREATED);
547+
let response: ClientRegistrationResponse = response.json();
548+
let client_id = response.client_id;
549+
550+
let response = state.request(request).await;
551+
response.assert_status(StatusCode::CREATED);
552+
let response: ClientRegistrationResponse = response.json();
553+
assert_eq!(response.client_id, client_id);
554+
555+
// Check that the order of some properties doesn't matter
556+
let request =
557+
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
558+
"client_uri": "https://example.com/",
559+
"client_name": "Example",
560+
"client_name#de": "Beispiel",
561+
"client_name#fr": "Exemple",
562+
"client_name#en": "Example",
563+
"redirect_uris": ["https://example.com/callback", "https://example.com/"],
564+
"response_types": ["code"],
565+
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "authorization_code"],
566+
"token_endpoint_auth_method": "none",
567+
}));
568+
569+
let response = state.request(request).await;
570+
response.assert_status(StatusCode::CREATED);
571+
let response: ClientRegistrationResponse = response.json();
572+
assert_eq!(response.client_id, client_id);
573+
574+
// Doing that with a client that has a client_secret should not deduplicate
575+
let request =
576+
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
577+
"client_uri": "https://example.com/",
578+
"redirect_uris": ["https://example.com/"],
579+
"response_types": ["code"],
580+
"grant_types": ["authorization_code"],
581+
"token_endpoint_auth_method": "client_secret_basic",
582+
}));
583+
584+
let response = state.request(request.clone()).await;
585+
response.assert_status(StatusCode::CREATED);
586+
let response: ClientRegistrationResponse = response.json();
587+
// Sanity check that the client_id is different
588+
assert_ne!(response.client_id, client_id);
589+
let client_id = response.client_id;
590+
591+
let response = state.request(request).await;
592+
response.assert_status(StatusCode::CREATED);
593+
let response: ClientRegistrationResponse = response.json();
594+
assert_ne!(response.client_id, client_id);
595+
}
493596
}

crates/jose/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ sec1 = "0.7.3"
2929
serde.workspace = true
3030
serde_json.workspace = true
3131
serde_with = "3.12.0"
32-
sha2 = { version = "0.10.8", features = ["oid"] }
32+
sha2.workspace = true
3333
signature = "2.2.0"
3434
thiserror.workspace = true
3535
url.workspace = true

crates/oauth2-types/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ language-tags = { version = "0.3.2", features = ["serde"] }
1919
url.workspace = true
2020
serde_with = { version = "3.12.0", features = ["chrono"] }
2121
chrono.workspace = true
22-
sha2 = "0.10.8"
22+
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

0 commit comments

Comments
 (0)