use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ClientMetadata {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redirect_uris: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub application_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_method: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub grant_types: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub response_types: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub contacts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tos_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub software_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub software_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub software_statement: Option<String>,
#[serde(flatten)]
pub additional_fields: HashMap<String, serde_json::Value>,
}
impl ClientMetadata {
pub fn new() -> Self {
Self {
grant_types: vec!["authorization_code".into()],
response_types: vec!["code".into()],
..Self::default()
}
}
pub fn with_redirect_uris<I, S>(mut self, uris: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.redirect_uris = uris.into_iter().map(Into::into).collect();
self
}
pub fn with_application_type(mut self, application_type: impl Into<String>) -> Self {
self.application_type = Some(application_type.into());
self
}
pub fn with_token_endpoint_auth_method(mut self, method: impl Into<String>) -> Self {
self.token_endpoint_auth_method = Some(method.into());
self
}
pub fn with_grant_types<I, S>(mut self, grant_types: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.grant_types = grant_types.into_iter().map(Into::into).collect();
if !self
.grant_types
.iter()
.any(|grant| grant == "authorization_code" || grant == "implicit")
{
self.response_types.clear();
}
self
}
pub fn with_response_types<I, S>(mut self, response_types: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.response_types = response_types.into_iter().map(Into::into).collect();
self
}
pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
self.client_name = Some(name.into());
self
}
pub fn with_client_uri(mut self, uri: impl Into<String>) -> Self {
self.client_uri = Some(uri.into());
self
}
pub fn with_logo_uri(mut self, uri: impl Into<String>) -> Self {
self.logo_uri = Some(uri.into());
self
}
pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let scopes: Vec<String> = scopes.into_iter().map(Into::into).collect();
self.scope = Some(scopes.join(" "));
self
}
pub fn with_contacts<I, S>(mut self, contacts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.contacts = contacts.into_iter().map(Into::into).collect();
self
}
pub fn with_tos_uri(mut self, uri: impl Into<String>) -> Self {
self.tos_uri = Some(uri.into());
self
}
pub fn with_policy_uri(mut self, uri: impl Into<String>) -> Self {
self.policy_uri = Some(uri.into());
self
}
pub fn with_jwks_uri(mut self, uri: impl Into<String>) -> Self {
self.jwks_uri = Some(uri.into());
self
}
pub fn with_jwks(mut self, jwks: impl Into<serde_json::Value>) -> Self {
self.jwks = Some(jwks.into());
self
}
pub fn with_software_id(mut self, id: impl Into<String>) -> Self {
self.software_id = Some(id.into());
self
}
pub fn with_software_version(mut self, version: impl Into<String>) -> Self {
self.software_version = Some(version.into());
self
}
pub fn with_software_statement(mut self, jwt: impl Into<String>) -> Self {
self.software_statement = Some(jwt.into());
self
}
pub fn with_additional_field(
mut self,
name: impl Into<String>,
value: impl Into<serde_json::Value>,
) -> Self {
self.additional_fields.insert(name.into(), value.into());
self
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct ClientRegistrationResponse {
pub client_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id_issued_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret_expires_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registration_access_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registration_client_uri: Option<String>,
#[serde(flatten)]
pub metadata: ClientMetadata,
}
impl std::fmt::Debug for ClientRegistrationResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientRegistrationResponse")
.field("client_id", &self.client_id)
.field(
"client_secret",
&self.client_secret.as_ref().map(|_| "[redacted]"),
)
.field("client_id_issued_at", &self.client_id_issued_at)
.field("client_secret_expires_at", &self.client_secret_expires_at)
.field(
"registration_access_token",
&self
.registration_access_token
.as_ref()
.map(|_| "[redacted]"),
)
.field("registration_client_uri", &self.registration_client_uri)
.field("metadata", &self.metadata)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn it_prefills_the_oauth21_profile() {
let metadata = ClientMetadata::new();
assert_eq!(metadata.grant_types, ["authorization_code"]);
assert_eq!(metadata.response_types, ["code"]);
}
#[test]
fn it_drops_response_types_for_non_redirect_grants() {
let metadata = ClientMetadata::new().with_grant_types(["client_credentials"]);
assert!(metadata.response_types.is_empty());
let json = serde_json::to_value(&metadata).unwrap();
assert_eq!(json, json!({ "grant_types": ["client_credentials"] }));
let metadata =
ClientMetadata::new().with_grant_types(["authorization_code", "refresh_token"]);
assert_eq!(metadata.response_types, ["code"]);
let metadata = ClientMetadata::new()
.with_grant_types(["urn:example:custom"])
.with_response_types(["custom"]);
assert_eq!(metadata.response_types, ["custom"]);
}
#[test]
fn it_serializes_only_populated_fields() {
let metadata = ClientMetadata::new()
.with_redirect_uris(["https://app.example.com/callback"])
.with_client_name("My App")
.with_scopes(["read", "write"]);
let json = serde_json::to_value(&metadata).unwrap();
assert_eq!(
json,
json!({
"redirect_uris": ["https://app.example.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"client_name": "My App",
"scope": "read write"
})
);
}
#[test]
fn it_preserves_extension_and_localized_fields() {
let document = json!({
"redirect_uris": ["https://app.example.com/callback"],
"client_name": "My App",
"client_name#ja-JP": "マイアプリ",
"backchannel_logout_uri": "https://app.example.com/logout"
});
let metadata: ClientMetadata = serde_json::from_value(document.clone()).unwrap();
assert_eq!(
metadata.additional_fields["client_name#ja-JP"],
json!("マイアプリ")
);
assert_eq!(
metadata.additional_fields["backchannel_logout_uri"],
json!("https://app.example.com/logout")
);
assert_eq!(serde_json::to_value(&metadata).unwrap(), document);
}
#[test]
fn it_round_trips_the_application_type() {
let metadata = ClientMetadata::new()
.with_redirect_uris(["http://127.0.0.1:8080/callback"])
.with_application_type("native");
assert_eq!(metadata.application_type.as_deref(), Some("native"));
let json = serde_json::to_value(&metadata).unwrap();
assert_eq!(json["application_type"], json!("native"));
assert!(!metadata.additional_fields.contains_key("application_type"));
let parsed: ClientMetadata = serde_json::from_value(json).unwrap();
assert_eq!(parsed, metadata);
let json = serde_json::to_value(ClientMetadata::new()).unwrap();
assert!(json.get("application_type").is_none());
}
#[test]
fn it_deserializes_a_registration_response() {
let response: ClientRegistrationResponse = serde_json::from_value(json!({
"client_id": "s6BhdRkqt3",
"client_secret": "cf136dc3c1fc93f31185e5885805d",
"client_id_issued_at": 2893256800u64,
"client_secret_expires_at": 0,
"registration_access_token": "this.is.an.access.token",
"registration_client_uri": "https://server.example.com/register/s6BhdRkqt3",
"redirect_uris": ["https://client.example.org/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"client_name": "My Example Client",
"token_endpoint_auth_method": "client_secret_basic"
}))
.unwrap();
assert_eq!(response.client_id, "s6BhdRkqt3");
assert_eq!(response.client_secret_expires_at, Some(0));
assert_eq!(
response.metadata.redirect_uris,
["https://client.example.org/callback"]
);
assert_eq!(
response.metadata.token_endpoint_auth_method.as_deref(),
Some("client_secret_basic")
);
}
#[test]
fn it_requires_a_client_id_in_the_response() {
let result = serde_json::from_value::<ClientRegistrationResponse>(json!({
"client_secret": "secret"
}));
assert!(result.is_err());
}
#[test]
fn it_redacts_credentials_in_debug_output() {
let response: ClientRegistrationResponse = serde_json::from_value(json!({
"client_id": "s6BhdRkqt3",
"client_secret": "s3cret-value",
"registration_access_token": "management-token"
}))
.unwrap();
let debug = format!("{response:?}");
assert!(debug.contains("s6BhdRkqt3"));
assert!(!debug.contains("s3cret-value"));
assert!(!debug.contains("management-token"));
assert!(debug.contains("[redacted]"));
}
}