diff --git a/docs/cedarling/cedarling-properties.md b/docs/cedarling/cedarling-properties.md index d871920f119..c0713ea363a 100644 --- a/docs/cedarling/cedarling-properties.md +++ b/docs/cedarling/cedarling-properties.md @@ -138,6 +138,22 @@ Below is an example of a bootstrap config in JSON format. - Note that properties set to `"disabled"`, an empty string `""`, zero `0`, and `null` can be ommited since they are the defaults. +#### Local JWKS + +A local JWKS can be used by setting the `CEDARLING_LOCAL_JWKS` bootstrap property to a path to a local JSON file. When providing a local Json Web Key Store (JWKS), the file must follow the following schema: + +```json +{ + "trusted_issuer_id": [ ... ] + "another_trusted_issuer_id": [ ... ] +} +``` + +- Where keys are `Trusted Issuer IDs` assigned to each key store +- and the values contains the JSON Web Keys as defined in [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517). +- The `trusted_issuers_id` is used to tag a JWKS with a unique identifier and enables using multiple key stores. + + ### Loading From YAML Below is an example of a bootstrap config in YAML format. diff --git a/jans-cedarling/bindings/cedarling_python/PYTHON_TYPES.md b/jans-cedarling/bindings/cedarling_python/PYTHON_TYPES.md index 7da43d4193a..f502bb2a450 100644 --- a/jans-cedarling/bindings/cedarling_python/PYTHON_TYPES.md +++ b/jans-cedarling/bindings/cedarling_python/PYTHON_TYPES.md @@ -54,7 +54,7 @@ Example ```python from cedarling import BootstrapConfig # Example configuration -bootstrap_config = NewBootstrapConfig({ +bootstrap_config = BootstrapConfig({ "application_name": "MyApp", "policy_store_uri": None, "policy_store_id": "policy123", diff --git a/jans-cedarling/bindings/cedarling_python/src/authorize/errors.rs b/jans-cedarling/bindings/cedarling_python/src/authorize/errors.rs index f0663fea262..239eea06531 100644 --- a/jans-cedarling/bindings/cedarling_python/src/authorize/errors.rs +++ b/jans-cedarling/bindings/cedarling_python/src/authorize/errors.rs @@ -23,6 +23,13 @@ create_exception!( "Error encountered while decoding JWT token data" ); +create_exception!( + authorize_errors, + ProcessTokens, + AuthorizeError, + "Error encountered while processing JWT token data" +); + create_exception!( authorize_errors, AccessTokenEntitiesError, @@ -138,7 +145,7 @@ macro_rules! errors_functions { // This function is used to convert `cedarling::AuthorizeError` to a Python exception. // For each possible case of `AuthorizeError`, we have created a corresponding Python exception that inherits from `cedarling::AuthorizeError`. errors_functions! { - DecodeTokens => DecodeTokens, + ProcessTokens => ProcessTokens, AccessTokenEntities => AccessTokenEntitiesError, CreateIdTokenEntity => CreateIdTokenEntityError, CreateUserinfoTokenEntity => CreateUserinfoTokenEntityError, diff --git a/jans-cedarling/bindings/cedarling_python/src/config/bootstrap_config.rs b/jans-cedarling/bindings/cedarling_python/src/config/bootstrap_config.rs index 7bdece7b3db..e07026639a2 100644 --- a/jans-cedarling/bindings/cedarling_python/src/config/bootstrap_config.rs +++ b/jans-cedarling/bindings/cedarling_python/src/config/bootstrap_config.rs @@ -6,7 +6,7 @@ */ use cedarling::bindings::PolicyStore; -use cedarling::{BootstrapConfigRaw, LoggerType, TrustMode, WorkloadBoolOp}; +use cedarling::{BootstrapConfigRaw, IdTokenTrustMode, LoggerType, WorkloadBoolOp}; use jsonwebtoken::Algorithm; use pyo3::exceptions::{PyKeyError, PyValueError}; use pyo3::prelude::*; @@ -17,7 +17,7 @@ use std::str::FromStr; /// BootstrapConfig /// =================== /// -/// A Python wrapper for the Rust `NewBootstrapConfig` struct. +/// A Python wrapper for the Rust `BootstrapConfig` struct. /// Configures the application, including authorization, logging, JWT validation, and policy store settings. /// /// Attributes @@ -65,7 +65,7 @@ use std::str::FromStr; /// from cedarling import BootstrapConfig /// /// # Example configuration -/// bootstrap_config = NewBootstrapConfig( +/// bootstrap_config = BootstrapConfig( /// application_name="MyApp", /// policy_store_uri=None, /// policy_store_id="policy123", @@ -508,7 +508,7 @@ impl TryFrom for cedarling::BootstrapConfig { .userinfo_exp_validation .try_into() .map_err(|e| PyValueError::new_err(format!("{}", e)))?, - id_token_trust_mode: TrustMode::from_str(value.id_token_trust_mode.as_str()) + id_token_trust_mode: IdTokenTrustMode::from_str(value.id_token_trust_mode.as_str()) .unwrap_or_default(), lock: value .lock diff --git a/jans-cedarling/bindings/cedarling_python/src/config/jwt_config.rs b/jans-cedarling/bindings/cedarling_python/src/config/jwt_config.rs deleted file mode 100644 index 2ea495ee1b2..00000000000 --- a/jans-cedarling/bindings/cedarling_python/src/config/jwt_config.rs +++ /dev/null @@ -1,72 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -use jsonwebtoken::Algorithm; -use pyo3::{exceptions::PyValueError, prelude::*}; -use std::{collections::HashSet, str::FromStr}; - -/// JwtConfig -/// ========= -/// -/// A Python wrapper for the Rust `cedarling::JwtConfig` struct. -/// Manages JWT validation settings in the `Cedarling` application, specifying supported signature algorithms. -/// -/// Attributes -/// ---------- -/// :param enabled: Enables JWT validation. -/// :param signature_algorithms: List of supported JWT signature algorithms. -/// -/// Example -/// ------- -/// ``` -/// # Initialize with JWT validation enabled -/// config = JwtConfig(enabled=True, signature_algorithms=["RS256", "HS256"]) -/// ``` -#[derive(Debug, Clone)] -#[pyclass(get_all, set_all)] -pub struct JwtConfig { - enabled: bool, - /// `CEDARLING_JWT_SIGNATURE_ALGORITHMS_SUPPORTED` in [bootstrap properties](https://github.com/JanssenProject/jans/wiki/Cedarling-Nativity-Plan#bootstrap-properties) documentation. - signature_algorithms: Option>, -} - -#[pymethods] -impl JwtConfig { - #[new] - #[pyo3(signature = (enabled, signature_algorithms=None))] - fn new(enabled: bool, signature_algorithms: Option>) -> PyResult { - Ok(JwtConfig { - enabled, - signature_algorithms, - }) - } -} - -impl TryFrom for cedarling::JwtConfig { - type Error = PyErr; - - fn try_from(value: JwtConfig) -> Result { - let cedarling_config = if value.enabled { - let str_algs = value.signature_algorithms.ok_or(PyValueError::new_err( - "Expected signature_algorithms for JwtConfig, but got: None", - ))?; - let mut signature_algorithms = HashSet::new(); - for alg in str_algs.iter() { - let alg = Algorithm::from_str(alg).map_err(|_| { - PyValueError::new_err(format!("Unsupported algorithm: {}", alg)) - })?; - signature_algorithms.insert(alg); - } - Self::Enabled { - signature_algorithms, - } - } else { - Self::Disabled - }; - Ok(cedarling_config) - } -} diff --git a/jans-cedarling/bindings/cedarling_python/src/config/mod.rs b/jans-cedarling/bindings/cedarling_python/src/config/mod.rs index 1b4e5265f8b..8f1b8c3a70e 100644 --- a/jans-cedarling/bindings/cedarling_python/src/config/mod.rs +++ b/jans-cedarling/bindings/cedarling_python/src/config/mod.rs @@ -8,11 +8,8 @@ use pyo3::prelude::*; use pyo3::Bound; pub(crate) mod bootstrap_config; -mod jwt_config; pub fn register_entities(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; - m.add_class::()?; - Ok(()) } diff --git a/jans-cedarling/cedarling/Cargo.toml b/jans-cedarling/cedarling/Cargo.toml index 94f23451c3f..d9b40648d37 100644 --- a/jans-cedarling/cedarling/Cargo.toml +++ b/jans-cedarling/cedarling/Cargo.toml @@ -25,6 +25,7 @@ derive_more = { version = "1.0.0", features = [ "display", "error", ] } +time = { version = "0.3.36", features = ["wasm-bindgen"] } regex = "1.11.1" [dev-dependencies] diff --git a/jans-cedarling/cedarling/examples/authorize_with_jwt_validation.rs b/jans-cedarling/cedarling/examples/authorize_with_jwt_validation.rs index 2fc80997515..fa9a6f7e2f9 100644 --- a/jans-cedarling/cedarling/examples/authorize_with_jwt_validation.rs +++ b/jans-cedarling/cedarling/examples/authorize_with_jwt_validation.rs @@ -6,8 +6,9 @@ */ use cedarling::{ - AuthorizationConfig, BootstrapConfig, Cedarling, JwtConfig, LogConfig, LogTypeConfig, - PolicyStoreConfig, PolicyStoreSource, Request, ResourceData, WorkloadBoolOp, + AuthorizationConfig, BootstrapConfig, Cedarling, IdTokenTrustMode, JwtConfig, LogConfig, + LogTypeConfig, PolicyStoreConfig, PolicyStoreSource, Request, ResourceData, + TokenValidationConfig, WorkloadBoolOp, }; use jsonwebtoken::Algorithm; use std::collections::{HashMap, HashSet}; @@ -19,8 +20,15 @@ fn main() -> Result<(), Box> { // Configure JWT validation settings. Enable the JwtService to validate JWT tokens // using specific algorithms: `HS256` and `RS256`. Only tokens signed with these algorithms // will be accepted; others will be marked as invalid during validation. - let jwt_config = JwtConfig::Enabled { - signature_algorithms: HashSet::from_iter([Algorithm::HS256, Algorithm::RS256]), + let jwt_config = JwtConfig { + jwks: None, + jwt_sig_validation: true, + jwt_status_validation: false, + id_token_trust_mode: IdTokenTrustMode::None, + signature_algorithms_supported: HashSet::from_iter([Algorithm::HS256, Algorithm::RS256]), + access_token_config: TokenValidationConfig::access_token(), + id_token_config: TokenValidationConfig::id_token(), + userinfo_token_config: TokenValidationConfig::userinfo_token(), }; // You must change this with your own tokens diff --git a/jans-cedarling/cedarling/examples/authorize_without_jwt_validation.rs b/jans-cedarling/cedarling/examples/authorize_without_jwt_validation.rs index ae17031d916..5b5844cd7cc 100644 --- a/jans-cedarling/cedarling/examples/authorize_without_jwt_validation.rs +++ b/jans-cedarling/cedarling/examples/authorize_without_jwt_validation.rs @@ -6,7 +6,7 @@ */ use cedarling::{ - AuthorizationConfig, BootstrapConfig, Cedarling, JwtConfig, LogConfig, LogTypeConfig, + AuthorizationConfig, BootstrapConfig, Cedarling, LogConfig, LogTypeConfig, JwtConfig, PolicyStoreConfig, PolicyStoreSource, Request, ResourceData, WorkloadBoolOp, }; use std::collections::HashMap; @@ -22,7 +22,7 @@ fn main() -> Result<(), Box> { policy_store_config: PolicyStoreConfig { source: PolicyStoreSource::Yaml(POLICY_STORE_RAW.to_string()), }, - jwt_config: JwtConfig::Disabled, + jwt_config: JwtConfig::new_without_validation(), authorization_config: AuthorizationConfig { use_user_principal: true, use_workload_principal: true, diff --git a/jans-cedarling/cedarling/examples/log_init.rs b/jans-cedarling/cedarling/examples/log_init.rs index 3ad3375f77e..b36d7d05370 100644 --- a/jans-cedarling/cedarling/examples/log_init.rs +++ b/jans-cedarling/cedarling/examples/log_init.rs @@ -46,7 +46,7 @@ fn main() -> Result<(), Box> { policy_store_config: PolicyStoreConfig { source: PolicyStoreSource::Yaml(POLICY_STORE_RAW.to_string()), }, - jwt_config: JwtConfig::Disabled, + jwt_config: JwtConfig::new_without_validation(), authorization_config: AuthorizationConfig { use_user_principal: true, use_workload_principal: true, diff --git a/jans-cedarling/cedarling/src/authz/authorize_result.rs b/jans-cedarling/cedarling/src/authz/authorize_result.rs index 4e0188c1027..12c690f55ba 100644 --- a/jans-cedarling/cedarling/src/authz/authorize_result.rs +++ b/jans-cedarling/cedarling/src/authz/authorize_result.rs @@ -1,3 +1,10 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + use cedar_policy::Decision; use crate::bootstrap_config::WorkloadBoolOp; diff --git a/jans-cedarling/cedarling/src/authz/entities/mod.rs b/jans-cedarling/cedarling/src/authz/entities/mod.rs index 9e7e47c7c40..38e487a7994 100644 --- a/jans-cedarling/cedarling/src/authz/entities/mod.rs +++ b/jans-cedarling/cedarling/src/authz/entities/mod.rs @@ -30,8 +30,8 @@ use create::{build_entity_uid, create_entity, parse_namespace_and_typename, Enti use super::request::ResourceData; use super::token_data::TokenPayload; -pub(crate) type DecodeTokensResult<'a> = - jwt::DecodeTokensResult<'a, AccessTokenData, IdTokenData, UserInfoTokenData>; +pub(crate) type ProcessTokensResult<'a> = + jwt::ProcessTokensResult<'a, AccessTokenData, IdTokenData, UserInfoTokenData>; /// Access token entities pub struct AccessTokenEntities { @@ -117,7 +117,7 @@ pub fn create_id_token_entity( /// Create user entity pub fn create_user_entity( policy_store: &PolicyStore, - tokens: &DecodeTokensResult, + tokens: &ProcessTokensResult, parents: HashSet, trusted_issuer: &TrustedIssuer, ) -> Result { @@ -211,7 +211,7 @@ pub enum RoleEntityError { /// Create `Role` entity from based on `TrustedIssuer` or default value of `RoleMapping` pub fn create_role_entities( policy_store: &PolicyStore, - tokens: &DecodeTokensResult, + tokens: &ProcessTokensResult, trusted_issuer: &TrustedIssuer, ) -> Result, RoleEntityError> { // get role mapping or default value diff --git a/jans-cedarling/cedarling/src/authz/mod.rs b/jans-cedarling/cedarling/src/authz/mod.rs index 382bf4c29d0..acf5cbf36c4 100644 --- a/jans-cedarling/cedarling/src/authz/mod.rs +++ b/jans-cedarling/cedarling/src/authz/mod.rs @@ -30,7 +30,7 @@ mod token_data; pub use authorize_result::AuthorizeResult; use cedar_policy::{Entities, Entity, EntityUid, Response}; use entities::CedarPolicyCreateTypeError; -use entities::DecodeTokensResult; +use entities::ProcessTokensResult; use entities::ResourceEntityError; use entities::{ create_access_token_entities, create_id_token_entity, create_role_entities, create_user_entity, @@ -53,7 +53,6 @@ pub(crate) struct AuthzConfig { /// Authorization Service /// The primary service of the Cedarling application responsible for evaluating authorization requests. /// It leverages other services as needed to complete its evaluations. -#[allow(dead_code)] pub struct Authz { config: AuthzConfig, authorizer: cedar_policy::Authorizer, @@ -211,13 +210,13 @@ impl Authz { let policy_store = &self.config.policy_store; // decode JWT tokens to structs AccessTokenData, IdTokenData, UserInfoTokenData using jwt service - let decode_result: DecodeTokensResult = self + let decode_result: ProcessTokensResult = self .config .jwt_service - .decode_tokens::( + .process_tokens::( &request.access_token, &request.id_token, - &request.userinfo_token, + Some(&request.userinfo_token), )?; let trusted_issuer = decode_result.trusted_issuer.unwrap_or_default(); @@ -312,12 +311,20 @@ impl AuthorizeEntitiesData { } } +/// Error type for Authorization Service +#[derive(thiserror::Error, Debug)] +pub enum AuthzInitError { + /// Error encountered while Initializing [`JwtService`] + #[error(transparent)] + JwtService(#[from] jwt::JwtServiceInitError), +} + /// Error type for Authorization Service #[derive(thiserror::Error, Debug)] pub enum AuthorizeError { - /// Error encountered while decoding JWT token data + /// Error encountered while processing JWT token data #[error(transparent)] - DecodeTokens(#[from] jwt::JwtServiceError), + ProcessTokens(#[from] jwt::JwtProcessingError), /// Error encountered while creating access token entities #[error("{0}")] AccessTokenEntities(#[from] AccessTokenEntitiesError), diff --git a/jans-cedarling/cedarling/src/bootstrap_config/decode.rs b/jans-cedarling/cedarling/src/bootstrap_config/decode.rs index d9e8449e86d..7f9f481c17b 100644 --- a/jans-cedarling/cedarling/src/bootstrap_config/decode.rs +++ b/jans-cedarling/cedarling/src/bootstrap_config/decode.rs @@ -1,6 +1,14 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + use super::{ - authorization_config::AuthorizationConfig, BootstrapConfig, JwtConfig, LogConfig, - LogTypeConfig, MemoryLogConfig, PolicyStoreConfig, PolicyStoreSource, + authorization_config::AuthorizationConfig, BootstrapConfig, IdTokenTrustMode, JwtConfig, + LogConfig, LogTypeConfig, MemoryLogConfig, PolicyStoreConfig, PolicyStoreSource, + TokenValidationConfig, }; use crate::common::policy_store::PolicyStore; use jsonwebtoken::Algorithm; @@ -162,7 +170,7 @@ pub struct BootstrapConfigRaw { /// 2. if a Userinfo token is present, the sub matches the id_token, and that /// the aud matches the access token client_id. #[serde(rename = "CEDARLING_ID_TOKEN_TRUST_MODE", default)] - pub id_token_trust_mode: TrustMode, + pub id_token_trust_mode: IdTokenTrustMode, /// If Enabled, the Cedarling will connect to the Lock Master for policies, /// and subscribe for SSE events. @@ -206,30 +214,6 @@ pub struct BootstrapConfigRaw { pub listen_sse: FeatureToggle, } -/// TrustMode can be `Strict` or `None` -#[derive(Default, Debug, PartialEq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TrustMode { - /// `Strict` level of validation - #[default] - Strict, - /// Disable validation. - None, -} - -impl FromStr for TrustMode { - type Err = ParseTrustModeError; - - fn from_str(s: &str) -> Result { - let s = s.to_lowercase(); - match s.as_str() { - "strict" => Ok(TrustMode::Strict), - "none" => Ok(TrustMode::None), - _ => Err(ParseTrustModeError { trust_mode: s }), - } - } -} - /// Type of logger #[derive(Debug, PartialEq, Deserialize, Default)] #[serde(rename_all = "lowercase")] @@ -264,7 +248,7 @@ impl FromStr for LoggerType { } /// Enum varians that represent if feature is enabled or disabled -#[derive(Debug, PartialEq, Deserialize, Default)] +#[derive(Debug, PartialEq, Deserialize, Default, Copy, Clone)] #[serde(rename_all = "lowercase")] pub enum FeatureToggle { /// Represent as disabled. @@ -274,6 +258,15 @@ pub enum FeatureToggle { Enabled, } +impl From for bool { + fn from(value: FeatureToggle) -> bool { + match value { + FeatureToggle::Disabled => false, + FeatureToggle::Enabled => true, + } + } +} + impl TryFrom for FeatureToggle { type Error = ParseFeatureToggleError; @@ -448,12 +441,34 @@ impl BootstrapConfig { (Some(_), Some(_)) => Err(BootstrapDecodingError::ConflictingPolicyStores)?, }; - // Decode JWT Config - // TODO: update this once Jwt Service implements the new bootstrap properties - let jwt_config = match raw.jwt_sig_validation { - FeatureToggle::Disabled => JwtConfig::Disabled, - FeatureToggle::Enabled => JwtConfig::Enabled { - signature_algorithms: raw.jwt_signature_algorithms_supported.clone(), + // JWT Config + let jwt_config = JwtConfig { + jwks: None, + jwt_sig_validation: raw.jwt_sig_validation.into(), + jwt_status_validation: raw.jwt_status_validation.into(), + id_token_trust_mode: raw.id_token_trust_mode, + signature_algorithms_supported: raw.jwt_signature_algorithms_supported.clone(), + access_token_config: TokenValidationConfig { + iss_validation: raw.at_iss_validation.into(), + jti_validation: raw.at_jti_validation.into(), + nbf_validation: raw.at_nbf_validation.into(), + exp_validation: raw.at_exp_validation.into(), + ..Default::default() + }, + id_token_config: TokenValidationConfig { + iss_validation: raw.idt_iss_validation.into(), + aud_validation: raw.idt_aud_validation.into(), + sub_validation: raw.idt_sub_validation.into(), + exp_validation: raw.idt_exp_validation.into(), + iat_validation: raw.idt_iat_validation.into(), + ..Default::default() + }, + userinfo_token_config: TokenValidationConfig { + iss_validation: raw.userinfo_iss_validation.into(), + aud_validation: raw.userinfo_aud_validation.into(), + sub_validation: raw.userinfo_sub_validation.into(), + exp_validation: raw.userinfo_exp_validation.into(), + ..Default::default() }, }; diff --git a/jans-cedarling/cedarling/src/bootstrap_config/jwt_config.rs b/jans-cedarling/cedarling/src/bootstrap_config/jwt_config.rs index f746c863d17..a2d4ef94301 100644 --- a/jans-cedarling/cedarling/src/bootstrap_config/jwt_config.rs +++ b/jans-cedarling/cedarling/src/bootstrap_config/jwt_config.rs @@ -6,19 +6,263 @@ */ use jsonwebtoken::Algorithm; -use std::collections::HashSet; - -/// A set of properties used to configure JWT in the `Cedarling` application. -#[derive(Debug, Clone, PartialEq)] -pub enum JwtConfig { - /// `CEDARLING_JWT_VALIDATION` in [bootstrap properties](https://github.com/JanssenProject/jans/wiki/Cedarling-Nativity-Plan#bootstrap-properties) documentation. - /// Represent `Disabled` value. - /// Meaning no JWT validation and no controls if Cedarling will discard id_token without an access token with the corresponding client_id. - Disabled, - /// `CEDARLING_JWT_VALIDATION` in [bootstrap properties](https://github.com/JanssenProject/jans/wiki/Cedarling-Nativity-Plan#bootstrap-properties) documentation. - /// Represent `Enabled` value - Enabled { - /// `CEDARLING_JWT_SIGNATURE_ALGORITHMS_SUPPORTED` in [bootstrap properties](https://github.com/JanssenProject/jans/wiki/Cedarling-Nativity-Plan#bootstrap-properties) documentation. - signature_algorithms: HashSet, - }, +use serde::Deserialize; +use std::{collections::HashSet, str::FromStr}; + +/// The set of Bootstrap properties related to JWT validation. +#[derive(Debug, PartialEq)] +pub struct JwtConfig { + /// A Json Web Key Store (JWKS) with public keys. + /// + /// If this is used, Cedarling will no longer try to fetch JWK Stores from + /// a trustede identity provider and stick to using the local JWKS. + pub jwks: Option, + /// Check the signature for all the Json Web Tokens. + /// + /// This Requires the `iss` claim to be present in all the tokens and + /// and the scheme must be `https`. + /// + /// This setting overrides the `iss` validation settings in the following: + /// + /// - `access_token_config` + /// - `id_token_config` + /// - `userinfo_token_config` + pub jwt_sig_validation: bool, + /// Whether to check the status of the JWT. + /// + /// On startup, the Cedarling will fetch and retreive the latest Status List + /// JWT from the `.well-known/openid-configuration` via the `status_list_endpoint` + /// claim and cache it. + /// + /// See the [`IETF Draft`] for more info. + /// + /// [`IETF Draft`]: https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/ + pub jwt_status_validation: bool, + /// Sets the validation level for ID tokens. + /// + /// The available levels are [`None`] and [`Strict`]. + /// + /// # Strict Mode + /// + /// In `Strict` mode, the following conditions must be met for a token + /// to be considered valid: + /// + /// - The `id_token`'s `aud` (audience) must match the `access_token`'s `client_id` + /// - If a Userinfo token is present: + /// - Its `sub` (subject) must match the `id_token`'s `sub`. + /// - Its `aud` (audience) must match the `access_token`'s `client_id`. + /// + /// [`None`]: IdTokenTrustMode::None + /// [`Strict`]: IdTokenTrustMode::Strict + pub id_token_trust_mode: IdTokenTrustMode, + /// Only tokens signed with algorithms in this list can be valid. + pub signature_algorithms_supported: HashSet, + /// Validation options related to the Access token + pub access_token_config: TokenValidationConfig, + /// Validation options related to the Id token + pub id_token_config: TokenValidationConfig, + /// Validation options related to the Userinfo token + pub userinfo_token_config: TokenValidationConfig, +} + +/// Validation options related to JSON Web Tokens (JWT). +/// +/// This struct provides the configuration for validating common JWT claims (`iss`, +/// `aud`, `sub`, `jti`, `exp`, `nbf`) across different types of JWTs. +/// +/// The default configuration for Access Tokens, ID Tokens, and Userinfo Tokens +/// can be easily instantiated via the provided methods. +#[derive(Debug, Default, PartialEq)] +pub struct TokenValidationConfig { + /// Requires the `iss` claim to be present in the JWT and the scheme + /// must be `https`. + pub iss_validation: bool, + /// Requires the `aud` claim to be present in the JWT. + pub aud_validation: bool, + /// Requires the `sub` claim to be present in the JWT. + pub sub_validation: bool, + /// Requires the `jti` claim to be present in the JWT. + pub jti_validation: bool, + /// Requires the `iat` claim to be present in the JWT. + pub iat_validation: bool, + /// Requires the `exp` claim to be present in the JWT and the current + /// timestamp isn't past the specified timestamp in the token. + pub exp_validation: bool, + /// Requires the `nbf` claim to be present in the JWT. + pub nbf_validation: bool, +} + +impl TokenValidationConfig { + /// Collects all the required claims into a HashSet. + pub fn required_claims(&self) -> HashSet> { + let mut req_claims = HashSet::new(); + if self.iss_validation { + req_claims.insert("iss".into()); + } + if self.aud_validation { + req_claims.insert("aud".into()); + } + if self.sub_validation { + req_claims.insert("sub".into()); + } + if self.jti_validation { + req_claims.insert("jti".into()); + } + if self.iat_validation { + req_claims.insert("iat".into()); + } + if self.exp_validation { + req_claims.insert("exp".into()); + } + if self.nbf_validation { + req_claims.insert("nbf".into()); + } + req_claims + } + + /// Returns a default configuration for validating Access Tokens. + /// + /// This configuration requires the following: + /// - `iss` (Issuer) + /// - `jti` (JWT ID) + /// - `exp` (Expiration) + pub fn access_token() -> Self { + Self { + iss_validation: true, + jti_validation: true, + exp_validation: true, + nbf_validation: false, + aud_validation: false, + sub_validation: false, + iat_validation: false, + } + } + + /// Returns a default configuration for validating ID Tokens. + /// + /// This configuration requires the following: + /// - `iss` (Issuer) + /// - `aud` (Audience) + /// - `sub` (Subject) + /// - `exp` (Expiration) + pub fn id_token() -> Self { + Self { + iss_validation: true, + aud_validation: true, + sub_validation: true, + exp_validation: true, + iat_validation: false, + jti_validation: false, + nbf_validation: false, + } + } + + /// Returns a default configuration for validating Userinfo Tokens. + /// + /// This configuration requires the following: + /// - `iss` (issuer) + /// - `aud` (audience) + /// - `sub` (subject) + /// - `exp` (expiration) + pub fn userinfo_token() -> Self { + Self { + iss_validation: true, + aud_validation: true, + sub_validation: true, + exp_validation: true, + jti_validation: false, + iat_validation: false, + nbf_validation: false, + } + } +} + +/// Defines the level of validation for ID tokens. +#[derive(Debug, Clone, PartialEq, Default, Deserialize, Copy)] +#[serde(rename_all = "lowercase")] +pub enum IdTokenTrustMode { + /// No validation is performed on the ID token. + None, + /// Strict validation of the ID token. + /// + /// In this mode, the following conditions must be met: + /// + /// - The `id_token`'s `aud` (audience) must match the `access_token`'s `client_id`. + /// - If a Userinfo token is present: + /// - Its `sub` (subject) must match the `id_token`'s `sub`. + /// - Its `aud` must match the `access_token`'s `client_id`. + #[default] + Strict, +} + +impl FromStr for IdTokenTrustMode { + type Err = IdTknTrustModeParseError; + + fn from_str(s: &str) -> Result { + let s = s.to_lowercase(); + match s.as_str() { + "strict" => Ok(IdTokenTrustMode::Strict), + "none" => Ok(IdTokenTrustMode::None), + _ => Err(IdTknTrustModeParseError { trust_mode: s }), + } + } +} + +/// Error when parsing [`IdTokenTrustMode`] +#[derive(Default, Debug, derive_more::Display, derive_more::Error)] +#[display("Invalid `IdTokenTrustMode`: {trust_mode}. should be `strict` or `none`")] +pub struct IdTknTrustModeParseError { + trust_mode: String, +} + +impl Default for JwtConfig { + /// Cedarling will use the strictest validation options by default. + fn default() -> Self { + Self { + jwks: None, + jwt_sig_validation: true, + jwt_status_validation: true, + id_token_trust_mode: IdTokenTrustMode::Strict, + signature_algorithms_supported: HashSet::new(), + access_token_config: TokenValidationConfig::access_token(), + id_token_config: TokenValidationConfig::id_token(), + userinfo_token_config: TokenValidationConfig::userinfo_token(), + } + } +} + +impl JwtConfig { + /// Creates a new `JwtConfig` instance with validation turned off for all tokens. + pub fn new_without_validation() -> Self { + Self { + jwks: None, + jwt_sig_validation: false, + jwt_status_validation: false, + id_token_trust_mode: IdTokenTrustMode::None, + signature_algorithms_supported: HashSet::new(), + access_token_config: TokenValidationConfig::default(), + id_token_config: TokenValidationConfig::default(), + userinfo_token_config: TokenValidationConfig::default(), + } + .allow_all_algorithms() + } + + /// Adds all supported algorithms to to `signature_algorithms_supported`. + pub fn allow_all_algorithms(mut self) -> Self { + self.signature_algorithms_supported = HashSet::from_iter([ + Algorithm::HS256, + Algorithm::HS384, + Algorithm::HS512, + Algorithm::ES256, + Algorithm::ES384, + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, + Algorithm::EdDSA, + ]); + self + } } diff --git a/jans-cedarling/cedarling/src/bootstrap_config/mod.rs b/jans-cedarling/cedarling/src/bootstrap_config/mod.rs index aab08bb9da6..fd99d3d4ac1 100644 --- a/jans-cedarling/cedarling/src/bootstrap_config/mod.rs +++ b/jans-cedarling/cedarling/src/bootstrap_config/mod.rs @@ -20,7 +20,7 @@ pub use jwt_config::*; pub use log_config::*; pub use policy_store_config::*; mod decode; -pub use decode::{BootstrapConfigRaw, FeatureToggle, LoggerType, TrustMode, WorkloadBoolOp}; +pub use decode::{BootstrapConfigRaw, FeatureToggle, LoggerType, WorkloadBoolOp}; /// Bootstrap configuration /// properties for configuration [`Cedarling`](crate::Cedarling) application. @@ -135,8 +135,31 @@ mod test { Path::new("../test_files/policy-store_blobby.json").into(), ), }, - jwt_config: crate::JwtConfig::Enabled { - signature_algorithms: HashSet::from_iter([Algorithm::HS256, Algorithm::RS256]), + jwt_config: JwtConfig { + jwks: None, + jwt_sig_validation: true, + jwt_status_validation: false, + id_token_trust_mode: IdTokenTrustMode::Strict, + signature_algorithms_supported: HashSet::from([Algorithm::HS256, Algorithm::RS256]), + access_token_config: TokenValidationConfig { + exp_validation: true, + ..Default::default() + }, + id_token_config: TokenValidationConfig { + iss_validation: true, + sub_validation: true, + exp_validation: true, + iat_validation: true, + aud_validation: true, + ..Default::default() + }, + userinfo_token_config: TokenValidationConfig { + iss_validation: true, + sub_validation: true, + aud_validation: true, + exp_validation: true, + ..Default::default() + }, }, authorization_config: AuthorizationConfig { use_user_principal: true, @@ -165,8 +188,31 @@ mod test { Path::new("../test_files/policy-store_blobby.json").into(), ), }, - jwt_config: crate::JwtConfig::Enabled { - signature_algorithms: HashSet::from_iter([Algorithm::HS256, Algorithm::RS256]), + jwt_config: JwtConfig { + jwks: None, + jwt_sig_validation: true, + jwt_status_validation: false, + id_token_trust_mode: IdTokenTrustMode::Strict, + signature_algorithms_supported: HashSet::from([Algorithm::HS256, Algorithm::RS256]), + access_token_config: TokenValidationConfig { + exp_validation: true, + ..Default::default() + }, + id_token_config: TokenValidationConfig { + iss_validation: true, + sub_validation: true, + exp_validation: true, + iat_validation: true, + aud_validation: true, + ..Default::default() + }, + userinfo_token_config: TokenValidationConfig { + iss_validation: true, + sub_validation: true, + aud_validation: true, + exp_validation: true, + ..Default::default() + }, }, authorization_config: AuthorizationConfig { use_user_principal: true, diff --git a/jans-cedarling/cedarling/src/common/cedar_schema/cedar_json.rs b/jans-cedarling/cedarling/src/common/cedar_schema/cedar_json.rs index 00dee39c8c3..c1f59734841 100644 --- a/jans-cedarling/cedarling/src/common/cedar_schema/cedar_json.rs +++ b/jans-cedarling/cedarling/src/common/cedar_schema/cedar_json.rs @@ -36,7 +36,6 @@ pub enum GetCedarTypeError { /// Enum to get info about type based on name. /// Is used as a result in [`CedarSchemaJson::find_type`] pub enum SchemaDefinedType<'a> { - #[allow(dead_code)] Entity(&'a CedarSchemaEntityShape), CommonType(&'a CedarSchemaRecord), } diff --git a/jans-cedarling/cedarling/src/common/cedar_schema/mod.rs b/jans-cedarling/cedarling/src/common/cedar_schema/mod.rs index 42eb69dc2e1..df9bb8dd367 100644 --- a/jans-cedarling/cedarling/src/common/cedar_schema/mod.rs +++ b/jans-cedarling/cedarling/src/common/cedar_schema/mod.rs @@ -258,7 +258,7 @@ mod deserialize { // In fact this fails because of limitations in cedar_policy::Policy::from_json // see PolicyContentType - #[allow(dead_code)] + #[test] fn test_both_ok() { static POLICY_STORE_RAW: &str = include_str!("../../../../test_files/policy-store_blobby.json"); diff --git a/jans-cedarling/cedarling/src/init/service_config.rs b/jans-cedarling/cedarling/src/init/service_config.rs index 7008550a3f4..462ff56e9cc 100644 --- a/jans-cedarling/cedarling/src/init/service_config.rs +++ b/jans-cedarling/cedarling/src/init/service_config.rs @@ -5,59 +5,29 @@ * Copyright (c) 2024, Gluu, Inc. */ -use std::collections::HashSet; - use super::policy_store::{load_policy_store, PolicyStoreLoadError}; +use crate::bootstrap_config; use crate::common::policy_store::PolicyStore; -use crate::jwt::TrustedIssuerAndOpenIdConfig; -use crate::{bootstrap_config, jwt}; use bootstrap_config::BootstrapConfig; /// Configuration that hold validated infomation from bootstrap config #[derive(typed_builder::TypedBuilder, Clone)] pub(crate) struct ServiceConfig { pub policy_store: PolicyStore, - pub jwt_algorithms: HashSet, - pub trusted_issuers_and_openid: Vec, } #[derive(thiserror::Error, Debug)] pub enum ServiceConfigError { - /// Parse jwt algorithm error. - #[error("could not parse an algorithim defined in the config: {0}")] - ParseAlgorithm(#[from] jwt::ParseAlgorithmError), /// Error that may occur during loading the policy store. #[error("Could not load policy: {0}")] PolicyStore(#[from] PolicyStoreLoadError), - #[error("Could not load openid config: {0}")] - // TODO: refactor error when remove panicking on init JWT server - OpenIdConfig(#[from] jwt::decoding_strategy::key_service::KeyServiceError), } impl ServiceConfig { pub fn new(bootstrap: &BootstrapConfig) -> Result { - let client = jwt::HttpClient::new()?; let policy_store = load_policy_store(&bootstrap.policy_store_config)?; - // We fetch `OpenidConfig` using `TrustedIssuer` - // and store both in the `TrustedIssuerAndOpenIdConfig` structure. - let trusted_issuers_and_openid = policy_store - .trusted_issuers - .clone() // we need clone to avoid borrowing - .unwrap_or_default() - .values() - .map(|trusted_issuer| { - TrustedIssuerAndOpenIdConfig::fetch(trusted_issuer.clone(), &client) - }) - .collect::, _>>()?; - - let builder = ServiceConfig::builder() - .jwt_algorithms(match &bootstrap.jwt_config { - crate::JwtConfig::Disabled => HashSet::new(), - crate::JwtConfig::Enabled { signature_algorithms, .. } => signature_algorithms.clone(), - }) - .policy_store(policy_store) - .trusted_issuers_and_openid(trusted_issuers_and_openid); + let builder = ServiceConfig::builder().policy_store(policy_store); Ok(builder.build()) } diff --git a/jans-cedarling/cedarling/src/init/service_factory.rs b/jans-cedarling/cedarling/src/init/service_factory.rs index dcdb75f5dd9..7a1eac52311 100644 --- a/jans-cedarling/cedarling/src/init/service_factory.rs +++ b/jans-cedarling/cedarling/src/init/service_factory.rs @@ -11,10 +11,10 @@ use std::sync::Arc; use crate::bootstrap_config::BootstrapConfig; use crate::common::policy_store::PolicyStore; -use crate::jwt::{JwtService, JwtServiceConfig}; +use crate::jwt::{JwtService, JwtServiceInitError}; use super::service_config::ServiceConfig; -use crate::authz::{Authz, AuthzConfig}; +use crate::authz::{Authz, AuthzConfig, AuthzInitError}; use crate::common::app_types; use crate::log; @@ -74,42 +74,34 @@ impl<'a> ServiceFactory<'a> { } // get jwt service - pub fn jwt_service(&mut self) -> Arc { + pub fn jwt_service(&mut self) -> Result, JwtServiceInitError> { if let Some(jwt_service) = &self.container.jwt_service { - jwt_service.clone() + Ok(jwt_service.clone()) } else { - let config = match self.bootstrap_config.jwt_config { - crate::JwtConfig::Disabled => JwtServiceConfig::WithoutValidation { - trusted_idps: self.service_config.trusted_issuers_and_openid.clone(), - }, - crate::JwtConfig::Enabled { .. } => JwtServiceConfig::WithValidation { - supported_algs: self.service_config.jwt_algorithms.clone(), - trusted_idps: self.service_config.trusted_issuers_and_openid.clone(), - }, - }; - - let service = Arc::new(JwtService::new_with_config(config)); + let config = &self.bootstrap_config.jwt_config; + let trusted_issuers = self.policy_store().trusted_issuers; + let service = Arc::new(JwtService::new(config, trusted_issuers)?); self.container.jwt_service = Some(service.clone()); - service + Ok(service) } } // get authz service - pub fn authz_service(&mut self) -> Arc { + pub fn authz_service(&mut self) -> Result, AuthzInitError> { if let Some(authz) = &self.container.authz_service { - authz.clone() + Ok(authz.clone()) } else { let config = AuthzConfig { log_service: self.log_service(), pdp_id: self.pdp_id(), application_name: self.application_name(), policy_store: self.policy_store(), - jwt_service: self.jwt_service(), + jwt_service: self.jwt_service()?, authorization: self.bootstrap_config.authorization_config, }; let service = Arc::new(Authz::new(config)); self.container.authz_service = Some(service.clone()); - service + Ok(service) } } } diff --git a/jans-cedarling/cedarling/src/jwt/README.md b/jans-cedarling/cedarling/src/jwt/README.md index 4eb119aa7ed..a559c41e3dc 100644 --- a/jans-cedarling/cedarling/src/jwt/README.md +++ b/jans-cedarling/cedarling/src/jwt/README.md @@ -1,56 +1,90 @@ # JWT Service -## Overview - The JwtService module is responsible for decoding and validating JWTs. This service is internal and designed to be used within the library by other modules, not directly by users of the library. ## Functionality The primary purpose of this module is to: -- Validate the `access_token` JWT (which is also the client_id). -- Validate the `id_token` JWT and ensure that `id_token.aud` matches the `client_id` from the `access_token`. +- Validate and extract the claims of the `access_token` JWT. +- Validate and extract the claims of the `id_token` JWT. +- Validate and extract the claims of the `userinfo_token` JWT. These tokens are validated sequentially, ensuring that the `access_token` proves client authentication (authN), while the id_token and `userinfo_token` are correlated to the same authenticated entity. -## Token Validation Steps: +## Strict Mode -1. Validate the `access_token` and keep track of it's `aud`. This `aud` is also the `client_id`. -2. Validate the `id_token` and ensure that its `aud` matches the `access_token`'s `client_id`. +If the bootstrap property `CEDARLING_ID_TOKEN_TRUST_MODE` is set to `STRICT`, the following validation checks are implemented: -## Initialization +1. Validate that the `id_token`'s `sub` claim is the same as the `access_token`'s `client_id` claim. +2. Validate that the `userinfo_token`'s `sub` claim is the same as the `id_token`'s `sub` claim. +3. Validate that the `userinfo_token`'s `aud` claim is the same as the `access_tokens`'s `client_id` claim. -The `JwtService` is initialized through dependency injection and configuration. It requires a dependency map and a configuration object to set up. +## Initialization -```rust -pub fn new_with_container(dep_map: &di::DependencyMap, config: JwtConfig) -> Result +The JwtService can be initialized via the `new` function which requires a `JwtConfig` and `TrustedIssuer`s. + +```rs +#[derive(Debug, PartialEq)] +pub struct JwtConfig { + pub jwks: Option, + pub jwt_sig_validation: bool, + pub jwt_status_validation: bool, + pub id_token_trust_mode: IdTokenTrustMode, + pub signature_algorithms_supported: HashSet, + pub access_token_config: TokenValidationConfig, + pub id_token_config: TokenValidationConfig, + pub userinfo_token_config: TokenValidationConfig, +} + +impl JwtService{ + pub fn new( + config: &JwtConfig, + trusted_issuers: Option>, + ) -> Result { +} ``` -## Usage +## Decoding and Validating Tokens -### Decoding and Validating Tokens +The JwtService exposes a `process_tokens` function that decodes and validates both the access_token and id_token. It expects JSON Web Tokens (JWTs) strings as input. -The JwtService exposes a `decode_tokens` function that decodes and validates both the access_token and id_token. It expects JSON Web Tokens (JWTs) strings as input. - -```rust -pub fn decode_tokens( - &self, - access_token_str: &str, - id_token_str: &str, -) -> Result<(A, T), Error> +```rs +pub fn process_tokens<'a, A, I, U>( + &'a self, + access_token: &'a str, + id_token: &'a str, + userinfo_token: Option<&'a str>, +) -> Result, JwtProcessingError> where - A: DeserializeOwned - T: DeserializeOwned + A: DeserializeOwned, + I: DeserializeOwned, + U: DeserializeOwned, ``` -### Example - -```rust -let jwt_service = JwtService::new_with_container(&dep_map, jwt_config)?; -let (access_token_claims, id_token_claims) = jwt_service.decode_tokens::( - access_token_str, - id_token_str, -)?; +## Example + +Below is an example on how to initialize and use the JwtService. + +```rs +let jwt_service = JwtService::new( + &JwtConfig { + jwks: Some(local_jwks), + jwt_sig_validation: true, + jwt_status_validation: false, + id_token_trust_mode: IdTokenTrustMode::Strict, + signature_algorithms_supported: HashSet::from_iter([Algorithm::HS256]), + access_token_config: TokenValidationConfig::access_token(), + id_token_config: TokenValidationConfig::id_token(), + userinfo_token_config: TokenValidationConfig::userinfo_token(), + }, + None, +) +.expect("Should create JwtService"); + +jwt_service + .process_tokens::(&access_tkn, &id_tkn, Some(&userinfo_tkn)) + .expect("Should process JWTs"); ``` diff --git a/jans-cedarling/cedarling/src/jwt/decoding_strategy.rs b/jans-cedarling/cedarling/src/jwt/decoding_strategy.rs deleted file mode 100644 index 37eca0ebcd6..00000000000 --- a/jans-cedarling/cedarling/src/jwt/decoding_strategy.rs +++ /dev/null @@ -1,154 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -pub mod error; -pub mod key_service; - -pub mod open_id_storage; -use std::collections::HashSet; - -use crate::common::policy_store::TrustedIssuer; -pub use error::JwtDecodingError; -use jsonwebtoken as jwt; -use key_service::KeyService; -use serde::de::DeserializeOwned; - -/// Represents the decoding strategy for JWT tokens. -/// -/// This enum defines two strategies for decoding JWT tokens: `WithoutValidation` -/// for decoding without validation and `WithValidation` for decoding with validation -/// using a key service and supported algorithms. -pub enum DecodingStrategy { - /// Decoding strategy that skips all validation. - WithoutValidation, - - /// Decoding strategy that performs validation using a key service and supported algorithms. - WithValidation { - key_service: KeyService, - supported_algs: HashSet, - }, -} - -impl DecodingStrategy { - /// Creates a new decoding strategy that does not perform validation. - pub fn new_without_validation() -> Self { - Self::WithoutValidation - } - - /// Creates a new decoding strategy that performs validation. - /// - /// This strategy uses the provided dependency map to configure a key service - /// and validates tokens based on the specified algorithms. - /// - /// # Errors - /// Returns an error if the specified algorithm is unrecognized or the key service initialization fails. - pub fn new_with_validation( - config_algs: HashSet, - trusted_idps: Vec, - ) -> Result { - // initialize the key service with OpenID configuration endpoints - let openid_conf_endpoints = trusted_idps - .iter() - .map(|x| x.openid_configuration_endpoint.as_ref()) - .collect(); - let key_service = KeyService::new(openid_conf_endpoints)?; - - Ok(Self::WithValidation { - key_service, - supported_algs: config_algs, - }) - } - - /// Decodes a JWT token according to the current decoding strategy. - /// - /// # Errors - /// Returns an error if decoding or validation fails. - pub fn decode(&self, jwt: &str) -> Result { - match self { - DecodingStrategy::WithoutValidation => Self::extract_claims(jwt), - DecodingStrategy::WithValidation { - key_service, - supported_algs, - } => decode_and_validate_jwt(jwt, supported_algs, key_service), - } - } - - /// Extracts the claims from a JWT token without performing validation. - /// - /// This method uses a default insecure validator that skips signature - /// validation and other checks (e.g., expiration). Only use in trusted environments. - /// - /// # Errors - /// Returns an error if the claims cannot be extracted. - pub fn extract_claims(jwt_str: &str) -> Result { - let mut validator = jwt::Validation::default(); - validator.insecure_disable_signature_validation(); - validator.required_spec_claims.clear(); - validator.validate_exp = false; - validator.validate_aud = false; - validator.validate_nbf = false; - - let key = jwt::DecodingKey::from_secret("some_secret".as_ref()); - - let claims = jwt::decode::(jwt_str, &key, &validator) - .map_err(JwtDecodingError::Parsing)? - .claims; - - Ok(claims) - } -} - -/// Decodes and validates a JWT token using supported algorithms and a key service. -/// -/// # Errors -/// Returns an error if the token uses an unsupported algorithm or if validation fails. -fn decode_and_validate_jwt( - jwt: &str, - supported_algs: &HashSet, - key_service: &KeyService, -) -> Result { - let header = jwt::decode_header(jwt).map_err(JwtDecodingError::Parsing)?; - - // reject unsupported algorithms early - if !supported_algs.contains(&header.alg) { - return Err(JwtDecodingError::TokenSignedWithUnsupportedAlgorithm( - header.alg, - )); - } - - // set up validation rules - let mut validator = jwt::Validation::new(header.alg); - // We clear the required claims because the validator requires - // `exp` by default. - validator.required_spec_claims.clear(); - // `aud` should be optional. - validator.validate_aud = false; - validator.validate_exp = true; - validator.validate_nbf = true; - - // fetch decoding key from the KeyService - let kid = &header - .kid - .ok_or_else(|| JwtDecodingError::JwtMissingKeyId)?; - let key = key_service - .get_key(kid) - .map_err(JwtDecodingError::KeyService)?; - // TODO: potentially handle JWTs without a `kid` in the future - - // decode and validate the jwt - let claims = jwt::decode::(jwt, &key, &validator) - .map_err(JwtDecodingError::Validation)? - .claims; - Ok(claims) -} - -#[derive(thiserror::Error, Debug)] -pub enum ParseAlgorithmError { - /// Config contains an unimplemented algorithm - #[error("algorithim is not yet implemented: {0}")] - UnimplementedAlgorithm(String), -} diff --git a/jans-cedarling/cedarling/src/jwt/decoding_strategy/error.rs b/jans-cedarling/cedarling/src/jwt/decoding_strategy/error.rs deleted file mode 100644 index b41c5dce9c8..00000000000 --- a/jans-cedarling/cedarling/src/jwt/decoding_strategy/error.rs +++ /dev/null @@ -1,53 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -use super::key_service; -use jsonwebtoken as jwt; - -/// Error type for JWT decoding_strategy operations. -/// -/// This enum defines errors that can occur during the parsing, validation, -/// and processing of JWTs, including issues with the key service and unsupported -/// algorithms. -#[derive(thiserror::Error, Debug)] -pub enum JwtDecodingError { - /// Error encountered while parsing the JWT. - /// - /// This error occurs when the provided JWT cannot be properly parsed, - /// possibly due to malformed structure or invalid encoding. - #[error("Error parsing the JWT: {0}")] - Parsing(#[source] jwt::errors::Error), - - /// Missing required `kid` header in the JWT. - /// - /// The `kid` (Key ID) header is essential for identifying the correct key - /// for JWT validation. Handling of JWTs without a `kid` is currently unsupported. - #[error("The JWT is missing a required `kid` header: `kid`")] - JwtMissingKeyId, - - /// Token signed with an unsupported algorithm. - /// - /// This error occurs when the JWT specifies a signing algorithm that is not - /// supported by the current validation configuration, making it invalid. - #[error("The JWT is signed with an unsupported algorithm: {0:?}")] - TokenSignedWithUnsupportedAlgorithm(jwt::Algorithm), - - /// Token failed validation. - /// - /// This error indicates that the token did not meet the necessary validation - /// criteria, which may involve signature verification, claim checks, or other - /// validation requirements. - #[error("Token validation failed: {0}")] - Validation(#[source] jwt::errors::Error), - - /// Error encountered in the Key Service. - /// - /// This error is returned when an issue arises with the Key Service, which - /// manages keys for signing and verifying JWTs. - #[error("There was an error with the Key Service: {0}")] - KeyService(#[from] key_service::KeyServiceError), -} diff --git a/jans-cedarling/cedarling/src/jwt/decoding_strategy/key_service.rs b/jans-cedarling/cedarling/src/jwt/decoding_strategy/key_service.rs deleted file mode 100644 index f78f9bdf5db..00000000000 --- a/jans-cedarling/cedarling/src/jwt/decoding_strategy/key_service.rs +++ /dev/null @@ -1,279 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -mod error; -mod openid_config; - -pub use error::KeyServiceError; -use jsonwebtoken::jwk::Jwk; -use jsonwebtoken::DecodingKey; -pub(crate) use openid_config::*; -use reqwest::blocking::Client; -use serde::Deserialize; -use std::collections::HashMap; -use std::sync::Arc; -use std::thread::sleep; -use std::time::Duration; - -/// Retrieves a [`DecodingKey`]-s based on the provided jwks_uri. -fn fetch_decoding_keys( - jwks_uri: &str, - http_client: &HttpClient, -) -> Result, DecodingKey>, KeyServiceError> { - let jwks: Jwks = http_client - .get(jwks_uri)? - .json() - .map_err(KeyServiceError::RequestDeserialization)?; - - let mut decoding_keys = HashMap::new(); - for jwk in jwks.keys { - let jwk = serde_json::from_str::(&jwk.to_string()); - - match jwk { - Ok(jwk) => { - // convert the parsed JWK to a DecodingKey and insert it into the decoding_keys map - // if the JWK does not have a key ID (kid), return a MissingKeyId error - let decoding_key = - DecodingKey::from_jwk(&jwk).map_err(KeyServiceError::KeyParsing)?; - let key_id = jwk.common.key_id.ok_or(KeyServiceError::MissingKeyId)?; - - // insert the key into the map, using the key ID as the map's key - decoding_keys.insert(key_id.into(), decoding_key); - }, - Err(e) => { - // if the error indicates an unknown variant, we can safely ignore it. - // - // TODO: also print it in the logging - if !e.to_string().contains("unknown variant") { - return Err(KeyServiceError::KeyParsing(e.into())); - } - }, - }; - } - - Ok(decoding_keys) -} - -/// Retrieves a [`OpenIdConfig`] based on the provided openid uri endpoint. -pub(crate) fn fetch_openid_config( - openid_endpoint: &str, - http_client: &HttpClient, -) -> Result { - let conf_src: OpenIdConfigSource = http_client - .get(openid_endpoint)? - .json() - .map_err(KeyServiceError::RequestDeserialization)?; - - let decoding_keys = fetch_decoding_keys(&conf_src.jwks_uri, http_client)?; - - Ok(OpenIdConfig::from_source(conf_src, decoding_keys)) -} - -/// A wrapper around `reqwest::blocking::Client` providing HTTP request functionality with retry logic. -/// -/// The `HttpClient` struct allows for sending GET requests with a retry mechanism that attempts to -/// fetch the requested resource up to a maximum number of times if an error occurs. -pub struct HttpClient { - client: reqwest::blocking::Client, - max_retries: u32, - retry_delay: Duration, -} - -impl HttpClient { - pub fn new() -> Result { - let client = Client::builder() - .build() - .map_err(KeyServiceError::HttpClientInitialization)?; - - // We're doing this for now since `ServiceConfig::new` is also calling this - // which is slowing down the tests. This would probably disappear once we - // implement lazy loading. - #[cfg(test)] - let retry_delay = Duration::from_millis(10); - #[cfg(not(test))] - let retry_delay = Duration::from_secs(1); - - Ok(Self { - client, - max_retries: 3, - retry_delay, - }) - } - - /// Sends a GET request to the specified URI with retry logic. - /// - /// This method will attempt to fetch the resource up to 3 times, with an increasing delay - /// between each attempt. - fn get(&self, uri: &str) -> Result { - // Fetch the JWKS from the jwks_uri - let mut attempts = 0; - let response = loop { - match self.client.get(uri).send() { - Ok(response) => break response, // Exit loop on success - Err(e) if attempts < self.max_retries => { - attempts += 1; - // TODO: pass this message to the logger - eprintln!( - "Request failed (attempt {} of {}): {}. Retrying...", - attempts, self.max_retries, e - ); - sleep(self.retry_delay * attempts); - }, - Err(e) => return Err(KeyServiceError::MaxHttpRetriesReached(e)), // Exit if max retries exceeded - } - }; - response - .error_for_status() - .map_err(KeyServiceError::HttpStatus) - } -} - -/// A service that manages key retrieval and caching for OpenID Connect configurations. -pub struct KeyService { - idp_configs: HashMap, OpenIdConfig>, // - http_client: HttpClient, -} - -impl KeyService { - /// initializes a new `KeyService` with the provided OpenID configuration endpoints. - /// - /// this method fetches the OpenID configuration and the associated keys (JWKS) for each - /// endpoint, populating the internal `idp_configs` map. any HTTP errors or parsing - /// failures will return a corresponding `Error`. - pub fn new(openid_conf_endpoints: Vec<&str>) -> Result { - let mut idp_configs = HashMap::new(); - let http_client = HttpClient::new()?; - - // fetch IDP configs - for endpoint in openid_conf_endpoints { - let conf = fetch_openid_config(endpoint, &http_client)?; - idp_configs.insert(conf.issuer.clone(), conf); - } - - Ok(Self { - idp_configs, - http_client, - }) - } - - /// retrieves a decoding key based on the provided key ID (`kid`). - /// - /// this method first attempts to retrieve the key from the local key store. if the key - /// is not found, it will refresh the JWKS and try again. if the key is still not found, - /// an error of type `KeyNotFound` is returned. - pub fn get_key(&self, kid: &str) -> Result, KeyServiceError> { - for iss in self.idp_configs.keys() { - // first try to get the key from the local keystore - if let Some(key) = self.get_key_from_iss(iss, kid)? { - return Ok(key.clone()); - } else { - // TODO: pass this on to the logger - eprintln!("could not find {}, updating jwks", kid); - // if the key is not found in the local keystore, update - // the local keystore and try again - self.update_jwks_for_iss(iss)?; - if let Some(key) = self.get_key_from_iss(iss, kid)? { - return Ok(key.clone()); - } - } - } - - Err(KeyServiceError::KeyNotFound(kid.into())) - } - - /// helper function to retrieve a key for a specific issuer (`iss`). - fn get_key_from_iss( - &self, - iss: &str, - kid: &str, - ) -> Result>, KeyServiceError> { - if let Some(idp) = self.idp_configs.get(iss) { - let decoding_keys = idp - .decoding_keys - .read() - .map_err(|_| KeyServiceError::Lock)?; - if let Some(key) = decoding_keys.get(kid) { - return Ok(Some(key.clone())); - } - } - - Ok(None) - } - - /// updates the JWKS for a given issuer (`iss`). - /// - /// this method fetches a fresh set of keys from the JWKS URI of the given issuer - /// and updates the local key store. - fn update_jwks_for_iss(&self, iss: &str) -> Result<(), KeyServiceError> { - match self.idp_configs.get(iss) { - Some(conf) => Ok(Self::update_decoding_keys(&self.http_client, conf)?), - // do nothing if the issuer isn't in the current store - None => Ok(()), - } - } - - /// Updates the keys in the given config - fn update_decoding_keys( - http_client: &HttpClient, - conf: &OpenIdConfig, - ) -> Result<(), KeyServiceError> { - let mut fetched_keys = HashMap::new(); - - // Fetch the JWKS from the jwks_uri - let response = http_client.get(&conf.jwks_uri)?; - - // Deserialize the response into a Jwks - let jwks: Jwks = response - .json() - .map_err(KeyServiceError::RequestDeserialization)?; - - // Deserialize the Jwk into multiple Decoding Keys then store them - for jwk in jwks.keys { - let jwk = serde_json::from_str::(&jwk.to_string()); - - match jwk { - Ok(jwk) => { - // convert the parsed JWK to a DecodingKey and insert it into the decoding_keys map - // if the JWK does not have a key ID (kid), return a MissingKeyId error - let decoding_key = - DecodingKey::from_jwk(&jwk).map_err(KeyServiceError::KeyParsing)?; - let key_id = jwk.common.key_id.ok_or(KeyServiceError::MissingKeyId)?; - - // insert the key into the map, using the key ID as the map's key - fetched_keys.insert(key_id.into(), Arc::new(decoding_key)); - }, - Err(e) => { - // if the error indicates an unknown variant, we can safely ignore it. - // - // TODO: also print it in the logging - if !e.to_string().contains("unknown variant") { - return Err(KeyServiceError::KeyParsing(e.into())); - } - }, - }; - } - - let mut decoding_keys = conf - .decoding_keys - .write() - .map_err(|_| KeyServiceError::Lock)?; - *decoding_keys = fetched_keys; - - Ok(()) - } -} - -/// A simple struct to deserialize a collection of JWKs (JSON Web Keys). -/// -/// This struct holds the raw keys in a vector of `serde_json::Value`, allowing -/// the keys to be processed or validated individually. It is primarily used -/// as an intermediary step for deserialization before iterating over each key -/// to check for errors or unsupported algorithms and skipping any invalid keys. -#[derive(Deserialize)] -struct Jwks { - keys: Vec, -} diff --git a/jans-cedarling/cedarling/src/jwt/decoding_strategy/key_service/error.rs b/jans-cedarling/cedarling/src/jwt/decoding_strategy/key_service/error.rs deleted file mode 100644 index 456c47e83ac..00000000000 --- a/jans-cedarling/cedarling/src/jwt/decoding_strategy/key_service/error.rs +++ /dev/null @@ -1,44 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -/// Error type for the Key Service -#[derive(thiserror::Error, Debug)] -pub enum KeyServiceError { - /// Indicates that a key with the specified `kid` was not found in the JWKS. - #[error("No key with `kid`=\"{0}\" found in the JWKS.")] - KeyNotFound(String), - - /// Indicates an HTTP error response received from an endpoint. - #[error("Received error HTTP status: {0}")] - HttpStatus(#[source] reqwest::Error), - - /// Indicates a failure to reach the endpoint after 3 attempts. - #[error("Could not reach endpoint after trying 3 times: {0}")] - MaxHttpRetriesReached(#[source] reqwest::Error), - - /// Indicates failure to deserialize the response from the HTTP request. - #[error("Failed to deserialize the response from the HTTP request: {0}")] - RequestDeserialization(#[source] reqwest::Error), - - /// Indicates failure to initialize the HTTP client. - #[error("Failed to initilize HTTP client: {0}")] - HttpClientInitialization(#[source] reqwest::Error), - - /// Indicates an error in parsing the decoding key from the JWKS JSON. - #[error("Error parsing decoding key from JWKS JSON: {0}")] - KeyParsing(#[source] jsonwebtoken::errors::Error), - - /// Indicates that the JWK is missing a `kid`. - #[error("The JWK is missing a required `kid`.")] - MissingKeyId, - - /// Indicates that acquiring a write lock on decoding keys failed. - /// - /// This error gets returned when a lock gets poisoned. - #[error("Failed to acquire write lock on decoding keys.")] - Lock, -} diff --git a/jans-cedarling/cedarling/src/jwt/decoding_strategy/key_service/openid_config.rs b/jans-cedarling/cedarling/src/jwt/decoding_strategy/key_service/openid_config.rs deleted file mode 100644 index cdc0b9a0f1b..00000000000 --- a/jans-cedarling/cedarling/src/jwt/decoding_strategy/key_service/openid_config.rs +++ /dev/null @@ -1,64 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -use jsonwebtoken::DecodingKey; -use serde::Deserialize; -use std::{ - collections::HashMap, - sync::{Arc, RwLock}, -}; - -/// represents the source data for OpenID configuration. -#[derive(Deserialize)] -pub(crate) struct OpenIdConfigSource { - pub issuer: Box, - pub jwks_uri: Box, - // The following values are also normally returned when sending - // a GET request to the `openid_configuration_endpoint` but are - // not currently being used. - // - // authorization_endpoint: Box, - // device_authorization_endpoint: Box, - // token_endpoint: Box, - // userinfo_endpoint: Box, - // revocation_endpoint: Box, - // response_types_supported: Vec>, - // subject_types_supported: Vec>, - // id_token_signing_algs_values_supported: Vec>, - // scopes_supported: Vec>, - // claims_supported: Vec>, -} - -/// represents the OpenID configuration for an identity provider. -#[derive(Clone)] -pub struct OpenIdConfig { - pub issuer: Box, - pub jwks_uri: Box, - pub decoding_keys: Arc, Arc>>>, // -} - -impl OpenIdConfig { - /// creates an `OpenIdConfig` from the provided source. - /// - /// this method extracts the issuer and constructs a new `OpenIdConfig` - /// instance, initializing the decoding keys storage. - pub fn from_source( - src: OpenIdConfigSource, - decoding_keys: HashMap, DecodingKey>, - ) -> OpenIdConfig { - OpenIdConfig { - issuer: src.issuer, - jwks_uri: src.jwks_uri, - decoding_keys: Arc::new(RwLock::new( - decoding_keys - .into_iter() - .map(|(k, v)| (k, Arc::new(v))) - .collect(), - )), - } - } -} diff --git a/jans-cedarling/cedarling/src/jwt/decoding_strategy/open_id_storage.rs b/jans-cedarling/cedarling/src/jwt/decoding_strategy/open_id_storage.rs deleted file mode 100644 index 6bfe7adf00e..00000000000 --- a/jans-cedarling/cedarling/src/jwt/decoding_strategy/open_id_storage.rs +++ /dev/null @@ -1,32 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -use std::collections::HashMap; - -use crate::jwt::TrustedIssuerAndOpenIdConfig; - -/// Storage to hold mapping issuer to trusted_issuer and openid config -/// ang get this values whe it is needed -#[derive(Default)] -pub struct OpenIdStorage { - issuers_map: HashMap, TrustedIssuerAndOpenIdConfig>, // issuer => TrustedIssuerAndOpenIdConfig -} - -impl OpenIdStorage { - pub fn new(trusted_idps: Vec) -> OpenIdStorage { - Self { - issuers_map: trusted_idps - .into_iter() - .map(|config| (config.openid_config.issuer.clone(), config)) - .collect(), - } - } - - pub fn get(&self, issuer: &str) -> Option<&TrustedIssuerAndOpenIdConfig> { - self.issuers_map.get(issuer) - } -} diff --git a/jans-cedarling/cedarling/src/jwt/error.rs b/jans-cedarling/cedarling/src/jwt/error.rs deleted file mode 100644 index 4f89377bc91..00000000000 --- a/jans-cedarling/cedarling/src/jwt/error.rs +++ /dev/null @@ -1,34 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -use super::decoding_strategy; - -/// Error type for the JWT service. -#[derive(thiserror::Error, Debug)] -#[allow(clippy::enum_variant_names)] -pub enum JwtServiceError { - /// Error indicating that the provided access token is invalid. - /// - /// This occurs when the given access token fails validation, such as when - /// it has an invalid signature, has expired, or contains incorrect claims. - #[error("The `access_token` is invalid or has failed validation: {0}")] - InvalidAccessToken(#[source] decoding_strategy::JwtDecodingError), - - /// Error indicating that the provided id token is invalid. - /// - /// This occurs when the given id token fails validation, such as when - /// it has an invali d signature, has expired, or contains incorrect claims. - #[error("The `id_token` is invalid or has failed validation: {0}")] - InvalidIdToken(#[source] decoding_strategy::JwtDecodingError), - - /// Error indicating that the provided userinfo token is invalid. - /// - /// This occurs when the given userinfo token fails validation, such as when - /// it has an invalid signature, has expired, or contains incorrect claims. - #[error("The `userinfo_token` is invalid or has failed validation: {0}")] - InvalidUserinfoToken(#[source] decoding_strategy::JwtDecodingError), -} diff --git a/jans-cedarling/cedarling/src/jwt/http_client.rs b/jans-cedarling/cedarling/src/jwt/http_client.rs new file mode 100644 index 00000000000..5164d7433be --- /dev/null +++ b/jans-cedarling/cedarling/src/jwt/http_client.rs @@ -0,0 +1,168 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + +use reqwest::blocking::Client; +use std::{thread::sleep, time::Duration}; + +/// A wrapper around `reqwest::blocking::Client` providing HTTP request functionality +/// with retry logic. +/// +/// The `HttpClient` struct allows for sending GET requests with a retry mechanism +/// that attempts to fetch the requested resource up to a maximum number of times +/// if an error occurs. +#[derive(Debug)] +pub struct HttpClient { + client: reqwest::blocking::Client, + max_retries: u32, + retry_delay: Duration, +} + +impl HttpClient { + pub fn new(max_retries: u32, retry_delay: Duration) -> Result { + let client = Client::builder() + .build() + .map_err(HttpClientError::Initialization)?; + + Ok(Self { + client, + max_retries, + retry_delay, + }) + } + + /// Sends a GET request to the specified URI with retry logic. + /// + /// This method will attempt to fetch the resource up to 3 times, with an increasing delay + /// between each attempt. + pub fn get(&self, uri: &str) -> Result { + // Fetch the JWKS from the jwks_uri + let mut attempts = 0; + let response = loop { + match self.client.get(uri).send() { + // Exit loop on success + Ok(response) => break response, + + Err(e) if attempts < self.max_retries => { + attempts += 1; + // TODO: pass this message to the logger + eprintln!( + "Request failed (attempt {} of {}): {}. Retrying...", + attempts, self.max_retries, e + ); + sleep(self.retry_delay * attempts); + }, + // Exit if max retries exceeded + Err(e) => return Err(HttpClientError::MaxHttpRetriesReached(e)), + } + }; + + response + .error_for_status() + .map_err(HttpClientError::HttpStatus) + } +} + +/// Error type for the HttpClient +#[derive(thiserror::Error, Debug)] +pub enum HttpClientError { + /// Indicates failure to initialize the HTTP client. + #[error("Failed to initilize HTTP client: {0}")] + Initialization(#[source] reqwest::Error), + /// Indicates an HTTP error response received from an endpoint. + #[error("Received error HTTP status: {0}")] + HttpStatus(#[source] reqwest::Error), + + /// Indicates a failure to reach the endpoint after 3 attempts. + #[error("Could not reach endpoint after trying 3 times: {0}")] + MaxHttpRetriesReached(#[source] reqwest::Error), +} + +#[cfg(test)] +mod test { + use crate::jwt::http_client::HttpClientError; + + use super::HttpClient; + use mockito::Server; + use serde_json::json; + use std::time::Duration; + use test_utils::assert_eq; + + #[test] + fn can_fetch() { + let mut mock_server = Server::new(); + + let expected = json!({ + "issuer": mock_server.url(), + "jwks_uri": &format!("{}/jwks", mock_server.url()), + }); + + let mock_endpoint = mock_server + .mock("GET", "/.well-known/openid-configuration") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(expected.to_string()) + .expect(1) + .create(); + + let client = + HttpClient::new(3, Duration::from_millis(1)).expect("Should create HttpClient."); + + let response = client + .get(&format!( + "{}/.well-known/openid-configuration", + mock_server.url() + )) + .expect("Should get response") + .json::() + .expect("Should deserialize JSON response."); + + assert_eq!( + response, expected, + "Expected: {expected:?}\nBut got: {response:?}" + ); + + mock_endpoint.assert(); + } + + #[test] + fn errors_when_max_http_retries_exceeded() { + let client = + HttpClient::new(3, Duration::from_millis(1)).expect("Should create HttpClient"); + let response = client.get("0.0.0.0"); + + assert!( + matches!(response, Err(HttpClientError::MaxHttpRetriesReached(_))), + "Expected error due to MaxHttpRetriesReached: {response:?}" + ); + } + + #[test] + fn errors_on_http_error_status() { + let mut mock_server = Server::new(); + + let mock_endpoint = mock_server + .mock("GET", "/.well-known/openid-configuration") + .with_status(500) + .expect(1) + .create(); + + let client = + HttpClient::new(3, Duration::from_millis(1)).expect("Should create HttpClient."); + + let response = client.get(&format!( + "{}/.well-known/openid-configuration", + mock_server.url() + )); + + assert!( + matches!(response, Err(HttpClientError::HttpStatus(_))), + "Expected error due to receiving an http error code: {response:?}" + ); + + mock_endpoint.assert(); + } +} diff --git a/jans-cedarling/cedarling/src/jwt/issuers_store.rs b/jans-cedarling/cedarling/src/jwt/issuers_store.rs new file mode 100644 index 00000000000..f7b1dbf3161 --- /dev/null +++ b/jans-cedarling/cedarling/src/jwt/issuers_store.rs @@ -0,0 +1,50 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + +use std::{collections::HashMap, sync::Arc}; + +use url::Url; + +use crate::common::policy_store::TrustedIssuer; + +type IssuerId = String; // e.g. '7ca8ccc6e8682ad91f47e651cf7e3dcea4f8133663ae' +type IssuerOrigin = String; // e.g. 'https://account.gluu.org' + +/// A pre-processed mapping of trusted issuers, optimized for efficient lookups by domain. +/// +/// This structure reorganizes trusted issuers to facilitate fast retrieval based on +/// their associated domains, represented by the `IssuerDomain` type. The internal +/// `HashMap` uses the domain as the key, enabling quick access to a `TrustedIssuer` +/// without requiring complex or iterative searches. +pub struct TrustedIssuersStore { + issuers: HashMap, +} + +impl TrustedIssuersStore { + // NOTE: once this store is initialized, it is not expected that the source + // will be updated. If ever Cedarling supports updating the Trusted Issuers in the + // future, make sure this implementation is updated. + pub fn new(source: Arc>>) -> Self { + let issuers = match source.as_ref() { + None => HashMap::new(), + Some(issuers) => issuers + .values() + .map(|iss| { + let endpoint = Url::parse(&iss.openid_configuration_endpoint).unwrap(); + let iss_origin: IssuerOrigin = endpoint.origin().ascii_serialization(); + (iss_origin, iss.clone()) + }) + .collect::>(), + }; + + Self { issuers } + } + + pub fn get(&self, iss_domain: &str) -> Option<&TrustedIssuer> { + self.issuers.get(iss_domain) + } +} diff --git a/jans-cedarling/cedarling/src/jwt/jwk_store.rs b/jans-cedarling/cedarling/src/jwt/jwk_store.rs new file mode 100644 index 00000000000..4b7d07d156b --- /dev/null +++ b/jans-cedarling/cedarling/src/jwt/jwk_store.rs @@ -0,0 +1,597 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + +use super::http_client::{HttpClient, HttpClientError}; +use super::{KeyId, TrustedIssuerId}; +use crate::common::policy_store::TrustedIssuer; +use jsonwebtoken::jwk::Jwk; +use jsonwebtoken::DecodingKey; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; +use std::sync::Arc; +use time::OffsetDateTime; + +#[derive(Deserialize)] +struct OpenIdConfig { + issuer: String, + jwks_uri: String, +} + +/// Represents a store of JSON Web Keys (JWK) used for decoding tokens. +/// +/// This store maintains a collection of keys identified by a unique `KeyId`. +/// It also supports keys without an identifier for situations where a key ID is +/// not provided. +pub struct JwkStore { + /// A unique identifier for the store. + store_id: Arc, + /// The issuer response from the IDP. + issuer: Option>, + /// A map of keys indexed by their `KeyId`. + keys: HashMap, + /// A collection of keys that do not have an associated ID. + keys_without_id: Vec, + /// The timestamp indicating when the store was last updated. + last_updated: OffsetDateTime, + /// From which TrustedIssuer this struct was built (if applicable). + source_iss: Option, +} + +// We cannot derive from Debug directly because DecodingKey does not implement Debug. +impl Debug for JwkStore { + /// Formats the `JwkStore` for debugging purposes. + /// + /// This implementation displays the store's `store_id`, optional `issuer`, + /// the list of `KeyId`s for stored keys, the count of keys without an ID, + /// and the `last_updated` timestamp. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("JwkStore") + .field("store_id", &self.store_id) + .field("issuer", &self.issuer) + .field("keys", &self.keys.keys()) + .field("keys_without_id_count", &self.keys_without_id.len()) + .field("last_updated", &self.last_updated) + .finish() + } +} + +// We cannot derive from PartialEq directly because DecodingKey does not implement +// PartialEq. +impl PartialEq for JwkStore { + /// Compares two `JwkStore` instances for equality. + /// + /// Two `JwkStore` instances are considered equal if: + /// - Their `store_id` values are the same. + /// - Their `issuer` values are the same. + /// - The set of key IDs in the `keys` map are the same. + /// - The length of the `keys_without_id` collections are the same. + /// - Their `last_updated` timestamps are the same. + fn eq(&self, other: &Self) -> bool { + self.store_id == other.store_id + && self.issuer == other.issuer + && self.keys.keys().collect::>() == other.keys.keys().collect::>() + && self.keys_without_id.len() == other.keys_without_id.len() + && self.last_updated == other.last_updated + } +} + +impl JwkStore { + /// Creates a JwkStore from a [`serde_json::Value`] + pub fn new_from_jwks_value(store_id: Arc, jwks: Value) -> Result { + let jwks = serde_json::from_value::(jwks)?; + Self::new_from_jwks(store_id, jwks) + } + /// Creates a JwkStore from a [`String`] + pub fn new_from_jwks_str(store_id: Arc, jwks: &str) -> Result { + let jwks = serde_json::from_str::(jwks)?; + Self::new_from_jwks(store_id, jwks) + } + + /// Creates a JwkStore from an [`IntermediateJwks`] + fn new_from_jwks(store_id: Arc, jwks: IntermediateJwks) -> Result { + let mut keys = HashMap::new(); + let mut keys_without_id = Vec::new(); + + for key in jwks.keys.into_iter() { + let kid = key + .get("kid") + .map(|kid| serde_json::from_value::(kid.clone())) + .transpose()?; + + // try to create a key + let key = match serde_json::from_value::(key) { + Ok(key) => key, + Err(e) => { + // if the error indicates an unknown variant, + // we can safely ignore it. + if e.to_string().contains("unknown variant") { + // TODO: pass this message to the logger + eprintln!( + "Encountered a JWK with an unsupported algorithm, ignoring it: {}", + e + ); + continue; + } else { + Err(JwkStoreError::DecodeJwk(e))? + } + }, + }; + + match &kid { + Some(kid) => { + keys.insert( + kid.as_str().into(), + DecodingKey::from_jwk(&key).map_err(JwkStoreError::CreateDecodingKey)?, + ); + }, + None => { + keys_without_id.push( + DecodingKey::from_jwk(&key).map_err(JwkStoreError::CreateDecodingKey)?, + ); + }, + } + } + + Ok(JwkStore { + store_id, + issuer: None, + keys, + keys_without_id, + last_updated: OffsetDateTime::now_utc(), + source_iss: None, + }) + } + + /// Creates a JwkStore by fetching the keys from the given [`TrustedIssuer`]. + pub fn new_from_trusted_issuer( + store_id: TrustedIssuerId, + issuer: &TrustedIssuer, + http_client: &HttpClient, + ) -> Result { + // fetch openid configuration + let response = http_client.get(&issuer.openid_configuration_endpoint)?; + let openid_config = response + .json::() + .map_err(JwkStoreError::FetchOpenIdConfig)?; + + // fetch jwks + let response = http_client.get(&openid_config.jwks_uri)?; + + let jwks = response.text().map_err(JwkStoreError::FetchJwks)?; + + let mut store = Self::new_from_jwks_str(store_id, &jwks)?; + store.issuer = Some(openid_config.issuer.into()); + store.source_iss = Some(issuer.clone()); + + Ok(store) + } + + /// Returns a reference to the source [`TrustedIssuer`] this struct was built on. + pub fn source_iss(&self) -> Option<&TrustedIssuer> { + self.source_iss.as_ref() + } + + /// Retrieves a Decoding Key from the store + pub fn get(&self, key_id: &str) -> Option<&DecodingKey> { + self.keys.get(key_id) + } + + /// Returns a &Vec of all the keys without a `kid` (Key ID). + // currently unused but we might need this when we try to implement support for + // keys without a `kid` claim. + #[allow(dead_code)] + pub fn get_keys_without_id(&self) -> Vec<&DecodingKey> { + self.keys_without_id.iter().collect() + } + + /// Returns a Vec containing a reference to all of the keys. + pub fn get_keys(&self) -> Vec<&DecodingKey> { + // PERF: We can cache the returned Vec so it doesn't + // get created every time this function is called. + let mut keys = Vec::new(); + self.keys.values().for_each(|key| keys.push(key)); + self.keys_without_id.iter().for_each(|key| keys.push(key)); + keys + } +} + +#[derive(thiserror::Error, Debug)] +pub enum JwkStoreError { + #[error("Failed to fetch OpenIdConfig remote server: {0}")] + FetchOpenIdConfig(#[source] reqwest::Error), + #[error("Failed to fetch JWKS from remote server: {0}")] + FetchJwks(#[source] reqwest::Error), + #[error("Failed to make HTTP Request: {0}")] + Http(#[from] HttpClientError), + #[error("Failed to create Decoding Key from JWK: {0}")] + CreateDecodingKey(#[from] jsonwebtoken::errors::Error), + #[error("Failed to decode JWK: {0}")] + DecodeJwk(#[from] serde_json::Error), +} + +/// A simple struct to deserialize a collection of JWKs (JSON Web Keys). +/// +/// This struct holds the raw keys in a vector of `serde_json::Value`, allowing +/// the keys to be processed or validated individually. It is primarily used +/// as an intermediary step for deserialization before iterating over each key +/// to check for errors or unsupported algorithms and skipping any invalid keys. +#[derive(Deserialize)] +struct IntermediateJwks { + keys: Vec, +} + +#[cfg(test)] +mod test { + use crate::{ + common::policy_store::TrustedIssuer, + jwt::{http_client::HttpClient, jwk_store::JwkStore}, + }; + use jsonwebtoken::{jwk::JwkSet, DecodingKey}; + use mockito::Server; + use serde_json::json; + use std::{collections::HashMap, time::Duration}; + use time::OffsetDateTime; + + #[test] + fn can_load_from_jwkset() { + let kid1 = "a50f6e70ef4b548a5fd9142eecd1fb8f54dce9ee"; + let kid2 = "73e25f9789119c7875d58087a78ac23f5ef2eda3"; + let keys_json = json!([ + { + "use": "sig", + "e": "AQAB", + "alg": "RS256", + "n": "4VI56fF0rcWHHVgHFLHrmEO5w8oN9gbSQ9TEQnlIKRg0zCtl2dLKtt0hC6WMrTA9cF7fnK4CLNkfV_Mytk-rydu2qRV_kah62v9uZmpbS5dcz5OMXmPuQdV8fDVIvscDK5dzkwD3_XJ2mzupvQN2reiYgce6-is23vwOyuT-n4vlxSqR7dWdssK5sj9mhPBEIlfbuKNykX5W6Rgu-DyuoKArc_aukWnLxWN-yoroP2IHYdCQm7Ol08vAXmrwMyDfvsmqdXUEx4om1UZ5WLf-JNaZp4lXhgF7Cur5066213jwpp4f_D3MyR-oa43fSa91gqp2berUgUyOWdYSIshABVQ", + "kty": "RSA", + "kid": kid1, + }, + { + "n": "tMXbmw7xEDVLLkAJdxpI-6pGywn0x9fHbD_mfgtFGZEs1LDjhDAJq6c-SoODeWQstjpetTgNqVCKOuU6zGyFPNtkDjhJqDW6THy06uJ8I85crILo3h-6NPclZ3bK9OzN5bIbzjbSvxrIM7ORZOlWzByOn5qGsMvI3aDrZ0lXNC1eCDWJpoJznG1fWcHYxbUy_CHDC3Cd26jX19aRALEEQU-y-wi9pv86qxEmrYMLsVN3__eWNNPkzxgf0eSOWFDv5_19YK7irYztqiwin6abxr9RHj3Qs21hpJ9A-YfsfmNkxmifgDeiTnXpZY8yfVTCJTtkgT7sjdU1lvhsMa4Z0w", + "e": "AQAB", + "use": "sig", + "alg": "RS256", + "kty": "RSA", + "kid": kid2, + } + ]); + + let jwks_json = json!({"keys": keys_json}); + let mut result = JwkStore::new_from_jwks_str("test".into(), &jwks_json.to_string()) + .expect("Should create JwkStore"); + // We edit the `last_updated` from the result so that the comparison + // wont fail because of the timestamp. + result.last_updated = OffsetDateTime::from_unix_timestamp(0).unwrap(); + + let expected_jwkset = + serde_json::from_value::(jwks_json).expect("Should create JwkSet"); + let expected_keys = expected_jwkset + .keys + .iter() + .filter_map(|key| match &key.common.key_id { + Some(key_id) => Some(( + key_id.as_str().into(), + DecodingKey::from_jwk(key).expect("Should create DecodingKey from Jwk"), + )), + None => None, + }) + .collect::, DecodingKey>>(); + + let expected = JwkStore { + store_id: "test".into(), + issuer: None, + keys: expected_keys, + keys_without_id: Vec::new(), + last_updated: OffsetDateTime::from_unix_timestamp(0).unwrap(), + source_iss: None, + }; + + assert_eq!(expected, result); + + // Asserts so we can check if using `get` works as expected + assert!( + result.get(kid1).is_some(), + "Expected to find key with id: {kid1}" + ); + assert!( + result.get(kid2).is_some(), + "Expected to find key with id: {kid2}" + ); + assert!( + result.get("unknown key id").is_none(), + "Expected to find None" + ); + } + + #[test] + fn can_load_from_trusted_issuers() { + let mut mock_server = Server::new(); + + // Setup OpenId config endpoint + let openid_config_json = json!({ + "issuer": mock_server.url(), + "jwks_uri": format!("{}/jwks", mock_server.url()) + }); + let openid_config_endpoint = mock_server + .mock("GET", "/.well-known/openid-configuration") + .with_status(200) + .with_body(openid_config_json.to_string()) + .expect(1) + .create(); + + // Setup JWKS endpoint + let kid1 = "a50f6e70ef4b548a5fd9142eecd1fb8f54dce9ee"; + let kid2 = "73e25f9789119c7875d58087a78ac23f5ef2eda3"; + let jwks_json = json!({ + "keys": [ + { + "use": "sig", + "e": "AQAB", + "alg": "RS256", + "n": "4VI56fF0rcWHHVgHFLHrmEO5w8oN9gbSQ9TEQnlIKRg0zCtl2dLKtt0hC6WMrTA9cF7fnK4CLNkfV_Mytk-rydu2qRV_kah62v9uZmpbS5dcz5OMXmPuQdV8fDVIvscDK5dzkwD3_XJ2mzupvQN2reiYgce6-is23vwOyuT-n4vlxSqR7dWdssK5sj9mhPBEIlfbuKNykX5W6Rgu-DyuoKArc_aukWnLxWN-yoroP2IHYdCQm7Ol08vAXmrwMyDfvsmqdXUEx4om1UZ5WLf-JNaZp4lXhgF7Cur5066213jwpp4f_D3MyR-oa43fSa91gqp2berUgUyOWdYSIshABVQ", + "kty": "RSA", + "kid": kid1, + }, + { + "n": "tMXbmw7xEDVLLkAJdxpI-6pGywn0x9fHbD_mfgtFGZEs1LDjhDAJq6c-SoODeWQstjpetTgNqVCKOuU6zGyFPNtkDjhJqDW6THy06uJ8I85crILo3h-6NPclZ3bK9OzN5bIbzjbSvxrIM7ORZOlWzByOn5qGsMvI3aDrZ0lXNC1eCDWJpoJznG1fWcHYxbUy_CHDC3Cd26jX19aRALEEQU-y-wi9pv86qxEmrYMLsVN3__eWNNPkzxgf0eSOWFDv5_19YK7irYztqiwin6abxr9RHj3Qs21hpJ9A-YfsfmNkxmifgDeiTnXpZY8yfVTCJTtkgT7sjdU1lvhsMa4Z0w", + "e": "AQAB", + "use": "sig", + "alg": "RS256", + "kty": "RSA", + "kid": kid2, + } + + ] + }); + let jwks_endpoint = mock_server + .mock("GET", "/jwks") + .with_status(200) + .with_body(jwks_json.to_string()) + .expect(1) + .create(); + + let http_client = + HttpClient::new(3, Duration::from_millis(1)).expect("Should create HttpClient"); + + let source_iss = TrustedIssuer { + name: "Test Trusted Issuer".to_string(), + description: "This is a test trusted issuer".to_string(), + openid_configuration_endpoint: format!( + "{}/.well-known/openid-configuration", + mock_server.url() + ), + ..Default::default() + }; + + let mut result = + JwkStore::new_from_trusted_issuer("test".into(), &source_iss, &http_client) + .expect("Should load JwkStore from Trusted Issuer"); + // We edit the `last_updated` from the result so that the comparison + // wont fail because of the timestamp. + result.last_updated = OffsetDateTime::from_unix_timestamp(0).unwrap(); + + let jwkset = + serde_json::from_value::(jwks_json).expect("Should create JwkSet from Value"); + let expected_keys = jwkset + .keys + .iter() + .filter_map(|key| match &key.common.key_id { + Some(key_id) => Some(( + key_id.as_str().into(), + DecodingKey::from_jwk(key).expect("Should create DecodingKey from Jwk"), + )), + None => None, + }) + .collect::, DecodingKey>>(); + let expected = JwkStore { + store_id: "test".into(), + issuer: Some(mock_server.url().into()), + keys: expected_keys, + keys_without_id: Vec::new(), + last_updated: OffsetDateTime::from_unix_timestamp(0).unwrap(), + source_iss: Some(source_iss), + }; + + assert_eq!(expected, result); + + // Asserts so we can check if using `get` works as expected + assert!( + result.get(kid1).is_some(), + "Expected to find key with id: {kid1}" + ); + assert!( + result.get(kid2).is_some(), + "Expected to find key with id: {kid2}" + ); + assert!( + result.get("unknown key id").is_none(), + "Expected to find None" + ); + + // Assert that the mock endpoints only gets called once. + openid_config_endpoint.assert(); + jwks_endpoint.assert(); + } + + #[test] + fn can_load_keys_without_ids() { + let keys_json = json!([ + { + "use": "sig", + "e": "AQAB", + "alg": "RS256", + "n": "4VI56fF0rcWHHVgHFLHrmEO5w8oN9gbSQ9TEQnlIKRg0zCtl2dLKtt0hC6WMrTA9cF7fnK4CLNkfV_Mytk-rydu2qRV_kah62v9uZmpbS5dcz5OMXmPuQdV8fDVIvscDK5dzkwD3_XJ2mzupvQN2reiYgce6-is23vwOyuT-n4vlxSqR7dWdssK5sj9mhPBEIlfbuKNykX5W6Rgu-DyuoKArc_aukWnLxWN-yoroP2IHYdCQm7Ol08vAXmrwMyDfvsmqdXUEx4om1UZ5WLf-JNaZp4lXhgF7Cur5066213jwpp4f_D3MyR-oa43fSa91gqp2berUgUyOWdYSIshABVQ", + "kty": "RSA", + }, + { + "n": "tMXbmw7xEDVLLkAJdxpI-6pGywn0x9fHbD_mfgtFGZEs1LDjhDAJq6c-SoODeWQstjpetTgNqVCKOuU6zGyFPNtkDjhJqDW6THy06uJ8I85crILo3h-6NPclZ3bK9OzN5bIbzjbSvxrIM7ORZOlWzByOn5qGsMvI3aDrZ0lXNC1eCDWJpoJznG1fWcHYxbUy_CHDC3Cd26jX19aRALEEQU-y-wi9pv86qxEmrYMLsVN3__eWNNPkzxgf0eSOWFDv5_19YK7irYztqiwin6abxr9RHj3Qs21hpJ9A-YfsfmNkxmifgDeiTnXpZY8yfVTCJTtkgT7sjdU1lvhsMa4Z0w", + "e": "AQAB", + "use": "sig", + "alg": "RS256", + "kty": "RSA", + } + ]); + + let jwks_json = json!({"keys": keys_json.clone()}); + let mut result = JwkStore::new_from_jwks_str("test".into(), &jwks_json.to_string()) + .expect("Should create JwkStore"); + // We edit the `last_updated` from the result so that the comparison + // wont fail because of the timestamp. + result.last_updated = OffsetDateTime::from_unix_timestamp(0).unwrap(); + + let jwkset = serde_json::from_value::(jwks_json).expect("Should create JwkSet"); + let expected_keys = jwkset + .keys + .iter() + .map(|key| DecodingKey::from_jwk(key).expect("Should create DecodingKey from Jwk")) + .collect::>(); + + let expected = JwkStore { + store_id: "test".into(), + issuer: None, + keys: HashMap::new(), + keys_without_id: expected_keys, + last_updated: OffsetDateTime::from_unix_timestamp(0).unwrap(), + source_iss: None, + }; + + assert_eq!(expected, result); + } + + #[test] + fn can_get_a_reference_to_all_keys() { + let keys_json = json!([ + { + "use": "sig", + "e": "AQAB", + "alg": "RS256", + "n": "4VI56fF0rcWHHVgHFLHrmEO5w8oN9gbSQ9TEQnlIKRg0zCtl2dLKtt0hC6WMrTA9cF7fnK4CLNkfV_Mytk-rydu2qRV_kah62v9uZmpbS5dcz5OMXmPuQdV8fDVIvscDK5dzkwD3_XJ2mzupvQN2reiYgce6-is23vwOyuT-n4vlxSqR7dWdssK5sj9mhPBEIlfbuKNykX5W6Rgu-DyuoKArc_aukWnLxWN-yoroP2IHYdCQm7Ol08vAXmrwMyDfvsmqdXUEx4om1UZ5WLf-JNaZp4lXhgF7Cur5066213jwpp4f_D3MyR-oa43fSa91gqp2berUgUyOWdYSIshABVQ", + "kty": "RSA", + "kid": "some_random_key_id", + }, + { + "n": "tMXbmw7xEDVLLkAJdxpI-6pGywn0x9fHbD_mfgtFGZEs1LDjhDAJq6c-SoODeWQstjpetTgNqVCKOuU6zGyFPNtkDjhJqDW6THy06uJ8I85crILo3h-6NPclZ3bK9OzN5bIbzjbSvxrIM7ORZOlWzByOn5qGsMvI3aDrZ0lXNC1eCDWJpoJznG1fWcHYxbUy_CHDC3Cd26jX19aRALEEQU-y-wi9pv86qxEmrYMLsVN3__eWNNPkzxgf0eSOWFDv5_19YK7irYztqiwin6abxr9RHj3Qs21hpJ9A-YfsfmNkxmifgDeiTnXpZY8yfVTCJTtkgT7sjdU1lvhsMa4Z0w", + "e": "AQAB", + "use": "sig", + "alg": "RS256", + "kty": "RSA", + } + ]); + + let jwks_json = json!({"keys": keys_json.clone()}); + + let mut result = JwkStore::new_from_jwks_str("test".into(), &jwks_json.to_string()) + .expect("Should create JwkStore"); + // We edit the `last_updated` from the result so that the comparison + // wont fail because of the timestamp. + result.last_updated = OffsetDateTime::from_unix_timestamp(0).unwrap(); + + assert_eq!(result.get_keys().len(), 2, "Expected 2 keys"); + } + + #[test] + fn can_gracefully_handle_unsupported_algs_from_jwks() { + let kid1 = "a50f6e70ef4b548a5fd9142eecd1fb8f54dce9ee"; + let kid2 = "73e25f9789119c7875d58087a78ac23f5ef2eda3"; + let keys_json = json!([ + { + "use": "sig", + "e": "AQAB", + "alg": "RS256", + "n": "4VI56fF0rcWHHVgHFLHrmEO5w8oN9gbSQ9TEQnlIKRg0zCtl2dLKtt0hC6WMrTA9cF7fnK4CLNkfV_Mytk-rydu2qRV_kah62v9uZmpbS5dcz5OMXmPuQdV8fDVIvscDK5dzkwD3_XJ2mzupvQN2reiYgce6-is23vwOyuT-n4vlxSqR7dWdssK5sj9mhPBEIlfbuKNykX5W6Rgu-DyuoKArc_aukWnLxWN-yoroP2IHYdCQm7Ol08vAXmrwMyDfvsmqdXUEx4om1UZ5WLf-JNaZp4lXhgF7Cur5066213jwpp4f_D3MyR-oa43fSa91gqp2berUgUyOWdYSIshABVQ", + "kty": "RSA", + "kid": kid1, + }, + { + "n": "tMXbmw7xEDVLLkAJdxpI-6pGywn0x9fHbD_mfgtFGZEs1LDjhDAJq6c-SoODeWQstjpetTgNqVCKOuU6zGyFPNtkDjhJqDW6THy06uJ8I85crILo3h-6NPclZ3bK9OzN5bIbzjbSvxrIM7ORZOlWzByOn5qGsMvI3aDrZ0lXNC1eCDWJpoJznG1fWcHYxbUy_CHDC3Cd26jX19aRALEEQU-y-wi9pv86qxEmrYMLsVN3__eWNNPkzxgf0eSOWFDv5_19YK7irYztqiwin6abxr9RHj3Qs21hpJ9A-YfsfmNkxmifgDeiTnXpZY8yfVTCJTtkgT7sjdU1lvhsMa4Z0w", + "e": "AQAB", + "use": "sig", + "alg": "RS256", + "kty": "RSA", + "kid": kid2, + }, + { + "kty": "EC", + "use": "sig", + "key_ops_type": [], + "crv": "P-521", + "kid": "connect_190362b7-efca-4674-9cb7-21b428cb682a_sig_es512", + "x5c": [ + "MIICBjCCAWegAwIBAgIhALe16fd76pin3igeUTiLhGW01wkEMVzBsmGdXVtYpeZuMAoGCCqGSM49BAMEMCQxIjAgBgNVBAMMGUphbnMgQXV0aCBDQSBDZXJ0aWZpY2F0ZXMwHhcNMjQxMDE5MTg1NzMyWhcNMjQxMDIxMTk1NzMxWjAkMSIwIAYDVQQDDBlKYW5zIEF1dGggQ0EgQ2VydGlmaWNhdGVzMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQAf4TdXH7umWW64g1w8+UZ0NhyRm6rWsRGL7E+bvS2cY+K6UPThM7/xy9nTs73Pw8OT26oUhBz1oM9Jhs0Qy/veXMAvgHuUeIT6CBV3aHr4osWFAnGwoh0pjd1NOU3TN+ms1ttcD1qyJcZxLOhvFr3VZ7/7p5gSOaY1MwEEG2Ka/itQTujJzAlMCMGA1UdJQQcMBoGCCsGAQUFBwMBBggrBgEFBQcDAgYEVR0lADAKBggqhkjOPQQDBAOBjAAwgYgCQgGBq8DEjIF1SwqFos+2mHA6XFO+pZfx9HESd8dUZxN3yA5yf1oFxhUCbviQeOCeATAITuEfSIIL8hAQ4uzQc7JYhgJCAfB8/JGumVAnU/3lx2aHVl8hpSXn/f2107VN4ld46dwy3r48Ioo8dfjN2dH0BOKNg2ddYPiORfrpI9Y/WF7vI4UT" + ], + "x": "f4TdXH7umWW64g1w8-UZ0NhyRm6rWsRGL7E-bvS2cY-K6UPThM7_xy9nTs73Pw8OT26oUhBz1oM9Jhs0Qy_veXM", + "y": "vgHuUeIT6CBV3aHr4osWFAnGwoh0pjd1NOU3TN-ms1ttcD1qyJcZxLOhvFr3VZ7_7p5gSOaY1MwEEG2Ka_itQTs", + "exp": 2729540651438u64, + "alg": "ES512" + } + ]); + let jwks_string = json!({"keys": keys_json.clone()}).to_string(); + + let mut result = JwkStore::new_from_jwks_str("test".into(), &jwks_string) + .expect("Should create JwkStore"); + // We edit the `last_updated` from the result so that the comparison + // wont fail because of the timestamp. + result.last_updated = OffsetDateTime::from_unix_timestamp(0).unwrap(); + + let expected_jwkset = serde_json::from_value::(json!({"keys": [ + { + "use": "sig", + "e": "AQAB", + "alg": "RS256", + "n": "4VI56fF0rcWHHVgHFLHrmEO5w8oN9gbSQ9TEQnlIKRg0zCtl2dLKtt0hC6WMrTA9cF7fnK4CLNkfV_Mytk-rydu2qRV_kah62v9uZmpbS5dcz5OMXmPuQdV8fDVIvscDK5dzkwD3_XJ2mzupvQN2reiYgce6-is23vwOyuT-n4vlxSqR7dWdssK5sj9mhPBEIlfbuKNykX5W6Rgu-DyuoKArc_aukWnLxWN-yoroP2IHYdCQm7Ol08vAXmrwMyDfvsmqdXUEx4om1UZ5WLf-JNaZp4lXhgF7Cur5066213jwpp4f_D3MyR-oa43fSa91gqp2berUgUyOWdYSIshABVQ", + "kty": "RSA", + "kid": kid1, + }, + { + "n": "tMXbmw7xEDVLLkAJdxpI-6pGywn0x9fHbD_mfgtFGZEs1LDjhDAJq6c-SoODeWQstjpetTgNqVCKOuU6zGyFPNtkDjhJqDW6THy06uJ8I85crILo3h-6NPclZ3bK9OzN5bIbzjbSvxrIM7ORZOlWzByOn5qGsMvI3aDrZ0lXNC1eCDWJpoJznG1fWcHYxbUy_CHDC3Cd26jX19aRALEEQU-y-wi9pv86qxEmrYMLsVN3__eWNNPkzxgf0eSOWFDv5_19YK7irYztqiwin6abxr9RHj3Qs21hpJ9A-YfsfmNkxmifgDeiTnXpZY8yfVTCJTtkgT7sjdU1lvhsMa4Z0w", + "e": "AQAB", + "use": "sig", + "alg": "RS256", + "kty": "RSA", + "kid": kid2, + }, + ]})) + .expect("Should deserialize JWKS"); + let expected_keys = expected_jwkset + .keys + .iter() + .filter_map(|key| match &key.common.key_id { + Some(key_id) => Some(( + key_id.as_str().into(), + DecodingKey::from_jwk(key).expect("Should create DecodingKey from Jwk"), + )), + None => None, + }) + .collect::, DecodingKey>>(); + + let expected = JwkStore { + store_id: "test".into(), + issuer: None, + keys: expected_keys, + keys_without_id: Vec::new(), + last_updated: OffsetDateTime::from_unix_timestamp(0).unwrap(), + source_iss: None, + }; + + assert_eq!(expected, result); + + // Asserts so we can check if using `get` works as expected + assert!( + result.get(kid1).is_some(), + "Expected to find key with id: {kid1}" + ); + assert!( + result.get(kid2).is_some(), + "Expected to find key with id: {kid2}" + ); + assert!( + result.get("unknown key id").is_none(), + "Expected to find None" + ); + } +} diff --git a/jans-cedarling/cedarling/src/jwt/jwt_service_config.rs b/jans-cedarling/cedarling/src/jwt/jwt_service_config.rs deleted file mode 100644 index 6ab9379f320..00000000000 --- a/jans-cedarling/cedarling/src/jwt/jwt_service_config.rs +++ /dev/null @@ -1,54 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -//! Module that contains strong typed configuaration for the JWT service. -//! This configuration allows to initialize service without any errors. - -use crate::common::policy_store::TrustedIssuer; -use crate::jwt; -use std::collections::HashSet; - -use super::decoding_strategy::key_service::{fetch_openid_config, OpenIdConfig}; - -/// Configuration for JWT service -pub enum JwtServiceConfig { - /// Decoding strategy that does not perform validation. - WithoutValidation { - trusted_idps: Vec, - }, - - /// Decoding strategy that performs validation using a key service and supported algorithms. - WithValidation { - supported_algs: HashSet, - trusted_idps: Vec, - }, -} - -/// Structure to store `TrustedIssuer` and `OpenIdConfig` in one place. -#[derive(Clone)] -pub struct TrustedIssuerAndOpenIdConfig { - pub trusted_issuer: TrustedIssuer, - pub openid_config: OpenIdConfig, -} - -impl TrustedIssuerAndOpenIdConfig { - /// Fetch openid configuration based on the `TrustedIssuer` and return config - pub fn fetch( - trusted_issuer: TrustedIssuer, - client: &jwt::HttpClient, - ) -> Result { - let openid_config = fetch_openid_config( - trusted_issuer.openid_configuration_endpoint.as_str(), - client, - )?; - - Ok(Self { - trusted_issuer, - openid_config, - }) - } -} diff --git a/jans-cedarling/cedarling/src/jwt/key_service.rs b/jans-cedarling/cedarling/src/jwt/key_service.rs new file mode 100644 index 00000000000..dfbcb33a912 --- /dev/null +++ b/jans-cedarling/cedarling/src/jwt/key_service.rs @@ -0,0 +1,294 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + +use super::{ + http_client::{HttpClient, HttpClientError}, + jwk_store::{JwkStore, JwkStoreError}, + TrustedIssuerId, +}; +use crate::common::policy_store::TrustedIssuer; +use jsonwebtoken::DecodingKey; +use serde_json::{json, Value}; +use std::{collections::HashMap, sync::Arc, time::Duration}; + +pub struct DecodingKeyWithIss<'a> { + /// The decoding key used to validate JWT signatures. + pub key: &'a DecodingKey, + /// The Trusted Issuer where the Key was fetched. + pub key_iss: Option<&'a TrustedIssuer>, +} + +/// Manages Json Web Keys (JWK). +// TODO: periodically update the key stores to ensure keys are valid. +#[derive(Debug)] +pub struct KeyService { + key_stores: HashMap, +} + +impl KeyService { + /// Loads JWK stores from a string. + /// + /// This enables loading keystores via a local JSON file. + /// + /// # JWKS Schema + /// + /// The JSON must follow this schema: + /// + /// ```txt + /// { + /// "trusted_issuer_id": [ ... ] + /// "another_trusted_issuer_id": [ ... ] + /// } + /// ``` + /// + /// - Where keys are `Trusted Issuer IDs` assinged to each key store + /// - and the values contains the JSON Web Keys as defined in [`RFC 7517`]. + /// + /// [`RFC 7517`]: https://datatracker.ietf.org/doc/html/rfc7517 + pub fn new_from_str(key_stores: &str) -> Result { + let parsed_stores = serde_json::from_str::>(key_stores) + .map_err(KeyServiceError::DecodeJwkStores)?; + let mut key_stores = HashMap::new(); + for (iss_id, keys) in &parsed_stores { + let iss_id = TrustedIssuerId::from(iss_id.as_str()); + let jwks = json!({"keys": keys}); + key_stores.insert(iss_id.clone(), JwkStore::new_from_jwks_value(iss_id, jwks)?); + } + Ok(Self { key_stores }) + } + + /// Loads key stores using a JSON string. + /// + /// Enables loading key stores from a local JSON file. + pub fn new_from_trusted_issuers( + trusted_issuers: &HashMap, + ) -> Result { + let http_client = HttpClient::new(3, Duration::from_secs(3))?; + + let mut key_stores = HashMap::new(); + for (iss_id, iss) in trusted_issuers.iter() { + let iss_id: Arc = iss_id.as_str().into(); + key_stores.insert( + iss_id.clone(), + JwkStore::new_from_trusted_issuer(iss_id, iss, &http_client)?, + ); + } + + Ok(Self { key_stores }) + } + + /// Gets the decoding key with the given key ID from the store with it's Trusted Issuer. + pub fn get_key(&self, key_id: &str) -> Option { + // PERF: We can add a reference of all the keys into a HashMap + // so we do not need to loop through all of these. + for store in self.key_stores.values() { + if let Some(key) = store.get(key_id) { + return Some(DecodingKeyWithIss { + key, + key_iss: store.source_iss(), + }); + } + } + + None + } + + /// Returns a Vec containing a reference to all of the keys. + /// + /// Useful if the keys from the JWKS do not have a `kid` (Key ID). + // this is currently unused since we don't handle JWTs without `kid` yet. + #[allow(dead_code)] + pub fn get_keys(&self) -> Vec<&DecodingKey> { + // PERF: We can cache the returned Vec so it doesn't + // get created every time this function is called. + let mut keys = Vec::new(); + self.key_stores + .values() + .for_each(|store| keys.extend(store.get_keys())); + keys + } +} + +#[derive(thiserror::Error, Debug)] +pub enum KeyServiceError { + #[error("Failed to decode JWK Stores from string: {0}")] + DecodeJwkStores(#[source] serde_json::Error), + #[error("Failed to make HTTP Request: {0}")] + Http(#[from] HttpClientError), + #[error("Failed to load JWKS: {0}")] + JwkStoreError(#[from] JwkStoreError), +} + +#[cfg(test)] +mod test { + use std::collections::HashMap; + + use crate::{common::policy_store::TrustedIssuer, jwt::key_service::KeyService}; + use mockito::Server; + use serde_json::json; + + #[test] + fn can_load_from_str() { + let kid1 = "a50f6e70ef4b548a5fd9142eecd1fb8f54dce9ee"; + let kid2 = "73e25f9789119c7875d58087a78ac23f5ef2eda3"; + let key_stores = json!({ + "some_issuer_id": [{ + "use": "sig", + "e": "AQAB", + "alg": "RS256", + "n": "4VI56fF0rcWHHVgHFLHrmEO5w8oN9gbSQ9TEQnlIKRg0zCtl2dLKtt0hC6WMrTA9cF7fnK4CLNkfV_Mytk-rydu2qRV_kah62v9uZmpbS5dcz5OMXmPuQdV8fDVIvscDK5dzkwD3_XJ2mzupvQN2reiYgce6-is23vwOyuT-n4vlxSqR7dWdssK5sj9mhPBEIlfbuKNykX5W6Rgu-DyuoKArc_aukWnLxWN-yoroP2IHYdCQm7Ol08vAXmrwMyDfvsmqdXUEx4om1UZ5WLf-JNaZp4lXhgF7Cur5066213jwpp4f_D3MyR-oa43fSa91gqp2berUgUyOWdYSIshABVQ", + "kty": "RSA", + "kid": "a50f6e70ef4b548a5fd9142eecd1fb8f54dce9ee" + }], + "anorther_issuer_id": [{ + "n": "tMXbmw7xEDVLLkAJdxpI-6pGywn0x9fHbD_mfgtFGZEs1LDjhDAJq6c-SoODeWQstjpetTgNqVCKOuU6zGyFPNtkDjhJqDW6THy06uJ8I85crILo3h-6NPclZ3bK9OzN5bIbzjbSvxrIM7ORZOlWzByOn5qGsMvI3aDrZ0lXNC1eCDWJpoJznG1fWcHYxbUy_CHDC3Cd26jX19aRALEEQU-y-wi9pv86qxEmrYMLsVN3__eWNNPkzxgf0eSOWFDv5_19YK7irYztqiwin6abxr9RHj3Qs21hpJ9A-YfsfmNkxmifgDeiTnXpZY8yfVTCJTtkgT7sjdU1lvhsMa4Z0w", + "e": "AQAB", + "use": "sig", + "alg": "RS256", + "kty": "RSA", + "kid": "73e25f9789119c7875d58087a78ac23f5ef2eda3" + }], + }); + + let key_service = KeyService::new_from_str(&key_stores.to_string()) + .expect("Should load KeyService from str"); + + assert!( + key_service.get_key(kid1).is_some(), + "Expected to find a key" + ); + assert!( + key_service.get_key(kid2).is_some(), + "Expected to find a key" + ); + assert!( + key_service.get_key("some unknown key id").is_none(), + "Expected to not find a key" + ); + } + + #[test] + fn can_load_jwk_stores_from_multiple_trusted_issuers() { + let kid1 = "a50f6e70ef4b548a5fd9142eecd1fb8f54dce9ee"; + let kid2 = "73e25f9789119c7875d58087a78ac23f5ef2eda3"; + + let mut mock_server = Server::new(); + + // Setup first OpenID config endpoint + let openid_config_endpoint1 = mock_server + .mock("GET", "/first/.well-known/openid-configuration") + .with_status(200) + .with_body( + json!({ + "issuer": mock_server.url(), + "jwks_uri": format!("{}/first/jwks", mock_server.url()) + }) + .to_string(), + ) + .expect(1) + .create(); + // Setup first JWKS endpoint + let jwks_endpoint1 = mock_server + .mock("GET", "/first/jwks") + .with_status(200) + .with_body( + json!({ + "keys": [ + { + "use": "sig", + "e": "AQAB", + "alg": "RS256", + "n": "4VI56fF0rcWHHVgHFLHrmEO5w8oN9gbSQ9TEQnlIKRg0zCtl2dLKtt0hC6WMrTA9cF7fnK4CLNkfV_Mytk-rydu2qRV_kah62v9uZmpbS5dcz5OMXmPuQdV8fDVIvscDK5dzkwD3_XJ2mzupvQN2reiYgce6-is23vwOyuT-n4vlxSqR7dWdssK5sj9mhPBEIlfbuKNykX5W6Rgu-DyuoKArc_aukWnLxWN-yoroP2IHYdCQm7Ol08vAXmrwMyDfvsmqdXUEx4om1UZ5WLf-JNaZp4lXhgF7Cur5066213jwpp4f_D3MyR-oa43fSa91gqp2berUgUyOWdYSIshABVQ", + "kty": "RSA", + "kid": kid1, + }, + ] + }).to_string()) + .expect(1) + .create(); + + // Setup second OpenID config endpoint + let openid_config_endpoint2 = mock_server + .mock("GET", "/second/.well-known/openid-configuration") + .with_status(200) + .with_body( + json!({ + "issuer": mock_server.url(), + "jwks_uri": format!("{}/second/jwks", mock_server.url()) + }) + .to_string(), + ) + .expect(1) + .create(); + // Setup second JWKS endpoint + let jwks_endpoint2 = mock_server + .mock("GET", "/second/jwks") + .with_status(200) + .with_body( + json!({ + "keys": [ + { + "n": "tMXbmw7xEDVLLkAJdxpI-6pGywn0x9fHbD_mfgtFGZEs1LDjhDAJq6c-SoODeWQstjpetTgNqVCKOuU6zGyFPNtkDjhJqDW6THy06uJ8I85crILo3h-6NPclZ3bK9OzN5bIbzjbSvxrIM7ORZOlWzByOn5qGsMvI3aDrZ0lXNC1eCDWJpoJznG1fWcHYxbUy_CHDC3Cd26jX19aRALEEQU-y-wi9pv86qxEmrYMLsVN3__eWNNPkzxgf0eSOWFDv5_19YK7irYztqiwin6abxr9RHj3Qs21hpJ9A-YfsfmNkxmifgDeiTnXpZY8yfVTCJTtkgT7sjdU1lvhsMa4Z0w", + "e": "AQAB", + "use": "sig", + "alg": "RS256", + "kty": "RSA", + "kid": kid2, + } + + ]}).to_string()) + .expect(1) + .create(); + + let key_service = KeyService::new_from_trusted_issuers(&HashMap::from([ + ( + "first".to_string(), + TrustedIssuer { + name: "First IDP".to_string(), + description: "".to_string(), + openid_configuration_endpoint: format!( + "{}/first/.well-known/openid-configuration", + mock_server.url() + ), + ..Default::default() + }, + ), + ( + "second".to_string(), + TrustedIssuer { + name: "Second IDP".to_string(), + description: "".to_string(), + openid_configuration_endpoint: format!( + "{}/second/.well-known/openid-configuration", + mock_server.url() + ), + ..Default::default() + }, + ), + ])) + .expect("Should load KeyService from trusted issuers"); + + assert!( + key_service.get_key(kid1).is_some(), + "Expected to find a key" + ); + assert!( + key_service.get_key(kid2).is_some(), + "Expected to find a key" + ); + assert!( + key_service.get_key("some unknown key id").is_none(), + "Expected to not find a key" + ); + + // Assert that each of the endpoints are only visited once. + openid_config_endpoint1.assert(); + openid_config_endpoint2.assert(); + jwks_endpoint1.assert(); + jwks_endpoint2.assert(); + } +} diff --git a/jans-cedarling/cedarling/src/jwt/mod.rs b/jans-cedarling/cedarling/src/jwt/mod.rs index 9473e8c08db..a257d6eafd6 100644 --- a/jans-cedarling/cedarling/src/jwt/mod.rs +++ b/jans-cedarling/cedarling/src/jwt/mod.rs @@ -13,139 +13,314 @@ //! - Validating the signatures of JWTs to ensure their integrity and authenticity. //! - Verifying the validity of JWTs based on claims such as expiration time and audience. +mod http_client; +mod issuers_store; +mod jwk_store; +mod key_service; #[cfg(test)] -mod test; - -mod error; -mod jwt_service_config; -mod token; +mod test_utils; +mod validator; use crate::common::policy_store::TrustedIssuer; -use decoding_strategy::{open_id_storage::OpenIdStorage, DecodingStrategy}; +use crate::{IdTokenTrustMode, JwtConfig}; +use key_service::{KeyService, KeyServiceError}; use serde::de::DeserializeOwned; -use token::*; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use validator::{JwtValidator, JwtValidatorConfig, JwtValidatorError}; -pub use decoding_strategy::key_service::{HttpClient, KeyServiceError}; -pub use decoding_strategy::ParseAlgorithmError; -pub use error::JwtServiceError; pub use jsonwebtoken::Algorithm; -pub use jwt_service_config::*; -pub(crate) mod decoding_strategy; + +/// Type alias for Trusted Issuers' ID. +type TrustedIssuerId = Arc; + +/// Type alias for a Json Web Key ID (`kid`). +type KeyId = Box; + +#[derive(Debug, thiserror::Error)] +pub enum JwtProcessingError { + #[error("Invalid Access token: {0}")] + InvalidAccessToken(#[source] JwtValidatorError), + #[error("Invalid ID token: {0}")] + InvalidIdToken(#[source] JwtValidatorError), + #[error("Invalid Userinfo token: {0}")] + InvalidUserinfoToken(#[source] JwtValidatorError), + #[error("Validation failed: id_token audience does not match the access_token client_id. id_token.aud: {0:?}, access_token.client_id: {1:?}")] + IdTokenAudienceMismatch(String, String), + #[error("Validation failed: Userinfo token subject does not match the id_token subject. userinfo_token.sub: {0:?}, id_token.sub: {1:?}")] + UserinfoSubMismatch(String, String), + #[error( + "Validation failed: Userinfo token audience ({0}) does not match the access_token client_id ({1})." + )] + UserinfoAudienceMismatch(String, String), + #[error("CEDARLING_ID_TOKEN_TRUST_MODE is set to 'Strict', but the {0} is missing a required claim: {1}")] + MissingClaimsInStrictMode(&'static str, &'static str), + #[error("Failed to deserialize from Value to String: {0}")] + StringDeserialization(#[from] serde_json::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum JwtServiceInitError { + #[error("Failed to initialize Key Service for JwtService due to a conflictig config: both a local JWKS and trusted issuers was provided.")] + ConflictingJwksConfig, + #[error("Failed to initialize Key Service for JwtService due to a missing config: no local JWKS or trusted issuers was provided.")] + MissingJwksConfig, + #[error("Failed to initialize Key Service: {0}")] + KeyService(#[from] KeyServiceError), + #[error("Encountered an unsupported algorithm in the config: {0}")] + UnsupportedAlgorithm(String), + #[error("Failed to initialize JwtValidator: {0}")] + InitJwtValidator(#[from] JwtValidatorError), +} pub struct JwtService { - decoding_strategy: DecodingStrategy, - open_id_storage: OpenIdStorage, + access_tkn_validator: JwtValidator, + id_tkn_validator: JwtValidator, + userinfo_tkn_validator: JwtValidator, + id_token_trust_mode: IdTokenTrustMode, } -/// A service for handling JSON Web Tokens (JWT). -/// -/// The `JwtService` struct provides functionality to decode and optionally validate -/// JWTs based on a specified decoding strategy. It can be configured to either -/// perform validation or to decode without validation, depending on the provided -/// configuration. It is an internal module used by other components of the library. impl JwtService { - /// Initializes a new `JwtService` instance based on the provided configuration. - pub(crate) fn new_with_config(config: JwtServiceConfig) -> Self { - match config { - JwtServiceConfig::WithoutValidation { trusted_idps } => { - let decoding_strategy = DecodingStrategy::new_without_validation(); - Self { - decoding_strategy, - open_id_storage: OpenIdStorage::new(trusted_idps), - } + pub fn new( + config: &JwtConfig, + trusted_issuers: Option>, + ) -> Result { + let key_service: Arc<_> = + match (&config.jwt_sig_validation, &config.jwks, &trusted_issuers) { + // Case: no JWKS provided + (true, None, None) => Err(JwtServiceInitError::MissingJwksConfig)?, + // Case: Trusted issuers provided + (true, None, Some(issuers)) => Some( + KeyService::new_from_trusted_issuers(issuers) + .map_err(JwtServiceInitError::KeyService)?, + ), + // Case: Local JWKS provided + (true, Some(jwks), None) => { + Some(KeyService::new_from_str(jwks).map_err(JwtServiceInitError::KeyService)?) + }, + // Case: Both a local JWKS and trusted issuers were provided + (true, Some(_), Some(_)) => Err(JwtServiceInitError::ConflictingJwksConfig)?, + // Case: Signature validation is Off so no key service is needed. + _ => None, + } + .into(); + + // prepare shared configs + let sig_validation: Arc<_> = config.jwt_sig_validation.into(); + let status_validation: Arc<_> = config.jwt_status_validation.into(); + let trusted_issuers: Arc<_> = trusted_issuers.clone().into(); + let algs_supported: Arc> = + config.signature_algorithms_supported.clone().into(); + + let access_tkn_validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: sig_validation.clone(), + status_validation: status_validation.clone(), + trusted_issuers: trusted_issuers.clone(), + algs_supported: algs_supported.clone(), + required_claims: config.access_token_config.required_claims(), + validate_exp: config.access_token_config.exp_validation, + validate_nbf: config.access_token_config.nbf_validation, }, - JwtServiceConfig::WithValidation { - supported_algs, - trusted_idps, - } => { - let decoding_strategy = DecodingStrategy::new_with_validation( - supported_algs, - // TODO: found the way to use `OpenIdStorage` in the decoding strategy. - // Or use more suitable structure - trusted_idps - .iter() - .map(|v| v.trusted_issuer.clone()) - .collect(), - ) - // TODO: remove expect here and all data should be already in the `JwtServiceConfig` - .expect("could not initialize decoding strategy with validation"); - Self { - decoding_strategy, - open_id_storage: OpenIdStorage::new(trusted_idps), - } + key_service.clone(), + )?; + + let id_tkn_validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: sig_validation.clone(), + status_validation: status_validation.clone(), + trusted_issuers: trusted_issuers.clone(), + algs_supported: algs_supported.clone(), + required_claims: config.id_token_config.required_claims(), + validate_exp: config.id_token_config.exp_validation, + validate_nbf: config.id_token_config.nbf_validation, }, - } + key_service.clone(), + )?; + + let userinfo_tkn_validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: sig_validation.clone(), + status_validation: status_validation.clone(), + trusted_issuers: trusted_issuers.clone(), + algs_supported: algs_supported.clone(), + required_claims: config.userinfo_token_config.required_claims(), + validate_exp: config.userinfo_token_config.exp_validation, + validate_nbf: config.userinfo_token_config.nbf_validation, + }, + key_service.clone(), + )?; + + Ok(Self { + access_tkn_validator, + id_tkn_validator, + userinfo_tkn_validator, + id_token_trust_mode: config.id_token_trust_mode, + }) } - /// Decodes and validates an `access_token`, `id_token`, and `userinfo_token`. - /// - /// # Token Validation Rules: - /// - token signature must be valid - /// - token must not be expired. - /// - token must not be used before the `nbf` timestamp. - /// - /// # Returns - /// A tuple containing the decoded claims for the `access_token`, `id_token`, and - /// `userinfo_token`. - /// - /// # Errors - /// Returns an error if decoding or validation of either any token fails. - pub fn decode_tokens( - &self, - access_token: &str, - id_token: &str, - userinfo_token: &str, - ) -> Result, JwtServiceError> + pub fn process_tokens<'a, A, I, U>( + &'a self, + access_token: &'a str, + id_token: &'a str, + userinfo_token: Option<&'a str>, + ) -> Result, JwtProcessingError> where A: DeserializeOwned, I: DeserializeOwned, U: DeserializeOwned, { - // extract claims without validation - let access_token_claims = DecodingStrategy::extract_claims(access_token) - .map_err(JwtServiceError::InvalidAccessToken)?; - let id_token_claims = - DecodingStrategy::extract_claims(id_token).map_err(JwtServiceError::InvalidIdToken)?; - let userinfo_token_claims = DecodingStrategy::extract_claims(userinfo_token) - .map_err(JwtServiceError::InvalidUserinfoToken)?; - - // Validate the access_token's signature and optionally, exp and nbf. let access_token = self - .decoding_strategy - .decode::(access_token) - .map_err(JwtServiceError::InvalidAccessToken)?; - - // Validate the id_token's signature and optionally, exp and nbf. - self.decoding_strategy - .decode::(id_token) - .map_err(JwtServiceError::InvalidIdToken)?; - - // validate the userinfo_token's signature and optionally, exp and nbf. - self.decoding_strategy - .decode::(userinfo_token) - .map_err(JwtServiceError::InvalidUserinfoToken)?; - - // assume that all tokens has the same `iss` (issuer) so we get config only for one JWT token - // this behavior can be changed in future - let trusted_issuer = self - .open_id_storage - .get(access_token.iss.as_str()) - .map(|config| &config.trusted_issuer); - - Ok(DecodeTokensResult { - access_token: access_token_claims, - id_token: id_token_claims, - userinfo_token: userinfo_token_claims, - trusted_issuer, + .access_tkn_validator + .process_jwt(access_token) + .map_err(JwtProcessingError::InvalidAccessToken)?; + let id_token = self + .id_tkn_validator + .process_jwt(id_token) + .map_err(JwtProcessingError::InvalidIdToken)?; + let userinfo_token = userinfo_token + .map(|jwt| self.userinfo_tkn_validator.process_jwt(jwt)) + .transpose() + .map_err(JwtProcessingError::InvalidUserinfoToken)?; + + // Additional checks for STRICT MODE + if self.id_token_trust_mode == IdTokenTrustMode::Strict { + // Check if id_token.sub == access_token.client_id + let id_tkn_aud = + id_token + .claims + .get("aud") + .ok_or(JwtProcessingError::MissingClaimsInStrictMode( + "id_token", "aud", + ))?; + let access_tkn_client_id = access_token.claims.get("client_id").ok_or( + JwtProcessingError::MissingClaimsInStrictMode("access_token", "client_id"), + )?; + if id_tkn_aud != access_tkn_client_id { + Err(JwtProcessingError::IdTokenAudienceMismatch( + serde_json::from_value::(id_tkn_aud.clone())?, + serde_json::from_value::(access_tkn_client_id.clone())?, + ))? + } + + // If userinfo token is present, check if: + // 1. userinfo_token.sub == id_token.sub + // 2. userinfo_token.aud == access_token.client_id + if let Some(token) = &userinfo_token { + let id_tkn_sub = id_token.claims.get("sub").ok_or( + JwtProcessingError::MissingClaimsInStrictMode("ID Token", "sub"), + )?; + let usrinfo_sub = token.claims.get("sub").ok_or( + JwtProcessingError::MissingClaimsInStrictMode("Userinfo Token", "sub"), + )?; + if usrinfo_sub != id_tkn_sub { + Err(JwtProcessingError::UserinfoSubMismatch( + serde_json::from_value::(usrinfo_sub.clone())?, + serde_json::from_value::(id_tkn_sub.clone())?, + ))? + } + + let usrinfo_aud = token.claims.get("aud").ok_or( + JwtProcessingError::MissingClaimsInStrictMode("Userinfo Token", "aud"), + )?; + if usrinfo_aud != access_tkn_client_id { + Err(JwtProcessingError::UserinfoAudienceMismatch( + serde_json::from_value::(usrinfo_aud.clone())?, + serde_json::from_value::(access_tkn_client_id.clone())?, + ))? + } + } + } + + let userinfo_token = match userinfo_token { + Some(token) => token, + None => unimplemented!("Having no userinfo token is not yet supported."), + }; + + Ok(ProcessTokensResult { + access_token: serde_json::from_value::(access_token.claims)?, + id_token: serde_json::from_value::(id_token.claims)?, + userinfo_token: serde_json::from_value::(userinfo_token.claims)?, + // we just assume that all the tokens have the same issuer so we get the + // issuer from the access token. + // this behavior might be changed in future + trusted_issuer: access_token.trusted_iss, }) } } #[derive(Debug)] -pub struct DecodeTokensResult<'a, A, I, U> { +pub struct ProcessTokensResult<'a, A, I, U> { pub access_token: A, pub id_token: I, pub userinfo_token: U, - pub trusted_issuer: Option<&'a TrustedIssuer>, } + +#[cfg(test)] +mod new_test { + use super::test_utils::*; + use super::JwtService; + use crate::IdTokenTrustMode; + use crate::JwtConfig; + use crate::TokenValidationConfig; + use jsonwebtoken::Algorithm; + use serde_json::json; + use serde_json::Value; + use std::collections::HashSet; + + #[test] + pub fn can_validate_tokens() { + // Generate token + let keys = generate_keypair_hs256(Some("some_hs256_key")).expect("Should generate keys"); + let access_tkn_claims = json!({ + "iss": "https://accounts.test.com", + "sub": "some_sub", + "jti": 1231231231, + "exp": u64::MAX, + "client_id": "test123", + }); + let access_tkn = generate_token_using_claims(&access_tkn_claims, &keys) + .expect("Should generate access token"); + let id_tkn_claims = json!({ + "iss": "https://accounts.test.com", + "aud": "test123", + "sub": "some_sub", + "name": "John Doe", + "exp": u64::MAX, + }); + let id_tkn = + generate_token_using_claims(&id_tkn_claims, &keys).expect("Should generate id token"); + let userinfo_tkn_claims = json!({ + "iss": "https://accounts.test.com", + "aud": "test123", + "sub": "some_sub", + "name": "John Doe", + "exp": u64::MAX, + }); + let userinfo_tkn = generate_token_using_claims(&userinfo_tkn_claims, &keys) + .expect("Should generate userinfo token"); + + // Prepare JWKS + let local_jwks = json!({"test_idp": generate_jwks(&vec![keys]).keys}).to_string(); + + let jwt_service = JwtService::new( + &JwtConfig { + jwks: Some(local_jwks), + jwt_sig_validation: true, + jwt_status_validation: false, + id_token_trust_mode: IdTokenTrustMode::Strict, + signature_algorithms_supported: HashSet::from_iter([Algorithm::HS256]), + access_token_config: TokenValidationConfig::access_token(), + id_token_config: TokenValidationConfig::id_token(), + userinfo_token_config: TokenValidationConfig::userinfo_token(), + }, + None, + ) + .expect("Should create JwtService"); + + jwt_service + .process_tokens::(&access_tkn, &id_tkn, Some(&userinfo_tkn)) + .expect("Should process JWTs"); + } +} diff --git a/jans-cedarling/cedarling/src/jwt/test.rs b/jans-cedarling/cedarling/src/jwt/test.rs deleted file mode 100644 index 81543fedf6e..00000000000 --- a/jans-cedarling/cedarling/src/jwt/test.rs +++ /dev/null @@ -1,12 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -mod utils; -mod with_validation; -mod without_validation; - -use utils::*; diff --git a/jans-cedarling/cedarling/src/jwt/test/utils.rs b/jans-cedarling/cedarling/src/jwt/test/utils.rs deleted file mode 100644 index 620becbd2be..00000000000 --- a/jans-cedarling/cedarling/src/jwt/test/utils.rs +++ /dev/null @@ -1,198 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -use core::panic; -use jsonwebkey as jwk; -use jsonwebtoken::{self as jwt}; -use serde::Serialize; -use std::{ - time::{SystemTime, UNIX_EPOCH}, - u64, -}; - -#[derive(Clone)] -pub struct EncodingKey { - pub key_id: String, - pub key: jwt::EncodingKey, - pub algorithm: jwt::Algorithm, -} - -/// Generates a set of private and public keys using ES256 -/// -/// Returns a tuple: (encoding_keys, jwks as a string) -pub fn generate_keys() -> (Vec, String) { - let mut public_keys = jwt::jwk::JwkSet { keys: vec![] }; - let mut encoding_keys = vec![]; - - // Generate a private key using ES256 - let kid = 1; - let mut jwk = jwk::JsonWebKey::new(jwk::Key::generate_p256()); - jwk.set_algorithm(jwk::Algorithm::ES256) - .expect("should set encryption algorithm"); - jwk.key_id = Some("some_id".to_string()); - - // Generate public key - let mut public_key = - serde_json::to_value(jwk.key.to_public()).expect("should serialize public key"); - public_key["kid"] = serde_json::Value::String(kid.to_string()); // set `kid` - let public_key: jwt::jwk::Jwk = - serde_json::from_value(public_key).expect("should deserialize public key"); - public_keys.keys.push(public_key); - - let encoding_key = jwt::EncodingKey::from_ec_pem(jwk.key.to_pem().as_bytes()) - .expect("should generate encoding key"); - encoding_keys.push(EncodingKey { - key_id: kid.to_string(), - key: encoding_key, - algorithm: jwt::Algorithm::ES256, - }); - - // Generate another private key using HS256 - let kid = 2; - let mut jwk = jwk::JsonWebKey::new(jwk::Key::generate_symmetric(256)); - jwk.set_algorithm(jwk::Algorithm::HS256) - .expect("should set encryption algorithm"); - jwk.key_id = Some("some_id".to_string()); - - // since this is a symmetric key, the public key is the same as the private - let mut public_key = - serde_json::to_value(jwk.key.clone()).expect("should serialize public key"); - - // set the key parameters - public_key["kid"] = serde_json::Value::String(kid.to_string()); // set `kid` - let mut public_key: jwt::jwk::Jwk = - serde_json::from_value(public_key).expect("should deserialize public key"); - public_key.common.key_algorithm = Some(jwt::jwk::KeyAlgorithm::HS256); - public_keys.keys.push(public_key); - - let private_key = match *jwk.key { - jsonwebkey::Key::Symmetric { key } => jwt::EncodingKey::from_secret(&key), - _ => panic!("Expected symmetric key for HS256"), // this shouldn't really happen unless - // code within this function changes - }; - encoding_keys.push(EncodingKey { - key_id: kid.to_string(), - key: private_key, - algorithm: jwt::Algorithm::HS256, - }); - - // serialize public keys - let public_keys = serde_json::to_string(&public_keys).expect("should serialize keyset"); - (encoding_keys, public_keys) -} - -pub struct Timestamp; - -impl Timestamp { - pub fn now() -> u64 { - let now = SystemTime::now(); - now.duration_since(UNIX_EPOCH).unwrap().as_secs() - } - - pub fn one_hour_before_now() -> u64 { - let now = SystemTime::now(); - now.duration_since(UNIX_EPOCH).unwrap().as_secs() - 3600 - } - - pub fn one_hour_after_now() -> u64 { - let now = SystemTime::now(); - now.duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600 - } -} - -/// The arguments for [`generate_token_using_claims`] -pub struct GenerateTokensArgs { - pub access_token_claims: serde_json::Value, - pub id_token_claims: serde_json::Value, - pub userinfo_token_claims: serde_json::Value, - pub encoding_keys: Vec, -} - -pub struct GeneratedTokens { - pub access_token: String, - pub id_token: String, - pub userinfo_token: String, -} - -/// Generates tokens using the given encoding keys. -/// -/// The `access_token` and `userinfo_token` will be encoded by the first key in the -/// `Vec` and the `id_token` will be encoded by the second. -/// -/// # Panics -/// -/// Panics when a token cannot be encoded. -pub fn generate_tokens_using_claims(args: GenerateTokensArgs) -> GeneratedTokens { - let access_token = - generate_token_using_claims(&args.access_token_claims, &args.encoding_keys[0]) - .expect("Should generate access_token"); - let id_token = generate_token_using_claims(&args.id_token_claims, &args.encoding_keys[1]) - .expect("Should generate id_token"); - let userinfo_token = - generate_token_using_claims(&args.userinfo_token_claims, &args.encoding_keys[0]) - .expect("Should generate userinfo_token"); - - GeneratedTokens { - access_token, - id_token, - userinfo_token, - } -} - -/// Generates a token string signed with ES256 -pub fn generate_token_using_claims( - claims: &impl Serialize, - encoding_key: &EncodingKey, -) -> Result { - // select a key from the keyset - // for simplicity, were just choosing the second one - - // specify the header - let header = jwt::Header { - alg: encoding_key.algorithm, - kid: Some(encoding_key.key_id.clone()), - ..Default::default() - }; - - // serialize token to a string - Ok(jwt::encode(&header, &claims, &encoding_key.key)?) -} - -/// Invalidates a JWT Token by altering the first two characters in its signature -/// -/// # Panics -/// -/// Panics when the input token is malformed. -pub fn invalidate_token(token: String) -> String { - let mut token_parts: Vec<&str> = token.split('.').collect(); - - if token_parts.len() < 3 { - panic!("Token is malformed") - } - - let mut new_signature = token_parts[2].to_string(); - let mut chars: Vec = new_signature.chars().collect(); - - if chars.len() >= 2 { - // Ensure the first character is different from the second - if chars[0] == chars[1] { - let mut new_char = 'A'; - // Find a character that differs from the second character - while new_char == chars[1] { - new_char = (new_char as u8 + 1) as char; // Cycle through ASCII values - } - chars[0] = new_char; - } else { - // Swap the first two characters if they're already different - chars.swap(0, 1); - } - new_signature = chars.into_iter().collect(); - } - - token_parts[2] = &new_signature; - token_parts.join(".") -} diff --git a/jans-cedarling/cedarling/src/jwt/test/with_validation.rs b/jans-cedarling/cedarling/src/jwt/test/with_validation.rs deleted file mode 100644 index 856b3778b6f..00000000000 --- a/jans-cedarling/cedarling/src/jwt/test/with_validation.rs +++ /dev/null @@ -1,404 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -//! This test module includes tests for when validation is off. - -mod access_token; -mod id_token; -mod key_service; -mod userinfo_token; - -use std::collections::HashSet; - -use super::*; -use crate::common::policy_store::TrustedIssuer; -use crate::jwt::{self, HttpClient, JwtService, JwtServiceError, TrustedIssuerAndOpenIdConfig}; -use jsonwebtoken::Algorithm; -use serde_json::json; - -#[test] -/// Tests if [`JwtService::decode_tokens`] can successfully decode tokens valid claims -fn can_decode_claims_with_validation() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Valid access_token claims - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid id_token token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims: access_token_claims.clone(), - id_token_claims: id_token_claims.clone(), - userinfo_token_claims: userinfo_token_claims.clone(), - encoding_keys: encoding_keys.clone(), - }); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - "unexpected": 123123, // a random number used to simulate having unexpected fields in the response - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ) - .expect("should decode token"); - - // assert that the decoded token claims match the input claims - assert_eq!( - result.access_token, access_token_claims, - "decoded access_token claims did not match the input claims" - ); - assert_eq!( - result.id_token, id_token_claims, - "decoded id_token claims did not match the input claims" - ); - assert_eq!( - result.userinfo_token, userinfo_token_claims, - "decoded id_token claims did not match the input claims" - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} - -#[test] -/// Tests if JWT validation fails due to unsupported algorithm. -/// -/// This test verifies that the `JwtService` correctly fails validation when the token -/// is signed with an unsupported algorithm. the service is configured to support `HS256`, -/// but the token is signed with `ES256`. -fn errors_on_unsupported_alg() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // setup claims for access token and ID token - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - let id_token_claims = json!({ - "iss": server.url(), - "sub": "some_sub".to_string(), - "aud": "some_aud".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - let userinfo_token_claims = json!({ - "sub": "some_sub".to_string(), - "client_id": "some_client_id".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims, - id_token_claims, - userinfo_token_claims, - encoding_keys, - }); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // assert that the validation fails due to the tokens being signed with an - // unsupported algorithm - let validation_result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ); - assert!( - matches!( - validation_result, - Err(JwtServiceError::InvalidAccessToken( - jwt::decoding_strategy::JwtDecodingError::TokenSignedWithUnsupportedAlgorithm( - jsonwebtoken::Algorithm::ES256 - ) - )) - ), - "Validation expected to failed due to unsupported algorithm" - ); - jwks_uri_mock.assert(); - - // check if the openid_conf_endpoint got called exactly once - openid_conf_mock.assert(); -} - -#[test] -/// Tests if receiving keys using unsupported algorithms from a JWKS gets handled gracefully -fn can_gracefully_handle_unsupported_algorithms_from_jwks() { - /// A struct used for manually editing the JSON Web Key Set (JWKS) in the mock response - #[derive(serde::Deserialize, serde::Serialize)] - struct Jwks { - keys: Vec, - } - - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Valid access_token claims - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid id_token token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gluu.org".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "admin".to_string(), - "email": "some_email@gluu.org".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed token strings - let access_token = generate_token_using_claims(&access_token_claims, &encoding_keys[1]) - .expect("should generate access_token"); - let id_token = generate_token_using_claims(&id_token_claims, &encoding_keys[1]) - .expect("should generate id_token"); - let userinfo_token = generate_token_using_claims(&userinfo_token_claims, &encoding_keys[1]) - .expect("should generate userinfo_token"); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - "unexpected": 123123, // a random number used to simulate unexpected data in the response - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - // we insert an unknown variant here to simulate getting a key with an unknown algorithm from - // the trusted issuer - let mut jwks = serde_json::from_str::(&jwks).unwrap(); - jwks.keys.push(json!({ - "kty": "EC", - "use": "sig", - "key_ops_type": [], - "crv": "P-521", - "kid": "connect_190362b7-efca-4674-9cb7-21b428cb682a_sig_es512", - "x5c": [ - "MIICBjCCAWegAwIBAgIhALe16fd76pin3igeUTiLhGW01wkEMVzBsmGdXVtYpeZuMAoGCCqGSM49BAMEMCQxIjAgBgNVBAMMGUphbnMgQXV0aCBDQSBDZXJ0aWZpY2F0ZXMwHhcNMjQxMDE5MTg1NzMyWhcNMjQxMDIxMTk1NzMxWjAkMSIwIAYDVQQDDBlKYW5zIEF1dGggQ0EgQ2VydGlmaWNhdGVzMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQAf4TdXH7umWW64g1w8+UZ0NhyRm6rWsRGL7E+bvS2cY+K6UPThM7/xy9nTs73Pw8OT26oUhBz1oM9Jhs0Qy/veXMAvgHuUeIT6CBV3aHr4osWFAnGwoh0pjd1NOU3TN+ms1ttcD1qyJcZxLOhvFr3VZ7/7p5gSOaY1MwEEG2Ka/itQTujJzAlMCMGA1UdJQQcMBoGCCsGAQUFBwMBBggrBgEFBQcDAgYEVR0lADAKBggqhkjOPQQDBAOBjAAwgYgCQgGBq8DEjIF1SwqFos+2mHA6XFO+pZfx9HESd8dUZxN3yA5yf1oFxhUCbviQeOCeATAITuEfSIIL8hAQ4uzQc7JYhgJCAfB8/JGumVAnU/3lx2aHVl8hpSXn/f2107VN4ld46dwy3r48Ioo8dfjN2dH0BOKNg2ddYPiORfrpI9Y/WF7vI4UT" - ], - "x": "f4TdXH7umWW64g1w8-UZ0NhyRm6rWsRGL7E-bvS2cY-K6UPThM7_xy9nTs73Pw8OT26oUhBz1oM9Jhs0Qy_veXM", - "y": "vgHuUeIT6CBV3aHr4osWFAnGwoh0pjd1NOU3TN-ms1ttcD1qyJcZxLOhvFr3VZ7_7p5gSOaY1MwEEG2Ka_itQTs", - "exp": 2729540651438u64, - "alg": "ES512" - })); - let jwks = serde_json::to_string(&jwks).unwrap(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let result = jwt_service - .decode_tokens::( - &access_token, - &id_token, - &userinfo_token, - ) - .expect("should decode token"); - - // assert that the decoded token claims match the input claims - assert_eq!( - result.access_token, access_token_claims, - "decoded access_token claims did not match the input claims" - ); - assert_eq!( - result.id_token, id_token_claims, - "decoded id_token claims did not match the input claims" - ); - assert_eq!( - result.userinfo_token, userinfo_token_claims, - "decoded id_token claims did not match the input claims" - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} diff --git a/jans-cedarling/cedarling/src/jwt/test/with_validation/access_token.rs b/jans-cedarling/cedarling/src/jwt/test/with_validation/access_token.rs deleted file mode 100644 index edeabf770c0..00000000000 --- a/jans-cedarling/cedarling/src/jwt/test/with_validation/access_token.rs +++ /dev/null @@ -1,385 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -//! This module contains negative tests for the `access_token` validation. -//! -//! ## Tests Included -//! - Tests for errors when the `access_token` has an invalid signature. -//! - Tests for errors when the `access_token` is expired. -//! - Tests for errors when the `nbf` has not passed yet. - -use std::collections::HashSet; - -use super::super::*; -use crate::common::policy_store::TrustedIssuer; -use crate::jwt::decoding_strategy::JwtDecodingError; -use crate::jwt::{self, HttpClient, JwtService, TrustedIssuerAndOpenIdConfig}; -use jsonwebtoken::Algorithm; -use serde_json::json; - -#[test] -/// Tests that [`JwtService::decode_tokens`] returns an error when the `access_token` -/// has an invalid signature -fn errors_on_invalid_signature() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Invalid access_token claims (missing `iss` field) - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // Valid id_token token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the access_token with invalid signature - let access_token = generate_token_using_claims(&access_token_claims, &encoding_keys[0]) - .expect("Should generate access_token"); - let access_token = invalidate_token(access_token); - - // generate the signed id_token - let id_token = generate_token_using_claims(&id_token_claims, &encoding_keys[1]) - .expect("Should generate id_token"); - - // generate the signed userinfo_token - let userinfo_token = generate_token_using_claims(&userinfo_token_claims, &encoding_keys[0]) - .expect("Should generate userinfo_token"); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &access_token, - &id_token, - &userinfo_token, - ); - - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidAccessToken( - jwt::decoding_strategy::JwtDecodingError::Validation(ref e) - )) if *e.kind() == jsonwebtoken::errors::ErrorKind::InvalidSignature, - ), - "Expected decoding to fail due to `access_token` having an invalid signature: {:?}", - decode_result - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} - -#[test] -/// Tests that [`JwtService::decode_tokens`] returns an error when the `access_token` is expired -fn errors_on_expired_token() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Invalid access_token claims (expired) - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_before_now(), - }); - // Valid id_token token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims, - id_token_claims, - userinfo_token_claims, - encoding_keys, - }); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ); - - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidAccessToken( - JwtDecodingError::Validation(ref e) - )) if *e.kind() == jsonwebtoken::errors::ErrorKind::ExpiredSignature, - ), - "Expected decoding to fail due to `access_token` being expired: {:?}", - decode_result - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} - -#[test] -/// Tests that [`JwtService::decode_tokens`] returns an error when the `access_token` is used -/// before the `nbf` timestamp -fn errors_on_token_used_before_nbf() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Invalid access_token claims (nbf has not yet passed) - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_before_now()*2, - "nbf": Timestamp::one_hour_after_now(), - }); - // Valid id_token token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims, - id_token_claims, - userinfo_token_claims, - encoding_keys, - }); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ); - - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidAccessToken( - JwtDecodingError::Validation(ref e) - )) if *e.kind() == jsonwebtoken::errors::ErrorKind::ImmatureSignature, - ), - "Expected decoding to fail due to `access_token` being used before the `nbf` timestamp: {:?}", - decode_result - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} diff --git a/jans-cedarling/cedarling/src/jwt/test/with_validation/id_token.rs b/jans-cedarling/cedarling/src/jwt/test/with_validation/id_token.rs deleted file mode 100644 index 820809742be..00000000000 --- a/jans-cedarling/cedarling/src/jwt/test/with_validation/id_token.rs +++ /dev/null @@ -1,386 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -//! This module contains negative tests for the `id_token` validation. -//! -//! ## Tests Included -//! - Tests for errors when the `id_token` has an invalid signature. -//! - Tests for errors when the `id_token` is expired. -//! - Tests for errors when the `nbf` has not passed yet. - -use std::collections::HashSet; - -use super::super::*; -use crate::common::policy_store::TrustedIssuer; -use crate::jwt::decoding_strategy::JwtDecodingError; -use crate::jwt::{self, HttpClient, JwtService, TrustedIssuerAndOpenIdConfig}; -use jsonwebtoken::Algorithm; -use serde_json::json; - -#[test] -/// Tests that [`JwtService::decode_tokens`] returns an error when the `id_token` -/// has an invalid signature -fn errors_on_invalid_signature() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Valid access_token claims - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // Valid id_token token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed access_token - let access_token = generate_token_using_claims(&access_token_claims, &encoding_keys[0]) - .expect("Should generate access_token"); - - // generate id_token with invalid signature - let id_token = generate_token_using_claims(&id_token_claims, &encoding_keys[1]) - .expect("Should generate id_token"); - let id_token = invalidate_token(id_token); - - // generate signed userinfo_token - let userinfo_token = generate_token_using_claims(&userinfo_token_claims, &encoding_keys[0]) - .expect("Should generate userinfo_token"); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &access_token, - &id_token, - &userinfo_token, - ); - - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidIdToken( - JwtDecodingError::Validation(ref e) - )) if *e.kind() == jsonwebtoken::errors::ErrorKind::InvalidSignature, - ), - "Expected decoding to fail due to `id_token` having an invalid signature: {:?}", - decode_result - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} - -#[test] -/// Tests that [`JwtService::decode_tokens`] returns an error when the `id_token` is expired -fn errors_on_expired_token() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Valid access_token claims - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Invalid id_token token claims (expired) - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_before_now(), - }); - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims, - id_token_claims, - userinfo_token_claims, - encoding_keys, - }); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ); - - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidIdToken( - JwtDecodingError::Validation(ref e) - )) if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature), - ), - "Expected decoding to fail due to `id_token` being expired: {:?}", - decode_result - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} - -#[test] -/// Tests that [`JwtService::decode_tokens`] returns an error when the `id_token` is used -/// before the `nbf` timestamp -fn errors_on_token_used_before_nbf() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Valid access_token claims - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Invalid id_token claims (nbf has not yet passed) - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now()*2, - "nbf": Timestamp::one_hour_after_now(), - }); - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims, - id_token_claims, - userinfo_token_claims, - encoding_keys, - }); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ); - - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidIdToken( - JwtDecodingError::Validation(ref e) - )) if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ImmatureSignature), - ), - "Expected decoding to fail due to `id_token` being used before the `nbf` timestamp: {:?}", - decode_result - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} diff --git a/jans-cedarling/cedarling/src/jwt/test/with_validation/key_service.rs b/jans-cedarling/cedarling/src/jwt/test/with_validation/key_service.rs deleted file mode 100644 index d19f96c42bd..00000000000 --- a/jans-cedarling/cedarling/src/jwt/test/with_validation/key_service.rs +++ /dev/null @@ -1,383 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -//! This module contains negative tests for the validation involving -//! [`jwt::decoding_strategy::KeyService`] -//! -//! ## Tests Included -//! -//! - Test expecting error when a key can't be found -//! - Test expecting panic for not being able to fetch openid configuration on [`JwtService::new_with_config`] init -//! - Test expecting panic for not being able to fetch JWKS [`JwtService::new_with_config`] init - -use std::collections::HashSet; - -use super::super::*; -use crate::common::policy_store::TrustedIssuer; -use crate::jwt::decoding_strategy::key_service::KeyService; -use crate::jwt::decoding_strategy::JwtDecodingError; -use crate::jwt::{self, HttpClient, JwtService}; -use crate::jwt::{KeyServiceError, TrustedIssuerAndOpenIdConfig}; -use jsonwebtoken::Algorithm; -use serde_json::json; - -#[test] -/// Tests if [`JwtService::decode_tokens`] fails if the `kid` of the tokens are not found -/// in the JWKS -fn errors_when_no_key_found() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Valid access_token claims - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid id_token token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed token strings - let access_token = generate_token_using_claims( - &access_token_claims, - &EncodingKey { - key_id: "some_key_id_not_in_the_jwks".to_string(), // we set an non-existing `kid` for the access_token - key: encoding_keys[0].key.clone(), - algorithm: encoding_keys[0].algorithm, - }, - ) - .expect("Should generate access_token"); - let id_token = generate_token_using_claims(&id_token_claims, &encoding_keys[1]) - .expect("Should generate id_token"); - let userinfo_token = generate_token_using_claims(&userinfo_token_claims, &encoding_keys[0]) - .expect("Should generate userinfo_token"); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(3) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(2) - .expect_at_most(3) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &access_token, - &id_token, - &userinfo_token, - ); - - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidAccessToken( - JwtDecodingError::KeyService(jwt::decoding_strategy::key_service::KeyServiceError::KeyNotFound(ref e)) - )) if **e == *"some_key_id_not_in_the_jwks", - ), - "Expected decoding to fail due to not being able to find a key to validate `access_token`: {:?}", - decode_result - ); - // key service should fetch the jwks again when it cant find the `kid` for the access_token - jwks_uri_mock.assert(); - - // assert that there aren't any additional calls to the openid_config_uri - openid_conf_mock.assert(); -} - -#[test] -/// Tests if [`JwtService::new_with_config`] returns an error if the JWKS cant be fetched -/// from the `jwks_uri`. -fn errors_when_cant_fetch_jwks_uri() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // setup mock server responses for OpenID configuration and JWKS URIs - // - // NOTE: we don't add a response for the `jwks_uri` so that we can simulate the endpoint being - // unreachable - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .create(); - - let openid_conf_endpoint = format!("{}/.well-known/openid-configuration", server.url()); - let result = KeyService::new(vec![&openid_conf_endpoint]); - - assert!( - matches!(result, Err(jwt::KeyServiceError::HttpStatus(_)),), - "Expected to error because the JWKS cant be fetched from the `jwks_uri`", - ); - - openid_conf_mock.assert(); -} - -#[test] -/// Tests if [`JwtService::new_with_config`] returns an error if the openid_configuration -/// cant be fetched from the `openid_configuration_endpoint` -fn errors_when_cant_fetch_openid_configuration() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(500) - .expect_at_least(1) - .create(); - - let openid_conf_endpoint = format!("{}/.well-known/openid-configuration", server.url()); - let key_service = KeyService::new(vec![&openid_conf_endpoint]); - - assert!( - matches!(key_service, Err(jwt::KeyServiceError::HttpStatus(_)),), - "Expected to error because the openid configuration cant be fetched from the `jwks_uri`" - ); - - openid_conf_mock.assert(); -} - -#[test] -/// Tests updating local JWKS and decoding JWT token claims with validation. -/// -/// This test ensures that `JwtService` can decode and validate claims from access tokens -/// and ID tokens after updating the local JWKS from the issuer's endpoint. -fn can_update_local_jwks() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // setup claims for access token and ID token - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - let id_token_claims = json!({ - "iss": server.url(), - "sub": "some_sub".to_string(), - "aud": "some_aud".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - let userinfo_token_claims = json!({ - "iss": server.url(), - "sub": "some_sub".to_string(), - "aud": "some_aud".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims: access_token_claims.clone(), - id_token_claims: id_token_claims.clone(), - userinfo_token_claims: userinfo_token_claims.clone(), - encoding_keys: encoding_keys.clone(), - }); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(json!({"keys": Vec::<&str>::new()}).to_string()) // empty JWKS - .expect_at_least(1) - .expect_at_most(3) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // assert that first call attempt to validate the token fails since a - // decoding key with the same `kid` could not be retrieved - let decode_result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ); - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidAccessToken( - JwtDecodingError::KeyService( - jwt::decoding_strategy::key_service::KeyServiceError::KeyNotFound(ref key_id) - ) - )) if key_id == &encoding_keys[0].key_id, - ), - "Expected decoding to fail due to missing key: {:?}", - decode_result - ); - jwks_uri_mock.assert(); - - // update the mock server's response for the jwks_uri - server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect(2) - .create(); - - // decode and validate the tokens again - let result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ) - .expect("should decode token"); - jwks_uri_mock.assert(); - - // assert that the decoded token claims match the expected claims - assert_eq!(result.access_token, access_token_claims); - assert_eq!(result.id_token, id_token_claims); - assert_eq!(result.userinfo_token, userinfo_token_claims); - - // verify that the OpenID configuration endpoints was called exactly once - openid_conf_mock.assert(); -} - -#[test] -/// Verifies that [`KeyService::new`] returns an error when the maximum number of HTTP retries -/// is reached without a successful response. -fn errors_when_max_http_retries_exceeded() { - let openid_conf_endpoint = "0.0.0.0"; - let key_service = KeyService::new(vec![&openid_conf_endpoint]); - - assert!(matches!( - key_service, - Err(KeyServiceError::MaxHttpRetriesReached(_)) - )); -} - -#[test] -/// Verifies that [`KeyService::new`] returns an error when the OpenID configuration endpoint -/// responds with an HTTP error status code (e.g., 500). -fn errors_on_http_error_status_from_endpoint() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(500) - .create(); - - let openid_conf_endpoint = format!("{}/.well-known/openid-configuration", server.url()); - let key_service = KeyService::new(vec![&openid_conf_endpoint]); - - assert!(matches!(key_service, Err(KeyServiceError::HttpStatus(_)))); - - openid_conf_mock.assert(); -} diff --git a/jans-cedarling/cedarling/src/jwt/test/with_validation/userinfo_token.rs b/jans-cedarling/cedarling/src/jwt/test/with_validation/userinfo_token.rs deleted file mode 100644 index e4f3af14518..00000000000 --- a/jans-cedarling/cedarling/src/jwt/test/with_validation/userinfo_token.rs +++ /dev/null @@ -1,391 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -//! This module contains negative tests for the `userinfo_token` validation. -//! -//! ## Tests Included -//! - Tests for errors when the `userinfo_token` has an invalid signature. -//! - Tests for errors when the `userinfo_token` is expired. -//! - Tests for errors when the `nbf` has not passed yet. - -use std::collections::HashSet; - -use super::super::*; -use crate::common::policy_store::TrustedIssuer; -use crate::jwt::decoding_strategy::JwtDecodingError; -use crate::jwt::{self, HttpClient, JwtService, TrustedIssuerAndOpenIdConfig}; -use jsonwebtoken::Algorithm; -use serde_json::json; - -#[test] -/// Tests that [`JwtService::decode_tokens`] returns an error when the `userinfo_token` -/// has an invalid signature -fn errors_on_invalid_signature() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Valid access_token claims - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // Valid id_token token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // Valid userinfo_token claims - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - - // generate the signed access_token - let access_token = generate_token_using_claims(&access_token_claims, &encoding_keys[0]) - .expect("Should generate access_token"); - - // generate signed id_token - let id_token = generate_token_using_claims(&id_token_claims, &encoding_keys[1]) - .expect("Should generate id_token"); - - // generate userinfo_token with invalid signature - let userinfo_token = generate_token_using_claims(&userinfo_token_claims, &encoding_keys[0]) - .expect("Should generate userinfo_token"); - let userinfo_token = invalidate_token(userinfo_token); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - "unexpected": 123123, // a random number used to simulate having unexpected fields in the response - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &access_token, - &id_token, - &userinfo_token, - ); - - // assert that decoding resulted in an error due to missing claims - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidUserinfoToken( - JwtDecodingError::Validation(ref e) - )) if matches!( - e.kind(), - jsonwebtoken::errors::ErrorKind::InvalidSignature - ), - ), - "Expected error due to invalid signature from `userinfo_token` during token decoding: {:?}", - decode_result, - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} - -#[test] -/// Tests that [`JwtService::decode_tokens`] returns an error when the `userinfo_token` is expired -fn errors_on_expired_token() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Valid access_token claims - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid id_token token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Invalid userinfo_token claims (expired) - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_before_now(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims, - id_token_claims, - userinfo_token_claims, - encoding_keys, - }); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ); - - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidUserinfoToken( - JwtDecodingError::Validation(ref e) - )) if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature), - ), - "Expected decoding to fail due to `userinfo_token` being expired: {:?}", - decode_result - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} - -#[test] -/// Tests that [`JwtService::decode_tokens`] returns an error when the `userinfo_token` is used -/// before the `nbf` timestamp -fn errors_on_token_used_before_nbf() { - // initialize mock server to simulate OpenID configuration and JWKS responses - let mut server = mockito::Server::new(); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, jwks) = generate_keys(); - - // Valid access_token claims - let access_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Valid id_token claims - let id_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "email": "some_email@gmail.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now(), - }); - // Invalid userinfo_token claims (nbf has not yet passed) - let userinfo_token_claims = json!({ - "iss": server.url(), - "aud": "some_aud".to_string(), - "sub": "some_sub".to_string(), - "client_id": "some_aud".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - "iat": Timestamp::now(), - "exp": Timestamp::one_hour_after_now()*2, - "nbf": Timestamp::one_hour_after_now(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims, - id_token_claims, - userinfo_token_claims, - encoding_keys, - }); - - // setup mock server responses for OpenID configuration and JWKS URIs - let openid_config_response = json!({ - "issuer": server.url(), - "jwks_uri": &format!("{}/jwks", server.url()), - }); - let openid_conf_mock = server - .mock("GET", "/.well-known/openid-configuration") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(openid_config_response.to_string()) - .expect_at_least(1) - .expect_at_most(2) - .create(); - let jwks_uri_mock = server - .mock("GET", "/jwks") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks) - .expect_at_least(1) - .expect_at_most(2) - .create(); - - let trusted_idp = TrustedIssuerAndOpenIdConfig::fetch( - TrustedIssuer { - name: "some_idp".to_string(), - description: "some_desc".to_string(), - openid_configuration_endpoint: format!( - "{}/.well-known/openid-configuration", - server.url() - ), - ..Default::default() - }, - &HttpClient::new().expect("should create http client"), - ) - .expect("openid config should be fetched successfully"); - - // initialize JwtService with validation enabled and ES256 as the supported algorithm - let jwt_service = JwtService::new_with_config(crate::jwt::JwtServiceConfig::WithValidation { - supported_algs: HashSet::from([Algorithm::ES256, Algorithm::HS256]), - trusted_idps: vec![trusted_idp], - }); - - // key service should fetch the jwks_uri on init - openid_conf_mock.assert(); - // key service should fetch the jwks on init - jwks_uri_mock.assert(); - - // decode and validate the tokens - let decode_result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ); - - assert!( - matches!( - decode_result, - Err(jwt::JwtServiceError::InvalidUserinfoToken( - JwtDecodingError::Validation(ref e) - )) if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ImmatureSignature), - ), - "Expected decoding to fail due to `userinfo_token` being used before the `nbf` timestamp: {:?}", - decode_result - ); - - // assert that there aren't any additional calls to the mock server - openid_conf_mock.assert(); - jwks_uri_mock.assert(); -} diff --git a/jans-cedarling/cedarling/src/jwt/test/without_validation.rs b/jans-cedarling/cedarling/src/jwt/test/without_validation.rs deleted file mode 100644 index a190649c1e3..00000000000 --- a/jans-cedarling/cedarling/src/jwt/test/without_validation.rs +++ /dev/null @@ -1,73 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -use crate::jwt::JwtServiceConfig; - -use super::{super::JwtService, *}; -use serde_json::json; - -#[test] -/// Tests decoding JWT token claims without validation. -/// -/// This test verifies the ability of the `JwtService` to decode claims from both -/// access tokens and ID tokens without performing validation on the token's signature or claims. -/// The decoded claims are compared to the expected claims to ensure correctness. -fn can_decode_claims_without_validation() { - // initialize JwtService with validation disabled - let jwt_service = JwtService::new_with_config(JwtServiceConfig::WithoutValidation { - trusted_idps: Vec::new(), - }); - - // generate keys and setup the encoding keys and JWKS (JSON Web Key Set) - let (encoding_keys, _jwks) = generate_keys(); - - // setup claims for access token and ID token - let access_token_claims = json!({ - "iss": "https://accounts.google.com".to_string(), - "aud": "some_other_aud".to_string(), - "sub": "some_sub".to_string(), - "scopes": "some_scope".to_string(), - "exp": Timestamp::now(), - "iat": Timestamp::one_hour_before_now(), // expired token - }); - let id_token_claims = json!({ - "iss": "https://accounts.facebook.com".to_string(), - "sub": "some_sub".to_string(), - "aud": "some_aud".to_string(), - "email": "some_email@gmail.com".to_string(), - "exp": Timestamp::now(), - "iat": Timestamp::one_hour_before_now(), // expired token - }); - let userinfo_token_claims = json!({ - "sub": "another_sub".to_string(), - "client_id": "some_client_id".to_string(), - "name": "ferris".to_string(), - "email": "ferris@gluu.com".to_string(), - }); - - // generate the signed token strings - let tokens = generate_tokens_using_claims(GenerateTokensArgs { - access_token_claims: access_token_claims.clone(), - id_token_claims: id_token_claims.clone(), - userinfo_token_claims: userinfo_token_claims.clone(), - encoding_keys, - }); - - // decode and validate both the access token and the ID token - let result = jwt_service - .decode_tokens::( - &tokens.access_token, - &tokens.id_token, - &tokens.userinfo_token, - ) - .expect("should decode token"); - - // assert that the decoded token claims match the expected claims - assert_eq!(result.access_token, access_token_claims); - assert_eq!(result.id_token, id_token_claims); - assert_eq!(result.userinfo_token, userinfo_token_claims); -} diff --git a/jans-cedarling/cedarling/src/jwt/test_utils.rs b/jans-cedarling/cedarling/src/jwt/test_utils.rs new file mode 100644 index 00000000000..7b33ee3f885 --- /dev/null +++ b/jans-cedarling/cedarling/src/jwt/test_utils.rs @@ -0,0 +1,86 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + +use jsonwebkey as jwk; +use jsonwebtoken as jwt; +use serde::Serialize; + +/// A pair of encoding and decoding keys. +pub struct KeyPair { + kid: Option, + encoding_key: jwt::EncodingKey, + decoding_key: jwt::jwk::Jwk, + alg: jwt::Algorithm, +} + +#[derive(Debug, thiserror::Error)] +pub enum KeyGenerationError { + #[error("Failed to serialize the decoding key onto the right struct")] + SerializeDecodingKey(#[from] serde_json::Error), + #[error("The given key was generated with the wrong algorithm")] + KeyMismatch, +} + +/// Generates a HS256-signed token using the given claims. +pub fn generate_keypair_hs256(kid: Option) -> Result { + let mut jwk = jwk::JsonWebKey::new(jwk::Key::generate_symmetric(256)); + jwk.set_algorithm(jwk::Algorithm::HS256) + .expect("should set encryption algorithm"); + jwk.key_id = Some("some_id".to_string()); + + // since this is a symmetric key, the public key is the same as the private + let mut decoding_key = serde_json::to_value(jwk.key.clone())?; + + // set the key parameters + if let Some(kid) = &kid { + decoding_key["kid"] = serde_json::Value::String(kid.to_string()); + } + let mut decoding_key: jwt::jwk::Jwk = serde_json::from_value(decoding_key)?; + decoding_key.common.key_algorithm = Some(jwt::jwk::KeyAlgorithm::HS256); + + let encoding_key = match *jwk.key { + jsonwebkey::Key::Symmetric { key } => jwt::EncodingKey::from_secret(&key), + _ => Err(KeyGenerationError::KeyMismatch)?, + }; + + Ok(KeyPair { + kid: kid.map(|s| s.to_string()), + encoding_key, + decoding_key, + alg: jwt::Algorithm::HS256, + }) +} + +#[derive(Debug, thiserror::Error)] +pub enum TokenGenerationError { + #[error("Failed to encode token into a JWT string")] + Encode(#[from] jwt::errors::Error), +} + +/// Generates a token string in the given format: `"header.claim.signature"` +pub fn generate_token_using_claims( + claims: &impl Serialize, + keypair: &KeyPair, +) -> Result { + let header = jwt::Header { + alg: keypair.alg, + kid: keypair.kid.clone(), + ..Default::default() + }; + + // serialize token to a string + Ok(jwt::encode(&header, &claims, &keypair.encoding_key)?) +} + +/// Generates a JwkSet from the given keys +pub fn generate_jwks(keys: &Vec) -> jwt::jwk::JwkSet { + let keys = keys + .iter() + .map(|key_pair| key_pair.decoding_key.clone()) + .collect::>(); + jwt::jwk::JwkSet { keys } +} diff --git a/jans-cedarling/cedarling/src/jwt/token.rs b/jans-cedarling/cedarling/src/jwt/token.rs deleted file mode 100644 index 3a36bdf4022..00000000000 --- a/jans-cedarling/cedarling/src/jwt/token.rs +++ /dev/null @@ -1,27 +0,0 @@ -/* - * This software is available under the Apache-2.0 license. - * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. - * - * Copyright (c) 2024, Gluu, Inc. - */ - -use serde::{Deserialize, Serialize}; - -#[derive(Deserialize, Serialize)] -pub struct AccessToken { - pub iss: String, - pub aud: String, -} - -#[derive(Deserialize, Serialize)] -pub struct IdToken { - pub iss: String, - pub aud: String, - pub sub: String, -} - -#[derive(Deserialize, Serialize)] -pub struct UserInfoToken { - pub sub: String, - pub client_id: String, -} diff --git a/jans-cedarling/cedarling/src/jwt/validator.rs b/jans-cedarling/cedarling/src/jwt/validator.rs new file mode 100644 index 00000000000..4b73110ca47 --- /dev/null +++ b/jans-cedarling/cedarling/src/jwt/validator.rs @@ -0,0 +1,233 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + +mod config; +#[cfg(test)] +mod test; + +use super::issuers_store::TrustedIssuersStore; +use super::key_service::KeyService; +use crate::common::policy_store::TrustedIssuer; +use base64::prelude::*; +pub use config::*; +use jsonwebtoken::{self as jwt}; +use jsonwebtoken::{decode_header, Algorithm, Validation}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use url::Url; + +type IssuerId = String; +pub type TokenClaims = Value; + +/// Validates Json Web Tokens. +pub struct JwtValidator { + config: JwtValidatorConfig, + key_service: Arc>, + validators: HashMap, + iss_store: TrustedIssuersStore, +} + +#[derive(Debug, PartialEq)] +pub struct ProcessedJwt<'a> { + pub claims: TokenClaims, + pub trusted_iss: Option<&'a TrustedIssuer>, +} + +impl JwtValidator { + pub fn new( + config: JwtValidatorConfig, + key_service: Arc>, + ) -> Result { + if *config.sig_validation && key_service.is_none() { + Err(JwtValidatorError::MissingKeyService)?; + } + + // we define all the signature validators at startup so we can reuse them. + let validators = config + .algs_supported + .iter() + .map(|alg| { + let mut validation = Validation::new(*alg); + + validation.validate_exp = config.validate_exp; + validation.validate_nbf = config.validate_nbf; + + // we will validate the missing claims in another function but this + // defaults to true so we need to set it to false. + validation.required_spec_claims.clear(); + validation.validate_aud = false; + + (*alg, validation) + }) + .collect::>(); + + let iss_store = TrustedIssuersStore::new(config.trusted_issuers.clone()); + + Ok(Self { + config, + key_service, + validators, + iss_store, + }) + } + + /// Decodes the JWT and optionally validates it depending on the config. + pub fn process_jwt<'a>(&'a self, jwt: &'a str) -> Result, JwtValidatorError> { + let processed_jwt = match *self.config.sig_validation { + true => self.decode_and_validate_token(jwt)?, + false => self.decode(jwt)?, + }; + + self.check_missing_claims(&processed_jwt.claims)?; + + // Check if the `iss` claim's scheme is `https` + if self.config.required_claims.contains("iss") { + let iss = processed_jwt + .claims + .get("iss") + .map(|iss| serde_json::from_value::(iss.clone())) + .transpose()? + .ok_or(JwtValidatorError::MissingClaims(vec!["iss".into()]))?; + let url = Url::parse(&iss)?; + if url.scheme() != "https" { + Err(JwtValidatorError::InvalidIssScheme(url.scheme().into()))? + } + } + + Ok(processed_jwt) + } + + /// Decodes a JWT without validating the signature. + fn decode(&self, jwt: &str) -> Result { + // Split the token into its three parts + let parts = jwt.split('.').collect::>(); + if parts.len() != 3 { + return Err(JwtValidatorError::InvalidShape); + } + + // Base64 decode the payload (the second part) + let decoded_payload = BASE64_STANDARD_NO_PAD + .decode(parts[1]) + .map_err(|e| JwtValidatorError::DecodeJwt(e.to_string()))?; + + // Deserialize the claims into a Value + let claims = serde_json::from_slice::(&decoded_payload) + .map_err(JwtValidatorError::DeserializeJwt)?; + + // fetch the trusted issuer using the `iss` claim + let trusted_iss = claims + .get("iss") + .map(|x| serde_json::from_value::(x.clone())) + .transpose()? + .and_then(|x| self.iss_store.get(&x)); + + Ok(ProcessedJwt { + claims, + trusted_iss, + }) + } + + /// Decodes and validates the JWT's signature and optionally, the `exp` and `nbf` claims. + fn decode_and_validate_token(&self, jwt: &str) -> Result { + let key_service = self + .key_service + .as_ref() + .as_ref() + .ok_or(JwtValidatorError::MissingKeyService)?; + + let header = decode_header(jwt).map_err(JwtValidatorError::DecodeHeader)?; + + // since we already initialized all the validators on startup, not finding one + // for a certain algorithm means it's unsupported. + let validation = self.validators.get(&header.alg).ok_or( + JwtValidatorError::JwtSignedWithUnsupportedAlgorithm(header.alg), + )?; + + let decoding_key = match header.kid { + Some(kid) => key_service + .get_key(&kid) + .ok_or(JwtValidatorError::MissingDecodingKey(kid))?, + None => unimplemented!("Handling JWTs without `kid`s hasn't been implemented yet."), + }; + + let decode_result = jsonwebtoken::decode::(jwt, decoding_key.key, validation) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::InvalidToken => JwtValidatorError::InvalidShape, + jsonwebtoken::errors::ErrorKind::InvalidSignature => { + JwtValidatorError::InvalidSignature(e) + }, + jsonwebtoken::errors::ErrorKind::ExpiredSignature => { + JwtValidatorError::ExpiredToken + }, + jsonwebtoken::errors::ErrorKind::ImmatureSignature => { + JwtValidatorError::ImmatureToken + }, + jsonwebtoken::errors::ErrorKind::Base64(decode_error) => { + JwtValidatorError::DecodeJwt(decode_error.to_string()) + }, + // the jsonwebtoken crate placed all it's errors onto a single enum, even the errors + // that wouldn't be returned when we call `decode`. + _ => JwtValidatorError::Unexpected(e), + })?; + + Ok(ProcessedJwt { + claims: decode_result.claims, + trusted_iss: decoding_key.key_iss, + }) + } + + fn check_missing_claims(&self, claims: &TokenClaims) -> Result<(), JwtValidatorError> { + let missing_claims = self + .config + .required_claims + .iter() + .filter(|claim| claims.get(claim.as_ref()).is_none()) + .cloned() + .collect::>>(); + + if !missing_claims.is_empty() { + Err(JwtValidatorError::MissingClaims(missing_claims))? + } + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum JwtValidatorError { + #[error("JWT signature validation is on but no key service was provided.")] + MissingKeyService, + #[error("Invalid JWT format. The JWT must be in the shape: `header.payload.signature`")] + InvalidShape, + #[error("Failed to decode JWT Header: {0}")] + DecodeHeader(#[source] jwt::errors::Error), + #[error("Failed to decode JWT from Base64: {0}")] + DecodeJwt(String), + #[error("Failed to deserialize JWT from JSON string: {0}")] + DeserializeJwt(#[from] serde_json::Error), + #[error("The JWT was singed with an unsupported algorithm: {0:?}")] + JwtSignedWithUnsupportedAlgorithm(Algorithm), + #[error("No decoding key with the matching `kid` was found: {0}")] + MissingDecodingKey(String), + #[error("Failed validating the JWT's signature: {0}")] + InvalidSignature(#[source] jwt::errors::Error), + #[error("Token is expired")] + ExpiredToken, + #[error("Token was used before the timestamp indicated in the `nbf` claim")] + ImmatureToken, + #[error("An unexpected error occured while validating the JWT: {0}")] + Unexpected(#[source] jwt::errors::Error), + #[error("Validation failed since the JWT is missing the following required claims: {0:#?}")] + MissingClaims(Vec>), + #[error("Failed to parse URL: {0}")] + ParseUrl(#[from] url::ParseError), + #[error( + "The `iss` claim on the token has an invalid scheme: `{0}`. The scheme must be `https`" + )] + InvalidIssScheme(String), +} diff --git a/jans-cedarling/cedarling/src/jwt/validator/config.rs b/jans-cedarling/cedarling/src/jwt/validator/config.rs new file mode 100644 index 00000000000..b65f86edb2d --- /dev/null +++ b/jans-cedarling/cedarling/src/jwt/validator/config.rs @@ -0,0 +1,104 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + +use super::IssuerId; +use crate::common::policy_store::TrustedIssuer; +use jsonwebtoken::Algorithm; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +/// Validation options related to JSON Web Tokens (JWT). +/// +/// This struct provides the configuration for validating common JWT claims (`iss`, +/// `aud`, `sub`, `jti`, `exp`, `nbf`) across different types of JWTs. +/// +/// The default configuration for Access Tokens, ID Tokens, and Userinfo Tokens +/// can be easily instantiated via the provided methods. +#[derive(Default)] +pub struct JwtValidatorConfig { + /// Validate the signature of the JWT. + pub sig_validation: Arc, + /// Validate the status of the JWT. + /// + /// The JWT status could be obatained from the `.well-known/openid-configuration` via + /// the `status_list_endpoint`. See the [`IETF Draft`] for more info. + /// + /// [`IETF Draft`]: https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/ + #[allow(dead_code)] + pub status_validation: Arc, + /// List of trusted issuers used to check the JWT status. + pub trusted_issuers: Arc>>, + /// Algorithms supported as defined in the Bootstrap properties. + /// + /// Tokens not signed with an algorithm within this HashSet will immediately be invalid. + pub algs_supported: Arc>, + /// Required claims that the JWTs are required to have. + // + /// Tokens with a missing required claim will immediately be invalid. + pub required_claims: HashSet>, + /// Validate the `exp` (Expiration) claim of the token if it's present. + pub validate_exp: bool, + /// Validate the `nbf` (Not Before) claim of the token if it's present. + pub validate_nbf: bool, +} + +#[allow(dead_code)] +impl JwtValidatorConfig { + /// Returns a default configuration for validating ID Tokens. + /// + /// This configuration requires and validates following claims: + /// - `iss` (issuer) + /// - `aud` (audience) + /// - `sub` (subject) + /// - `exp` (expiration) + /// + /// `jti` (JWT ID) and `nbf` (not before) are not required for ID Tokens. + fn id_token( + sig_validation: Arc, + status_validation: Arc, + trusted_issuers: Arc>>, + algs_supported: Arc>, + ) -> Self { + Self { + sig_validation, + status_validation, + trusted_issuers, + algs_supported, + required_claims: HashSet::from(["iss", "aud", "sub", "exp"].map(|x| x.into())), + validate_exp: true, + validate_nbf: true, + } + } + + /// Returns a default configuration for validating Userinfo Tokens. + /// + /// This configuration requires the following: + /// - `iss` (issuer) validation + /// - `aud` (audience) validation + /// - `sub` (subject) validation + /// - `exp` (expiration) validation + /// + /// `jti` (JWT ID) and `nbf` (not before) are not required for Userinfo Tokens. + fn userinfo_token( + sig_validation: Arc, + status_validation: Arc, + trusted_issuers: Arc>>, + algs_supported: Arc>, + ) -> Self { + Self { + sig_validation, + status_validation, + trusted_issuers, + algs_supported, + required_claims: HashSet::from(["iss", "aud", "sub", "exp"].map(|x| x.into())), + validate_exp: true, + validate_nbf: true, + } + } +} diff --git a/jans-cedarling/cedarling/src/jwt/validator/test.rs b/jans-cedarling/cedarling/src/jwt/validator/test.rs new file mode 100644 index 00000000000..31c5da0cb2b --- /dev/null +++ b/jans-cedarling/cedarling/src/jwt/validator/test.rs @@ -0,0 +1,311 @@ +/* + * This software is available under the Apache-2.0 license. + * See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. + * + * Copyright (c) 2024, Gluu, Inc. + */ + +use super::super::test_utils::*; +use super::{JwtValidator, JwtValidatorConfig, JwtValidatorError}; +use crate::jwt::key_service::KeyService; +use crate::jwt::validator::ProcessedJwt; +use jsonwebtoken::Algorithm; +use serde_json::json; +use std::collections::HashSet; +use std::sync::Arc; +use test_utils::assert_eq; + +#[test] +fn can_decode_jwt() { + // Generate token + let claims = json!({ + "sub": "1234567890", + "name": "John Doe", + "iat": 1516239022, + }); + let keys = generate_keypair_hs256(Some("some_hs256_key")).expect("Should generate keys"); + let token = + generate_token_using_claims(&claims, &keys).expect("Should generate token using keys"); + + let validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: false.into(), + status_validation: false.into(), + trusted_issuers: None.into(), + algs_supported: HashSet::from([Algorithm::HS256]).into(), + required_claims: HashSet::new(), + validate_exp: true, + validate_nbf: true, + }, + None.into(), + ) + .expect("Should create validator"); + + let result = validator.decode(&token).expect("Should decode JWT"); + + let expected = ProcessedJwt { + claims, + trusted_iss: None, + }; + + assert_eq!(result, expected); +} + +#[test] +fn can_decode_and_validate_jwt() { + // Generate token + let keys = generate_keypair_hs256(Some("some_hs256_key")).expect("Should generate keys"); + let claims = json!({ + "sub": "1234567890", + "name": "John Doe", + "iat": 1516239022, + }); + let token = + generate_token_using_claims(&claims, &keys).expect("Should generate token using keys"); + + // Prepare Key Service + let jwks = generate_jwks(&vec![keys]); + let key_service = KeyService::new_from_str( + &json!({ + "test_idp": jwks.keys, + }) + .to_string(), + ) + .expect("Should create KeyService"); + + let validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: true.into(), + status_validation: false.into(), + trusted_issuers: None.into(), + algs_supported: HashSet::from([Algorithm::HS256]).into(), + required_claims: HashSet::new(), + validate_exp: true, + validate_nbf: true, + }, + Some(key_service).into(), + ) + .expect("Should create validator"); + + let result = validator + .process_jwt(&token) + .expect("Should successfully process JWT"); + + let expected = ProcessedJwt { + claims, + trusted_iss: None, + }; + + assert_eq!(result, expected); +} + +#[test] +fn errors_on_invalid_iss_scheme() { + // Generate token + let keys = generate_keypair_hs256(Some("some_hs256_key")).expect("Should generate keys"); + let claims = json!({ + "iss": "http://account.gluu.org", + "sub": "1234567890", + "name": "John Doe", + "iat": 1516239022, + "exp": u64::MAX, + }); + let token = + generate_token_using_claims(&claims, &keys).expect("Should generate token using keys"); + + // Prepare Key Service + let jwks = generate_jwks(&vec![keys]); + let key_service = KeyService::new_from_str( + &json!({ + "test_idp": jwks.keys, + }) + .to_string(), + ) + .expect("Should create KeyService"); + + let validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: true.into(), + status_validation: false.into(), + trusted_issuers: None.into(), + algs_supported: HashSet::from([Algorithm::HS256]).into(), + required_claims: HashSet::from(["iss".into()]), + validate_exp: true, + validate_nbf: true, + }, + Some(key_service).into(), + ) + .expect("Should create validator"); + + let result = validator.process_jwt(&token); + + assert!( + matches!(result, Err(JwtValidatorError::InvalidIssScheme(_))), + "Expected validation to fail due to the scheme of the token's iss claim not being `https`." + ); +} + +#[test] +fn errors_on_expired_token() { + // Generate token + let keys = generate_keypair_hs256(Some("some_hs256_key")).expect("Should generate keys"); + let claims = json!({ + "sub": "1234567890", + "name": "John Doe", + "iat": 1516239022, + "exp": 0, + }); + let token = + generate_token_using_claims(&claims, &keys).expect("Should generate token using keys"); + + // Prepare Key Service + let jwks = generate_jwks(&vec![keys]); + let key_service = KeyService::new_from_str( + &json!({ + "test_idp": jwks.keys, + }) + .to_string(), + ) + .expect("Should create KeyService"); + + let validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: true.into(), + status_validation: false.into(), + trusted_issuers: None.into(), + algs_supported: HashSet::from([Algorithm::HS256]).into(), + required_claims: HashSet::new(), + validate_exp: true, + validate_nbf: true, + }, + Some(key_service).into(), + ) + .expect("Should create validator"); + + let result = validator.process_jwt(&token); + + assert!( + matches!(result, Err(JwtValidatorError::ExpiredToken)), + "Expected validation to fail due to the token being expired." + ); +} + +#[test] +fn errors_on_immature_token() { + // Generate token + let keys = generate_keypair_hs256(Some("some_hs256_key")).expect("Should generate keys"); + let claims = json!({ + "sub": "1234567890", + "name": "John Doe", + "iat": 1516239022, + "nbf": u64::MAX, + }); + let token = + generate_token_using_claims(&claims, &keys).expect("Should generate token using keys"); + + // Prepare Key Service + let jwks = generate_jwks(&vec![keys]); + let key_service = KeyService::new_from_str( + &json!({ + "test_idp": jwks.keys, + }) + .to_string(), + ) + .expect("Should create KeyService"); + + let validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: true.into(), + status_validation: false.into(), + trusted_issuers: None.into(), + algs_supported: HashSet::from([Algorithm::HS256]).into(), + required_claims: HashSet::new(), + validate_exp: true, + validate_nbf: true, + }, + Some(key_service).into(), + ) + .expect("Should create validator"); + + let result = validator.process_jwt(&token); + + assert!( + matches!(result, Err(JwtValidatorError::ImmatureToken)), + "Expected validation to fail due to the token being immature." + ); +} + +#[test] +fn can_check_missing_claims() { + // Generate token + let keys = generate_keypair_hs256(Some("some_hs256_key")).expect("Should generate keys"); + let claims = json!({ + "sub": "1234567890", + "name": "John Doe", + "iat": 1516239022, + }); + let token = + generate_token_using_claims(&claims, &keys).expect("Should generate token using keys"); + + // Prepare Key Service + let jwks = generate_jwks(&vec![keys]); + let key_service: Arc> = Some( + KeyService::new_from_str( + &json!({ + "test_idp": jwks.keys, + }) + .to_string(), + ) + .expect("Should create KeyService"), + ) + .into(); + + // Base case where all required claims are present + let validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: true.into(), + status_validation: false.into(), + trusted_issuers: None.into(), + algs_supported: HashSet::from([Algorithm::HS256]).into(), + required_claims: HashSet::from(["sub", "name", "iat"].map(|x| x.into())), + validate_exp: true, + validate_nbf: true, + }, + key_service.clone(), + ) + .expect("Should create validator"); + let result = validator + .process_jwt(&token) + .expect("Should process JWT successfully"); + + let expected = ProcessedJwt { + claims, + trusted_iss: None, + }; + + assert_eq!(result, expected); + + // Error case where `nbf` is missing from the token. + let validator = JwtValidator::new( + JwtValidatorConfig { + sig_validation: true.into(), + status_validation: false.into(), + trusted_issuers: None.into(), + algs_supported: HashSet::from([Algorithm::HS256]).into(), + required_claims: HashSet::from(["sub", "name", "iat", "nbf"].map(|x| x.into())), + validate_exp: true, + validate_nbf: true, + }, + key_service.clone(), + ) + .expect("Should create validator"); + let result = validator.process_jwt(&token); + assert!( + matches!( + result, + Err(JwtValidatorError::MissingClaims(missing_claims)) + if missing_claims == Vec::from(["nbf"].map(|s| s.into())) + ), + "Expected an error due to missing `nbf` claim" + ); +} diff --git a/jans-cedarling/cedarling/src/lib.rs b/jans-cedarling/cedarling/src/lib.rs index dc825a5d907..5f13ef6ce1a 100644 --- a/jans-cedarling/cedarling/src/lib.rs +++ b/jans-cedarling/cedarling/src/lib.rs @@ -28,8 +28,8 @@ mod tests; use std::sync::Arc; pub use authz::request::{Request, ResourceData}; -use authz::Authz; pub use authz::{AuthorizeError, AuthorizeResult}; +use authz::{Authz, AuthzInitError}; pub use bootstrap_config::*; use init::service_config::{ServiceConfig, ServiceConfigError}; use init::ServiceFactory; @@ -57,6 +57,9 @@ pub enum InitCedarlingError { /// Error while preparing config for internal services #[error(transparent)] ServiceConfig(#[from] ServiceConfigError), + /// Error while initializeing AuthZ module + #[error(transparent)] + AuthzInit(#[from] AuthzInitError), } /// The instance of the Cedarling application. @@ -64,7 +67,6 @@ pub enum InitCedarlingError { #[derive(Clone)] pub struct Cedarling { log: log::Logger, - #[allow(dead_code)] authz: Arc, } @@ -93,7 +95,7 @@ impl Cedarling { Ok(Cedarling { log, - authz: service_factory.authz_service(), + authz: service_factory.authz_service()?, }) } diff --git a/jans-cedarling/cedarling/src/tests/utils/cedarling_util.rs b/jans-cedarling/cedarling/src/tests/utils/cedarling_util.rs index 57aff4e9752..84c1a67e207 100644 --- a/jans-cedarling/cedarling/src/tests/utils/cedarling_util.rs +++ b/jans-cedarling/cedarling/src/tests/utils/cedarling_util.rs @@ -5,10 +5,9 @@ * Copyright (c) 2024, Gluu, Inc. */ -use crate::{AuthorizationConfig, WorkloadBoolOp}; +use crate::{AuthorizationConfig, JwtConfig, WorkloadBoolOp}; pub use crate::{ - BootstrapConfig, Cedarling, JwtConfig, LogConfig, LogTypeConfig, PolicyStoreConfig, - PolicyStoreSource, + BootstrapConfig, Cedarling, LogConfig, LogTypeConfig, PolicyStoreConfig, PolicyStoreSource, }; /// create [`Cedarling`] from [`PolicyStoreSource`] @@ -21,7 +20,7 @@ pub fn get_cedarling(policy_source: PolicyStoreSource) -> Cedarling { policy_store_config: PolicyStoreConfig { source: policy_source, }, - jwt_config: JwtConfig::Disabled, + jwt_config: JwtConfig::new_without_validation(), authorization_config: AuthorizationConfig { use_user_principal: true, use_workload_principal: true, @@ -44,7 +43,7 @@ pub fn get_cedarling_with_authorization_conf( policy_store_config: PolicyStoreConfig { source: policy_source, }, - jwt_config: JwtConfig::Disabled, + jwt_config: JwtConfig::new_without_validation(), authorization_config: auth_conf, }) .expect("bootstrap config should initialize correctly")