diff --git a/Cargo.lock b/Cargo.lock index f2bf22bd0..e91f70268 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3298,6 +3298,7 @@ dependencies = [ "futures-util", "governor", "headers", + "hex", "hyper", "indexmap 2.8.0", "insta", @@ -3336,6 +3337,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "serde_with", + "sha2", "sqlx", "thiserror 2.0.12", "time", @@ -4007,6 +4009,8 @@ dependencies = [ "assert_matches", "base64ct", "chrono", + "indexmap 2.8.0", + "insta", "language-tags", "mas-iana", "mas-jose", diff --git a/Cargo.toml b/Cargo.toml index e5a415a48..3c0bb8b83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,10 @@ version = "0.8.0" [workspace.dependencies.headers] version = "0.4.0" +# Hex encoding and decoding +[workspace.dependencies.hex] +version = "0.4.3" + # HTTP request/response [workspace.dependencies.http] version = "1.3.1" @@ -184,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" @@ -278,6 +287,11 @@ version = "0.5.1" version = "0.8.22" features = ["url", "chrono", "preserve_order"] +# SHA-2 cryptographic hash algorithm +[workspace.dependencies.sha2] +version = "0.10.8" +features = ["oid"] + # Query builder [workspace.dependencies.sea-query] version = "0.32.3" diff --git a/crates/data-model/src/oauth2/client.rs b/crates/data-model/src/oauth2/client.rs index ef7b029ba..c184d6fce 100644 --- a/crates/data-model/src/oauth2/client.rs +++ b/crates/data-model/src/oauth2/client.rs @@ -35,6 +35,9 @@ pub struct Client { /// Client identifier pub client_id: String, + /// Hash of the client metadata + pub metadata_digest: Option, + pub encrypted_client_secret: Option, pub application_type: Option, @@ -177,6 +180,7 @@ impl Client { Self { id: Ulid::from_datetime_with_source(now.into(), rng), client_id: "client1".to_owned(), + metadata_digest: None, encrypted_client_secret: None, application_type: Some(ApplicationType::Web), redirect_uris: vec![ @@ -202,6 +206,7 @@ impl Client { Self { id: Ulid::from_datetime_with_source(now.into(), rng), client_id: "client2".to_owned(), + metadata_digest: None, encrypted_client_secret: None, application_type: Some(ApplicationType::Native), redirect_uris: vec![Url::parse("https://client2.example.com/redirect").unwrap()], diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index fd2eb72c2..bd78f9dc2 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -72,10 +72,12 @@ base64ct.workspace = true camino.workspace = true 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 time = "0.3.41" url.workspace = true mime = "0.3.17" diff --git a/crates/handlers/src/graphql/tests.rs b/crates/handlers/src/graphql/tests.rs index 02399884b..08567314f 100644 --- a/crates/handlers/src/graphql/tests.rs +++ b/crates/handlers/src/graphql/tests.rs @@ -37,6 +37,7 @@ async fn create_test_client(state: &TestState) -> Client { vec![], None, None, + None, vec![], None, None, diff --git a/crates/handlers/src/oauth2/registration.rs b/crates/handlers/src/oauth2/registration.rs index a89e4665f..71812fac6 100644 --- a/crates/handlers/src/oauth2/registration.rs +++ b/crates/handlers/src/oauth2/registration.rs @@ -22,6 +22,7 @@ use oauth2_types::{ use psl::Psl; use rand::distributions::{Alphanumeric, DistString}; use serde::Serialize; +use sha2::Digest as _; use thiserror::Error; use tracing::info; use url::Url; @@ -50,6 +51,7 @@ impl_from_error_for_route!(mas_storage::RepositoryError); impl_from_error_for_route!(mas_policy::LoadError); impl_from_error_for_route!(mas_policy::EvaluationError); impl_from_error_for_route!(mas_keystore::aead::Error); +impl_from_error_for_route!(serde_json::Error); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { @@ -204,7 +206,13 @@ pub(crate) async fn post( // Propagate any JSON extraction error let Json(body) = body?; - info!(?body, "Client registration"); + // Sort the properties to ensure a stable serialisation order for hashing + let body = body.sorted(); + + // We need to serialize the body to compute the hash, and to log it + let body_json = serde_json::to_string(&body)?; + + info!(body = body_json, "Client registration"); let user_agent = user_agent.map(|ua| ua.to_string()); @@ -276,34 +284,59 @@ pub(crate) async fn post( _ => (None, None), }; - let client = repo - .oauth2_client() - .add( - &mut rng, - &clock, - metadata.redirect_uris().to_vec(), - encrypted_client_secret, - metadata.application_type.clone(), - //&metadata.response_types(), - metadata.grant_types().to_vec(), - metadata - .client_name - .clone() - .map(Localized::to_non_localized), - metadata.logo_uri.clone().map(Localized::to_non_localized), - metadata.client_uri.clone().map(Localized::to_non_localized), - metadata.policy_uri.clone().map(Localized::to_non_localized), - metadata.tos_uri.clone().map(Localized::to_non_localized), - metadata.jwks_uri.clone(), - metadata.jwks.clone(), - // XXX: those might not be right, should be function calls - metadata.id_token_signed_response_alg.clone(), - metadata.userinfo_signed_response_alg.clone(), - metadata.token_endpoint_auth_method.clone(), - metadata.token_endpoint_auth_signing_alg.clone(), - metadata.initiate_login_uri.clone(), - ) - .await?; + // If the client doesn't have a secret, we may be able to deduplicate it. To + // do so, we hash the client metadata, and look for it in the database + let (digest_hash, existing_client) = if client_secret.is_none() { + // XXX: One interesting caveat is that we hash *before* saving to the database. + // It means it takes into account fields that we don't care about *yet*. + // + // This means that if later we start supporting a particular field, we + // will still serve the 'old' client_id, without updating the client in the + // database + let hash = sha2::Sha256::digest(body_json); + let hash = hex::encode(hash); + let client = repo.oauth2_client().find_by_metadata_digest(&hash).await?; + (Some(hash), client) + } else { + (None, None) + }; + + let client = if let Some(client) = existing_client { + tracing::info!(%client.id, "Reusing existing client"); + client + } else { + let client = repo + .oauth2_client() + .add( + &mut rng, + &clock, + metadata.redirect_uris().to_vec(), + digest_hash, + encrypted_client_secret, + metadata.application_type.clone(), + //&metadata.response_types(), + metadata.grant_types().to_vec(), + metadata + .client_name + .clone() + .map(Localized::to_non_localized), + metadata.logo_uri.clone().map(Localized::to_non_localized), + metadata.client_uri.clone().map(Localized::to_non_localized), + metadata.policy_uri.clone().map(Localized::to_non_localized), + metadata.tos_uri.clone().map(Localized::to_non_localized), + metadata.jwks_uri.clone(), + metadata.jwks.clone(), + // XXX: those might not be right, should be function calls + metadata.id_token_signed_response_alg.clone(), + metadata.userinfo_signed_response_alg.clone(), + metadata.token_endpoint_auth_method.clone(), + metadata.token_endpoint_auth_signing_alg.clone(), + metadata.initiate_login_uri.clone(), + ) + .await?; + tracing::info!(%client.id, "Registered new client"); + client + }; let response = ClientRegistrationResponse { client_id: client.client_id.clone(), @@ -490,4 +523,74 @@ mod tests { let response: ClientRegistrationResponse = response.json(); assert!(response.client_secret.is_some()); } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_registration_dedupe(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Post a client registration twice, we should get the same client ID + let request = + Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({ + "client_uri": "https://example.com/", + "client_name": "Example", + "client_name#en": "Example", + "client_name#fr": "Exemple", + "client_name#de": "Beispiel", + "redirect_uris": ["https://example.com/", "https://example.com/callback"], + "response_types": ["code"], + "grant_types": ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"], + "token_endpoint_auth_method": "none", + })); + + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::CREATED); + let response: ClientRegistrationResponse = response.json(); + let client_id = response.client_id; + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let response: ClientRegistrationResponse = response.json(); + assert_eq!(response.client_id, client_id); + + // Check that the order of some properties doesn't matter + let request = + Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({ + "client_uri": "https://example.com/", + "client_name": "Example", + "client_name#de": "Beispiel", + "client_name#fr": "Exemple", + "client_name#en": "Example", + "redirect_uris": ["https://example.com/callback", "https://example.com/"], + "response_types": ["code"], + "grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "authorization_code"], + "token_endpoint_auth_method": "none", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let response: ClientRegistrationResponse = response.json(); + assert_eq!(response.client_id, client_id); + + // Doing that with a client that has a client_secret should not deduplicate + let request = + Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({ + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/"], + "response_types": ["code"], + "grant_types": ["authorization_code"], + "token_endpoint_auth_method": "client_secret_basic", + })); + + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::CREATED); + let response: ClientRegistrationResponse = response.json(); + // Sanity check that the client_id is different + assert_ne!(response.client_id, client_id); + let client_id = response.client_id; + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let response: ClientRegistrationResponse = response.json(); + assert_ne!(response.client_id, client_id); + } } diff --git a/crates/jose/Cargo.toml b/crates/jose/Cargo.toml index b84ba413b..7b3f6cbee 100644 --- a/crates/jose/Cargo.toml +++ b/crates/jose/Cargo.toml @@ -29,7 +29,7 @@ sec1 = "0.7.3" serde.workspace = true serde_json.workspace = true serde_with = "3.12.0" -sha2 = { version = "0.10.8", features = ["oid"] } +sha2.workspace = true signature = "2.2.0" thiserror.workspace = true url.workspace = true diff --git a/crates/oauth2-types/Cargo.toml b/crates/oauth2-types/Cargo.toml index d8e49f380..b0f4ffa69 100644 --- a/crates/oauth2-types/Cargo.toml +++ b/crates/oauth2-types/Cargo.toml @@ -19,11 +19,13 @@ language-tags = { version = "0.3.2", features = ["serde"] } url.workspace = true serde_with = { version = "3.12.0", features = ["chrono"] } chrono.workspace = true -sha2 = "0.10.8" +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 diff --git a/crates/oauth2-types/src/registration/client_metadata_serde.rs b/crates/oauth2-types/src/registration/client_metadata_serde.rs index 427306ffd..8ccefe36f 100644 --- a/crates/oauth2-types/src/registration/client_metadata_serde.rs +++ b/crates/oauth2-types/src/registration/client_metadata_serde.rs @@ -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 Localized { } fn deserialize( - map: &mut HashMap, Value>>, + map: &mut IndexMap, Value>>, field_name: &'static str, ) -> Result, 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)?; @@ -79,6 +80,13 @@ impl Localized { localized, })) } + + /// Sort the localized keys. This is inteded to ensure a stable + /// serialization order when needed. + pub(super) fn sort(&mut self) { + self.localized + .sort_unstable_by(|k1, _v1, k2, _v2| k1.as_str().cmp(k2.as_str())); + } } #[serde_as] @@ -125,48 +133,51 @@ pub struct ClientMetadataSerdeHelper { impl From for ClientMetadataSerdeHelper { fn from(metadata: VerifiedClientMetadata) -> Self { - let VerifiedClientMetadata { - inner: - ClientMetadata { - redirect_uris, - response_types, - grant_types, - application_type, - contacts, - client_name, - logo_uri, - client_uri, - policy_uri, - tos_uri, - jwks_uri, - jwks, - software_id, - software_version, - sector_identifier_uri, - subject_type, - token_endpoint_auth_method, - token_endpoint_auth_signing_alg, - id_token_signed_response_alg, - id_token_encrypted_response_alg, - id_token_encrypted_response_enc, - userinfo_signed_response_alg, - userinfo_encrypted_response_alg, - userinfo_encrypted_response_enc, - request_object_signing_alg, - request_object_encryption_alg, - request_object_encryption_enc, - default_max_age, - require_auth_time, - default_acr_values, - initiate_login_uri, - request_uris, - require_signed_request_object, - require_pushed_authorization_requests, - introspection_signed_response_alg, - introspection_encrypted_response_alg, - introspection_encrypted_response_enc, - post_logout_redirect_uris, - }, + metadata.inner.into() + } +} + +impl From for ClientMetadataSerdeHelper { + fn from(metadata: ClientMetadata) -> Self { + let ClientMetadata { + redirect_uris, + response_types, + grant_types, + application_type, + contacts, + client_name, + logo_uri, + client_uri, + policy_uri, + tos_uri, + jwks_uri, + jwks, + software_id, + software_version, + sector_identifier_uri, + subject_type, + token_endpoint_auth_method, + token_endpoint_auth_signing_alg, + id_token_signed_response_alg, + id_token_encrypted_response_alg, + id_token_encrypted_response_enc, + userinfo_signed_response_alg, + userinfo_encrypted_response_alg, + userinfo_encrypted_response_enc, + request_object_signing_alg, + request_object_encryption_alg, + request_object_encryption_enc, + default_max_age, + require_auth_time, + default_acr_values, + initiate_login_uri, + request_uris, + require_signed_request_object, + require_pushed_authorization_requests, + introspection_signed_response_alg, + introspection_encrypted_response_alg, + introspection_encrypted_response_enc, + post_logout_redirect_uris, } = metadata; ClientMetadataSerdeHelper { @@ -347,8 +358,8 @@ impl<'de> Deserialize<'de> for ClientMetadataLocalizedFields { where D: serde::Deserializer<'de>, { - let map = HashMap::, Value>::deserialize(deserializer)?; - let mut new_map: HashMap, Value>> = HashMap::new(); + let map = IndexMap::, Value>::deserialize(deserializer)?; + let mut new_map: IndexMap, Value>> = IndexMap::new(); for (k, v) in map { let (prefix, lang) = if let Some((prefix, lang)) = k.split_once('#') { @@ -389,6 +400,8 @@ impl<'de> Deserialize<'de> for ClientMetadataLocalizedFields { #[cfg(test)] mod tests { + use insta::assert_yaml_snapshot; + use super::*; #[test] @@ -461,16 +474,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" + "###); } } diff --git a/crates/oauth2-types/src/registration/mod.rs b/crates/oauth2-types/src/registration/mod.rs index 19b462f7c..27d99c2a8 100644 --- a/crates/oauth2-types/src/registration/mod.rs +++ b/crates/oauth2-types/src/registration/mod.rs @@ -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 { non_localized: T, - localized: HashMap, + localized: IndexMap, } impl Localized { @@ -104,8 +105,8 @@ impl Localized { } } -impl From<(T, HashMap)> for Localized { - fn from(t: (T, HashMap)) -> Self { +impl From<(T, IndexMap)> for Localized { + fn from(t: (T, IndexMap)) -> Self { Localized { non_localized: t.0, localized: t.1, @@ -118,8 +119,8 @@ impl From<(T, HashMap)> for Localized { /// All the fields with a default value are accessible via methods. /// /// [IANA registry]: https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#client-metadata -#[derive(Deserialize, Debug, PartialEq, Eq, Clone, Default)] -#[serde(from = "ClientMetadataSerdeHelper")] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)] +#[serde(from = "ClientMetadataSerdeHelper", into = "ClientMetadataSerdeHelper")] pub struct ClientMetadata { /// Array of redirection URIs for use in redirect-based flows such as the /// [authorization code flow]. @@ -563,6 +564,51 @@ impl ClientMetadata { Ok(VerifiedClientMetadata { inner: self }) } + /// Sort the properties. This is inteded to ensure a stable serialization + /// order when needed. + #[must_use] + pub fn sorted(mut self) -> Self { + // This sorts all the Vec and Localized fields + if let Some(redirect_uris) = &mut self.redirect_uris { + redirect_uris.sort(); + } + if let Some(response_types) = &mut self.response_types { + response_types.sort(); + } + if let Some(grant_types) = &mut self.grant_types { + grant_types.sort(); + } + if let Some(contacts) = &mut self.contacts { + contacts.sort(); + } + if let Some(client_name) = &mut self.client_name { + client_name.sort(); + } + if let Some(logo_uri) = &mut self.logo_uri { + logo_uri.sort(); + } + if let Some(client_uri) = &mut self.client_uri { + client_uri.sort(); + } + if let Some(policy_uri) = &mut self.policy_uri { + policy_uri.sort(); + } + if let Some(tos_uri) = &mut self.tos_uri { + tos_uri.sort(); + } + if let Some(default_acr_values) = &mut self.default_acr_values { + default_acr_values.sort(); + } + if let Some(request_uris) = &mut self.request_uris { + request_uris.sort(); + } + if let Some(post_logout_redirect_uris) = &mut self.post_logout_redirect_uris { + post_logout_redirect_uris.sort(); + } + + self + } + /// Array of the [OAuth 2.0 `response_type` values] that the client can use /// at the [authorization endpoint]. /// diff --git a/crates/oauth2-types/src/response_type.rs b/crates/oauth2-types/src/response_type.rs index eb6fa7e02..1b4c4da03 100644 --- a/crates/oauth2-types/src/response_type.rs +++ b/crates/oauth2-types/src/response_type.rs @@ -78,7 +78,7 @@ impl core::str::FromStr for ResponseTypeToken { /// /// [OAuth 2.0 `response_type` value]: https://www.rfc-editor.org/rfc/rfc7591#page-9 /// [authorization endpoint]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.1 -#[derive(Debug, Clone, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)] +#[derive(Debug, Clone, PartialEq, Eq, SerializeDisplay, DeserializeFromStr, PartialOrd, Ord)] pub struct ResponseType(BTreeSet); impl std::ops::Deref for ResponseType { diff --git a/crates/storage-pg/.sqlx/query-afef7e8248b415dd1fbf86748cbf5b37fbfeaf6fd0fbdaddfdf4db7feb7546b3.json b/crates/storage-pg/.sqlx/query-38d0608b7d8ba30927f939491c1d43cfd962c729298ad07ee1ade2f2880c0eb3.json similarity index 69% rename from crates/storage-pg/.sqlx/query-afef7e8248b415dd1fbf86748cbf5b37fbfeaf6fd0fbdaddfdf4db7feb7546b3.json rename to crates/storage-pg/.sqlx/query-38d0608b7d8ba30927f939491c1d43cfd962c729298ad07ee1ade2f2880c0eb3.json index 1dd6fe51a..d4dcbda4b 100644 --- a/crates/storage-pg/.sqlx/query-afef7e8248b415dd1fbf86748cbf5b37fbfeaf6fd0fbdaddfdf4db7feb7546b3.json +++ b/crates/storage-pg/.sqlx/query-38d0608b7d8ba30927f939491c1d43cfd962c729298ad07ee1ade2f2880c0eb3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n\n WHERE oauth2_client_id = $1\n ", + "query": "\n SELECT oauth2_client_id\n , metadata_digest\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n\n WHERE oauth2_client_id = $1\n ", "describe": { "columns": [ { @@ -10,96 +10,101 @@ }, { "ordinal": 1, - "name": "encrypted_client_secret", + "name": "metadata_digest", "type_info": "Text" }, { "ordinal": 2, - "name": "application_type", + "name": "encrypted_client_secret", "type_info": "Text" }, { "ordinal": 3, + "name": "application_type", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "redirect_uris", "type_info": "TextArray" }, { - "ordinal": 4, + "ordinal": 5, "name": "grant_type_authorization_code", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "grant_type_refresh_token", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "grant_type_client_credentials", "type_info": "Bool" }, { - "ordinal": 7, + "ordinal": 8, "name": "grant_type_device_code", "type_info": "Bool" }, { - "ordinal": 8, + "ordinal": 9, "name": "client_name", "type_info": "Text" }, { - "ordinal": 9, + "ordinal": 10, "name": "logo_uri", "type_info": "Text" }, { - "ordinal": 10, + "ordinal": 11, "name": "client_uri", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 12, "name": "policy_uri", "type_info": "Text" }, { - "ordinal": 12, + "ordinal": 13, "name": "tos_uri", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 14, "name": "jwks_uri", "type_info": "Text" }, { - "ordinal": 14, + "ordinal": 15, "name": "jwks", "type_info": "Jsonb" }, { - "ordinal": 15, + "ordinal": 16, "name": "id_token_signed_response_alg", "type_info": "Text" }, { - "ordinal": 16, + "ordinal": 17, "name": "userinfo_signed_response_alg", "type_info": "Text" }, { - "ordinal": 17, + "ordinal": 18, "name": "token_endpoint_auth_method", "type_info": "Text" }, { - "ordinal": 18, + "ordinal": 19, "name": "token_endpoint_auth_signing_alg", "type_info": "Text" }, { - "ordinal": 19, + "ordinal": 20, "name": "initiate_login_uri", "type_info": "Text" } @@ -113,6 +118,7 @@ false, true, true, + true, false, false, false, @@ -132,5 +138,5 @@ true ] }, - "hash": "afef7e8248b415dd1fbf86748cbf5b37fbfeaf6fd0fbdaddfdf4db7feb7546b3" + "hash": "38d0608b7d8ba30927f939491c1d43cfd962c729298ad07ee1ade2f2880c0eb3" } diff --git a/crates/storage-pg/.sqlx/query-4d0386ad2fe47f1aded46917abe6141752ba90d36467693a68318573171d57b0.json b/crates/storage-pg/.sqlx/query-4d0386ad2fe47f1aded46917abe6141752ba90d36467693a68318573171d57b0.json new file mode 100644 index 000000000..0b5fb6205 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-4d0386ad2fe47f1aded46917abe6141752ba90d36467693a68318573171d57b0.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth2_clients\n ( oauth2_client_id\n , metadata_digest\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n , is_static\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, FALSE)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "TextArray", + "Bool", + "Bool", + "Bool", + "Bool", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4d0386ad2fe47f1aded46917abe6141752ba90d36467693a68318573171d57b0" +} diff --git a/crates/storage-pg/.sqlx/query-92fb511938dff21e5e0f7800c742b852b8c4468d1770c4cbc0b51611ce50e922.json b/crates/storage-pg/.sqlx/query-92fb511938dff21e5e0f7800c742b852b8c4468d1770c4cbc0b51611ce50e922.json deleted file mode 100644 index 2b33f6935..000000000 --- a/crates/storage-pg/.sqlx/query-92fb511938dff21e5e0f7800c742b852b8c4468d1770c4cbc0b51611ce50e922.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO oauth2_clients\n ( oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n , is_static\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, FALSE)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "TextArray", - "Bool", - "Bool", - "Bool", - "Bool", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Jsonb", - "Text", - "Text", - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "92fb511938dff21e5e0f7800c742b852b8c4468d1770c4cbc0b51611ce50e922" -} diff --git a/crates/storage-pg/.sqlx/query-bb0f782756c274c06c1b63af6fc3ac2a7cedfd4247b57f062d348b4b1b36bef1.json b/crates/storage-pg/.sqlx/query-bb0f782756c274c06c1b63af6fc3ac2a7cedfd4247b57f062d348b4b1b36bef1.json new file mode 100644 index 000000000..f6edc0ee0 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-bb0f782756c274c06c1b63af6fc3ac2a7cedfd4247b57f062d348b4b1b36bef1.json @@ -0,0 +1,142 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT oauth2_client_id\n , metadata_digest\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n\n WHERE oauth2_client_id = ANY($1::uuid[])\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "oauth2_client_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "metadata_digest", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "encrypted_client_secret", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "application_type", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "redirect_uris", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "grant_type_authorization_code", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "grant_type_refresh_token", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "grant_type_client_credentials", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "grant_type_device_code", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "client_name", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "logo_uri", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "client_uri", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "policy_uri", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "tos_uri", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "jwks_uri", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "jwks", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "id_token_signed_response_alg", + "type_info": "Text" + }, + { + "ordinal": 17, + "name": "userinfo_signed_response_alg", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "token_endpoint_auth_method", + "type_info": "Text" + }, + { + "ordinal": 19, + "name": "token_endpoint_auth_signing_alg", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "initiate_login_uri", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + true, + true, + true, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "bb0f782756c274c06c1b63af6fc3ac2a7cedfd4247b57f062d348b4b1b36bef1" +} diff --git a/crates/storage-pg/.sqlx/query-1aa4c541af7e12431a58f43a1882a14314cc1833a6be272056e09d07c21ba9ef.json b/crates/storage-pg/.sqlx/query-cf654533cfed946e9ac52dbcea1f50be3dfdac0fbfb1e8a0204c0c9c103ba5b0.json similarity index 69% rename from crates/storage-pg/.sqlx/query-1aa4c541af7e12431a58f43a1882a14314cc1833a6be272056e09d07c21ba9ef.json rename to crates/storage-pg/.sqlx/query-cf654533cfed946e9ac52dbcea1f50be3dfdac0fbfb1e8a0204c0c9c103ba5b0.json index 7f7cd96fe..78ca4d0a4 100644 --- a/crates/storage-pg/.sqlx/query-1aa4c541af7e12431a58f43a1882a14314cc1833a6be272056e09d07c21ba9ef.json +++ b/crates/storage-pg/.sqlx/query-cf654533cfed946e9ac52dbcea1f50be3dfdac0fbfb1e8a0204c0c9c103ba5b0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n\n WHERE oauth2_client_id = ANY($1::uuid[])\n ", + "query": "\n SELECT oauth2_client_id\n , metadata_digest\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients\n WHERE metadata_digest = $1\n ", "describe": { "columns": [ { @@ -10,109 +10,115 @@ }, { "ordinal": 1, - "name": "encrypted_client_secret", + "name": "metadata_digest", "type_info": "Text" }, { "ordinal": 2, - "name": "application_type", + "name": "encrypted_client_secret", "type_info": "Text" }, { "ordinal": 3, + "name": "application_type", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "redirect_uris", "type_info": "TextArray" }, { - "ordinal": 4, + "ordinal": 5, "name": "grant_type_authorization_code", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "grant_type_refresh_token", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "grant_type_client_credentials", "type_info": "Bool" }, { - "ordinal": 7, + "ordinal": 8, "name": "grant_type_device_code", "type_info": "Bool" }, { - "ordinal": 8, + "ordinal": 9, "name": "client_name", "type_info": "Text" }, { - "ordinal": 9, + "ordinal": 10, "name": "logo_uri", "type_info": "Text" }, { - "ordinal": 10, + "ordinal": 11, "name": "client_uri", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 12, "name": "policy_uri", "type_info": "Text" }, { - "ordinal": 12, + "ordinal": 13, "name": "tos_uri", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 14, "name": "jwks_uri", "type_info": "Text" }, { - "ordinal": 14, + "ordinal": 15, "name": "jwks", "type_info": "Jsonb" }, { - "ordinal": 15, + "ordinal": 16, "name": "id_token_signed_response_alg", "type_info": "Text" }, { - "ordinal": 16, + "ordinal": 17, "name": "userinfo_signed_response_alg", "type_info": "Text" }, { - "ordinal": 17, + "ordinal": 18, "name": "token_endpoint_auth_method", "type_info": "Text" }, { - "ordinal": 18, + "ordinal": 19, "name": "token_endpoint_auth_signing_alg", "type_info": "Text" }, { - "ordinal": 19, + "ordinal": 20, "name": "initiate_login_uri", "type_info": "Text" } ], "parameters": { "Left": [ - "UuidArray" + "Text" ] }, "nullable": [ false, true, true, + true, false, false, false, @@ -132,5 +138,5 @@ true ] }, - "hash": "1aa4c541af7e12431a58f43a1882a14314cc1833a6be272056e09d07c21ba9ef" + "hash": "cf654533cfed946e9ac52dbcea1f50be3dfdac0fbfb1e8a0204c0c9c103ba5b0" } diff --git a/crates/storage-pg/.sqlx/query-199819516dce285771a75a48a687b285225aeae7a4d1ca91084ae84f25dcbbec.json b/crates/storage-pg/.sqlx/query-fc9925e19000d79c0bb020ea44e13cbb364b3505626d34550e38f6f7397b9d42.json similarity index 69% rename from crates/storage-pg/.sqlx/query-199819516dce285771a75a48a687b285225aeae7a4d1ca91084ae84f25dcbbec.json rename to crates/storage-pg/.sqlx/query-fc9925e19000d79c0bb020ea44e13cbb364b3505626d34550e38f6f7397b9d42.json index 49c3731c3..bb080ade5 100644 --- a/crates/storage-pg/.sqlx/query-199819516dce285771a75a48a687b285225aeae7a4d1ca91084ae84f25dcbbec.json +++ b/crates/storage-pg/.sqlx/query-fc9925e19000d79c0bb020ea44e13cbb364b3505626d34550e38f6f7397b9d42.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n WHERE is_static = TRUE\n ", + "query": "\n SELECT oauth2_client_id\n , metadata_digest\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n WHERE is_static = TRUE\n ", "describe": { "columns": [ { @@ -10,96 +10,101 @@ }, { "ordinal": 1, - "name": "encrypted_client_secret", + "name": "metadata_digest", "type_info": "Text" }, { "ordinal": 2, - "name": "application_type", + "name": "encrypted_client_secret", "type_info": "Text" }, { "ordinal": 3, + "name": "application_type", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "redirect_uris", "type_info": "TextArray" }, { - "ordinal": 4, + "ordinal": 5, "name": "grant_type_authorization_code", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "grant_type_refresh_token", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "grant_type_client_credentials", "type_info": "Bool" }, { - "ordinal": 7, + "ordinal": 8, "name": "grant_type_device_code", "type_info": "Bool" }, { - "ordinal": 8, + "ordinal": 9, "name": "client_name", "type_info": "Text" }, { - "ordinal": 9, + "ordinal": 10, "name": "logo_uri", "type_info": "Text" }, { - "ordinal": 10, + "ordinal": 11, "name": "client_uri", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 12, "name": "policy_uri", "type_info": "Text" }, { - "ordinal": 12, + "ordinal": 13, "name": "tos_uri", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 14, "name": "jwks_uri", "type_info": "Text" }, { - "ordinal": 14, + "ordinal": 15, "name": "jwks", "type_info": "Jsonb" }, { - "ordinal": 15, + "ordinal": 16, "name": "id_token_signed_response_alg", "type_info": "Text" }, { - "ordinal": 16, + "ordinal": 17, "name": "userinfo_signed_response_alg", "type_info": "Text" }, { - "ordinal": 17, + "ordinal": 18, "name": "token_endpoint_auth_method", "type_info": "Text" }, { - "ordinal": 18, + "ordinal": 19, "name": "token_endpoint_auth_signing_alg", "type_info": "Text" }, { - "ordinal": 19, + "ordinal": 20, "name": "initiate_login_uri", "type_info": "Text" } @@ -111,6 +116,7 @@ false, true, true, + true, false, false, false, @@ -130,5 +136,5 @@ true ] }, - "hash": "199819516dce285771a75a48a687b285225aeae7a4d1ca91084ae84f25dcbbec" + "hash": "fc9925e19000d79c0bb020ea44e13cbb364b3505626d34550e38f6f7397b9d42" } diff --git a/crates/storage-pg/migrations/20250325102310_oauth2_clients_hash.sql b/crates/storage-pg/migrations/20250325102310_oauth2_clients_hash.sql new file mode 100644 index 000000000..3a4dc8413 --- /dev/null +++ b/crates/storage-pg/migrations/20250325102310_oauth2_clients_hash.sql @@ -0,0 +1,13 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Adds a column which stores a hash of the client metadata, so that we can +-- deduplicate client registrations +-- +-- This hash is a SHA-256 hash of the JSON-encoded client metadata. Note that we +-- don't retroactively hash existing clients, so this will only be populated for +-- new clients. +ALTER TABLE oauth2_clients + ADD COLUMN metadata_digest TEXT UNIQUE; diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index ea97ec2d4..5864dbf76 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -570,6 +570,7 @@ mod tests { vec!["https://example.com/redirect".parse().unwrap()], None, None, + None, vec![GrantType::AuthorizationCode], Some("First client".to_owned()), Some("https://example.com/logo.png".parse().unwrap()), diff --git a/crates/storage-pg/src/oauth2/client.rs b/crates/storage-pg/src/oauth2/client.rs index 81298fe9d..b846e80d4 100644 --- a/crates/storage-pg/src/oauth2/client.rs +++ b/crates/storage-pg/src/oauth2/client.rs @@ -47,6 +47,7 @@ impl<'c> PgOAuth2ClientRepository<'c> { #[derive(Debug)] struct OAuth2ClientLookup { oauth2_client_id: Uuid, + metadata_digest: Option, encrypted_client_secret: Option, application_type: Option, redirect_uris: Vec, @@ -231,6 +232,7 @@ impl TryInto for OAuth2ClientLookup { Ok(Client { id, client_id: id.to_string(), + metadata_digest: self.metadata_digest, encrypted_client_secret: self.encrypted_client_secret, application_type, redirect_uris, @@ -268,6 +270,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { OAuth2ClientLookup, r#" SELECT oauth2_client_id + , metadata_digest , encrypted_client_secret , application_type , redirect_uris @@ -302,6 +305,56 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { Ok(Some(res.try_into()?)) } + #[tracing::instrument( + name = "db.oauth2_client.find_by_metadata_digest", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn find_by_metadata_digest( + &mut self, + digest: &str, + ) -> Result, Self::Error> { + let res = sqlx::query_as!( + OAuth2ClientLookup, + r#" + SELECT oauth2_client_id + , metadata_digest + , encrypted_client_secret + , application_type + , redirect_uris + , grant_type_authorization_code + , grant_type_refresh_token + , grant_type_client_credentials + , grant_type_device_code + , client_name + , logo_uri + , client_uri + , policy_uri + , tos_uri + , jwks_uri + , jwks + , id_token_signed_response_alg + , userinfo_signed_response_alg + , token_endpoint_auth_method + , token_endpoint_auth_signing_alg + , initiate_login_uri + FROM oauth2_clients + WHERE metadata_digest = $1 + "#, + digest, + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.try_into()?)) + } + #[tracing::instrument( name = "db.oauth2_client.load_batch", skip_all, @@ -319,6 +372,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { OAuth2ClientLookup, r#" SELECT oauth2_client_id + , metadata_digest , encrypted_client_secret , application_type , redirect_uris @@ -373,6 +427,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { rng: &mut (dyn RngCore + Send), clock: &dyn Clock, redirect_uris: Vec, + metadata_digest: Option, encrypted_client_secret: Option, application_type: Option, grant_types: Vec, @@ -405,6 +460,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { r#" INSERT INTO oauth2_clients ( oauth2_client_id + , metadata_digest , encrypted_client_secret , application_type , redirect_uris @@ -427,9 +483,11 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { , is_static ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, FALSE) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, $17, $18, $19, $20, $21, FALSE) "#, Uuid::from(id), + metadata_digest, encrypted_client_secret, application_type.as_ref().map(ToString::to_string), &redirect_uris_array, @@ -470,6 +528,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { Ok(Client { id, client_id: id.to_string(), + metadata_digest: None, encrypted_client_secret, application_type, redirect_uris, @@ -570,6 +629,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { Ok(Client { id: client_id, client_id: client_id.to_string(), + metadata_digest: None, encrypted_client_secret, application_type: None, redirect_uris, @@ -605,6 +665,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { OAuth2ClientLookup, r#" SELECT oauth2_client_id + , metadata_digest , encrypted_client_secret , application_type , redirect_uris diff --git a/crates/storage-pg/src/oauth2/mod.rs b/crates/storage-pg/src/oauth2/mod.rs index e94f1e6c6..a3aadc2ad 100644 --- a/crates/storage-pg/src/oauth2/mod.rs +++ b/crates/storage-pg/src/oauth2/mod.rs @@ -68,6 +68,7 @@ mod tests { vec!["https://example.com/redirect".parse().unwrap()], None, None, + None, vec![GrantType::AuthorizationCode], Some("Test client".to_owned()), Some("https://example.com/logo.png".parse().unwrap()), @@ -435,6 +436,7 @@ mod tests { vec!["https://first.example.com/redirect".parse().unwrap()], None, None, + None, vec![GrantType::AuthorizationCode], Some("First client".to_owned()), Some("https://first.example.com/logo.png".parse().unwrap()), @@ -459,6 +461,7 @@ mod tests { vec!["https://second.example.com/redirect".parse().unwrap()], None, None, + None, vec![GrantType::AuthorizationCode], Some("Second client".to_owned()), Some("https://second.example.com/logo.png".parse().unwrap()), @@ -758,6 +761,7 @@ mod tests { vec!["https://example.com/redirect".parse().unwrap()], None, None, + None, vec![GrantType::AuthorizationCode], Some("Example".to_owned()), Some("https://example.com/logo.png".parse().unwrap()), diff --git a/crates/storage/src/oauth2/client.rs b/crates/storage/src/oauth2/client.rs index 6c8ba61a6..113fe3e37 100644 --- a/crates/storage/src/oauth2/client.rs +++ b/crates/storage/src/oauth2/client.rs @@ -45,6 +45,23 @@ pub trait OAuth2ClientRepository: Send + Sync { self.lookup(id).await } + /// Find an OAuth2 client by its metadata digest + /// + /// Returns `None` if the client does not exist + /// + /// # Parameters + /// + /// * `digest`: The metadata digest (SHA-256 hash encoded in hex) of the + /// client to find + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn find_by_metadata_digest( + &mut self, + digest: &str, + ) -> Result, Self::Error>; + /// Load a batch of OAuth2 clients by their IDs /// /// Returns a map of client IDs to clients. If a client does not exist, it @@ -71,6 +88,7 @@ pub trait OAuth2ClientRepository: Send + Sync { /// * `rng`: The random number generator to use /// * `clock`: The clock used to generate timestamps /// * `redirect_uris`: The list of redirect URIs used by this client + /// * `metadata_digest`: The hash of the client metadata, if computed /// * `encrypted_client_secret`: The encrypted client secret, if any /// * `application_type`: The application type of this client /// * `grant_types`: The list of grant types this client can use @@ -101,6 +119,7 @@ pub trait OAuth2ClientRepository: Send + Sync { rng: &mut (dyn RngCore + Send), clock: &dyn Clock, redirect_uris: Vec, + metadata_digest: Option, encrypted_client_secret: Option, application_type: Option, grant_types: Vec, @@ -221,6 +240,11 @@ pub trait OAuth2ClientRepository: Send + Sync { repository_impl!(OAuth2ClientRepository: async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + async fn find_by_metadata_digest( + &mut self, + digest: &str, + ) -> Result, Self::Error>; + async fn load_batch( &mut self, ids: BTreeSet, @@ -231,6 +255,7 @@ repository_impl!(OAuth2ClientRepository: rng: &mut (dyn RngCore + Send), clock: &dyn Clock, redirect_uris: Vec, + metadata_digest: Option, encrypted_client_secret: Option, application_type: Option, grant_types: Vec,