Deduplicate client registrations by hashing the metadata

This commit is contained in:
Quentin Gliech
2025-03-25 13:32:54 +01:00
parent 20acebdb96
commit 8fbd75eb7e
21 changed files with 534 additions and 170 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"
@@ -278,6 +282,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"

View File

@@ -35,6 +35,9 @@ pub struct Client {
/// Client identifier
pub client_id: String,
/// Hash of the client metadata
pub metadata_digest: Option<String>,
pub encrypted_client_secret: Option<String>,
pub application_type: Option<ApplicationType>,
@@ -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()],

View File

@@ -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"
pkcs8.workspace = true
psl = "2.1.96"
sha2.workspace = true
time = "0.3.41"
url.workspace = true
mime = "0.3.17"

View File

@@ -37,6 +37,7 @@ async fn create_test_client(state: &TestState) -> Client {
vec![],
None,
None,
None,
vec![],
None,
None,

View File

@@ -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,10 @@ pub(crate) async fn post(
// Propagate any JSON extraction error
let Json(body) = body?;
info!(?body, "Client registration");
// 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 +281,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 +520,51 @@ 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/",
"redirect_uris": ["https://example.com/"],
"response_types": ["code"],
"grant_types": ["authorization_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);
// 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);
}
}

View File

@@ -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

View File

@@ -19,7 +19,7 @@ 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
mas-iana.workspace = true

View File

@@ -125,48 +125,51 @@ pub struct ClientMetadataSerdeHelper {
impl From<VerifiedClientMetadata> 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<ClientMetadata> 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 {

View File

@@ -118,8 +118,8 @@ impl<T> From<(T, HashMap<LanguageTag, T>)> for Localized<T> {
/// 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].

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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()),

View File

@@ -47,6 +47,7 @@ impl<'c> PgOAuth2ClientRepository<'c> {
#[derive(Debug)]
struct OAuth2ClientLookup {
oauth2_client_id: Uuid,
metadata_digest: Option<String>,
encrypted_client_secret: Option<String>,
application_type: Option<String>,
redirect_uris: Vec<String>,
@@ -231,6 +232,7 @@ impl TryInto<Client> 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<Option<Client>, 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<Url>,
metadata_digest: Option<String>,
encrypted_client_secret: Option<String>,
application_type: Option<ApplicationType>,
grant_types: Vec<GrantType>,
@@ -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

View File

@@ -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()),

View File

@@ -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<Option<Client>, 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<Url>,
metadata_digest: Option<String>,
encrypted_client_secret: Option<String>,
application_type: Option<ApplicationType>,
grant_types: Vec<GrantType>,
@@ -221,6 +240,11 @@ pub trait OAuth2ClientRepository: Send + Sync {
repository_impl!(OAuth2ClientRepository:
async fn lookup(&mut self, id: Ulid) -> Result<Option<Client>, Self::Error>;
async fn find_by_metadata_digest(
&mut self,
digest: &str,
) -> Result<Option<Client>, Self::Error>;
async fn load_batch(
&mut self,
ids: BTreeSet<Ulid>,
@@ -231,6 +255,7 @@ repository_impl!(OAuth2ClientRepository:
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
redirect_uris: Vec<Url>,
metadata_digest: Option<String>,
encrypted_client_secret: Option<String>,
application_type: Option<ApplicationType>,
grant_types: Vec<GrantType>,