diff --git a/crates/handlers/src/oauth2/registration.rs b/crates/handlers/src/oauth2/registration.rs index 40622671..c46fab1e 100644 --- a/crates/handlers/src/oauth2/registration.rs +++ b/crates/handlers/src/oauth2/registration.rs @@ -16,13 +16,13 @@ use std::sync::Arc; use axum::{response::IntoResponse, Extension, Json}; use hyper::StatusCode; -use mas_iana::oauth::{OAuthAuthorizationEndpointResponseType, OAuthClientAuthenticationMethod}; use mas_policy::{PolicyFactory, Violation}; use mas_storage::oauth2::client::insert_client; use oauth2_types::{ errors::{INVALID_CLIENT_METADATA, INVALID_REDIRECT_URI, SERVER_ERROR}, - registration::{ClientMetadata, ClientRegistrationResponse}, - requests::GrantType, + registration::{ + ClientMetadata, ClientMetadataVerificationError, ClientRegistrationResponse, Localized, + }, }; use rand::{distributions::Alphanumeric, thread_rng, Rng}; use sqlx::PgPool; @@ -53,6 +53,18 @@ impl From for RouteError { } } +impl From for RouteError { + fn from(e: ClientMetadataVerificationError) -> Self { + match e { + ClientMetadataVerificationError::MissingRedirectUris + | ClientMetadataVerificationError::RedirectUriWithFragment(_) => { + Self::InvalidRedirectUri + } + _ => Self::InvalidClientMetadata, + } + } +} + // TODO: there is probably a better way to do achieve this. ClientError only // works for static strings #[derive(serde::Serialize)] @@ -111,55 +123,18 @@ pub(crate) async fn post( ) -> Result { info!(?body, "Client registration"); - // Let's validate a bunch of things on the client body first - for uri in &body.redirect_uris { - if uri.fragment().is_some() { - return Err(RouteError::InvalidRedirectUri); - } - } - - // Check that the client did not send both a jwks and a jwks_uri - if body.jwks_uri.is_some() && body.jwks.is_some() { - return Err(RouteError::InvalidClientMetadata); - } - - // Check that the grant_types and the response_types are coherent - let has_implicit = body.grant_types.contains(&GrantType::Implicit); - let has_authorization_code = body.grant_types.contains(&GrantType::AuthorizationCode); - let has_both = has_implicit && has_authorization_code; - - for response_type in &body.response_types { - let is_ok = match response_type { - OAuthAuthorizationEndpointResponseType::Code => has_authorization_code, - OAuthAuthorizationEndpointResponseType::CodeIdToken - | OAuthAuthorizationEndpointResponseType::CodeIdTokenToken - | OAuthAuthorizationEndpointResponseType::CodeToken => has_both, - OAuthAuthorizationEndpointResponseType::IdToken - | OAuthAuthorizationEndpointResponseType::IdTokenToken - | OAuthAuthorizationEndpointResponseType::Token => has_implicit, - OAuthAuthorizationEndpointResponseType::None => true, - }; - - if !is_ok { - return Err(RouteError::InvalidClientMetadata); - } - } - - // If the private_key_jwt auth method is used, check that we actually have a - // JWKS for that client - if body.token_endpoint_auth_method == Some(OAuthClientAuthenticationMethod::PrivateKeyJwt) - && body.jwks_uri.is_none() - && body.jwks.is_none() - { - return Err(RouteError::InvalidClientMetadata); - } + // Validate the body + let metadata = body.validate()?; let mut policy = policy_factory.instantiate().await?; - let res = policy.evaluate_client_registration(&body).await?; + let res = policy.evaluate_client_registration(&metadata).await?; if !res.valid() { return Err(RouteError::PolicyDenied(res.violations)); } + // Contacts was checked by the policy + let contacts = metadata.contacts.as_deref().unwrap_or_default(); + // Grab a txn let mut txn = pool.begin().await?; @@ -173,23 +148,26 @@ pub(crate) async fn post( insert_client( &mut txn, &client_id, - &body.redirect_uris, + metadata.redirect_uris(), None, - &body.response_types, - &body.grant_types, - &body.contacts, - body.client_name.as_deref(), - body.logo_uri.as_ref(), - body.client_uri.as_ref(), - body.policy_uri.as_ref(), - body.tos_uri.as_ref(), - body.jwks_uri.as_ref(), - body.jwks.as_ref(), - body.id_token_signed_response_alg, - body.userinfo_signed_response_alg, - body.token_endpoint_auth_method, - body.token_endpoint_auth_signing_alg, - body.initiate_login_uri.as_ref(), + metadata.response_types(), + metadata.grant_types(), + contacts, + metadata + .client_name + .as_ref() + .map(|l| l.non_localized().as_ref()), + metadata.logo_uri.as_ref().map(Localized::non_localized), + metadata.client_uri.as_ref().map(Localized::non_localized), + metadata.policy_uri.as_ref().map(Localized::non_localized), + metadata.tos_uri.as_ref().map(Localized::non_localized), + metadata.jwks_uri.as_ref(), + metadata.jwks.as_ref(), + metadata.id_token_signed_response_alg, + metadata.userinfo_signed_response_alg, + metadata.token_endpoint_auth_method, + metadata.token_endpoint_auth_signing_alg, + metadata.initiate_login_uri.as_ref(), ) .await?; diff --git a/crates/oauth2-types/src/lib.rs b/crates/oauth2-types/src/lib.rs index c6aed87e..b4c1a459 100644 --- a/crates/oauth2-types/src/lib.rs +++ b/crates/oauth2-types/src/lib.rs @@ -15,6 +15,7 @@ #![forbid(unsafe_code)] #![deny(clippy::all, clippy::str_to_string, rustdoc::broken_intra_doc_links)] #![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] use mas_iana::oauth::OAuthAuthorizationEndpointResponseType; diff --git a/crates/oauth2-types/src/oidc.rs b/crates/oauth2-types/src/oidc.rs index bc6179db..30ef3811 100644 --- a/crates/oauth2-types/src/oidc.rs +++ b/crates/oauth2-types/src/oidc.rs @@ -778,7 +778,7 @@ pub enum ProviderMetadataVerificationError { /// The given endpoint is missing auth signing algorithm values, but they /// are required because it supports at least one of the `client_secret_jwt` /// or `private_key_jwt` authentication methods. - #[error("{0} auth signing algorithm values contain `none`")] + #[error("{0} missing auth signing algorithm values")] MissingAuthSigningAlgValues(&'static str), /// `none` is in the given endpoint's signing algorithm values, but is not diff --git a/crates/oauth2-types/src/registration.rs b/crates/oauth2-types/src/registration.rs deleted file mode 100644 index cbb14dd5..00000000 --- a/crates/oauth2-types/src/registration.rs +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright 2022 The Matrix.org Foundation C.I.C. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use chrono::{DateTime, Duration, Utc}; -use mas_iana::{ - jose::{JsonWebEncryptionAlg, JsonWebSignatureAlg}, - oauth::{OAuthAuthorizationEndpointResponseType, OAuthClientAuthenticationMethod}, -}; -use mas_jose::JsonWebKeySet; -use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, skip_serializing_none, DurationSeconds, TimestampSeconds}; -use url::Url; - -use crate::{ - oidc::{ApplicationType, SubjectType}, - requests::GrantType, -}; - -fn default_response_types() -> Vec { - vec![OAuthAuthorizationEndpointResponseType::Code] -} - -fn default_grant_types() -> Vec { - vec![GrantType::AuthorizationCode] -} - -const fn default_application_type() -> ApplicationType { - ApplicationType::Web -} - -#[serde_as] -#[skip_serializing_none] -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct ClientMetadata { - pub redirect_uris: Vec, - - #[serde(default = "default_response_types")] - pub response_types: Vec, - - #[serde(default = "default_grant_types")] - pub grant_types: Vec, - - #[serde(default = "default_application_type")] - pub application_type: ApplicationType, - - #[serde(default)] - pub contacts: Vec, - - #[serde(default)] - pub client_name: Option, - - #[serde(default)] - pub logo_uri: Option, - - #[serde(default)] - pub client_uri: Option, - - #[serde(default)] - pub policy_uri: Option, - - #[serde(default)] - pub tos_uri: Option, - - #[serde(default)] - pub jwks_uri: Option, - - #[serde(default)] - pub jwks: Option, - - #[serde(default)] - pub sector_identifier_uri: Option, - - #[serde(default)] - pub subject_type: Option, - - #[serde(default)] - pub token_endpoint_auth_method: Option, - - #[serde(default)] - pub token_endpoint_auth_signing_alg: Option, - - #[serde(default)] - pub id_token_signed_response_alg: Option, - - #[serde(default)] - pub id_token_encrypted_response_alg: Option, - - #[serde(default)] - pub id_token_encrypted_response_enc: Option, - - #[serde(default)] - pub userinfo_signed_response_alg: Option, - - #[serde(default)] - pub userinfo_encrypted_response_alg: Option, - - #[serde(default)] - pub userinfo_encrypted_response_enc: Option, - - #[serde(default)] - pub request_object_signing_alg: Option, - - #[serde(default)] - pub request_object_encryption_alg: Option, - - #[serde(default)] - pub request_object_encryption_enc: Option, - - #[serde(default)] - #[serde_as(as = "Option>")] - pub default_max_age: Option, - - #[serde(default)] - pub require_auth_time: bool, - - #[serde(default)] - pub default_acr_values: Vec, - - #[serde(default)] - pub initiate_login_uri: Option, - - #[serde(default)] - pub request_uris: Option>, - - #[serde(default)] - pub require_signed_request_object: bool, - - #[serde(default)] - pub require_pushed_authorization_requests: bool, - - #[serde(default)] - pub introspection_signed_response_alg: Option, - - #[serde(default)] - pub introspection_encrypted_response_alg: Option, - - #[serde(default)] - pub introspection_encrypted_response_enc: Option, -} - -#[serde_as] -#[skip_serializing_none] -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct ClientRegistrationResponse { - pub client_id: String, - - #[serde(default)] - pub client_secret: Option, - - #[serde(default)] - #[serde_as(as = "Option>")] - pub client_id_issued_at: Option>, - - #[serde(default)] - #[serde_as(as = "Option>")] - pub client_secret_expires_at: Option>, -} diff --git a/crates/oauth2-types/src/registration/client_metadata_serde.rs b/crates/oauth2-types/src/registration/client_metadata_serde.rs new file mode 100644 index 00000000..f866ba67 --- /dev/null +++ b/crates/oauth2-types/src/registration/client_metadata_serde.rs @@ -0,0 +1,469 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{borrow::Cow, collections::HashMap}; + +use chrono::Duration; +use language_tags::LanguageTag; +use mas_iana::{ + jose::{JsonWebEncryptionAlg, JsonWebEncryptionEnc, JsonWebSignatureAlg}, + oauth::{OAuthAuthorizationEndpointResponseType, OAuthClientAuthenticationMethod}, +}; +use mas_jose::JsonWebKeySet; +use serde::{ + de::{DeserializeOwned, Error}, + ser::SerializeMap, + Deserialize, Serialize, +}; +use serde_json::Value; +use serde_with::{serde_as, skip_serializing_none, DurationSeconds}; +use url::Url; + +use super::{ClientMetadata, Localized, VerifiedClientMetadata}; +use crate::{ + oidc::{ApplicationType, SubjectType}, + requests::GrantType, +}; + +impl Localized { + fn serialize(&self, map: &mut M, field_name: &str) -> Result<(), M::Error> + where + M: SerializeMap, + T: Serialize, + { + map.serialize_entry(field_name, &self.non_localized)?; + + for (lang, localized) in &self.localized { + map.serialize_entry(&format!("{field_name}#{lang}"), localized)?; + } + + Ok(()) + } + + fn deserialize( + map: &mut HashMap, Value>>, + field_name: &'static str, + ) -> Result, serde_json::Error> + where + T: DeserializeOwned, + { + let map = match map.remove(field_name) { + Some(map) => map, + None => return Ok(None), + }; + + let mut non_localized = None; + let mut localized = HashMap::with_capacity(map.len() - 1); + + for (k, v) in map { + let value = serde_json::from_value(v)?; + + if let Some(lang) = k { + localized.insert(lang, value); + } else { + non_localized = Some(value); + } + } + + let non_localized = non_localized.ok_or_else(|| { + serde_json::Error::custom(format!( + "missing non-localized variant of field '{field_name}'" + )) + })?; + + Ok(Some(Localized { + non_localized, + localized, + })) + } +} + +#[serde_as] +#[skip_serializing_none] +#[derive(Serialize, Deserialize)] +pub struct ClientMetadataSerdeHelper { + redirect_uris: Option>, + response_types: Option>, + grant_types: Option>, + application_type: Option, + contacts: Option>, + jwks_uri: Option, + jwks: Option, + sector_identifier_uri: Option, + subject_type: Option, + token_endpoint_auth_method: Option, + token_endpoint_auth_signing_alg: Option, + id_token_signed_response_alg: Option, + id_token_encrypted_response_alg: Option, + id_token_encrypted_response_enc: Option, + userinfo_signed_response_alg: Option, + userinfo_encrypted_response_alg: Option, + userinfo_encrypted_response_enc: Option, + request_object_signing_alg: Option, + request_object_encryption_alg: Option, + request_object_encryption_enc: Option, + #[serde_as(as = "Option>")] + default_max_age: Option, + require_auth_time: Option, + default_acr_values: Option>, + initiate_login_uri: Option, + request_uris: Option>, + require_signed_request_object: Option, + require_pushed_authorization_requests: Option, + introspection_signed_response_alg: Option, + introspection_encrypted_response_alg: Option, + introspection_encrypted_response_enc: Option, + #[serde(flatten)] + extra: ClientMetadataLocalizedFields, +} + +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, + 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, + }, + } = metadata; + + ClientMetadataSerdeHelper { + redirect_uris, + response_types, + grant_types, + application_type, + contacts, + jwks_uri, + jwks, + 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, + extra: ClientMetadataLocalizedFields { + client_name, + logo_uri, + client_uri, + policy_uri, + tos_uri, + }, + } + } +} + +impl From for ClientMetadata { + fn from(metadata: ClientMetadataSerdeHelper) -> Self { + let ClientMetadataSerdeHelper { + redirect_uris, + response_types, + grant_types, + application_type, + contacts, + jwks_uri, + jwks, + 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, + extra: + ClientMetadataLocalizedFields { + client_name, + logo_uri, + client_uri, + policy_uri, + tos_uri, + }, + } = metadata; + + ClientMetadata { + redirect_uris, + response_types, + grant_types, + application_type, + contacts, + client_name, + logo_uri, + client_uri, + policy_uri, + tos_uri, + jwks_uri, + jwks, + 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, + } + } +} + +struct ClientMetadataLocalizedFields { + client_name: Option>, + logo_uri: Option>, + client_uri: Option>, + policy_uri: Option>, + tos_uri: Option>, +} + +impl Serialize for ClientMetadataLocalizedFields { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(None)?; + + if let Some(client_name) = &self.client_name { + client_name.serialize(&mut map, "client_name")?; + } + + if let Some(logo_uri) = &self.logo_uri { + logo_uri.serialize(&mut map, "logo_uri")?; + } + + if let Some(client_uri) = &self.client_uri { + client_uri.serialize(&mut map, "client_uri")?; + } + + if let Some(policy_uri) = &self.policy_uri { + policy_uri.serialize(&mut map, "policy_uri")?; + } + + if let Some(tos_uri) = &self.tos_uri { + tos_uri.serialize(&mut map, "tos_uri")?; + } + + map.end() + } +} + +impl<'de> Deserialize<'de> for ClientMetadataLocalizedFields { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let map = HashMap::, Value>::deserialize(deserializer)?; + let mut new_map: HashMap, Value>> = HashMap::new(); + + for (k, v) in map { + let (prefix, lang) = if let Some((prefix, lang)) = k.split_once('#') { + let lang = LanguageTag::parse(lang).map_err(|_| { + D::Error::invalid_value(serde::de::Unexpected::Str(lang), &"language tag") + })?; + (prefix.to_owned(), Some(lang)) + } else { + (k.into_owned(), None) + }; + + new_map.entry(prefix).or_default().insert(lang, v); + } + + let client_name = + Localized::deserialize(&mut new_map, "client_name").map_err(D::Error::custom)?; + + let logo_uri = + Localized::deserialize(&mut new_map, "logo_uri").map_err(D::Error::custom)?; + + let client_uri = + Localized::deserialize(&mut new_map, "client_uri").map_err(D::Error::custom)?; + + let policy_uri = + Localized::deserialize(&mut new_map, "policy_uri").map_err(D::Error::custom)?; + + let tos_uri = Localized::deserialize(&mut new_map, "tos_uri").map_err(D::Error::custom)?; + + Ok(Self { + client_name, + logo_uri, + client_uri, + policy_uri, + tos_uri, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_localized_fields() { + let metadata = 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", + }); + + let metadata: ClientMetadata = serde_json::from_value(metadata).unwrap(); + + let name = metadata.client_name.unwrap(); + assert_eq!(name.non_localized(), "Postbox"); + assert_eq!( + name.get(Some(&LanguageTag::parse("fr").unwrap())).unwrap(), + "Boîte à lettres" + ); + assert_eq!(name.get(Some(&LanguageTag::parse("de").unwrap())), None); + + let client_uri = metadata.client_uri.unwrap(); + assert_eq!(client_uri.non_localized().as_ref(), "https://localhost/"); + assert_eq!( + client_uri + .get(Some(&LanguageTag::parse("fr").unwrap())) + .unwrap() + .as_ref(), + "https://localhost/fr" + ); + assert_eq!( + client_uri + .get(Some(&LanguageTag::parse("de").unwrap())) + .unwrap() + .as_ref(), + "https://localhost/de" + ); + } + + #[test] + fn serialize_localized_fields() { + let client_name = Localized::new( + "Postbox".to_owned(), + [( + LanguageTag::parse("fr").unwrap(), + "Boîte à lettres".to_owned(), + )], + ); + let client_uri = Localized::new( + Url::parse("https://localhost").unwrap(), + [ + ( + LanguageTag::parse("fr").unwrap(), + Url::parse("https://localhost/fr").unwrap(), + ), + ( + LanguageTag::parse("de").unwrap(), + Url::parse("https://localhost/de").unwrap(), + ), + ], + ); + let metadata = ClientMetadata { + redirect_uris: Some(vec![Url::parse("http://localhost/oidc").unwrap()]), + client_name: Some(client_name), + client_uri: Some(client_uri), + ..Default::default() + } + .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", + }) + ); + } +} diff --git a/crates/oauth2-types/src/registration/mod.rs b/crates/oauth2-types/src/registration/mod.rs new file mode 100644 index 00000000..99075320 --- /dev/null +++ b/crates/oauth2-types/src/registration/mod.rs @@ -0,0 +1,1334 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{collections::HashMap, ops::Deref}; + +use chrono::{DateTime, Duration, Utc}; +use language_tags::LanguageTag; +use mas_iana::{ + jose::{JsonWebEncryptionAlg, JsonWebEncryptionEnc, JsonWebSignatureAlg}, + oauth::{OAuthAuthorizationEndpointResponseType, OAuthClientAuthenticationMethod}, +}; +use mas_jose::JsonWebKeySet; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, skip_serializing_none, TimestampSeconds}; +use thiserror::Error; +use url::Url; + +use crate::{ + oidc::{ApplicationType, SubjectType}, + requests::GrantType, +}; + +mod client_metadata_serde; +use client_metadata_serde::ClientMetadataSerdeHelper; + +pub const DEFAULT_RESPONSE_TYPES: &[OAuthAuthorizationEndpointResponseType] = + &[OAuthAuthorizationEndpointResponseType::Code]; + +pub const DEFAULT_GRANT_TYPES: &[GrantType] = &[GrantType::AuthorizationCode]; + +pub const DEFAULT_APPLICATION_TYPE: ApplicationType = ApplicationType::Web; + +pub const DEFAULT_TOKEN_AUTH_METHOD: OAuthClientAuthenticationMethod = + OAuthClientAuthenticationMethod::ClientSecretBasic; + +pub const DEFAULT_SIGNING_ALGORITHM: JsonWebSignatureAlg = JsonWebSignatureAlg::Rs256; + +pub const DEFAULT_ENCRYPTION_ENC_ALGORITHM: JsonWebEncryptionEnc = + JsonWebEncryptionEnc::A128CbcHs256; + +/// A collection of localized variants. +/// +/// Always includes one non-localized variant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Localized { + non_localized: T, + localized: HashMap, +} + +impl Localized { + /// Constructs a new `Localized` with the given non-localized and localized + /// variants. + pub fn new(non_localized: T, localized: impl IntoIterator) -> Self { + Self { + non_localized, + localized: localized.into_iter().collect(), + } + } + + /// Returns the number of variants. + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + self.localized.len() + 1 + } + + /// Get the non-localized variant. + pub fn non_localized(&self) -> &T { + &self.non_localized + } + + /// Get the variant corresponding to the given language, if it exists. + pub fn get(&self, language: Option<&LanguageTag>) -> Option<&T> { + match language { + Some(lang) => self.localized.get(lang), + None => Some(&self.non_localized), + } + } + + /// Get an iterator over the variants. + pub fn iter(&self) -> impl Iterator, &T)> { + Some(&self.non_localized) + .into_iter() + .map(|val| (None, val)) + .chain(self.localized.iter().map(|(lang, val)| (Some(lang), val))) + } +} + +impl From<(T, HashMap)> for Localized { + fn from(t: (T, HashMap)) -> Self { + Localized { + non_localized: t.0, + localized: t.1, + } + } +} + +/// Client metadata, as described by the [IANA registry]. +/// +/// 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")] +pub struct ClientMetadata { + /// Array of redirection URIs for use in redirect-based flows such as the + /// [authorization code flow]. + /// + /// All the URIs used by the client in an authorization request's + /// `redirect_uri` field must appear in this list. + /// + /// This field is required and the URIs must not contain a fragment. + /// + /// [authorization code flow]: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth + pub redirect_uris: Option>, + + /// Array of the [OAuth 2.0 `response_type` values] that the client can use + /// at the [authorization endpoint]. + /// + /// All the types used by the client in an authorization request's + /// `response_type` field must appear in this list. + /// + /// Defaults to [`DEFAULT_RESPONSE_TYPES`]. + /// + /// [OAuth 2.0 `response_type` values]: https://www.rfc-editor.org/rfc/rfc7591#page-9 + /// [authorization endpoint]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.1 + pub response_types: Option>, + + /// Array of [OAuth 2.0 `grant_type` values] that the client can use at the + /// [token endpoint]. + /// + /// The possible grant types depend on the response types. Declaring support + /// for a grant type that is not compatible with the supported response + /// types will trigger an error during validation. + /// + /// All the types used by the client in a token request's `grant_type` field + /// must appear in this list. + /// + /// Defaults to [`DEFAULT_GRANT_TYPES`]. + /// + /// [OAuth 2.0 `grant_type` values]: https://www.rfc-editor.org/rfc/rfc7591#page-9 + /// [token endpoint]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.2 + pub grant_types: Option>, + + /// The kind of the application. + /// + /// Defaults to [`DEFAULT_APPLICATION_TYPE`]. + pub application_type: Option, + + /// Array of e-mail addresses of people responsible for this client. + pub contacts: Option>, + + /// Name of the client to be presented to the end-user during authorization. + pub client_name: Option>, + + /// URL that references a logo for the client application. + pub logo_uri: Option>, + + /// URL of the home page of the client. + pub client_uri: Option>, + + /// URL that the client provides to the end-user to read about the how the + /// profile data will be used. + pub policy_uri: Option>, + + /// URL that the client provides to the end-user to read about the client's + /// terms of service. + pub tos_uri: Option>, + + /// URL for the client's [JWK] Set document. + /// + /// If the client signs requests to the server, it contains the signing + /// key(s) the server uses to validate signatures from the client. The JWK + /// Set may also contain the client's encryption keys(s), which are used by + /// the server to encrypt responses to the client. + /// + /// This field is mutually exclusive with `jwks`. + /// + /// [JWK]: https://www.rfc-editor.org/rfc/rfc7517.html + pub jwks_uri: Option, + + /// Client's [JWK] Set document, passed by value. + /// + /// The semantics of this field are the same as `jwks_uri`, other than that + /// the JWK Set is passed by value, rather than by reference. + /// + /// This field is mutually exclusive with `jwks_uri`. + /// + /// [JWK]: https://www.rfc-editor.org/rfc/rfc7517.html + pub jwks: Option, + + /// URL to be used in calculating pseudonymous identifiers by the OpenID + /// Connect provider when [pairwise subject identifiers] are used. + /// + /// If present, this must use the `https` scheme. + /// + /// [pairwise subject identifiers]: https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg + pub sector_identifier_uri: Option, + + /// Subject type requested for responses to this client. + /// + /// This field must match one of the supported types by the provider. + pub subject_type: Option, + + /// Requested client authentication method for the [token endpoint]. + /// + /// If this is set to [`OAuthClientAuthenticationMethod::PrivateKeyJwt`], + /// one of the `jwks_uri` or `jwks` fields is required. + /// + /// Defaults to [`DEFAULT_TOKEN_AUTH_METHOD`]. + /// + /// [token endpoint]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.2 + pub token_endpoint_auth_method: Option, + + /// [JWS] `alg` algorithm that must be used for signing the [JWT] used to + /// authenticate the client at the token endpoint. + /// + /// If this field is present, it must not be + /// [`JsonWebSignatureAlg::None`]. This field is required if + /// `token_endpoint_auth_method` is one of + /// [`OAuthClientAuthenticationMethod::PrivateKeyJwt`] or + /// [`OAuthClientAuthenticationMethod::ClientSecretJwt`]. + /// + /// [JWS]: http://tools.ietf.org/html/draft-ietf-jose-json-web-signature + /// [JWT]: http://tools.ietf.org/html/draft-ietf-oauth-json-web-token + pub token_endpoint_auth_signing_alg: Option, + + /// [JWS] `alg` algorithm required for signing the ID Token issued to this + /// client. + /// + /// If this field is present, it must not be + /// [`JsonWebSignatureAlg::None`], unless the client uses only response + /// types that return no ID Token from the authorization endpoint. + /// + /// Defaults to [`DEFAULT_SIGNING_ALGORITHM`]. + /// + /// [JWS]: http://tools.ietf.org/html/draft-ietf-jose-json-web-signature + pub id_token_signed_response_alg: Option, + + /// [JWE] `alg` algorithm required for encrypting the ID Token issued to + /// this client. + /// + /// This field is required if `id_token_encrypted_response_enc` is provided. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + pub id_token_encrypted_response_alg: Option, + + /// [JWE] `enc` algorithm required for encrypting the ID Token issued to + /// this client. + /// + /// Defaults to [`DEFAULT_ENCRYPTION_ENC_ALGORITHM`] if + /// `id_token_encrypted_response_alg` is provided. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + pub id_token_encrypted_response_enc: Option, + + /// [JWS] `alg` algorithm required for signing user info responses. + /// + /// [JWS]: http://tools.ietf.org/html/draft-ietf-jose-json-web-signature + pub userinfo_signed_response_alg: Option, + + /// [JWE] `alg` algorithm required for encrypting user info responses. + /// + /// If `userinfo_signed_response_alg` is not provided, this field has no + /// effect. + /// + /// This field is required if `userinfo_encrypted_response_enc` is provided. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + pub userinfo_encrypted_response_alg: Option, + + /// [JWE] `enc` algorithm required for encrypting user info responses. + /// + /// If `userinfo_signed_response_alg` is not provided, this field has no + /// effect. + /// + /// Defaults to [`DEFAULT_ENCRYPTION_ENC_ALGORITHM`] if + /// `userinfo_encrypted_response_alg` is provided. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + pub userinfo_encrypted_response_enc: Option, + + /// [JWS] `alg` algorithm that must be used for signing Request Objects sent + /// to the provider. + /// + /// Defaults to any algorithm supported by the client and the provider. + /// + /// [JWS]: http://tools.ietf.org/html/draft-ietf-jose-json-web-signature + pub request_object_signing_alg: Option, + + /// [JWE] `alg` algorithm the client is declaring that it may use for + /// encrypting Request Objects sent to the provider. + /// + /// This field is required if `request_object_encryption_enc` is provided. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + pub request_object_encryption_alg: Option, + + /// [JWE] `enc` algorithm the client is declaring that it may use for + /// encrypting Request Objects sent to the provider. + /// + /// Defaults to [`DEFAULT_ENCRYPTION_ENC_ALGORITHM`] if + /// `request_object_encryption_alg` is provided. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + pub request_object_encryption_enc: Option, + + /// Default maximum authentication age. + /// + /// Specifies that the End-User must be actively authenticated if the + /// end-user was authenticated longer ago than the specified number of + /// seconds. + /// + /// The `max_age` request parameter overrides this default value. + pub default_max_age: Option, + + /// Whether the `auth_time` Claim in the ID Token is required. + /// + /// Defaults to `false`. + pub require_auth_time: Option, + + /// Default requested Authentication Context Class Reference values. + pub default_acr_values: Option>, + + /// URI that a third party can use to [initiate a login by the client]. + /// + /// If present, this must use the `https` scheme. + /// + /// [initiate a login by the client]: https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin + pub initiate_login_uri: Option, + + /// `request_uri` values that are pre-registered by the client for use at + /// the provider. + /// + /// Providers can require that `request_uri` values used be pre-registered + /// with the `require_request_uri_registration` discovery parameter. + /// + /// Servers MAY cache the contents of the files referenced by these URIs and + /// not retrieve them at the time they are used in a request. If the + /// contents of the request file could ever change, these URI values should + /// include the base64url encoded SHA-256 hash value of the file contents + /// referenced by the URI as the value of the URI fragment. If the fragment + /// value used for a URI changes, that signals the server that its cached + /// value for that URI with the old fragment value is no longer valid. + pub request_uris: Option>, + + /// Whether the client will only send authorization requests as [Request + /// Objects]. + /// + /// Defaults to `false`. + /// + /// [Request Object]: https://www.rfc-editor.org/rfc/rfc9101.html + pub require_signed_request_object: Option, + + /// Whether the client will only send authorization requests via the [pushed + /// authorization request endpoint]. + /// + /// Defaults to `false`. + /// + /// [pushed authorization request endpoint]: https://www.rfc-editor.org/rfc/rfc9126.html + pub require_pushed_authorization_requests: Option, + + /// [JWS] `alg` algorithm for signing responses of the [introspection + /// endpoint]. + /// + /// [JWS]: http://tools.ietf.org/html/draft-ietf-jose-json-web-signature + /// [introspection endpoint]: https://www.rfc-editor.org/info/rfc7662 + pub introspection_signed_response_alg: Option, + + /// [JWE] `alg` algorithm for encrypting responses of the [introspection + /// endpoint]. + /// + /// If `introspection_signed_response_alg` is not provided, this field has + /// no effect. + /// + /// This field is required if `introspection_encrypted_response_enc` is + /// provided. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + /// [introspection endpoint]: https://www.rfc-editor.org/info/rfc7662 + pub introspection_encrypted_response_alg: Option, + + /// [JWE] `enc` algorithm for encrypting responses of the [introspection + /// endpoint]. + /// + /// If `introspection_signed_response_alg` is not provided, this field has + /// no effect. + /// + /// Defaults to [`DEFAULT_ENCRYPTION_ENC_ALGORITHM`] if + /// `introspection_encrypted_response_alg` is provided. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + /// [introspection endpoint]: https://www.rfc-editor.org/info/rfc7662 + pub introspection_encrypted_response_enc: Option, +} + +impl ClientMetadata { + /// Validate this `ClientMetadata` according to the [OpenID Connect Dynamic + /// Client Registration Spec 1.0]. + /// + /// # Errors + /// + /// Will return `Err` if validation fails. + /// + /// [OpenID Connect Dynamic Client Registration Spec 1.0]: https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata + #[allow(clippy::too_many_lines)] + pub fn validate(self) -> Result { + if let Some(uris) = &self.redirect_uris { + if let Some(uri) = uris.iter().find(|uri| uri.fragment().is_some()) { + return Err(ClientMetadataVerificationError::RedirectUriWithFragment( + uri.clone(), + )); + } + } else { + return Err(ClientMetadataVerificationError::MissingRedirectUris); + } + + let response_types = self.response_types(); + let grant_types = self.grant_types(); + let has_implicit = grant_types.contains(&GrantType::Implicit); + let has_authorization_code = grant_types.contains(&GrantType::AuthorizationCode); + let has_both = has_implicit && has_authorization_code; + + for response_type in response_types { + let is_ok = match response_type { + OAuthAuthorizationEndpointResponseType::Code => has_authorization_code, + OAuthAuthorizationEndpointResponseType::CodeIdToken + | OAuthAuthorizationEndpointResponseType::CodeIdTokenToken + | OAuthAuthorizationEndpointResponseType::CodeToken => has_both, + OAuthAuthorizationEndpointResponseType::IdToken + | OAuthAuthorizationEndpointResponseType::IdTokenToken + | OAuthAuthorizationEndpointResponseType::Token => has_implicit, + OAuthAuthorizationEndpointResponseType::None => true, + }; + + if !is_ok { + return Err(ClientMetadataVerificationError::IncoherentResponseType( + *response_type, + )); + } + } + + if self.jwks_uri.is_some() && self.jwks.is_some() { + return Err(ClientMetadataVerificationError::JwksUriAndJwksMutuallyExclusive); + } + + if let Some(url) = self + .sector_identifier_uri + .as_ref() + .filter(|url| url.scheme() != "https") + { + return Err(ClientMetadataVerificationError::UrlNonHttpsScheme( + "sector_identifier_uri", + url.clone(), + )); + } + + if self.token_endpoint_auth_method() == OAuthClientAuthenticationMethod::PrivateKeyJwt + && self.jwks_uri.is_none() + && self.jwks.is_none() + { + return Err(ClientMetadataVerificationError::MissingJwksForTokenMethod); + } + + if let Some(alg) = &self.token_endpoint_auth_signing_alg { + if *alg == JsonWebSignatureAlg::None { + return Err(ClientMetadataVerificationError::UnauthorizedSigningAlgNone( + "token_endpoint", + )); + } + } else if matches!( + self.token_endpoint_auth_method(), + OAuthClientAuthenticationMethod::PrivateKeyJwt + | OAuthClientAuthenticationMethod::ClientSecretJwt + ) { + return Err(ClientMetadataVerificationError::MissingAuthSigningAlg( + "token_endpoint", + )); + } + + if self.id_token_signed_response_alg() == JsonWebSignatureAlg::None + && (response_types.contains(&OAuthAuthorizationEndpointResponseType::CodeIdToken) + || response_types + .contains(&OAuthAuthorizationEndpointResponseType::CodeIdTokenToken) + || response_types.contains(&OAuthAuthorizationEndpointResponseType::IdToken) + || response_types.contains(&OAuthAuthorizationEndpointResponseType::IdTokenToken)) + { + return Err(ClientMetadataVerificationError::IdTokenSigningAlgNone); + } + + if self.id_token_encrypted_response_enc.is_some() { + self.id_token_encrypted_response_alg.ok_or( + ClientMetadataVerificationError::MissingEncryptionAlg("id_token"), + )?; + } + + if self.userinfo_encrypted_response_enc.is_some() { + self.userinfo_encrypted_response_alg.ok_or( + ClientMetadataVerificationError::MissingEncryptionAlg("userinfo"), + )?; + } + + if self.request_object_encryption_enc.is_some() { + self.request_object_encryption_alg.ok_or( + ClientMetadataVerificationError::MissingEncryptionAlg("request_object"), + )?; + } + + if let Some(url) = self + .initiate_login_uri + .as_ref() + .filter(|url| url.scheme() != "https") + { + return Err(ClientMetadataVerificationError::UrlNonHttpsScheme( + "initiate_login_uri", + url.clone(), + )); + } + + if self.introspection_encrypted_response_enc.is_some() { + self.introspection_encrypted_response_alg.ok_or( + ClientMetadataVerificationError::MissingEncryptionAlg("introspection"), + )?; + } + + Ok(VerifiedClientMetadata { inner: self }) + } + + /// Array of the [OAuth 2.0 `response_type` values] that the client can use + /// at the [authorization endpoint]. + /// + /// All the types used by the client in an authorization request's + /// `response_type` field must appear in this list. + /// + /// Defaults to [`DEFAULT_RESPONSE_TYPES`]. + /// + /// [OAuth 2.0 `response_type` values]: https://www.rfc-editor.org/rfc/rfc7591#page-9 + /// [authorization endpoint]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.1 + #[must_use] + pub fn response_types(&self) -> &[OAuthAuthorizationEndpointResponseType] { + self.response_types + .as_deref() + .unwrap_or(DEFAULT_RESPONSE_TYPES) + } + + /// Array of [OAuth 2.0 `grant_type` values] that the client can use at the + /// [token endpoint]. + /// + /// Note that the possible grant types depend on the response types. + /// + /// All the types used by the client in a token request's `grant_type` field + /// must appear in this list. + /// + /// Defaults to [`DEFAULT_GRANT_TYPES`]. + /// + /// [OAuth 2.0 `grant_type` values]: https://www.rfc-editor.org/rfc/rfc7591#page-9 + /// [token endpoint]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.2 + #[must_use] + pub fn grant_types(&self) -> &[GrantType] { + self.grant_types.as_deref().unwrap_or(DEFAULT_GRANT_TYPES) + } + + /// The kind of the application. + /// + /// Defaults to [`DEFAULT_APPLICATION_TYPE`]. + #[must_use] + pub fn application_type(&self) -> ApplicationType { + self.application_type.unwrap_or(DEFAULT_APPLICATION_TYPE) + } + + /// Requested client authentication method for the [token endpoint]. + /// + /// Defaults to [`DEFAULT_TOKEN_AUTH_METHOD`]. + /// + /// [token endpoint]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.2 + #[must_use] + pub fn token_endpoint_auth_method(&self) -> OAuthClientAuthenticationMethod { + self.token_endpoint_auth_method + .unwrap_or(DEFAULT_TOKEN_AUTH_METHOD) + } + + /// [JWS] `alg` algorithm required for signing the ID Token issued to this + /// client. + /// + /// If this field is present, it must not be + /// [`JsonWebSignatureAlg::None`], unless the client uses only response + /// types that return no ID Token from the authorization endpoint. + /// + /// Defaults to [`DEFAULT_SIGNING_ALGORITHM`]. + /// + /// [JWS]: http://tools.ietf.org/html/draft-ietf-jose-json-web-signature + #[must_use] + pub fn id_token_signed_response_alg(&self) -> JsonWebSignatureAlg { + self.id_token_signed_response_alg + .unwrap_or(DEFAULT_SIGNING_ALGORITHM) + } + + /// [JWE] `alg` and `enc` algorithms required for encrypting the ID Token + /// issued to this client. + /// + /// Always returns `Some` if `id_token_encrypted_response_alg` is provided, + /// using the default of [`DEFAULT_ENCRYPTION_ENC_ALGORITHM`] for the `enc` + /// value if needed. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + #[must_use] + pub fn id_token_encrypted_response( + &self, + ) -> Option<(JsonWebEncryptionAlg, JsonWebEncryptionEnc)> { + self.id_token_encrypted_response_alg.map(|alg| { + ( + alg, + self.id_token_encrypted_response_enc + .unwrap_or(DEFAULT_ENCRYPTION_ENC_ALGORITHM), + ) + }) + } + + /// [JWE] `alg` and `enc` algorithms required for encrypting user info + /// responses. + /// + /// Always returns `Some` if `userinfo_encrypted_response_alg` is provided, + /// using the default of [`DEFAULT_ENCRYPTION_ENC_ALGORITHM`] for the `enc` + /// value if needed. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + #[must_use] + pub fn userinfo_encrypted_response( + &self, + ) -> Option<(JsonWebEncryptionAlg, JsonWebEncryptionEnc)> { + self.userinfo_encrypted_response_alg.map(|alg| { + ( + alg, + self.userinfo_encrypted_response_enc + .unwrap_or(DEFAULT_ENCRYPTION_ENC_ALGORITHM), + ) + }) + } + + /// [JWE] `alg` and `enc` algorithms the client is declaring that it may use + /// for encrypting Request Objects sent to the provider. + /// + /// Always returns `Some` if `request_object_encryption_alg` is provided, + /// using the default of [`DEFAULT_ENCRYPTION_ENC_ALGORITHM`] for the `enc` + /// value if needed. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + #[must_use] + pub fn request_object_encryption( + &self, + ) -> Option<(JsonWebEncryptionAlg, JsonWebEncryptionEnc)> { + self.request_object_encryption_alg.map(|alg| { + ( + alg, + self.request_object_encryption_enc + .unwrap_or(DEFAULT_ENCRYPTION_ENC_ALGORITHM), + ) + }) + } + + /// Whether the `auth_time` Claim in the ID Token is required. + /// + /// Defaults to `false`. + #[must_use] + pub fn require_auth_time(&self) -> bool { + self.require_auth_time.unwrap_or_default() + } + + /// Whether the client will only send authorization requests as [Request + /// Objects]. + /// + /// Defaults to `false`. + /// + /// [Request Object]: https://www.rfc-editor.org/rfc/rfc9101.html + #[must_use] + pub fn require_signed_request_object(&self) -> bool { + self.require_signed_request_object.unwrap_or_default() + } + + /// Whether the client will only send authorization requests via the [pushed + /// authorization request endpoint]. + /// + /// Defaults to `false`. + /// + /// [pushed authorization request endpoint]: https://www.rfc-editor.org/rfc/rfc9126.html + #[must_use] + pub fn require_pushed_authorization_requests(&self) -> bool { + self.require_pushed_authorization_requests + .unwrap_or_default() + } + + /// [JWE] `alg` and `enc` algorithms for encrypting responses of the + /// [introspection endpoint]. + /// + /// Always returns `Some` if `introspection_encrypted_response_alg` is + /// provided, using the default of [`DEFAULT_ENCRYPTION_ENC_ALGORITHM`] for + /// the `enc` value if needed. + /// + /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption + /// [introspection endpoint]: https://www.rfc-editor.org/info/rfc7662 + #[must_use] + pub fn introspection_encrypted_response( + &self, + ) -> Option<(JsonWebEncryptionAlg, JsonWebEncryptionEnc)> { + self.introspection_encrypted_response_alg.map(|alg| { + ( + alg, + self.introspection_encrypted_response_enc + .unwrap_or(DEFAULT_ENCRYPTION_ENC_ALGORITHM), + ) + }) + } +} + +/// The verified client metadata. +/// +/// All the fields required by the [OpenID Connect Dynamic Client Registration +/// Spec 1.0] or with a default value are accessible via methods. +/// +/// To access other fields, use this type's `Deref` implementation. +/// +/// # Example +/// +/// ```no_run +/// use oauth2_types::{ +/// oidc::ApplicationType, +/// registration::VerifiedClientMetadata, +/// requests::GrantType, +/// }; +/// use url::Url; +/// # use oauth2_types::registration::{ClientMetadata, ClientMetadataVerificationError}; +/// # let metadata = ClientMetadata::default(); +/// # let issuer = Url::parse("http://localhost").unwrap(); +/// let verified_metadata = metadata.validate()?; +/// +/// // The redirect URIs are required during validation so this is not an `Option`. +/// let _: &[Url] = verified_metadata.redirect_uris(); +/// +/// // The field has a default value so this is not an `Option`. +/// let _: ApplicationType = verified_metadata.application_type(); +/// +/// // Other fields can be accessed via `Deref`. +/// if let Some(jwks_uri) = &verified_metadata.jwks_uri { +/// println!("Client's JWK Set is available at {jwks_uri}"); +/// } +/// # Ok::<(), ClientMetadataVerificationError>(()) +/// ``` +/// +/// [OpenID Connect Dynamic Client Registration Spec 1.0]: https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata +#[derive(Serialize, Debug, PartialEq, Eq, Clone)] +#[serde(into = "ClientMetadataSerdeHelper")] +pub struct VerifiedClientMetadata { + inner: ClientMetadata, +} + +impl VerifiedClientMetadata { + /// Array of redirection URIs for use in redirect-based flows such as the + /// [authorization code flow]. + /// + /// All the URIs used by the client in an authorization request's + /// `redirect_uri` field must appear in this list. + /// + /// [authorization code flow]: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth + #[must_use] + pub fn redirect_uris(&self) -> &[Url] { + match &self.redirect_uris { + Some(v) => v, + None => unreachable!(), + } + } +} + +impl Deref for VerifiedClientMetadata { + type Target = ClientMetadata; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// All errors that can happen when verifying [`ClientMetadata`]. +#[derive(Debug, Error)] +pub enum ClientMetadataVerificationError { + /// The redirect URIs are missing. + #[error("redirect URIs are missing")] + MissingRedirectUris, + + /// The redirect URI has a fragment, which is not allowed. + #[error("redirect URI with fragment: {0}")] + RedirectUriWithFragment(Url), + + /// The given response type is not compatible with the grant types. + #[error("'{0}' response type not compatible with grant types")] + IncoherentResponseType(OAuthAuthorizationEndpointResponseType), + + /// Both the `jwks_uri` and `jwks` fields are present but only one is + /// allowed. + #[error("jwks_uri and jwks are mutually exclusive")] + JwksUriAndJwksMutuallyExclusive, + + /// The URL of the given field doesn't use a `https` scheme. + #[error("{0}'s URL doesn't use a https scheme: {1}")] + UrlNonHttpsScheme(&'static str, Url), + + /// No JWK Set was provided but one is required for the token auth method. + #[error("missing JWK Set for token auth method")] + MissingJwksForTokenMethod, + + /// The given endpoint doesn't allow `none` as a signing algorithm. + #[error("none signing alg unauthorized for {0}")] + UnauthorizedSigningAlgNone(&'static str), + + /// The given endpoint is missing an auth signing algorithm, but it is + /// required because it uses one of the `client_secret_jwt` or + /// `private_key_jwt` authentication methods. + #[error("{0} missing auth signing algorithm")] + MissingAuthSigningAlg(&'static str), + + /// `none` is used as the signing algorithm for ID Tokens, but is not + /// allowed. + #[error("ID Token signing alg is none")] + IdTokenSigningAlgNone, + + /// The given encryption field has an `enc` value but not `alg` value. + #[error("{0} missing encryption alg value")] + MissingEncryptionAlg(&'static str), +} + +#[serde_as] +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct ClientRegistrationResponse { + pub client_id: String, + + #[serde(default)] + pub client_secret: Option, + + #[serde(default)] + #[serde_as(as = "Option>")] + pub client_id_issued_at: Option>, + + #[serde(default)] + #[serde_as(as = "Option>")] + pub client_secret_expires_at: Option>, +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use mas_iana::{ + jose::{JsonWebEncryptionAlg, JsonWebEncryptionEnc, JsonWebSignatureAlg}, + oauth::{OAuthAuthorizationEndpointResponseType, OAuthClientAuthenticationMethod}, + }; + use mas_jose::JsonWebKeySet; + use url::Url; + + use super::{ClientMetadata, ClientMetadataVerificationError}; + use crate::requests::GrantType; + + fn valid_client_metadata() -> ClientMetadata { + ClientMetadata { + redirect_uris: Some(vec![Url::parse("http://localhost/oidc").unwrap()]), + ..Default::default() + } + } + + fn jwks() -> JsonWebKeySet { + serde_json::from_value(serde_json::json!({ + "keys": [ + { + "alg": "RS256", + "kty": "RSA", + "n": "tCwhHOxX_ylh5kVwfVqW7QIBTIsPjkjCjVCppDrynuF_3msEdtEaG64eJUz84ODFNMCC0BQ57G7wrKQVWkdSDxWUEqGk2BixBiHJRWZdofz1WOBTdPVicvHW5Zl_aIt7uXWMdOp_SODw-O2y2f05EqbFWFnR2-1y9K8KbiOp82CD72ny1Jbb_3PxTs2Z0F4ECAtTzpDteaJtjeeueRjr7040JAjQ-5fpL5D1g8x14LJyVIo-FL_y94NPFbMp7UCi69CIfVHXFO8WYFz949og-47mWRrID5lS4zpx-QLuvNhUb_lSqmylUdQB3HpRdOcYdj3xwy4MHJuu7tTaf0AmCQ", + "use": "sig", + "kid": "d98f49bc6ca4581eae8dfadd494fce10ea23aab0", + "e": "AQAB" + } + ] + })).unwrap() + } + + #[test] + fn validate_required_metadata() { + let metadata = valid_client_metadata(); + metadata.validate().unwrap(); + } + + #[test] + fn validate_redirect_uris() { + let mut metadata = ClientMetadata::default(); + + // Err - Missing + assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::MissingRedirectUris) + ); + + // Err - Fragment + let wrong_uri = Url::parse("http://localhost/#fragment").unwrap(); + metadata.redirect_uris = Some(vec![ + Url::parse("http://localhost/").unwrap(), + wrong_uri.clone(), + ]); + let uri = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::RedirectUriWithFragment(uri)) => uri + ); + assert_eq!(uri, wrong_uri); + + // Ok - Path & Query + metadata.redirect_uris = Some(vec![ + Url::parse("http://localhost/").unwrap(), + Url::parse("http://localhost/oidc").unwrap(), + Url::parse("http://localhost/?oidc").unwrap(), + Url::parse("http://localhost/my-client?oidc").unwrap(), + ]); + metadata.validate().unwrap(); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn validate_response_types() { + let mut metadata = valid_client_metadata(); + + // grant_type = authorization_code + // code - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::Code]); + metadata.clone().validate().unwrap(); + + // code id_token - Err + let response_type = OAuthAuthorizationEndpointResponseType::CodeIdToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // code id_token token - Err + let response_type = OAuthAuthorizationEndpointResponseType::CodeIdTokenToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // code token - Err + let response_type = OAuthAuthorizationEndpointResponseType::CodeToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // id_token - Err + let response_type = OAuthAuthorizationEndpointResponseType::IdToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // id_token token - Err + let response_type = OAuthAuthorizationEndpointResponseType::IdTokenToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // token - Err + let response_type = OAuthAuthorizationEndpointResponseType::IdTokenToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // none - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::None]); + metadata.clone().validate().unwrap(); + + // grant_type = implicit + metadata.grant_types = Some(vec![GrantType::Implicit]); + // code - Err + let response_type = OAuthAuthorizationEndpointResponseType::Code; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // code id_token - Err + let response_type = OAuthAuthorizationEndpointResponseType::CodeIdToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // code id_token token - Err + let response_type = OAuthAuthorizationEndpointResponseType::CodeIdTokenToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // code token - Err + let response_type = OAuthAuthorizationEndpointResponseType::CodeToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // id_token - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::IdToken]); + metadata.clone().validate().unwrap(); + + // id_token token - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::IdTokenToken]); + metadata.clone().validate().unwrap(); + + // token - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::Token]); + metadata.clone().validate().unwrap(); + + // none - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::None]); + metadata.clone().validate().unwrap(); + + // grant_types = [authorization_code, implicit] + metadata.grant_types = Some(vec![GrantType::AuthorizationCode, GrantType::Implicit]); + // code - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::Code]); + metadata.clone().validate().unwrap(); + + // code id_token - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::CodeIdToken]); + metadata.clone().validate().unwrap(); + + // code id_token token - Ok + metadata.response_types = Some(vec![ + OAuthAuthorizationEndpointResponseType::CodeIdTokenToken, + ]); + metadata.clone().validate().unwrap(); + + // code token - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::CodeToken]); + metadata.clone().validate().unwrap(); + + // id_token - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::IdToken]); + metadata.clone().validate().unwrap(); + + // id_token token - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::IdTokenToken]); + metadata.clone().validate().unwrap(); + + // token - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::Token]); + metadata.clone().validate().unwrap(); + + // none - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::None]); + metadata.clone().validate().unwrap(); + + // other grant_types + metadata.grant_types = Some(vec![GrantType::RefreshToken, GrantType::ClientCredentials]); + // code - Err + let response_type = OAuthAuthorizationEndpointResponseType::Code; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // code id_token - Err + let response_type = OAuthAuthorizationEndpointResponseType::CodeIdToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // code id_token token - Err + let response_type = OAuthAuthorizationEndpointResponseType::CodeIdTokenToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // code token - Err + let response_type = OAuthAuthorizationEndpointResponseType::CodeToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // id_token - Err + let response_type = OAuthAuthorizationEndpointResponseType::IdToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // id_token token - Err + let response_type = OAuthAuthorizationEndpointResponseType::IdTokenToken; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // token - Err + let response_type = OAuthAuthorizationEndpointResponseType::Token; + metadata.response_types = Some(vec![response_type]); + let res = assert_matches!(metadata.clone().validate(), Err(ClientMetadataVerificationError::IncoherentResponseType(res)) => res); + assert_eq!(res, response_type); + + // none - Ok + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::None]); + metadata.validate().unwrap(); + } + + #[test] + fn validate_jwks() { + let mut metadata = valid_client_metadata(); + + // Ok - jwks_uri is set + metadata.jwks_uri = Some(Url::parse("http://localhost/jwks").unwrap()); + metadata.clone().validate().unwrap(); + + // Err - Both are set + metadata.jwks = Some(jwks()); + assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::JwksUriAndJwksMutuallyExclusive) + ); + + // Ok - jwks is set + metadata.jwks_uri = None; + metadata.validate().unwrap(); + } + + #[test] + fn validate_sector_identifier_uri() { + let mut metadata = valid_client_metadata(); + + // Err - Non-https URL + let identifier_uri = Url::parse("http://localhost/").unwrap(); + metadata.sector_identifier_uri = Some(identifier_uri.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "sector_identifier_uri"); + assert_eq!(url, identifier_uri); + + // Ok - https URL + metadata.sector_identifier_uri = Some(Url::parse("https://localhost/").unwrap()); + metadata.validate().unwrap(); + } + + #[test] + fn validate_token_endpoint_auth_method() { + let mut metadata = valid_client_metadata(); + + // Err - token_endpoint_auth_signing_alg is none + metadata.token_endpoint_auth_signing_alg = Some(JsonWebSignatureAlg::None); + let field = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::UnauthorizedSigningAlgNone(field)) => field + ); + assert_eq!(field, "token_endpoint"); + + // private_key_jwt + metadata.token_endpoint_auth_method = Some(OAuthClientAuthenticationMethod::PrivateKeyJwt); + metadata.token_endpoint_auth_signing_alg = Some(JsonWebSignatureAlg::Rs256); + + // Err - No JWKS + assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::MissingJwksForTokenMethod) + ); + + // Ok - jwks_uri + metadata.jwks_uri = Some(Url::parse("https://localhost/jwks").unwrap()); + metadata.clone().validate().unwrap(); + + // Ok - jwks + metadata.jwks_uri = None; + metadata.jwks = Some(jwks()); + metadata.clone().validate().unwrap(); + + // Err - No token_endpoint_auth_signing_alg + metadata.token_endpoint_auth_signing_alg = None; + let field = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::MissingAuthSigningAlg(field)) => field + ); + assert_eq!(field, "token_endpoint"); + + // client_secret_jwt + metadata.token_endpoint_auth_method = + Some(OAuthClientAuthenticationMethod::ClientSecretJwt); + metadata.jwks = None; + + // Err - No token_endpoint_auth_signing_alg + let field = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::MissingAuthSigningAlg(field)) => field + ); + assert_eq!(field, "token_endpoint"); + + // Ok - Has token_endpoint_auth_signing_alg + metadata.token_endpoint_auth_signing_alg = Some(JsonWebSignatureAlg::Rs256); + metadata.validate().unwrap(); + } + + #[test] + fn validate_id_token_signed_response_alg() { + let mut metadata = valid_client_metadata(); + metadata.id_token_signed_response_alg = Some(JsonWebSignatureAlg::None); + metadata.grant_types = Some(vec![GrantType::AuthorizationCode, GrantType::Implicit]); + + // Err - code id_token + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::CodeIdToken]); + assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::IdTokenSigningAlgNone) + ); + + // Err - code id_token token + metadata.response_types = Some(vec![ + OAuthAuthorizationEndpointResponseType::CodeIdTokenToken, + ]); + assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::IdTokenSigningAlgNone) + ); + + // Err - id_token + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::IdToken]); + assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::IdTokenSigningAlgNone) + ); + + // Err - id_token token + metadata.response_types = Some(vec![OAuthAuthorizationEndpointResponseType::IdTokenToken]); + assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::IdTokenSigningAlgNone) + ); + + // Ok - Other response types + metadata.response_types = Some(vec![ + OAuthAuthorizationEndpointResponseType::Code, + OAuthAuthorizationEndpointResponseType::CodeToken, + OAuthAuthorizationEndpointResponseType::Token, + OAuthAuthorizationEndpointResponseType::None, + ]); + metadata.validate().unwrap(); + } + + #[test] + fn validate_id_token_encrypted_response() { + let mut metadata = valid_client_metadata(); + metadata.id_token_encrypted_response_enc = Some(JsonWebEncryptionEnc::A128CbcHs256); + + // Err - No id_token_encrypted_response_alg + let field = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::MissingEncryptionAlg(field)) => field + ); + assert_eq!(field, "id_token"); + + // Ok - Has id_token_encrypted_response_alg + metadata.id_token_encrypted_response_alg = Some(JsonWebEncryptionAlg::RsaOaep); + metadata.validate().unwrap(); + } + + #[test] + fn validate_userinfo_encrypted_response() { + let mut metadata = valid_client_metadata(); + metadata.userinfo_encrypted_response_enc = Some(JsonWebEncryptionEnc::A128CbcHs256); + + // Err - No userinfo_encrypted_response_alg + let field = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::MissingEncryptionAlg(field)) => field + ); + assert_eq!(field, "userinfo"); + + // Ok - Has userinfo_encrypted_response_alg + metadata.userinfo_encrypted_response_alg = Some(JsonWebEncryptionAlg::RsaOaep); + metadata.validate().unwrap(); + } + + #[test] + fn validate_request_object_encryption() { + let mut metadata = valid_client_metadata(); + metadata.request_object_encryption_enc = Some(JsonWebEncryptionEnc::A128CbcHs256); + + // Err - No request_object_encryption_alg + let field = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::MissingEncryptionAlg(field)) => field + ); + assert_eq!(field, "request_object"); + + // Ok - Has request_object_encryption_alg + metadata.request_object_encryption_alg = Some(JsonWebEncryptionAlg::RsaOaep); + metadata.validate().unwrap(); + } + + #[test] + fn validate_initiate_login_uri() { + let mut metadata = valid_client_metadata(); + + // Err - Non-https URL + let initiate_uri = Url::parse("http://localhost/").unwrap(); + metadata.initiate_login_uri = Some(initiate_uri.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "initiate_login_uri"); + assert_eq!(url, initiate_uri); + + // Ok - https URL + metadata.initiate_login_uri = Some(Url::parse("https://localhost/").unwrap()); + metadata.validate().unwrap(); + } + + #[test] + fn validate_introspection_encrypted_response() { + let mut metadata = valid_client_metadata(); + metadata.introspection_encrypted_response_enc = Some(JsonWebEncryptionEnc::A128CbcHs256); + + // Err - No introspection_encrypted_response_alg + let field = assert_matches!( + metadata.clone().validate(), + Err(ClientMetadataVerificationError::MissingEncryptionAlg(field)) => field + ); + assert_eq!(field, "introspection"); + + // Ok - Has introspection_encrypted_response_alg + metadata.introspection_encrypted_response_alg = Some(JsonWebEncryptionAlg::RsaOaep); + metadata.validate().unwrap(); + } +} diff --git a/crates/policy/policies/client_registration.rego b/crates/policy/policies/client_registration.rego index 3c125c8f..8513b028 100644 --- a/crates/policy/policies/client_registration.rego +++ b/crates/policy/policies/client_registration.rego @@ -79,6 +79,18 @@ violation[{"msg": "logo_uri not on the same host as the client_uri"}] { not host_matches_client_uri(input.client_metadata.logo_uri) } +violation[{"msg": "missing contacts"}] { + not input.client_metadata.contacts +} + +violation[{"msg": "invalid contacts"}] { + not is_array(input.client_metadata.contacts) +} + +violation[{"msg": "empty contacts"}] { + count(input.client_metadata.contacts) == 0 +} + violation[{"msg": "missing redirect_uris"}] { not input.client_metadata.redirect_uris } diff --git a/crates/policy/policies/client_registration_test.rego b/crates/policy/policies/client_registration_test.rego index 308ec4d0..1429542a 100644 --- a/crates/policy/policies/client_registration_test.rego +++ b/crates/policy/policies/client_registration_test.rego @@ -4,17 +4,22 @@ test_valid { allow with input.client_metadata as { "client_uri": "https://example.com/", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } } test_missing_client_uri { - not allow with input.client_metadata as {"redirect_uris": ["https://example.com/callback"]} + not allow with input.client_metadata as { + "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], + } } test_insecure_client_uri { not allow with input.client_metadata as { "client_uri": "http://example.com/", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } } @@ -23,6 +28,7 @@ test_tos_uri { "client_uri": "https://example.com/", "tos_uri": "https://example.com/tos", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } # Insecure @@ -30,6 +36,7 @@ test_tos_uri { "client_uri": "https://example.com/", "tos_uri": "http://example.com/tos", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } # Insecure, but allowed by the config @@ -37,6 +44,7 @@ test_tos_uri { "client_uri": "https://example.com/", "tos_uri": "http://example.com/tos", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } with data.client_registration.allow_insecure_uris as true @@ -45,6 +53,7 @@ test_tos_uri { "client_uri": "https://example.com/", "tos_uri": "https://example.org/tos", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } # Host mistmatch, but allowed by the config @@ -52,6 +61,7 @@ test_tos_uri { "client_uri": "https://example.com/", "tos_uri": "https://example.org/tos", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } with data.client_registration.allow_host_mismatch as true } @@ -61,6 +71,7 @@ test_logo_uri { "client_uri": "https://example.com/", "logo_uri": "https://example.com/logo.png", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } # Insecure @@ -68,6 +79,7 @@ test_logo_uri { "client_uri": "https://example.com/", "logo_uri": "http://example.com/logo.png", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } # Insecure, but allowed by the config @@ -75,6 +87,7 @@ test_logo_uri { "client_uri": "https://example.com/", "logo_uri": "http://example.com/logo.png", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } with data.client_registration.allow_insecure_uris as true @@ -83,6 +96,7 @@ test_logo_uri { "client_uri": "https://example.com/", "logo_uri": "https://example.org/logo.png", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } # Host mistmatch, but allowed by the config @@ -90,6 +104,7 @@ test_logo_uri { "client_uri": "https://example.com/", "logo_uri": "https://example.org/logo.png", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } with data.client_registration.allow_host_mismatch as true } @@ -99,6 +114,7 @@ test_policy_uri { "client_uri": "https://example.com/", "policy_uri": "https://example.com/policy", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } # Insecure @@ -106,6 +122,7 @@ test_policy_uri { "client_uri": "https://example.com/", "policy_uri": "http://example.com/policy", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } # Insecure, but allowed by the config @@ -113,6 +130,7 @@ test_policy_uri { "client_uri": "https://example.com/", "policy_uri": "http://example.com/policy", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } with data.client_registration.allow_insecure_uris as true @@ -121,6 +139,7 @@ test_policy_uri { "client_uri": "https://example.com/", "policy_uri": "https://example.org/policy", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } # Host mistmatch, but allowed by the config @@ -128,24 +147,30 @@ test_policy_uri { "client_uri": "https://example.com/", "policy_uri": "https://example.org/policy", "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], } with data.client_registration.allow_host_mismatch as true } test_redirect_uris { # Missing redirect_uris - not allow with input.client_metadata as {"client_uri": "https://example.com/"} + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "contacts": ["contact@example.com"], + } # redirect_uris is not an array not allow with input.client_metadata as { "client_uri": "https://example.com/", "redirect_uris": "https://example.com/callback", + "contacts": ["contact@example.com"], } # Empty redirect_uris not allow with input.client_metadata as { "client_uri": "https://example.com/", "redirect_uris": [], + "contacts": ["contact@example.com"], } } @@ -154,6 +179,7 @@ test_web_redirect_uri { "application_type": "web", "client_uri": "https://example.com/", "redirect_uris": ["https://example.com/second/callback", "https://example.com/callback"], + "contacts": ["contact@example.com"], } # Insecure URL @@ -161,6 +187,7 @@ test_web_redirect_uri { "application_type": "web", "client_uri": "https://example.com/", "redirect_uris": ["http://example.com/callback", "https://example.com/callback"], + "contacts": ["contact@example.com"], } # Insecure URL, but allowed by the config @@ -168,6 +195,7 @@ test_web_redirect_uri { "application_type": "web", "client_uri": "https://example.com/", "redirect_uris": ["http://example.com/callback", "https://example.com/callback"], + "contacts": ["contact@example.com"], } with data.client_registration.allow_insecure_uris as true @@ -176,6 +204,7 @@ test_web_redirect_uri { "application_type": "web", "client_uri": "https://example.com/", "redirect_uris": ["https://example.com/second/callback", "https://example.org/callback"], + "contacts": ["contact@example.com"], } # Host mismatch, but allowed by the config @@ -183,6 +212,7 @@ test_web_redirect_uri { "application_type": "web", "client_uri": "https://example.com/", "redirect_uris": ["https://example.com/second/callback", "https://example.org/callback"], + "contacts": ["contact@example.com"], } with data.client_registration.allow_host_mismatch as true @@ -191,6 +221,7 @@ test_web_redirect_uri { "application_type": "web", "client_uri": "https://example.com/", "redirect_uris": ["com.example.app:/callback"], + "contacts": ["contact@example.com"], } # localhost not allowed @@ -198,6 +229,7 @@ test_web_redirect_uri { "application_type": "web", "client_uri": "https://example.com/", "redirect_uris": ["http://locahost:1234/callback"], + "contacts": ["contact@example.com"], } # localhost not allowed @@ -205,6 +237,7 @@ test_web_redirect_uri { "application_type": "web", "client_uri": "https://example.com/", "redirect_uris": ["http://127.0.0.1:1234/callback"], + "contacts": ["contact@example.com"], } # localhost not allowed @@ -212,6 +245,7 @@ test_web_redirect_uri { "application_type": "web", "client_uri": "https://example.com/", "redirect_uris": ["http://[::1]:1234/callback"], + "contacts": ["contact@example.com"], } } @@ -229,6 +263,7 @@ test_native_redirect_uri { "http://[::1]/callback", "http://[::1]:1234/callback", ], + "contacts": ["contact@example.com"], } # We don't allow HTTP URLs other than localhost @@ -236,12 +271,14 @@ test_native_redirect_uri { "application_type": "native", "client_uri": "https://example.com/", "redirect_uris": ["https://example.com/"], + "contacts": ["contact@example.com"], } not allow with input.client_metadata as { "application_type": "native", "client_uri": "https://example.com/", "redirect_uris": ["http://example.com/"], + "contacts": ["contact@example.com"], } # We don't allow HTTPS on localhost @@ -249,6 +286,7 @@ test_native_redirect_uri { "application_type": "native", "client_uri": "https://example.com/", "redirect_uris": ["https://localhost:1234/"], + "contacts": ["contact@example.com"], } # Ensure we're not allowing localhost as a prefix @@ -256,6 +294,7 @@ test_native_redirect_uri { "application_type": "native", "client_uri": "https://example.com/", "redirect_uris": ["http://localhost.com/"], + "contacts": ["contact@example.com"], } # For custom schemes, it should match the client_uri hostname @@ -263,6 +302,7 @@ test_native_redirect_uri { "application_type": "native", "client_uri": "https://example.com/", "redirect_uris": ["org.example.app:/callback"], + "contacts": ["contact@example.com"], } } @@ -271,3 +311,25 @@ test_reverse_dns_match { redirect_uri := parse_uri("io.element.app:/callback") reverse_dns_match(client_uri.host, redirect_uri.scheme) } + +test_contacts { + # Missing contacts + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/callback"], + } + + # contacts is not an array + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/callback"], + "contacts": "contact@example.com", + } + + # Empty contacts + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/callback"], + "contacts": [], + } +} diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 21e942b2..7b2a20c8 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -21,7 +21,7 @@ use std::io::Cursor; use anyhow::bail; use mas_data_model::{AuthorizationGrant, StorageBackend, User}; -use oauth2_types::registration::ClientMetadata; +use oauth2_types::registration::VerifiedClientMetadata; use opa_wasm::Runtime; use serde::Deserialize; use thiserror::Error; @@ -205,7 +205,7 @@ impl Policy { #[tracing::instrument(skip(self))] pub async fn evaluate_client_registration( &mut self, - client_metadata: &ClientMetadata, + client_metadata: &VerifiedClientMetadata, ) -> Result { let client_metadata = serde_json::to_value(client_metadata)?; let input = serde_json::json!({