From 0ad2cf1f509d88b74f5d705d7a970732094fb985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <tom@tomhoule.com> Date: Fri, 28 Mar 2025 06:07:29 +0000 Subject: [PATCH] Apply tls-roots cargo feature to reqwest The tls-roots feature makes axum discover system TLS certificates through rustls-native-certs, but not reqwest. This PR changes that so that the corresponding feature on reqwest is activated as well, so e.g. oidc discovery requests behave the same as gRPC requests with regards to TLS certificates. --- crates/zitadel/Cargo.toml | 2 +- crates/zitadel/src/api/interceptors.rs | 10 +- .../src/credentials/service_account.rs | 15 +- crates/zitadel/src/oidc/introspection/mod.rs | 150 ++++++++++-------- .../zitadel/src/rocket/introspection/guard.rs | 1 - 5 files changed, 102 insertions(+), 76 deletions(-) diff --git a/crates/zitadel/Cargo.toml b/crates/zitadel/Cargo.toml index 7e9e3e8..d4dc159 100644 --- a/crates/zitadel/Cargo.toml +++ b/crates/zitadel/Cargo.toml @@ -55,7 +55,7 @@ api-settings-v2 = ["api-common", "zitadel-gen/zitadel-settings-v2" ] api-user-v2 = ["api-common", "zitadel-gen/zitadel-user-v2" ] -tls-roots = ["tonic/tls-roots"] +tls-roots = ["tonic/tls-roots", "reqwest/rustls-tls-native-roots"] tls-webpki-roots = ["tonic/tls-webpki-roots"] diff --git a/crates/zitadel/src/api/interceptors.rs b/crates/zitadel/src/api/interceptors.rs index 52f93d9..c10b7f7 100644 --- a/crates/zitadel/src/api/interceptors.rs +++ b/crates/zitadel/src/api/interceptors.rs @@ -180,10 +180,7 @@ impl Interceptor for ServiceAccountInterceptor { }) = state_read_guard.deref() { if token_expiry > &time::OffsetDateTime::now_utc() { - meta.insert( - "authorization", - format!("Bearer {}", token).parse().unwrap(), - ); + meta.insert("authorization", format!("Bearer {token}").parse().unwrap()); return Ok(request); } @@ -216,10 +213,7 @@ impl Interceptor for ServiceAccountInterceptor { token_expiry: time::OffsetDateTime::now_utc() + time::Duration::minutes(59), }); - meta.insert( - "authorization", - format!("Bearer {}", token).parse().unwrap(), - ); + meta.insert("authorization", format!("Bearer {token}").parse().unwrap()); } Ok(request) diff --git a/crates/zitadel/src/credentials/service_account.rs b/crates/zitadel/src/credentials/service_account.rs index ea81840..6484ecc 100644 --- a/crates/zitadel/src/credentials/service_account.rs +++ b/crates/zitadel/src/credentials/service_account.rs @@ -237,7 +237,9 @@ impl ServiceAccount { ) -> Result<String, ServiceAccountError> { let issuer = IssuerUrl::new(audience.to_string()) .map_err(|e| ServiceAccountError::AudienceUrl { source: e })?; - let async_http_client = reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build()?; + let async_http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build()?; let metadata = CoreProviderMetadata::discover_async(issuer, &async_http_client) .await .map_err(|e| ServiceAccountError::DiscoveryError { @@ -271,7 +273,12 @@ impl ServiceAccount { // }) // .await // .map_err(|e| ServiceAccountError::HttpError { source: e })?; - let response = async_http_client.post(url).headers(headers).body(body).send().await?; + let response = async_http_client + .post(url) + .headers(headers) + .body(body) + .send() + .await?; serde_json::from_slice(response.bytes().await?.to_vec().as_slice()) .map_err(|e| ServiceAccountError::Json { source: e }) @@ -299,14 +306,14 @@ impl AuthenticationOptions { let mut result = vec!["openid".to_string()]; for role in &self.roles { - let scope = format!("urn:zitadel:iam:org:project:role:{}", role); + let scope = format!("urn:zitadel:iam:org:project:role:{role}"); if !result.contains(&scope) { result.push(scope); } } for p_id in &self.project_audiences { - let scope = format!("urn:zitadel:iam:org:project:id:{}:aud", p_id); + let scope = format!("urn:zitadel:iam:org:project:id:{p_id}:aud"); if !result.contains(&scope) { result.push(scope); } diff --git a/crates/zitadel/src/oidc/introspection/mod.rs b/crates/zitadel/src/oidc/introspection/mod.rs index 50a4e81..470366f 100644 --- a/crates/zitadel/src/oidc/introspection/mod.rs +++ b/crates/zitadel/src/oidc/introspection/mod.rs @@ -1,21 +1,19 @@ +use crate::credentials::{Application, ApplicationError}; +use crate::oidc::discovery::{discover, DiscoveryError}; use custom_error::custom_error; +use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet}; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Header, TokenData, Validation}; use openidconnect::url::{ParseError, Url}; -use openidconnect::{ - core::CoreTokenType, ExtraTokenFields, StandardTokenIntrospectionResponse, -}; +use openidconnect::{core::CoreTokenType, ExtraTokenFields, StandardTokenIntrospectionResponse}; use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::Client; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; use std::collections::HashMap; use std::error::Error; use std::fmt::{Debug, Display}; -use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Header, TokenData, Validation}; -use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet}; use std::str::FromStr; -use reqwest::{Client}; -use serde::de::DeserializeOwned; -use serde_json::Value as JsonValue; -use crate::credentials::{Application, ApplicationError}; -use crate::oidc::discovery::{discover, DiscoveryError}; #[cfg(feature = "introspection_cache")] pub mod cache; @@ -49,8 +47,7 @@ custom_error! { /// if requested by scope: /// - When scope contains `urn:zitadel:iam:user:resourceowner`, the fields prefixed with /// `resource_owner_` are set. -/// - When scope contains `urn:zitadel:iam:user:metadata`, the metadata hashmap will be -/// filled with the user metadata. +/// - When scope contains `urn:zitadel:iam:user:metadata`, the metadata hashmap will be filled with the user metadata. /// - When scope contains `urn:zitadel:iam:org:projects:roles`, the project_roles hashmap will be /// filled with the project roles. /// - When using custom claims through Zitadel Actions, the custom_claims hashmap will be filled with @@ -140,7 +137,7 @@ pub struct ZitadelIntrospectionExtraTokenFields { #[serde(rename = "urn:zitadel:iam:user:metadata")] pub metadata: Option<HashMap<String, String>>, #[serde(flatten)] - custom_claims: Option<HashMap<String, JsonValue>> + custom_claims: Option<HashMap<String, JsonValue>>, } impl ExtraTokenFields for ZitadelIntrospectionExtraTokenFields {} @@ -188,7 +185,7 @@ fn headers(auth: &AuthorityAuthentication) -> HeaderMap { AUTHORIZATION, format!( "Basic {}", - base64::encode(&format!("{}:{}", client_id, client_secret)) + base64::encode(&format!("{client_id}:{client_secret}")) ) .parse() .unwrap(), @@ -269,17 +266,22 @@ pub async fn introspect( authentication: &AuthorityAuthentication, token: &str, ) -> Result<ZitadelIntrospectionResponse, IntrospectionError> { - let async_http_client = reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build()?; + let async_http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build()?; - let url= Url::parse(introspection_uri) - .map_err(|source| IntrospectionError::ParseUrl { source })?; + let url = + Url::parse(introspection_uri).map_err(|source| IntrospectionError::ParseUrl { source })?; let response = async_http_client .post(url) .headers(headers(authentication)) .body(payload(authority, authentication, token)?) .send() .await - .map_err(|source| IntrospectionError::RequestFailed {origin: "The introspection".to_string(), source })?; + .map_err(|source| IntrospectionError::RequestFailed { + origin: "The introspection".to_string(), + source, + })?; if !response.status().is_success() { let status = response.status(); @@ -303,12 +305,12 @@ struct ZitadelResponseError { body: String, } impl ZitadelResponseError { - fn new(status_code: reqwest::StatusCode, body: &[u8]) -> Self { - Self { - status_code: status_code.to_string(), - body: String::from_utf8_lossy(body).to_string(), - } + fn new(status_code: reqwest::StatusCode, body: &[u8]) -> Self { + Self { + status_code: status_code.to_string(), + body: String::from_utf8_lossy(body).to_string(), } + } } impl Display for ZitadelResponseError { @@ -335,32 +337,35 @@ fn decode_metadata(response: &mut ZitadelIntrospectionResponse) -> Result<(), In Ok(()) } - pub async fn fetch_jwks(idm_url: &str) -> Result<JwkSet, IntrospectionError> { let client: Client = Client::new(); - let openid_config = discover(idm_url).await.map_err(|err| { - IntrospectionError::DiscoveryError { source: err } - })?; + let openid_config = discover(idm_url) + .await + .map_err(|err| IntrospectionError::DiscoveryError { source: err })?; let jwks_url = openid_config.jwks_uri().url().as_ref(); - let response = client - .get(jwks_url) - .send() - .await?; - let jwks_keys: JwkSet = response.json::<JwkSet>().await.map_err(|err| IntrospectionError::RequestFailed {origin: "Could not fetch jwks keys because ".to_string(), source: err })?; + let response = client.get(jwks_url).send().await?; + let jwks_keys: JwkSet = + response + .json::<JwkSet>() + .await + .map_err(|err| IntrospectionError::RequestFailed { + origin: "Could not fetch jwks keys because ".to_string(), + source: err, + })?; Ok(jwks_keys) } - -pub async fn local_jwt_validation<U>(issuers: &[&str], - audiences: &[&str], - jwks_keys: JwkSet, - token: &str, ) -> Result<U, IntrospectionError> - +pub async fn local_jwt_validation<U>( + issuers: &[&str], + audiences: &[&str], + jwks_keys: JwkSet, + token: &str, +) -> Result<U, IntrospectionError> where U: DeserializeOwned, { - - let unverified_token_header: Header = decode_header(token).map_err(|source| IntrospectionError::JsonWebTokenErrors { source })?; + let unverified_token_header: Header = + decode_header(token).map_err(|source| IntrospectionError::JsonWebTokenErrors { source })?; let user_kid = match unverified_token_header.kid { Some(k) => k, None => return Err(IntrospectionError::MissingJwksKey), @@ -369,16 +374,21 @@ where match &j.algorithm { AlgorithmParameters::RSA(rsa) => { let decoding_key = DecodingKey::from_rsa_components(&rsa.n, &rsa.e)?; - let algorithm_key = j.common.key_algorithm.ok_or(IntrospectionError::JWTUnsupportedAlgorithm)?; - let algorithm_str = format!("{}", algorithm_key); - let algorithm = Algorithm::from_str(&algorithm_str).map_err(|source| IntrospectionError::JsonWebTokenErrors { source })?; + let algorithm_key = j + .common + .key_algorithm + .ok_or(IntrospectionError::JWTUnsupportedAlgorithm)?; + let algorithm_str = format!("{algorithm_key}"); + let algorithm = Algorithm::from_str(&algorithm_str) + .map_err(|source| IntrospectionError::JsonWebTokenErrors { source })?; let mut validation = Validation::new(algorithm); validation.set_audience(audiences); validation.leeway = 5; validation.set_issuer(issuers); validation.validate_exp = true; - let decoded_token: TokenData<U> = decode::<U>(token, &decoding_key, &validation).map_err(|source| IntrospectionError::JsonWebTokenErrors { source })?; + let decoded_token: TokenData<U> = decode::<U>(token, &decoding_key, &validation) + .map_err(|source| IntrospectionError::JsonWebTokenErrors { source })?; Ok(decoded_token.claims) } _ => unreachable!("Not yet Implemented or supported by Zitadel"), @@ -388,15 +398,14 @@ where } } - #[cfg(test)] mod tests { #![allow(clippy::all)] + use super::*; + use crate::credentials::{AuthenticationOptions, ServiceAccount}; use crate::oidc::discovery::discover; use openidconnect::TokenIntrospectionResponse; - use crate::credentials::{AuthenticationOptions, ServiceAccount}; - use super::*; const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud"; const ZITADEL_URL_ALTER: &str = "https://ferris-hk3otq.us1.zitadel.cloud"; @@ -413,7 +422,7 @@ mod tests { const PERSONAL_ACCESS_TOKEN: &str = "dEnGhIFs3VnqcQU5D2zRSeiarB1nwH6goIKY0J8MWZbsnWcTuu1C59lW9DgCq1y096GYdXA"; - const PERSONAL_ACCESS_TOKEN_ALTER : &str = + const PERSONAL_ACCESS_TOKEN_ALTER: &str = "KyX1Pw1bVfYFSE0g6s3Io12I4sC-feEtkaShWstZJ0h34JHfE29q4oIOJFF0PZlfMDvaCvk"; #[derive(Debug, serde::Deserialize, serde::Serialize)] @@ -437,18 +446,18 @@ mod tests { pub taste: Option<String>, #[serde(rename = "year")] pub anum: Option<i32>, - } + } - pub trait ExtIntrospectedUser { + pub trait ExtIntrospectedUser { fn custom_claims(&self) -> Result<CustomClaims, serde_json::Error>; - } - impl ExtIntrospectedUser for ZitadelIntrospectionResponse { - fn custom_claims(&self) -> Result<CustomClaims, serde_json::Error> { + } + impl ExtIntrospectedUser for ZitadelIntrospectionResponse { + fn custom_claims(&self) -> Result<CustomClaims, serde_json::Error> { let as_value = serde_json::to_value(self)?; - let custom_claims: CustomClaims = serde_json::from_value(as_value)?; + let custom_claims: CustomClaims = serde_json::from_value(as_value)?; Ok(custom_claims) } - } + } #[tokio::test] async fn introspect_fails_with_invalid_url() { @@ -536,13 +545,30 @@ mod tests { // let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap(); - let access_token = sa.authenticate_with_options(ZITADEL_URL_ALTER, &AuthenticationOptions { - scopes: vec!["profile".to_string(), "email".to_string(), "urn:zitadel:iam:user:resourceowner".to_string()], - ..Default::default() - }).await.unwrap(); + let access_token = sa + .authenticate_with_options( + ZITADEL_URL_ALTER, + &AuthenticationOptions { + scopes: vec![ + "profile".to_string(), + "email".to_string(), + "urn:zitadel:iam:user:resourceowner".to_string(), + ], + ..Default::default() + }, + ) + .await + .unwrap(); // move fetch_jwks after login has jwks can be purged after 30 hours of no login let jwks: JwkSet = fetch_jwks(ZITADEL_URL_ALTER).await.unwrap(); - let result: CustomClaims = local_jwt_validation::<CustomClaims>(&ZITADEL_ISSUERS, &ZITADEL_AUDIENCES, jwks, &access_token).await.unwrap(); + let result: CustomClaims = local_jwt_validation::<CustomClaims>( + &ZITADEL_ISSUERS, + &ZITADEL_AUDIENCES, + jwks, + &access_token, + ) + .await + .unwrap(); assert_eq!(result.taste.unwrap(), "funk"); assert_eq!(result.anum.unwrap(), 2025); } @@ -565,8 +591,8 @@ mod tests { }, PERSONAL_ACCESS_TOKEN_ALTER, ) - .await - .unwrap(); + .await + .unwrap(); let custom_claims = result.custom_claims().unwrap(); diff --git a/crates/zitadel/src/rocket/introspection/guard.rs b/crates/zitadel/src/rocket/introspection/guard.rs index 42f23c5..85cfc61 100644 --- a/crates/zitadel/src/rocket/introspection/guard.rs +++ b/crates/zitadel/src/rocket/introspection/guard.rs @@ -20,7 +20,6 @@ use rocket_okapi::{ #[cfg(feature = "rocket_okapi")] use schemars::schema::{InstanceType, ObjectValidation, Schema, SchemaObject}; #[cfg(feature = "rocket_okapi")] - use crate::oidc::introspection::{introspect, IntrospectionError, ZitadelIntrospectionResponse}; use crate::rocket::introspection::IntrospectionConfig;