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;