From 222551ad7f77aea7e32d907d700102925244cc9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 4 Aug 2022 13:52:02 +0200 Subject: [PATCH] Allow to validate provider metadata According to the OpenID Connect Discovery 1.0 spec. Provide the default values for fields when they are defined. Introduce VerifiedProviderMetadata. Rename Metadata to ProviderMetadata. Implement Deserialize for ProviderMetadata. --- Cargo.lock | 7 + clippy.toml | 1 + crates/handlers/src/oauth2/discovery.rs | 6 +- crates/oauth2-types/Cargo.toml | 3 + crates/oauth2-types/src/oidc.rs | 1050 ++++++++++++++++++++++- 5 files changed, 1060 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eeb4703e..b03521ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-compression" version = "0.3.14" @@ -2803,6 +2809,7 @@ dependencies = [ name = "oauth2-types" version = "0.1.0" dependencies = [ + "assert_matches", "chrono", "data-encoding", "http", diff --git a/clippy.toml b/clippy.toml index 16caf02e..9961995c 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1,2 @@ msrv = "1.60.0" +doc-valid-idents = ["OpenID", "OAuth", ".."] \ No newline at end of file diff --git a/crates/handlers/src/oauth2/discovery.rs b/crates/handlers/src/oauth2/discovery.rs index 77ef1572..00d419e6 100644 --- a/crates/handlers/src/oauth2/discovery.rs +++ b/crates/handlers/src/oauth2/discovery.rs @@ -25,7 +25,7 @@ use mas_iana::{ use mas_jose::{SigningKeystore, StaticKeystore}; use mas_router::UrlBuilder; use oauth2_types::{ - oidc::{ClaimType, Metadata, SubjectType}, + oidc::{ClaimType, ProviderMetadata, SubjectType}, requests::{Display, GrantType, Prompt, ResponseMode}, scope, }; @@ -134,7 +134,7 @@ pub(crate) async fn get( let prompt_values_supported = Some(vec![Prompt::None, Prompt::Login, Prompt::Create]); - let metadata = Metadata { + let metadata = ProviderMetadata { issuer, authorization_endpoint, token_endpoint, @@ -161,7 +161,7 @@ pub(crate) async fn get( request_parameter_supported, request_uri_parameter_supported, prompt_values_supported, - ..Metadata::default() + ..ProviderMetadata::default() }; Json(metadata) diff --git a/crates/oauth2-types/Cargo.toml b/crates/oauth2-types/Cargo.toml index dcc867a9..e7dedcc6 100644 --- a/crates/oauth2-types/Cargo.toml +++ b/crates/oauth2-types/Cargo.toml @@ -22,3 +22,6 @@ itertools = "0.10.3" mas-iana = { path = "../iana" } mas-jose = { path = "../jose" } + +[dev-dependencies] +assert_matches = "1.5.0" diff --git a/crates/oauth2-types/src/oidc.rs b/crates/oauth2-types/src/oidc.rs index 8e946c37..1d3b81a8 100644 --- a/crates/oauth2-types/src/oidc.rs +++ b/crates/oauth2-types/src/oidc.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::ops::Deref; + use mas_iana::{ jose::{JsonWebEncryptionAlg, JsonWebEncryptionEnc, JsonWebSignatureAlg}, oauth::{ @@ -21,6 +23,7 @@ use mas_iana::{ }; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; +use thiserror::Error; use url::Url; use crate::requests::{Display, GrantType, Prompt, ResponseMode}; @@ -47,11 +50,25 @@ pub enum ClaimType { Distributed, } -/// Authorization server metadata, as described by the -/// [IANA registry](https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#authorization-server-metadata) +pub static DEFAULT_RESPONSE_MODES_SUPPORTED: &[ResponseMode] = + &[ResponseMode::Query, ResponseMode::Fragment]; + +pub static DEFAULT_GRANT_TYPES_SUPPORTED: &[GrantType] = + &[GrantType::AuthorizationCode, GrantType::Implicit]; + +pub static DEFAULT_AUTH_METHODS_SUPPORTED: &[OAuthClientAuthenticationMethod] = + &[OAuthClientAuthenticationMethod::ClientSecretBasic]; + +pub static DEFAULT_CLAIM_TYPES_SUPPORTED: &[ClaimType] = &[ClaimType::Normal]; + +/// Authorization server 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#authorization-server-metadata #[skip_serializing_none] -#[derive(Serialize, Clone, Default)] -pub struct Metadata { +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ProviderMetadata { /// Authorization server's issuer identifier URL. pub issuer: Option, @@ -233,3 +250,1028 @@ pub struct Metadata { /// Array containing the list of prompt values that this OP supports. pub prompt_values_supported: Option>, } + +impl ProviderMetadata { + /// Validate this `ProviderMetadata` according to the [OpenID Connect + /// Discovery Spec 1.0]. + /// + /// # Parameters + /// + /// - `issuer`: The issuer that was discovered to get this + /// `ProviderMetadata`. + /// + /// # Errors + /// + /// Will return `Err` if validation fails. + /// + /// [OpenID Connect Discovery Spec 1.0]: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + #[allow(clippy::too_many_lines)] + pub fn validate( + self, + issuer: &Url, + ) -> Result { + self.issuer + .as_ref() + .ok_or(ProviderMetadataVerificationError::MissingIssuer) + .and_then(|url| { + validate_url("issuer", url, ExtraUrlRestrictions::NoQueryOrFragment)?; + + if url != issuer { + return Err(ProviderMetadataVerificationError::IssuerUrlsDontMatch); + } + + Ok(()) + })?; + + self.authorization_endpoint + .as_ref() + .ok_or(ProviderMetadataVerificationError::MissingAuthorizationEndpoint) + .and_then(|url| { + validate_url( + "authorization_endpoint", + url, + ExtraUrlRestrictions::NoFragment, + ) + })?; + + self.token_endpoint + .as_ref() + .ok_or(ProviderMetadataVerificationError::MissingTokenEndpoint) + .and_then(|url| { + validate_url("token_endpoint", url, ExtraUrlRestrictions::NoFragment) + })?; + + self.jwks_uri + .as_ref() + .ok_or(ProviderMetadataVerificationError::MissingJwksUri) + .and_then(|url| validate_url("jwks_uri", url, ExtraUrlRestrictions::None))?; + + if let Some(url) = &self.registration_endpoint { + validate_url("registration_endpoint", url, ExtraUrlRestrictions::None)?; + } + + if let Some(scopes) = &self.scopes_supported { + if !scopes.iter().any(|s| s == "openid") { + return Err(ProviderMetadataVerificationError::ScopesMissingOpenid); + } + } + + self.response_types_supported + .as_ref() + .ok_or(ProviderMetadataVerificationError::MissingResponseTypesSupported)?; + + validate_signing_alg_values_supported( + "token_endpoint", + &self.token_endpoint_auth_signing_alg_values_supported, + &self.token_endpoint_auth_methods_supported, + )?; + + if let Some(url) = &self.revocation_endpoint { + validate_url("revocation_endpoint", url, ExtraUrlRestrictions::NoFragment)?; + } + + validate_signing_alg_values_supported( + "revocation_endpoint", + &self.revocation_endpoint_auth_signing_alg_values_supported, + &self.revocation_endpoint_auth_methods_supported, + )?; + + if let Some(url) = &self.introspection_endpoint { + validate_url("introspection_endpoint", url, ExtraUrlRestrictions::None)?; + } + + validate_signing_alg_values_supported( + "introspection_endpoint", + &self.introspection_endpoint_auth_signing_alg_values_supported, + &self.introspection_endpoint_auth_methods_supported, + )?; + + if let Some(url) = &self.userinfo_endpoint { + validate_url("userinfo_endpoint", url, ExtraUrlRestrictions::None)?; + } + + self.subject_types_supported + .as_ref() + .ok_or(ProviderMetadataVerificationError::MissingSubjectTypesSupported)?; + + self.id_token_signing_alg_values_supported + .as_ref() + .ok_or(ProviderMetadataVerificationError::MissingIdTokenSigningAlgValuesSupported) + .and_then(|types| { + if !types.contains(&JsonWebSignatureAlg::Rs256) { + return Err( + ProviderMetadataVerificationError::SigningAlgValuesMissingRs256("id_token"), + ); + } + + Ok(()) + })?; + + if let Some(url) = &self.pushed_authorization_request_endpoint { + validate_url( + "pushed_authorization_request_endpoint", + url, + ExtraUrlRestrictions::None, + )?; + } + + Ok(VerifiedProviderMetadata { inner: self }) + } + + /// JSON array containing a list of the OAuth 2.0 `response_mode` values + /// that this authorization server supports. + /// + /// Defaults to [`DEFAULT_RESPONSE_MODES_SUPPORTED`]. + #[must_use] + pub fn response_modes_supported(&self) -> &[ResponseMode] { + self.response_modes_supported + .as_deref() + .unwrap_or(DEFAULT_RESPONSE_MODES_SUPPORTED) + } + + /// JSON array containing a list of the OAuth 2.0 grant type values that + /// this authorization server supports. + /// + /// Defaults to [`DEFAULT_GRANT_TYPES_SUPPORTED`]. + #[must_use] + pub fn grant_types_supported(&self) -> &[GrantType] { + self.grant_types_supported + .as_deref() + .unwrap_or(DEFAULT_GRANT_TYPES_SUPPORTED) + } + + /// JSON array containing a list of client authentication methods supported + /// by the token endpoint. + /// + /// Defaults to [`DEFAULT_AUTH_METHODS_SUPPORTED`]. + #[must_use] + pub fn token_endpoint_auth_methods_supported(&self) -> &[OAuthClientAuthenticationMethod] { + self.token_endpoint_auth_methods_supported + .as_deref() + .unwrap_or(DEFAULT_AUTH_METHODS_SUPPORTED) + } + + /// JSON array containing a list of client authentication methods supported + /// by the revocation endpoint. + /// + /// Defaults to [`DEFAULT_AUTH_METHODS_SUPPORTED`]. + #[must_use] + pub fn revocation_endpoint_auth_methods_supported(&self) -> &[OAuthClientAuthenticationMethod] { + self.revocation_endpoint_auth_methods_supported + .as_deref() + .unwrap_or(DEFAULT_AUTH_METHODS_SUPPORTED) + } + + /// JSON array containing a list of the Claim Types that the OpenID Provider + /// supports. + /// + /// Defaults to [`DEFAULT_CLAIM_TYPES_SUPPORTED`]. + #[must_use] + pub fn claim_types_supported(&self) -> &[ClaimType] { + self.claim_types_supported + .as_deref() + .unwrap_or(DEFAULT_CLAIM_TYPES_SUPPORTED) + } + + /// Boolean value specifying whether the OP supports use of the `claims` + /// parameter. + /// + /// Defaults to `false`. + #[must_use] + pub fn claims_parameter_supported(&self) -> bool { + self.claims_parameter_supported.unwrap_or(false) + } + + /// Boolean value specifying whether the OP supports use of the `request` + /// parameter. + /// + /// Defaults to `false`. + #[must_use] + pub fn request_parameter_supported(&self) -> bool { + self.request_parameter_supported.unwrap_or(false) + } + + /// Boolean value specifying whether the OP supports use of the + /// `request_uri` parameter. + /// + /// Defaults to `true`. + #[must_use] + pub fn request_uri_parameter_supported(&self) -> bool { + self.request_uri_parameter_supported.unwrap_or(true) + } + + /// Boolean value specifying whether the OP requires any `request_uri` + /// values used to be pre-registered. + /// + /// Defaults to `false`. + #[must_use] + pub fn require_request_uri_registration(&self) -> bool { + self.require_request_uri_registration.unwrap_or(false) + } + + /// Indicates where authorization request needs to be protected as Request + /// Object and provided through either `request` or `request_uri` parameter. + /// + /// Defaults to `false`. + #[must_use] + pub fn require_signed_request_object(&self) -> bool { + self.require_signed_request_object.unwrap_or(false) + } + + /// Indicates whether the authorization server accepts authorization + /// requests only via PAR. + /// + /// Defaults to `false`. + #[must_use] + pub fn require_pushed_authorization_requests(&self) -> bool { + self.require_pushed_authorization_requests.unwrap_or(false) + } +} + +/// The verified authorization server metadata. +/// +/// All the fields required by the [OpenID Connect Discovery 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::VerifiedProviderMetadata, +/// requests::GrantType, +/// }; +/// use url::Url; +/// # use oauth2_types::oidc::{ProviderMetadata, ProviderMetadataVerificationError}; +/// # let metadata = ProviderMetadata::default(); +/// # let issuer = Url::parse("http://localhost").unwrap(); +/// let verified_metadata = metadata.validate(&issuer)?; +/// +/// // The endpoint is required during validation so this is not an `Option`. +/// let _: &Url = verified_metadata.authorization_endpoint(); +/// +/// // The field has a default value so this is not an `Option`. +/// let _: &[GrantType] = verified_metadata.grant_types_supported(); +/// +/// // Other fields can be accessed via `Deref`. +/// if let Some(registration_endpoint) = &verified_metadata.registration_endpoint { +/// println!("Registration is supported at {registration_endpoint}"); +/// } +/// # Ok::<(), ProviderMetadataVerificationError>(()) +/// ``` +/// +/// [OpenID Connect Discovery Spec 1.0]: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata +#[derive(Debug, Clone)] +pub struct VerifiedProviderMetadata { + inner: ProviderMetadata, +} + +impl VerifiedProviderMetadata { + /// Authorization server's issuer identifier URL. + #[must_use] + pub fn issuer(&self) -> &Url { + match &self.issuer { + Some(u) => u, + None => unreachable!(), + } + } + + /// URL of the authorization server's authorization endpoint. + #[must_use] + pub fn authorization_endpoint(&self) -> &Url { + match &self.authorization_endpoint { + Some(u) => u, + None => unreachable!(), + } + } + + /// URL of the authorization server's token endpoint. + #[must_use] + pub fn token_endpoint(&self) -> &Url { + match &self.token_endpoint { + Some(u) => u, + None => unreachable!(), + } + } + + /// URL of the authorization server's JWK Set document. + #[must_use] + pub fn jwks_uri(&self) -> &Url { + match &self.jwks_uri { + Some(u) => u, + None => unreachable!(), + } + } + + /// JSON array containing a list of the OAuth 2.0 `response_type` values + /// that this authorization server supports. + #[must_use] + pub fn response_types_supported(&self) -> &[OAuthAuthorizationEndpointResponseType] { + match &self.response_types_supported { + Some(u) => u, + None => unreachable!(), + } + } + + /// JSON array containing a list of the Subject Identifier types that this + /// OP supports. + #[must_use] + pub fn subject_types_supported(&self) -> &[SubjectType] { + match &self.subject_types_supported { + Some(u) => u, + None => unreachable!(), + } + } + + /// JSON array containing a list of the JWS `alg` values supported by the OP + /// for the ID Token. + #[must_use] + pub fn id_token_signing_alg_values_supported(&self) -> &[JsonWebSignatureAlg] { + match &self.id_token_signing_alg_values_supported { + Some(u) => u, + None => unreachable!(), + } + } +} + +impl Deref for VerifiedProviderMetadata { + type Target = ProviderMetadata; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// All errors that can happen when verifying [`ProviderMetadata`] +#[derive(Debug, Error)] +pub enum ProviderMetadataVerificationError { + /// The issuer is missing. + #[error("issuer is missing")] + MissingIssuer, + + /// The authorization endpoint is missing. + #[error("authorization endpoint is missing")] + MissingAuthorizationEndpoint, + + /// The token endpoint is missing. + #[error("token endpoint is missing")] + MissingTokenEndpoint, + + /// The JWK Set URI is missing. + #[error("JWK Set URI is missing")] + MissingJwksUri, + + /// The supported response types are missing. + #[error("supported response types are missing")] + MissingResponseTypesSupported, + + /// The supported subject types are missing. + #[error("supported subject types are missing")] + MissingSubjectTypesSupported, + + /// The supported ID token signing algorithm values are missing. + #[error("supported ID token signing algorithm values are missing")] + MissingIdTokenSigningAlgValuesSupported, + + /// 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), + + /// The URL of the given field contains a query, but it's not allowed. + #[error("{0}'s URL contains a query: {1}")] + UrlWithQuery(&'static str, Url), + + /// The URL of the given field contains a fragment, but it's not allowed. + #[error("{0}'s URL contains a fragment: {1}")] + UrlWithFragment(&'static str, Url), + + /// The issuer URL doesn't match the one that was discovered. + #[error("issuer URLs don't match")] + IssuerUrlsDontMatch, + + /// `openid` is missing from the supported scopes. + #[error("missing openid scope")] + ScopesMissingOpenid, + + /// `code` is missing from the supported response types. + #[error("missing `code` response type")] + ResponseTypesMissingCode, + + /// `id_token` is missing from the supported response types. + #[error("missing `id_token` response type")] + ResponseTypesMissingIdToken, + + /// `id_token token` is missing from the supported response types. + #[error("missing `id_token token` response type")] + ResponseTypesMissingIdTokenToken, + + /// `authorization_code` is missing from the supported grant types. + #[error("missing `authorization_code` grant type")] + GrantTypesMissingAuthorizationCode, + + /// `implicit` is missing from the supported grant types. + #[error("missing `implicit` grant type")] + GrantTypesMissingImplicit, + + /// 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`")] + MissingAuthSigningAlgValues(&'static str), + + /// `none` is in the given endpoint's signing algorithm values, but is not + /// allowed. + #[error("{0} signing algorithm values contain `none`")] + SigningAlgValuesWithNone(&'static str), + + /// `RS256` is missing from the given endpoint's signing algorithm values. + #[error("missing RS256 in {0} signing algorithm values")] + SigningAlgValuesMissingRs256(&'static str), +} + +/// Possible extra restrictions on a URL. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ExtraUrlRestrictions { + /// No extra restrictions. + None, + + /// The URL must not contain a fragment. + NoFragment, + + /// The URL must not contain a query or a fragment. + NoQueryOrFragment, +} + +impl ExtraUrlRestrictions { + fn can_have_fragment(self) -> bool { + self == Self::None + } + + fn can_have_query(self) -> bool { + self != Self::NoQueryOrFragment + } +} + +/// Validate the URL of the field with the given extra restrictions. +/// +/// The basic restriction is that the URL must use the `https` scheme. +fn validate_url( + field: &'static str, + url: &Url, + restrictions: ExtraUrlRestrictions, +) -> Result<(), ProviderMetadataVerificationError> { + if url.scheme() != "https" { + return Err(ProviderMetadataVerificationError::UrlNonHttpsScheme( + field, + url.clone(), + )); + } + + if !restrictions.can_have_query() && url.query().is_some() { + return Err(ProviderMetadataVerificationError::UrlWithQuery( + field, + url.clone(), + )); + } + + if !restrictions.can_have_fragment() && url.fragment().is_some() { + return Err(ProviderMetadataVerificationError::UrlWithFragment( + field, + url.clone(), + )); + } + + Ok(()) +} + +/// Validate the algorithm values of the endpoint according to the +/// authentication methods. +/// +/// The restrictions are: +/// - The algorithm values must not contain `none`, +/// - If the `client_secret_jwt` or `private_key_jwt` authentication methods are +/// supported, the values must be present. +fn validate_signing_alg_values_supported( + endpoint: &'static str, + values: &Option>, + methods: &Option>, +) -> Result<(), ProviderMetadataVerificationError> { + if let Some(values) = values { + if values.contains(&JsonWebSignatureAlg::None) { + return Err(ProviderMetadataVerificationError::SigningAlgValuesWithNone( + endpoint, + )); + } + } else if methods.iter().flatten().any(|method| { + matches!( + method, + OAuthClientAuthenticationMethod::ClientSecretJwt + | OAuthClientAuthenticationMethod::PrivateKeyJwt + ) + }) { + return Err(ProviderMetadataVerificationError::MissingAuthSigningAlgValues(endpoint)); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use mas_iana::{ + jose::JsonWebSignatureAlg, + oauth::{OAuthAuthorizationEndpointResponseType, OAuthClientAuthenticationMethod}, + }; + use url::Url; + + use super::{ProviderMetadata, ProviderMetadataVerificationError, SubjectType}; + + fn valid_provider_metadata() -> (ProviderMetadata, Url) { + let issuer = Url::parse("https://localhost").unwrap(); + let metadata = ProviderMetadata { + issuer: Some(issuer.clone()), + authorization_endpoint: Some(Url::parse("https://localhost/auth").unwrap()), + token_endpoint: Some(Url::parse("https://localhost/token").unwrap()), + jwks_uri: Some(Url::parse("https://localhost/jwks").unwrap()), + response_types_supported: Some(vec![OAuthAuthorizationEndpointResponseType::Code]), + subject_types_supported: Some(vec![SubjectType::Public]), + id_token_signing_alg_values_supported: Some(vec![JsonWebSignatureAlg::Rs256]), + ..Default::default() + }; + + (metadata, issuer) + } + + #[test] + fn validate_required_metadata() { + let (metadata, issuer) = valid_provider_metadata(); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_issuer() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Err - Missing + metadata.issuer = None; + assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::MissingIssuer) + ); + + // Err - Wrong issuer + metadata.issuer = Some(Url::parse("https://example.com").unwrap()); + assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::IssuerUrlsDontMatch) + ); + + // Err - Not https + let issuer = Url::parse("http://localhost").unwrap(); + metadata.issuer = Some(issuer.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "issuer"); + assert_eq!(url, issuer); + + // Err - Query + let issuer = Url::parse("https://localhost/?query").unwrap(); + metadata.issuer = Some(issuer.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlWithQuery(field, url)) => (field, url) + ); + assert_eq!(field, "issuer"); + assert_eq!(url, issuer); + + // Err - Fragment + let issuer = Url::parse("https://localhost/#fragment").unwrap(); + metadata.issuer = Some(issuer.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlWithFragment(field, url)) => (field, url) + ); + assert_eq!(field, "issuer"); + assert_eq!(url, issuer); + + // Ok - Path + let issuer = Url::parse("https://localhost/issuer1").unwrap(); + metadata.issuer = Some(issuer.clone()); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_authorization_endpoint() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Err - Missing + metadata.authorization_endpoint = None; + assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::MissingAuthorizationEndpoint) + ); + + // Err - Not https + let endpoint = Url::parse("http://localhost/auth").unwrap(); + metadata.authorization_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "authorization_endpoint"); + assert_eq!(url, endpoint); + + // Err - Fragment + let endpoint = Url::parse("https://localhost/auth#fragment").unwrap(); + metadata.authorization_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlWithFragment(field, url)) => (field, url) + ); + assert_eq!(field, "authorization_endpoint"); + assert_eq!(url, endpoint); + + // Ok - Query + metadata.authorization_endpoint = Some(Url::parse("https://localhost/auth?query").unwrap()); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_token_endpoint() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Err - Missing + metadata.token_endpoint = None; + assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::MissingTokenEndpoint) + ); + + // Err - Not https + let endpoint = Url::parse("http://localhost/token").unwrap(); + metadata.token_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "token_endpoint"); + assert_eq!(url, endpoint); + + // Err - Fragment + let endpoint = Url::parse("https://localhost/token#fragment").unwrap(); + metadata.token_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlWithFragment(field, url)) => (field, url) + ); + assert_eq!(field, "token_endpoint"); + assert_eq!(url, endpoint); + + // Ok - Query + metadata.token_endpoint = Some(Url::parse("https://localhost/token?query").unwrap()); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_jwks_uri() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Err - Missing + metadata.jwks_uri = None; + assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::MissingJwksUri) + ); + + // Err - Not https + let endpoint = Url::parse("http://localhost/jwks").unwrap(); + metadata.jwks_uri = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "jwks_uri"); + assert_eq!(url, endpoint); + + // Ok - Query & fragment + metadata.jwks_uri = Some(Url::parse("https://localhost/token?query#fragment").unwrap()); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_registration_endpoint() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Err - Not https + let endpoint = Url::parse("http://localhost/registration").unwrap(); + metadata.registration_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "registration_endpoint"); + assert_eq!(url, endpoint); + + // Ok - Missing + metadata.registration_endpoint = None; + metadata.clone().validate(&issuer).unwrap(); + + // Ok - Query & fragment + metadata.registration_endpoint = + Some(Url::parse("https://localhost/registration?query#fragment").unwrap()); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_scopes_supported() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Err - No `openid` + metadata.scopes_supported = Some(vec!["custom".to_owned()]); + assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::ScopesMissingOpenid) + ); + + // Ok - Missing + metadata.scopes_supported = None; + metadata.clone().validate(&issuer).unwrap(); + + // Ok - With `openid` + metadata.scopes_supported = Some(vec!["openid".to_owned(), "custom".to_owned()]); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_response_types_supported() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Err - Missing + metadata.response_types_supported = None; + assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::MissingResponseTypesSupported) + ); + + // Ok - Present + metadata.response_types_supported = + Some(vec![OAuthAuthorizationEndpointResponseType::Code]); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_token_endpoint_signing_alg_values_supported() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Ok - Missing + metadata.token_endpoint_auth_signing_alg_values_supported = None; + metadata.token_endpoint_auth_methods_supported = None; + metadata.clone().validate(&issuer).unwrap(); + + // Err - With `none` + metadata.token_endpoint_auth_signing_alg_values_supported = + Some(vec![JsonWebSignatureAlg::None]); + let endpoint = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::SigningAlgValuesWithNone(endpoint)) => endpoint + ); + assert_eq!(endpoint, "token_endpoint"); + + // Ok - Other signing alg values. + metadata.token_endpoint_auth_signing_alg_values_supported = + Some(vec![JsonWebSignatureAlg::Rs256, JsonWebSignatureAlg::EdDsa]); + metadata.clone().validate(&issuer).unwrap(); + + // Err - `client_secret_jwt` without signing alg values. + metadata.token_endpoint_auth_methods_supported = + Some(vec![OAuthClientAuthenticationMethod::ClientSecretJwt]); + metadata.token_endpoint_auth_signing_alg_values_supported = None; + let endpoint = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::MissingAuthSigningAlgValues(endpoint)) => endpoint + ); + assert_eq!(endpoint, "token_endpoint"); + + // Ok - `client_secret_jwt` with signing alg values. + metadata.token_endpoint_auth_signing_alg_values_supported = + Some(vec![JsonWebSignatureAlg::Rs256]); + metadata.clone().validate(&issuer).unwrap(); + + // Err - `private_key_jwt` without signing alg values. + metadata.token_endpoint_auth_methods_supported = + Some(vec![OAuthClientAuthenticationMethod::PrivateKeyJwt]); + metadata.token_endpoint_auth_signing_alg_values_supported = None; + let endpoint = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::MissingAuthSigningAlgValues(endpoint)) => endpoint + ); + assert_eq!(endpoint, "token_endpoint"); + + // Ok - `private_key_jwt` with signing alg values. + metadata.token_endpoint_auth_signing_alg_values_supported = + Some(vec![JsonWebSignatureAlg::Rs256]); + metadata.clone().validate(&issuer).unwrap(); + + // Ok - Other auth methods without signing alg values. + metadata.token_endpoint_auth_methods_supported = Some(vec![ + OAuthClientAuthenticationMethod::ClientSecretBasic, + OAuthClientAuthenticationMethod::ClientSecretPost, + ]); + metadata.token_endpoint_auth_signing_alg_values_supported = None; + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_revocation_endpoint() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Ok - Missing + metadata.revocation_endpoint = None; + metadata.clone().validate(&issuer).unwrap(); + + // Err - Not https + let endpoint = Url::parse("http://localhost/revocation").unwrap(); + metadata.revocation_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "revocation_endpoint"); + assert_eq!(url, endpoint); + + // Err - Fragment + let endpoint = Url::parse("https://localhost/revocation#fragment").unwrap(); + metadata.revocation_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlWithFragment(field, url)) => (field, url) + ); + assert_eq!(field, "revocation_endpoint"); + assert_eq!(url, endpoint); + + // Ok - Query + metadata.revocation_endpoint = + Some(Url::parse("https://localhost/revocation?query").unwrap()); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_revocation_endpoint_signing_alg_values_supported() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Only check that this field is validated, algorithm checks are already + // tested for the token endpoint. + + // Ok - Missing + metadata.revocation_endpoint_auth_signing_alg_values_supported = None; + metadata.revocation_endpoint_auth_methods_supported = None; + metadata.clone().validate(&issuer).unwrap(); + + // Err - With `none` + metadata.revocation_endpoint_auth_signing_alg_values_supported = + Some(vec![JsonWebSignatureAlg::None]); + let endpoint = assert_matches!( + metadata.validate(&issuer), + Err(ProviderMetadataVerificationError::SigningAlgValuesWithNone(endpoint)) => endpoint + ); + assert_eq!(endpoint, "revocation_endpoint"); + } + + #[test] + fn validate_introspection_endpoint() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Ok - Missing + metadata.introspection_endpoint = None; + metadata.clone().validate(&issuer).unwrap(); + + // Err - Not https + let endpoint = Url::parse("http://localhost/introspection").unwrap(); + metadata.introspection_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "introspection_endpoint"); + assert_eq!(url, endpoint); + + // Ok - Query & Fragment + metadata.introspection_endpoint = + Some(Url::parse("https://localhost/introspection?query#fragment").unwrap()); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_introspection_endpoint_signing_alg_values_supported() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Only check that this field is validated, algorithm checks are already + // tested for the token endpoint. + + // Ok - Missing + metadata.introspection_endpoint_auth_signing_alg_values_supported = None; + metadata.introspection_endpoint_auth_methods_supported = None; + metadata.clone().validate(&issuer).unwrap(); + + // Err - With `none` + metadata.introspection_endpoint_auth_signing_alg_values_supported = + Some(vec![JsonWebSignatureAlg::None]); + let endpoint = assert_matches!( + metadata.validate(&issuer), + Err(ProviderMetadataVerificationError::SigningAlgValuesWithNone(endpoint)) => endpoint + ); + assert_eq!(endpoint, "introspection_endpoint"); + } + + #[test] + fn validate_userinfo_endpoint() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Ok - Missing + metadata.userinfo_endpoint = None; + metadata.clone().validate(&issuer).unwrap(); + + // Err - Not https + let endpoint = Url::parse("http://localhost/userinfo").unwrap(); + metadata.userinfo_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "userinfo_endpoint"); + assert_eq!(url, endpoint); + + // Ok - Query & Fragment + metadata.userinfo_endpoint = + Some(Url::parse("https://localhost/userinfo?query#fragment").unwrap()); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_subject_types_supported() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Err - Missing + metadata.subject_types_supported = None; + assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::MissingSubjectTypesSupported) + ); + + // Ok - Present + metadata.subject_types_supported = Some(vec![SubjectType::Public, SubjectType::Pairwise]); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_id_token_signing_alg_values_supported() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Err - Missing + metadata.id_token_signing_alg_values_supported = None; + assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::MissingIdTokenSigningAlgValuesSupported) + ); + + // Err - No RS256 + metadata.id_token_signing_alg_values_supported = Some(vec![JsonWebSignatureAlg::EdDsa]); + let endpoint = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::SigningAlgValuesMissingRs256(endpoint)) => endpoint + ); + assert_eq!(endpoint, "id_token"); + + // Ok - With RS256 + metadata.id_token_signing_alg_values_supported = + Some(vec![JsonWebSignatureAlg::Rs256, JsonWebSignatureAlg::EdDsa]); + metadata.validate(&issuer).unwrap(); + } + + #[test] + fn validate_pushed_authorization_request_endpoint() { + let (mut metadata, issuer) = valid_provider_metadata(); + + // Ok - Missing + metadata.pushed_authorization_request_endpoint = None; + metadata.clone().validate(&issuer).unwrap(); + + // Err - Not https + let endpoint = Url::parse("http://localhost/par").unwrap(); + metadata.pushed_authorization_request_endpoint = Some(endpoint.clone()); + let (field, url) = assert_matches!( + metadata.clone().validate(&issuer), + Err(ProviderMetadataVerificationError::UrlNonHttpsScheme(field, url)) => (field, url) + ); + assert_eq!(field, "pushed_authorization_request_endpoint"); + assert_eq!(url, endpoint); + + // Ok - Query & Fragment + metadata.pushed_authorization_request_endpoint = + Some(Url::parse("https://localhost/par?query#fragment").unwrap()); + metadata.validate(&issuer).unwrap(); + } +}