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(); + } +}