use std::collections::HashMap;
use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::core::{jwk::PublicJwks, secrets::SecretString};
#[derive(Clone, Debug, Default, Serialize, Deserialize, Builder)]
#[builder(on(String, into))]
#[non_exhaustive]
pub struct ClientMetadata {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub redirect_uris: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_method: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub grant_types: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub response_types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,
#[serde(
default,
skip_serializing_if = "scope_serde::is_empty",
serialize_with = "scope_serde::serialize",
deserialize_with = "scope_serde::deserialize"
)]
pub scope: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub contacts: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tos_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "jwks_serde::deserialize"
)]
#[builder(into)]
pub jwks: Option<PublicJwks>,
#[serde(skip_serializing_if = "Option::is_none")]
pub software_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub software_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub software_statement: Option<SecretString>,
#[serde(skip_serializing_if = "Option::is_none")]
pub application_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sector_identifier_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_signed_response_alg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_encrypted_response_alg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_encrypted_response_enc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_signed_response_alg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_encrypted_response_alg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_encrypted_response_enc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_object_signing_alg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_object_encryption_alg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_object_encryption_enc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_signing_alg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_max_age: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub require_auth_time: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub default_acr_values: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initiate_login_uri: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub request_uris: Vec<String>,
#[serde(flatten)]
#[builder(default)]
pub extra: HashMap<String, serde_json::Value>,
}
mod jwks_serde {
use serde::{Deserialize, Deserializer};
use crate::core::jwk::{Jwks, PublicJwks};
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<PublicJwks>, D::Error> {
Ok(Option::<Jwks>::deserialize(deserializer)?.map(PublicJwks::from))
}
}
#[allow(clippy::ref_option)]
mod scope_serde {
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn is_empty(scope: &Option<Vec<String>>) -> bool {
scope.as_ref().is_none_or(Vec::is_empty)
}
pub(super) fn serialize<S: Serializer>(
scope: &Option<Vec<String>>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match scope {
Some(values) => serializer.serialize_str(&values.join(" ")),
None => serializer.serialize_none(),
}
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Vec<String>>, D::Error> {
Ok(Option::<String>::deserialize(deserializer)?
.map(|s| s.split_whitespace().map(str::to_owned).collect()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn omitted_fields_are_absent_on_the_wire() {
let metadata = ClientMetadata::builder().client_name("My App").build();
let value = serde_json::to_value(&metadata).unwrap();
let object = value.as_object().unwrap();
assert_eq!(object.get("client_name").unwrap(), "My App");
assert!(!object.contains_key("redirect_uris"));
assert!(!object.contains_key("token_endpoint_auth_method"));
assert!(!object.contains_key("grant_types"));
assert!(!object.contains_key("jwks"));
}
#[test]
fn software_statement_is_secret_on_the_type_but_plaintext_on_the_wire() {
let jwt = "eyJhbGciOiJSUzI1NiJ9.eyJzb2Z0d2FyZV9pZCI6IngifQ.sig";
let metadata = ClientMetadata::builder().software_statement(jwt).build();
let value = serde_json::to_value(&metadata).unwrap();
assert_eq!(value.get("software_statement").unwrap(), jwt);
let debug = format!("{metadata:?}");
assert!(
!debug.contains(jwt),
"software_statement leaked in Debug: {debug}"
);
let parsed: ClientMetadata = serde_json::from_value(value).unwrap();
assert_eq!(parsed.software_statement.unwrap().expose_secret(), jwt);
}
#[test]
fn jwks_is_a_public_key_set_object_on_the_wire_and_round_trips() {
let metadata = ClientMetadata::builder()
.jwks(PublicJwks::new(vec![]))
.build();
let value = serde_json::to_value(&metadata).unwrap();
assert!(value.get("jwks").unwrap().is_object());
assert_eq!(value["jwks"]["keys"], serde_json::json!([]));
let parsed: ClientMetadata =
serde_json::from_value(serde_json::json!({ "jwks": { "keys": [] } })).unwrap();
assert_eq!(parsed.jwks, Some(PublicJwks::new(vec![])));
}
#[test]
fn oidc_dcr_parameters_are_typed_under_their_wire_names_and_round_trip() {
let metadata = ClientMetadata::builder()
.application_type("web")
.request_object_signing_alg("ES256")
.token_endpoint_auth_signing_alg("RS256")
.default_max_age(3600)
.require_auth_time(true)
.default_acr_values(vec!["urn:mace:incommon:iap:silver".to_owned()])
.request_uris(vec!["https://app.example/req/1".to_owned()])
.build();
let value = serde_json::to_value(&metadata).unwrap();
assert_eq!(value["application_type"], "web");
assert_eq!(value["request_object_signing_alg"], "ES256");
assert_eq!(value["token_endpoint_auth_signing_alg"], "RS256");
assert_eq!(value["default_max_age"], 3600);
assert_eq!(value["require_auth_time"], true);
assert_eq!(
value["default_acr_values"],
serde_json::json!(["urn:mace:incommon:iap:silver"])
);
assert_eq!(
value["request_uris"],
serde_json::json!(["https://app.example/req/1"])
);
let parsed: ClientMetadata = serde_json::from_value(value).unwrap();
assert_eq!(parsed.application_type.as_deref(), Some("web"));
assert_eq!(parsed.request_object_signing_alg.as_deref(), Some("ES256"));
assert_eq!(parsed.default_max_age, Some(3600));
assert_eq!(parsed.require_auth_time, Some(true));
assert!(
parsed.extra.is_empty(),
"typed fields must not fall into extra: {:?}",
parsed.extra
);
}
#[test]
fn scope_is_a_space_delimited_string_on_the_wire_and_round_trips() {
let metadata = ClientMetadata::builder()
.scope(bon::vec!["openid", "profile", "email"])
.build();
let value = serde_json::to_value(&metadata).unwrap();
assert_eq!(value["scope"], "openid profile email");
let parsed: ClientMetadata = serde_json::from_value(value).unwrap();
assert_eq!(
parsed.scope,
Some(vec![
"openid".to_owned(),
"profile".to_owned(),
"email".to_owned()
])
);
let empty = ClientMetadata::builder().scope(vec![]).build();
let value = serde_json::to_value(&empty).unwrap();
assert!(!value.as_object().unwrap().contains_key("scope"));
}
#[test]
fn extension_parameters_round_trip_through_extra() {
let json = serde_json::json!({
"client_name": "My App",
"redirect_uris": ["https://app.example/cb"],
"example_extension_parameter": "value",
});
let metadata: ClientMetadata = serde_json::from_value(json).unwrap();
assert_eq!(metadata.client_name.as_deref(), Some("My App"));
assert_eq!(metadata.redirect_uris, vec!["https://app.example/cb"]);
assert_eq!(
metadata.extra.get("example_extension_parameter").unwrap(),
"value"
);
let value = serde_json::to_value(&metadata).unwrap();
assert_eq!(value.get("example_extension_parameter").unwrap(), "value");
}
}