Skip to content

Commit 5c13757

Browse files
committed
Deduplicate client registrations by hashing the metadata
1 parent 771a970 commit 5c13757

21 files changed

+534
-170
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: 9 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"
@@ -278,6 +282,11 @@ version = "0.5.1"
278282
version = "0.8.22"
279283
features = ["url", "chrono", "preserve_order"]
280284

285+
# SHA-2 cryptographic hash algorithm
286+
[workspace.dependencies.sha2]
287+
version = "0.10.8"
288+
features = ["oid"]
289+
281290
# Query builder
282291
[workspace.dependencies.sea-query]
283292
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: 2 additions & 0 deletions
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
7677
indexmap = "2.8.0"
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: 106 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,10 @@ pub(crate) async fn post(
204206
// Propagate any JSON extraction error
205207
let Json(body) = body?;
206208

207-
info!(?body, "Client registration");
209+
// We need to serialize the body to compute the hash, and to log it
210+
let body_json = serde_json::to_string(&body)?;
211+
212+
info!(body = body_json, "Client registration");
208213

209214
let user_agent = user_agent.map(|ua| ua.to_string());
210215

@@ -276,34 +281,59 @@ pub(crate) async fn post(
276281
_ => (None, None),
277282
};
278283

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

308338
let response = ClientRegistrationResponse {
309339
client_id: client.client_id.clone(),
@@ -490,4 +520,51 @@ mod tests {
490520
let response: ClientRegistrationResponse = response.json();
491521
assert!(response.client_secret.is_some());
492522
}
523+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
524+
async fn test_registration_dedupe(pool: PgPool) {
525+
setup();
526+
let state = TestState::from_pool(pool).await.unwrap();
527+
528+
// Post a client registration twice, we should get the same client ID
529+
let request =
530+
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
531+
"client_uri": "https://example.com/",
532+
"redirect_uris": ["https://example.com/"],
533+
"response_types": ["code"],
534+
"grant_types": ["authorization_code"],
535+
"token_endpoint_auth_method": "none",
536+
}));
537+
538+
let response = state.request(request.clone()).await;
539+
response.assert_status(StatusCode::CREATED);
540+
let response: ClientRegistrationResponse = response.json();
541+
let client_id = response.client_id;
542+
543+
let response = state.request(request).await;
544+
response.assert_status(StatusCode::CREATED);
545+
let response: ClientRegistrationResponse = response.json();
546+
assert_eq!(response.client_id, client_id);
547+
548+
// Doing that with a client that has a client_secret should not deduplicate
549+
let request =
550+
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
551+
"client_uri": "https://example.com/",
552+
"redirect_uris": ["https://example.com/"],
553+
"response_types": ["code"],
554+
"grant_types": ["authorization_code"],
555+
"token_endpoint_auth_method": "client_secret_basic",
556+
}));
557+
558+
let response = state.request(request.clone()).await;
559+
response.assert_status(StatusCode::CREATED);
560+
let response: ClientRegistrationResponse = response.json();
561+
// Sanity check that the client_id is different
562+
assert_ne!(response.client_id, client_id);
563+
let client_id = response.client_id;
564+
565+
let response = state.request(request).await;
566+
response.assert_status(StatusCode::CREATED);
567+
let response: ClientRegistrationResponse = response.json();
568+
assert_ne!(response.client_id, client_id);
569+
}
493570
}

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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ 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
2424

2525
mas-iana.workspace = true

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

Lines changed: 45 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -125,48 +125,51 @@ pub struct ClientMetadataSerdeHelper {
125125

126126
impl From<VerifiedClientMetadata> for ClientMetadataSerdeHelper {
127127
fn from(metadata: VerifiedClientMetadata) -> Self {
128-
let VerifiedClientMetadata {
129-
inner:
130-
ClientMetadata {
131-
redirect_uris,
132-
response_types,
133-
grant_types,
134-
application_type,
135-
contacts,
136-
client_name,
137-
logo_uri,
138-
client_uri,
139-
policy_uri,
140-
tos_uri,
141-
jwks_uri,
142-
jwks,
143-
software_id,
144-
software_version,
145-
sector_identifier_uri,
146-
subject_type,
147-
token_endpoint_auth_method,
148-
token_endpoint_auth_signing_alg,
149-
id_token_signed_response_alg,
150-
id_token_encrypted_response_alg,
151-
id_token_encrypted_response_enc,
152-
userinfo_signed_response_alg,
153-
userinfo_encrypted_response_alg,
154-
userinfo_encrypted_response_enc,
155-
request_object_signing_alg,
156-
request_object_encryption_alg,
157-
request_object_encryption_enc,
158-
default_max_age,
159-
require_auth_time,
160-
default_acr_values,
161-
initiate_login_uri,
162-
request_uris,
163-
require_signed_request_object,
164-
require_pushed_authorization_requests,
165-
introspection_signed_response_alg,
166-
introspection_encrypted_response_alg,
167-
introspection_encrypted_response_enc,
168-
post_logout_redirect_uris,
169-
},
128+
metadata.inner.into()
129+
}
130+
}
131+
132+
impl From<ClientMetadata> for ClientMetadataSerdeHelper {
133+
fn from(metadata: ClientMetadata) -> Self {
134+
let ClientMetadata {
135+
redirect_uris,
136+
response_types,
137+
grant_types,
138+
application_type,
139+
contacts,
140+
client_name,
141+
logo_uri,
142+
client_uri,
143+
policy_uri,
144+
tos_uri,
145+
jwks_uri,
146+
jwks,
147+
software_id,
148+
software_version,
149+
sector_identifier_uri,
150+
subject_type,
151+
token_endpoint_auth_method,
152+
token_endpoint_auth_signing_alg,
153+
id_token_signed_response_alg,
154+
id_token_encrypted_response_alg,
155+
id_token_encrypted_response_enc,
156+
userinfo_signed_response_alg,
157+
userinfo_encrypted_response_alg,
158+
userinfo_encrypted_response_enc,
159+
request_object_signing_alg,
160+
request_object_encryption_alg,
161+
request_object_encryption_enc,
162+
default_max_age,
163+
require_auth_time,
164+
default_acr_values,
165+
initiate_login_uri,
166+
request_uris,
167+
require_signed_request_object,
168+
require_pushed_authorization_requests,
169+
introspection_signed_response_alg,
170+
introspection_encrypted_response_alg,
171+
introspection_encrypted_response_enc,
172+
post_logout_redirect_uris,
170173
} = metadata;
171174

172175
ClientMetadataSerdeHelper {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ impl<T> From<(T, HashMap<LanguageTag, T>)> for Localized<T> {
118118
/// All the fields with a default value are accessible via methods.
119119
///
120120
/// [IANA registry]: https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#client-metadata
121-
#[derive(Deserialize, Debug, PartialEq, Eq, Clone, Default)]
122-
#[serde(from = "ClientMetadataSerdeHelper")]
121+
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
122+
#[serde(from = "ClientMetadataSerdeHelper", into = "ClientMetadataSerdeHelper")]
123123
pub struct ClientMetadata {
124124
/// Array of redirection URIs for use in redirect-based flows such as the
125125
/// [authorization code flow].

0 commit comments

Comments
 (0)