diff --git a/src/jwt/mod.rs b/src/jwt/mod.rs index a314f25..b3adfe0 100644 --- a/src/jwt/mod.rs +++ b/src/jwt/mod.rs @@ -20,11 +20,108 @@ new_type![ JsonWebTokenContentType(String) ]; +/// Error type used when normalizing [`JsonWebTokenType`] objects +#[derive(Error, Debug)] +#[error("Invalid JWT type: {typ}")] +pub struct InvalidJsonWebTokenTypeError { + typ: String, +} + new_type![ - #[derive(Deserialize, Hash, Ord, PartialOrd, Serialize)] + /// JSON Web Token type field (typ) + /// + /// This type stores the raw (deserialized) value. + /// + /// To compare two different JSON Web Token types, please use the normalized version via [`JsonWebTokenType::normalize`]. + #[derive(Deserialize, Hash, Serialize)] JsonWebTokenType(String) + + impl { + /// Expands a [`JsonWebTokenType`] and produces a [`NormalizedJsonWebTokenType`] according to RFC2045 and RFC7515. + /// + /// See [RFC 2045 section 5.1](https://tools.ietf.org/html/rfc2045#section-5.1) for the full Content-Type Header Field spec. + /// See [RFC 7515 section 4.19](https://tools.ietf.org/html/rfc7515#section-4.1.9) for specific requirements of JSON Web Token Types. + pub fn normalize(&self) -> Result { + self.try_into() + } + } ]; +/// Normalized JSON Web Token type field (typ) +/// +/// This type stores the normalized value of a [`JsonWebTokenType`]. +/// To retrieve a normalized value according to RFC2045 and RFC7515 see [`JsonWebTokenType::normalize`] +/// +/// See [RFC 2045 section 5.1](https://tools.ietf.org/html/rfc2045#section-5.1) for the full Content-Type Header Field spec. +/// See [RFC 7515 section 4.1.9](https://tools.ietf.org/html/rfc7515#section-4.1.9) for specific requirements of JSON Web Token Types. +/// +/// It is recommended to instantiate `NormalizedJsonWebTokenType` objects via [`JsonWebTokenType`] and then call [`JsonWebTokenType::normalize`]. +/// +/// ```rust +/// # use openidconnect::{NormalizedJsonWebTokenType, JsonWebTokenType}; +/// let token_type = JsonWebTokenType::new("jwt+at".to_string()).normalize(); +/// // normalized value looks like "application/jwt+at" +/// # assert_eq!(*token_type.unwrap(), "application/jwt+at") +/// ``` +#[derive(Clone, Debug, Deserialize, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize)] +pub struct NormalizedJsonWebTokenType(String); + +impl std::ops::Deref for NormalizedJsonWebTokenType { + type Target = String; + fn deref(&self) -> &String { + &self.0 + } +} +impl From for String { + fn from(t: NormalizedJsonWebTokenType) -> String { + t.0 + } +} + +impl From for JsonWebTokenType { + fn from(t: NormalizedJsonWebTokenType) -> JsonWebTokenType { + JsonWebTokenType::new(t.0) + } +} + +impl TryFrom<&JsonWebTokenType> for NormalizedJsonWebTokenType { + type Error = InvalidJsonWebTokenTypeError; + + /// Normalizes a [`JsonWebTokenType`] and produces a [`NormalizedJsonWebTokenType`] according to RFC2045. + /// + /// See [RFC 2045 section 5.1](https://tools.ietf.org/html/rfc2045#section-5.1) for the full Content-Type Header Field spec. + /// See [RFC 7515 section 4.19](https://tools.ietf.org/html/rfc7515#section-4.1.9) for specific requirements of JSON Web Token Types. + fn try_from(t: &JsonWebTokenType) -> Result { + let lowercase_jwt_type = t.0.to_lowercase(); + if let Some(slash_location) = lowercase_jwt_type.find('/') { + if let Some(semicolon_location) = lowercase_jwt_type.find(';') { + // If '/' is not before ';' as then the MIME type is invalid + // e.g. some;arg="1/2" is invalid, but application/some;arg=1 is valid + // OR + // If MIME type has not at least one character + // OR + // If MIME subtype has not at least one character + if slash_location > semicolon_location + || slash_location == 0 + || slash_location.saturating_add(1) >= semicolon_location + { + Err(InvalidJsonWebTokenTypeError { + typ: lowercase_jwt_type, + }) + } else { + Ok(NormalizedJsonWebTokenType(lowercase_jwt_type)) + } + } else { + Ok(NormalizedJsonWebTokenType(lowercase_jwt_type)) + } + } else { + Ok(NormalizedJsonWebTokenType(format!( + "application/{lowercase_jwt_type}" + ))) + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum JsonWebTokenAlgorithm diff --git a/src/jwt/tests.rs b/src/jwt/tests.rs index 1d622b9..55c04e8 100644 --- a/src/jwt/tests.rs +++ b/src/jwt/tests.rs @@ -3,10 +3,10 @@ use crate::core::{ CoreRsaPrivateSigningKey, }; use crate::jwt::{ - JsonWebToken, JsonWebTokenAccess, JsonWebTokenAlgorithm, JsonWebTokenJsonPayloadSerde, - JsonWebTokenPayloadSerde, + InvalidJsonWebTokenTypeError, JsonWebToken, JsonWebTokenAccess, JsonWebTokenAlgorithm, + JsonWebTokenJsonPayloadSerde, JsonWebTokenPayloadSerde, }; -use crate::JsonWebKeyId; +use crate::{JsonWebKeyId, JsonWebTokenType}; use serde::{Deserialize, Serialize}; @@ -364,3 +364,32 @@ fn test_invalid_deserialization() { .expect("failed to deserialize"); assert_eq!(deserialized.unverified_payload().foo, "bar"); } + +#[test] +fn test_json_web_token_type_normalization() { + fn assert_token_type_normalization( + jwt_type_string: &str, + expected_normalized_jwt_type_string: &str, + ) -> Result<(), InvalidJsonWebTokenTypeError> { + let jwt_type = JsonWebTokenType::new(jwt_type_string.to_string()); + let normalized_jwt_type = jwt_type.normalize()?; + + assert_eq!(*normalized_jwt_type, expected_normalized_jwt_type_string); + Ok(()) + } + + assert_token_type_normalization("jwt", "application/jwt").unwrap(); + assert_token_type_normalization("jwt;arg=some", "application/jwt;arg=some").unwrap(); + assert!(assert_token_type_normalization("jwt;arg=some/other", "").is_err()); + assert!(assert_token_type_normalization("/jwt;arg=some/other", "").is_err()); + assert!(assert_token_type_normalization("application/;arg=some/other", "").is_err()); + assert_token_type_normalization("application/jwt", "application/jwt").unwrap(); + assert_token_type_normalization( + "application/jwt;arg=some/other", + "application/jwt;arg=some/other", + ) + .unwrap(); + assert_token_type_normalization("special/type", "special/type").unwrap(); + assert_token_type_normalization("special/type;arg=some", "special/type;arg=some").unwrap(); + assert_token_type_normalization("s/t;arg=some/o", "s/t;arg=some/o").unwrap(); +} diff --git a/src/lib.rs b/src/lib.rs index 9eb7a7a..dbb235b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -705,7 +705,7 @@ pub use crate::discovery::{ }; pub use crate::id_token::IdTokenFields; pub use crate::id_token::{IdToken, IdTokenClaims}; -pub use crate::jwt::JsonWebTokenError; +pub use crate::jwt::{JsonWebTokenError, JsonWebTokenType, NormalizedJsonWebTokenType}; pub use crate::logout::{LogoutProviderMetadata, LogoutRequest, ProviderMetadataWithLogout}; pub use crate::token::TokenResponse; // Flatten the module hierarchy involving types. They're only separated to improve code diff --git a/src/verification/mod.rs b/src/verification/mod.rs index d8b2a88..47b64f5 100644 --- a/src/verification/mod.rs +++ b/src/verification/mod.rs @@ -1,10 +1,10 @@ -use crate::jwt::{JsonWebToken, JsonWebTokenJsonPayloadSerde}; +use crate::jwt::{JsonWebToken, JsonWebTokenJsonPayloadSerde, NormalizedJsonWebTokenType}; use crate::user_info::UserInfoClaimsImpl; use crate::{ AdditionalClaims, Audience, AuthenticationContextClass, ClientId, ClientSecret, GenderClaim, IdTokenClaims, IssuerUrl, JsonWebKey, JsonWebKeyId, JsonWebKeySet, JsonWebTokenAccess, - JsonWebTokenAlgorithm, JsonWebTokenHeader, JweContentEncryptionAlgorithm, JwsSigningAlgorithm, - Nonce, SubjectIdentifier, + JsonWebTokenAlgorithm, JsonWebTokenHeader, JsonWebTokenType, JweContentEncryptionAlgorithm, + JwsSigningAlgorithm, Nonce, SubjectIdentifier, }; use chrono::{DateTime, Utc}; @@ -118,6 +118,7 @@ pub(crate) struct JwtClaimsVerifier<'a, K> where K: JsonWebKey, { + allowed_jose_types: Option>, allowed_algs: Option>, aud_match_required: bool, client_id: ClientId, @@ -140,6 +141,15 @@ where .cloned() .collect(), ), + allowed_jose_types: Some(HashSet::from([ + JsonWebTokenType::new("application/jwt".to_string()) + .normalize() + .expect("application/jwt should be a valid JWT type"), // used by many IdP, but not standardized + JsonWebTokenType::new("application/jose".to_string()) + .normalize() + .expect("application/jose should be a valid JWT type"), // standard as defined in https://tools.ietf.org/html/rfc7515#section-4.1.9 + // we do not support JOSE+JSON, so we omit this here in the default configuration + ])), aud_match_required: true, client_id, client_secret: None, @@ -181,6 +191,21 @@ where self } + /// Allows setting specific JOSE types. The verifier will check against them during verification. + /// + /// See [RFC 7515 section 4.1.9](https://tools.ietf.org/html/rfc7515#section-4.1.9) for more details. + pub fn set_allowed_jose_types(mut self, types: I) -> Self + where + I: IntoIterator, + { + self.allowed_jose_types = Some(types.into_iter().collect()); + self + } + pub fn allow_all_jose_types(mut self) -> Self { + self.allowed_jose_types = None; + self + } + pub fn set_client_secret(mut self, client_secret: ClientSecret) -> Self { self.client_secret = Some(client_secret); self @@ -195,6 +220,7 @@ where } fn validate_jose_header( + &self, jose_header: &JsonWebTokenHeader, ) -> Result<(), ClaimsVerificationError> where @@ -203,14 +229,29 @@ where >, { // The 'typ' header field must either be omitted or have the canonicalized value JWT. + // see https://tools.ietf.org/html/rfc7519#section-5.1 if let Some(ref jwt_type) = jose_header.typ { - if jwt_type.to_uppercase() != "JWT" { - return Err(ClaimsVerificationError::Unsupported(format!( - "unexpected or unsupported JWT type `{}`", - **jwt_type - ))); + if let Some(allowed_jose_types) = &self.allowed_jose_types { + // Check according to https://tools.ietf.org/html/rfc7515#section-4.1.9 + // See https://tools.ietf.org/html/rfc2045#section-5.1 for the full Content-Type Header Field spec. + // + // For sake of simplicity, we do not support matching on application types with parameters like + // application/example;part="1/2". If you know your parameters exactly, just set the whole Content Type manually. + let valid_jwt_type = if let Ok(normalized_jwt_type) = jwt_type.normalize() { + allowed_jose_types.contains(&normalized_jwt_type) + } else { + false + }; + + if !valid_jwt_type { + return Err(ClaimsVerificationError::Unsupported(format!( + "unexpected or unsupported JWT type `{}`", + **jwt_type + ))); + } } } + // The 'cty' header field must be omitted, since it's only used for JWTs that contain // content types other than JSON-encoded claims. This may include nested JWTs, such as if // JWE encryption is used. This is currently unsupported. @@ -250,7 +291,7 @@ where { { let jose_header = jwt.unverified_header(); - Self::validate_jose_header(jose_header)?; + self.validate_jose_header(jose_header)?; // The code below roughly follows the validation steps described in // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation @@ -569,6 +610,23 @@ where self } + /// Allows setting specific JOSE types. The verifier will check against them during verification. + /// + /// See [RFC 7515 section 4.1.9](https://tools.ietf.org/html/rfc7515#section-4.1.9) for more details. + pub fn set_allowed_jose_types(mut self, types: I) -> Self + where + I: IntoIterator, + { + self.jwt_verifier = self.jwt_verifier.set_allowed_jose_types(types); + self + } + + /// Allow all JSON Web Token Header types. + pub fn allow_all_jose_types(mut self) -> Self { + self.jwt_verifier = self.jwt_verifier.allow_all_jose_types(); + self + } + /// Specifies a function for verifying the `acr` claim. /// /// The function should return `Ok(())` if the claim is valid, or a string describing the error diff --git a/src/verification/tests.rs b/src/verification/tests.rs index bd73663..47fe12f 100644 --- a/src/verification/tests.rs +++ b/src/verification/tests.rs @@ -6,7 +6,9 @@ use crate::core::{ }; use crate::helpers::{timestamp_to_utc, Base64UrlEncodedBytes, Timestamp}; use crate::jwt::tests::{TEST_RSA_PRIV_KEY, TEST_RSA_PUB_KEY}; -use crate::jwt::{JsonWebToken, JsonWebTokenHeader, JsonWebTokenJsonPayloadSerde}; +use crate::jwt::{ + JsonWebToken, JsonWebTokenHeader, JsonWebTokenJsonPayloadSerde, JsonWebTokenType, +}; use crate::verification::{AudiencesClaim, IssuerClaim, JwtClaimsVerifier}; use crate::{ AccessToken, Audience, AuthenticationContextClass, AuthorizationCode, ClaimsVerificationError, @@ -36,9 +38,60 @@ fn assert_unsupported(result: Result, expected_su #[test] fn test_jose_header() { + let client_id = ClientId::new("my_client".to_string()); + let issuer = IssuerUrl::new("https://example.com".to_string()).unwrap(); + let verifier = CoreJwtClaimsVerifier::new( + client_id.clone(), + issuer.clone(), + CoreJsonWebKeySet::new(vec![]), + ); + + // Happy path JWT + verifier + .validate_jose_header( + &serde_json::from_str::("{\"alg\":\"RS256\", \"typ\":\"JWT\"}") + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + verifier + .validate_jose_header( + &serde_json::from_str::("{\"alg\":\"RS256\", \"typ\":\"jwt\"}") + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\", \"typ\":\"application/JWT\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + // Happy path JOSE + verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\", \"typ\":\"jose\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\", \"typ\":\"JOSE\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + // Unexpected JWT type. assert_unsupported( - CoreJwtClaimsVerifier::validate_jose_header( + verifier.validate_jose_header( &serde_json::from_str::( "{\"alg\":\"RS256\",\"typ\":\"NOT_A_JWT\"}", ) @@ -47,16 +100,168 @@ fn test_jose_header() { "unsupported JWT type", ); + // No typ at all. + verifier + .validate_jose_header( + &serde_json::from_str::("{\"alg\":\"RS256\"}") + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + // Specific JWT type from list. + { + let custom_verifier = CoreJwtClaimsVerifier::new( + client_id.clone(), + issuer.clone(), + CoreJsonWebKeySet::new(vec![]), + ) + .set_allowed_jose_types(vec![ + JsonWebTokenType::new("application/NOT_A_JWT".to_string()) + .normalize() + .unwrap(), + JsonWebTokenType::new("APPLICATION/AT+jwt".to_string()) + .normalize() + .unwrap(), + JsonWebTokenType::new("X-special-app/jwt;param=some".to_string()) + .normalize() + .unwrap(), + JsonWebTokenType::new("X-special-app/jwt".to_string()) + .normalize() + .unwrap(), + ]); + + custom_verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"NOT_A_JWT\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + custom_verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"application/NOT_A_JWT\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + assert_unsupported( + custom_verifier.validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"application/NOT_A_JWT;bla=test\"}", + ) + .expect("failed to deserialize"), + ), + "unsupported JWT type", + ); + + custom_verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"application/at+jwt\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + assert_unsupported( + custom_verifier.validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"NOT_A_JWT_REALLY\"}", + ) + .expect("failed to deserialize"), + ), + "unsupported JWT type", + ); + + custom_verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"X-special-app/jwt\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + custom_verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"X-special-app/jwt;param=some\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + assert_unsupported( + custom_verifier.validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"X-special-app/jwt;param=other\"}", + ) + .expect("failed to deserialize"), + ), + "unsupported JWT type", + ); + } + + // Allow all JWT types. + { + let custom_verifier = CoreJwtClaimsVerifier::new( + client_id.clone(), + issuer.clone(), + CoreJsonWebKeySet::new(vec![]), + ) + .allow_all_jose_types(); + + custom_verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"NOT_A_JWT\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + custom_verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"application/at+jwt\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + custom_verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"application/at+jwt;oidc=cool\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + + custom_verifier + .validate_jose_header( + &serde_json::from_str::( + "{\"alg\":\"RS256\",\"typ\":\"NOT_A_JWT_REALLY\"}", + ) + .expect("failed to deserialize"), + ) + .expect("JWT type should be allowed but is not"); + } + // Nested JWTs. assert_unsupported( - CoreJwtClaimsVerifier::validate_jose_header( + verifier.validate_jose_header( &serde_json::from_str::("{\"alg\":\"RS256\",\"cty\":\"JWT\"}") .expect("failed to deserialize"), ), "nested JWT", ); assert_unsupported( - CoreJwtClaimsVerifier::validate_jose_header( + verifier.validate_jose_header( &serde_json::from_str::( "{\"alg\":\"RS256\",\"cty\":\"NOT_A_JWT\"}", ) @@ -67,7 +272,7 @@ fn test_jose_header() { // Critical fields. Adapted from https://tools.ietf.org/html/rfc7515#appendix-E assert_unsupported( - CoreJwtClaimsVerifier::validate_jose_header( + verifier.validate_jose_header( &serde_json::from_str::( "{\ \"alg\":\"RS256\",\