From 52fb65a27b8f8f41d8972f3bcaf3d4a6ab30f75b Mon Sep 17 00:00:00 2001 From: Hannes Herrmann Date: Tue, 27 Aug 2024 10:12:36 +0200 Subject: [PATCH] feat: Improve introspection cache (#567) Add introspection cache to axum and use `moka` as cache implementation. BREAKING CHANGE: This removes the "roles" enum from the introspected user. It is possible to achieve the same mechanism with the following work around: ```rust enum Role { Admin, Client } trait MyExtIntrospectedUser { fn role(&self, role: Role) -> Option<..>; } impl MyExtIntrospectedUser for IntrospectedUser { fn role(&self, role: Role) -> Option<..> { // convenience impl here } } ``` --- Cargo.toml | 4 +- src/axum/introspection/mod.rs | 7 +- src/axum/introspection/state.rs | 45 ++-- src/axum/introspection/state_builder.rs | 25 ++- src/axum/introspection/user.rs | 250 ++++++++++++++++++---- src/oidc/introspection/cache/in_memory.rs | 67 ++++-- src/oidc/introspection/cache/mod.rs | 25 ++- src/oidc/introspection/mod.rs | 69 +++--- src/rocket/introspection/config.rs | 4 +- 9 files changed, 379 insertions(+), 117 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 526eeac0..0dcacdf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,7 @@ interceptors = ["credentials", "dep:time", "dep:tokio"] ## By default, only the in-memory cache is available. To use a different cache, ## enable specific features of this crate, or implement your own cache with ## the trait. -introspection_cache = ["dep:async-trait", "dep:time"] +introspection_cache = ["dep:async-trait", "dep:time", "dep:moka"] ## The OIDC module enables basic OIDC (OpenID Connect) features to communicate ## with ZITADEL. Two examples are the `discover` and `introspect` functions. @@ -146,6 +146,7 @@ base64-compat = { version = "1", optional = true } custom_error = "1.9.2" document-features = { version = "0.2.8", optional = true } jsonwebtoken = { version = "9.3.0", optional = true } +moka = { version = "0.12.8", features = ["future"], optional = true } openidconnect = { version = "3.5.0", optional = true } pbjson-types = { version = "0.7.0", optional = true } prost = { version = "0.13.1", optional = true } @@ -170,6 +171,7 @@ tonic-types = { version = "0.12.1", optional = true } chrono = "0.4.38" tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] } tower = { version = "0.4.13" } +http-body-util = "0.1.0" [package.metadata.docs.rs] all-features = true \ No newline at end of file diff --git a/src/axum/introspection/mod.rs b/src/axum/introspection/mod.rs index a3c55500..daf3c0d4 100644 --- a/src/axum/introspection/mod.rs +++ b/src/axum/introspection/mod.rs @@ -1,11 +1,12 @@ -//! The intropection module allows you to use the OAuth 2.0 Token Introspection flow to authenticate users against ZITADEL. +//! The introspection module allows you to use the OAuth 2.0 Token Introspection flow to authenticate users against ZITADEL. //! -//! Axum uses "extracters" and "middlewares" to intercept calls. To authenticate a user against ZITADEL, you can use the [IntrospectedUser]. +//! Axum uses "extractors" and "middlewares" to intercept calls. To authenticate a user against ZITADEL, you can use the [IntrospectedUser]. //! Which enables an extractor workflow: [extractor](https://docs.rs/axum/latest/axum/extract/index.html) //! //! #### Configure Axum //! //! To use the introspection flow, you need to configure the [IntrospectionState] and add it to your [Router](https://docs.rs/axum/latest/axum/routing/struct.Router.html). +//! When a custom state is used, [FromRef](axum::extract::FromRef) must be implemented. See [IntrospectionState] for more Details. //! //! ```no_run //! # @@ -60,6 +61,6 @@ mod state; mod state_builder; mod user; -pub use state::{IntrospectionConfig, IntrospectionState}; +pub use state::IntrospectionState; pub use state_builder::{IntrospectionStateBuilder, IntrospectionStateBuilderError}; pub use user::{IntrospectedUser, IntrospectionGuardError}; diff --git a/src/axum/introspection/state.rs b/src/axum/introspection/state.rs index eba83644..964ce034 100644 --- a/src/axum/introspection/state.rs +++ b/src/axum/introspection/state.rs @@ -1,30 +1,39 @@ -use axum::extract::FromRef; use openidconnect::IntrospectionUrl; +use std::sync::Arc; +#[cfg(feature = "introspection_cache")] +use crate::oidc::introspection::cache::IntrospectionCache; use crate::oidc::introspection::AuthorityAuthentication; +/// State which must be present for extractor to work, +/// compare [axum's official documentation](https://docs.rs/axum/0.6.4/axum/extract/struct.State.html#for-library-authors). +/// Use [IntrospectionStateBuilder](super::IntrospectionStateBuilder) to configure the respective parameters. +/// +/// If a custom state is used, then [FromRef](axum::extract::FromRef) must be implemented, +/// to make the necessary state available. +/// +/// ``` +/// use axum::extract::FromRef; +/// use zitadel::axum::introspection::IntrospectionState; +/// struct UserState { +/// introspection_state: IntrospectionState +/// } +/// +/// impl FromRef for IntrospectionState { +/// fn from_ref(input: &UserState) -> Self { +/// input.introspection_state.clone() +/// } +/// } #[derive(Clone, Debug)] pub struct IntrospectionState { - pub(crate) config: IntrospectionConfig, + pub(crate) config: Arc, } -impl IntrospectionState { - pub fn config(&self) -> &IntrospectionConfig { - &self.config - } -} - -/// Configuration that must be inject into the axum application state. Used by the -/// [IntrospectionStateBuilder](super::IntrospectionStateBuilder). This struct is also used to create the [IntrospectionState](IntrospectionState) -#[derive(Debug, Clone)] -pub struct IntrospectionConfig { +#[derive(Debug)] +pub(crate) struct IntrospectionConfig { pub(crate) authority: String, pub(crate) authentication: AuthorityAuthentication, pub(crate) introspection_uri: IntrospectionUrl, -} - -impl FromRef for IntrospectionConfig { - fn from_ref(input: &IntrospectionState) -> Self { - input.config.clone() - } + #[cfg(feature = "introspection_cache")] + pub(crate) cache: Option>, } diff --git a/src/axum/introspection/state_builder.rs b/src/axum/introspection/state_builder.rs index d191b26c..7d0fcae3 100644 --- a/src/axum/introspection/state_builder.rs +++ b/src/axum/introspection/state_builder.rs @@ -1,10 +1,14 @@ use custom_error::custom_error; +use std::sync::Arc; use crate::axum::introspection::state::IntrospectionConfig; use crate::credentials::Application; use crate::oidc::discovery::{discover, DiscoveryError}; use crate::oidc::introspection::AuthorityAuthentication; +#[cfg(feature = "introspection_cache")] +use crate::oidc::introspection::cache::IntrospectionCache; + use super::state::IntrospectionState; custom_error! { @@ -18,6 +22,8 @@ custom_error! { pub struct IntrospectionStateBuilder { authority: String, authentication: Option, + #[cfg(feature = "introspection_cache")] + cache: Option>, } /// Builder for [IntrospectionConfig] @@ -26,6 +32,8 @@ impl IntrospectionStateBuilder { Self { authority: authority.to_string(), authentication: None, + #[cfg(feature = "introspection_cache")] + cache: None, } } @@ -48,6 +56,17 @@ impl IntrospectionStateBuilder { self } + /// Set the [IntrospectionCache] to use for caching introspection responses. + #[cfg(feature = "introspection_cache")] + pub fn with_introspection_cache( + &mut self, + cache: impl IntrospectionCache + 'static, + ) -> &mut IntrospectionStateBuilder { + self.cache = Some(Box::new(cache)); + + self + } + pub async fn build(&mut self) -> Result { if self.authentication.is_none() { return Err(IntrospectionStateBuilderError::NoAuthSchema); @@ -67,11 +86,13 @@ impl IntrospectionStateBuilder { } Ok(IntrospectionState { - config: IntrospectionConfig { + config: Arc::new(IntrospectionConfig { authority: self.authority.clone(), introspection_uri: introspection_uri.unwrap(), authentication: self.authentication.as_ref().unwrap().clone(), - }, + #[cfg(feature = "introspection_cache")] + cache: self.cache.take(), + }), }) } } diff --git a/src/axum/introspection/user.rs b/src/axum/introspection/user.rs index 3b0b9e59..ea4def45 100644 --- a/src/axum/introspection/user.rs +++ b/src/axum/introspection/user.rs @@ -1,8 +1,7 @@ -use std::cmp::Eq; use std::collections::HashMap; use std::fmt::Debug; -use std::hash::Hash; +use crate::axum::introspection::IntrospectionState; use axum::http::StatusCode; use axum::{ async_trait, @@ -16,14 +15,10 @@ use axum_extra::headers::Authorization; use axum_extra::TypedHeader; use custom_error::custom_error; use openidconnect::TokenIntrospectionResponse; -use serde::de::DeserializeOwned; -use serde::Serialize; use serde_json::json; use crate::oidc::introspection::{introspect, IntrospectionError, ZitadelIntrospectionResponse}; -use super::state::IntrospectionConfig; - custom_error! { /// Error type for guard related errors. pub IntrospectionGuardError @@ -62,8 +57,46 @@ impl IntoResponse for IntrospectionGuardError { /// Struct for the extracted user. The extracted user will always be valid, when fetched in a /// request function arguments. If not the api will return with an appropriate error. +/// +/// It can be used as a basis for further customized authorization checks with a custom extractor +/// or an extension trait. +/// +/// ``` +/// use axum::http::StatusCode; +/// use axum::response::IntoResponse; +/// use zitadel::axum::introspection::IntrospectedUser; +/// +/// enum Role { +/// Admin, +/// Client +/// } +/// +/// async fn my_handler(user: IntrospectedUser) -> impl IntoResponse { +/// if !user.has_role(Role::Admin, "MY-ORG-ID") { +/// return StatusCode::FORBIDDEN.into_response(); +/// } +/// "Hello Admin".into_response() +/// } +/// +/// trait MyAuthorizationChecks { +/// fn has_role(&self, role: Role, org_id: &str) -> bool; +/// } +/// +/// impl MyAuthorizationChecks for IntrospectedUser { +/// fn has_role(&self, role: Role, org_id: &str) -> bool { +/// let role = match role { +/// Role::Admin => "Admin", +/// Role::Client => "Client", +/// }; +/// self.project_roles.as_ref() +/// .and_then(|roles| roles.get(role)) +/// .map(|org_ids| org_ids.contains_key(org_id)) +/// .unwrap_or(false) +/// } +/// } +/// ``` #[derive(Debug)] -pub struct IntrospectedUser { +pub struct IntrospectedUser { /// UserID of the introspected user (OIDC Field "sub"). pub user_id: String, pub username: Option, @@ -74,15 +107,14 @@ pub struct IntrospectedUser { pub email: Option, pub email_verified: Option, pub locale: Option, - pub project_roles: Option>>, + pub project_roles: Option>>, } #[async_trait] -impl FromRequestParts for IntrospectedUser +impl FromRequestParts for IntrospectedUser where - IntrospectionConfig: FromRef, + IntrospectionState: FromRef, S: Send + Sync, - Role: Hash + Eq + Debug + Serialize + DeserializeOwned + Clone, { type Rejection = IntrospectionGuardError; @@ -92,9 +124,42 @@ where .await .map_err(|_| IntrospectionGuardError::InvalidHeader)?; - let config = IntrospectionConfig::from_ref(state); + let state = IntrospectionState::from_ref(state); + let config = &state.config; + + #[cfg(feature = "introspection_cache")] + let res = { + match state.config.cache.as_deref() { + None => { + introspect( + &config.introspection_uri, + &config.authority, + &config.authentication, + bearer.token(), + ) + .await + } + Some(cache) => match cache.get(bearer.token()).await { + Some(cached_response) => Ok(cached_response), + None => { + let res = introspect( + &config.introspection_uri, + &config.authority, + &config.authentication, + bearer.token(), + ) + .await; + if let Ok(res) = &res { + cache.set(bearer.token(), res.clone()).await; + } + res + } + }, + } + }; - let res = introspect::( + #[cfg(not(feature = "introspection_cache"))] + let res = introspect( &config.introspection_uri, &config.authority, &config.authentication, @@ -102,7 +167,7 @@ where ) .await; - let user: Result, IntrospectionGuardError> = match res { + let user: Result = match res { Ok(res) => match res.active() { true if res.sub().is_some() => Ok(res.into()), false => Err(IntrospectionGuardError::Inactive), @@ -115,12 +180,8 @@ where } } -impl - From> for IntrospectedUser -where - Role: Hash, -{ - fn from(response: ZitadelIntrospectionResponse) -> Self { +impl From for IntrospectedUser { + fn from(response: ZitadelIntrospectionResponse) -> Self { Self { user_id: response.sub().unwrap().to_string(), username: response.username().map(|s| s.to_string()), @@ -140,14 +201,12 @@ where mod tests { #![allow(clippy::all)] - use std::thread; - use axum::body::Body; use axum::http::Request; use axum::response::IntoResponse; use axum::routing::get; use axum::Router; - use tokio::runtime::Builder; + use tower::ServiceExt; use crate::axum::introspection::{IntrospectionState, IntrospectionStateBuilder}; @@ -178,33 +237,39 @@ mod tests { "Hello unauthorized" } - fn get_config() -> IntrospectionState { - let config = thread::spawn(move || { - let rt = Builder::new_multi_thread().enable_all().build().unwrap(); - rt.block_on(async { - IntrospectionStateBuilder::new(ZITADEL_URL) - .with_jwt_profile(Application::load_from_json(APPLICATION).unwrap()) - .build() - .await - .unwrap() - }) - }); + #[derive(Clone)] + struct SomeUserState { + introspection_state: IntrospectionState, + } - config.join().unwrap() + impl FromRef for IntrospectionState { + fn from_ref(input: &SomeUserState) -> Self { + input.introspection_state.clone() + } } - fn app() -> Router { + async fn app() -> Router { + let introspection_state = IntrospectionStateBuilder::new(ZITADEL_URL) + .with_jwt_profile(Application::load_from_json(APPLICATION).unwrap()) + .build() + .await + .unwrap(); + + let state = SomeUserState { + introspection_state, + }; + let app = Router::new() .route("/unauthed", get(unauthed)) .route("/authed", get(authed)) - .with_state(get_config()); + .with_state(state); return app; } #[tokio::test] async fn can_guard() { - let app = app(); + let app = app().await; let resp = app .oneshot( @@ -221,7 +286,7 @@ mod tests { #[tokio::test] async fn guard_protects_if_non_bearer_present() { - let app = app(); + let app = app().await; let resp = app .oneshot( @@ -239,7 +304,7 @@ mod tests { #[tokio::test] async fn guard_protects_if_multiple_auth_headers_present() { - let app = app(); + let app = app().await; let resp = app .oneshot( @@ -258,7 +323,7 @@ mod tests { #[tokio::test] async fn guard_protects_if_invalid_token() { - let app = app(); + let app = app().await; let resp = app .oneshot( @@ -276,7 +341,7 @@ mod tests { #[tokio::test] async fn guard_allows_valid_token() { - let app = app(); + let app = app().await; let resp = app .oneshot( @@ -291,4 +356,105 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); } + + #[cfg(feature = "introspection_cache")] + mod introspection_cache { + use super::*; + use crate::oidc::introspection::cache::in_memory::InMemoryIntrospectionCache; + use crate::oidc::introspection::cache::IntrospectionCache; + use crate::oidc::introspection::ZitadelIntrospectionExtraTokenFields; + use chrono::{TimeDelta, Utc}; + use http_body_util::BodyExt; + use std::ops::Add; + use std::sync::Arc; + + async fn app_witch_cache(cache: impl IntrospectionCache + 'static) -> Router { + let introspection_state = IntrospectionStateBuilder::new(ZITADEL_URL) + .with_jwt_profile(Application::load_from_json(APPLICATION).unwrap()) + .with_introspection_cache(cache) + .build() + .await + .unwrap(); + + let state = SomeUserState { + introspection_state, + }; + + let app = Router::new() + .route("/unauthed", get(unauthed)) + .route("/authed", get(authed)) + .with_state(state); + + return app; + } + + #[tokio::test] + async fn guard_uses_cached_response() { + let cache = Arc::new(InMemoryIntrospectionCache::default()); + let app = app_witch_cache(cache.clone()).await; + + let mut res = ZitadelIntrospectionResponse::new( + true, + ZitadelIntrospectionExtraTokenFields::default(), + ); + res.set_sub(Some("cached_sub".to_string())); + res.set_exp(Some(Utc::now().add(TimeDelta::days(1)))); + cache.set(PERSONAL_ACCESS_TOKEN, res).await; + + let response = app + .oneshot( + Request::builder() + .uri("/authed") + .header("authorization", format!("Bearer {PERSONAL_ACCESS_TOKEN}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let text = String::from_utf8( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + .unwrap(); + assert!(text.contains("cached_sub")); + } + + #[tokio::test] + async fn guard_caches_response() { + let cache = Arc::new(InMemoryIntrospectionCache::default()); + let app = app_witch_cache(cache.clone()).await; + + let response = app + .oneshot( + Request::builder() + .uri("/authed") + .header("authorization", format!("Bearer {PERSONAL_ACCESS_TOKEN}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let text = String::from_utf8( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + .unwrap(); + + let cached_response = cache.get(PERSONAL_ACCESS_TOKEN).await.unwrap(); + + assert!(text.contains(cached_response.sub().unwrap())); + } + } } diff --git a/src/oidc/introspection/cache/in_memory.rs b/src/oidc/introspection/cache/in_memory.rs index 657c98d4..0d1eee77 100644 --- a/src/oidc/introspection/cache/in_memory.rs +++ b/src/oidc/introspection/cache/in_memory.rs @@ -1,4 +1,5 @@ -use std::{collections::HashMap, sync::Mutex}; +pub use moka::future::{Cache, CacheBuilder}; +use std::time::Duration; use openidconnect::TokenIntrospectionResponse; @@ -6,15 +7,41 @@ type Response = super::super::ZitadelIntrospectionResponse; #[derive(Debug)] pub struct InMemoryIntrospectionCache { - cache: Mutex>, + cache: Cache, } impl InMemoryIntrospectionCache { + /// Creates a new in memory cache, with setting a default `max_capacity` of `10_000` entries. + /// and a TTL expiry of 15 minutes, which is much smaller than the default access token lifetime + /// in Zitadel. + /// A small cache expiry is desired to detect revoked tokens and changes in role assignments + /// outside the token lifetime. + /// + /// For more fine-grained control use [new_from_cache]. pub fn new() -> Self { Self { - cache: Mutex::new(HashMap::new()), + cache: CacheBuilder::new(10_000) + .time_to_live(Duration::from_secs(15 * 60)) + .build(), } } + + /// Create a new in memory cache from a preconfigured Moka cache. + /// Use the re-exposed CacheBuilder to set custom configuration values. + /// + /// ``` + /// use std::time::Duration; + /// use zitadel::oidc::introspection::cache::in_memory::InMemoryIntrospectionCache; + /// use zitadel::oidc::introspection::cache::in_memory::CacheBuilder; + /// let cache = InMemoryIntrospectionCache::new_from_cache( + /// CacheBuilder::new(10_000) + /// // use short lifetime to make sure token invalidation can be detected + /// .time_to_live(Duration::from_secs(60*15)) + /// .build() + /// ); + pub fn new_from_cache(cache: Cache) -> Self { + Self { cache } + } } impl Default for InMemoryIntrospectionCache { @@ -26,16 +53,14 @@ impl Default for InMemoryIntrospectionCache { #[async_trait::async_trait] impl super::IntrospectionCache for InMemoryIntrospectionCache { async fn get(&self, token: &str) -> Option { - let mut cache = self.cache.lock().unwrap(); - - match cache.get(token) { + match self.cache.get(token).await { Some((_, expires_at)) - if expires_at < &time::OffsetDateTime::now_utc().unix_timestamp() => + if expires_at < time::OffsetDateTime::now_utc().unix_timestamp() => { - cache.remove(token); + self.cache.invalidate(token).await; None } - Some((response, _)) => Some(response.clone()), + Some((response, _)) => Some(response), None => None, } } @@ -44,15 +69,14 @@ impl super::IntrospectionCache for InMemoryIntrospectionCache { if !response.active() || response.exp().is_none() { return; } - - let mut cache = self.cache.lock().unwrap(); let expires_at = response.exp().unwrap().timestamp(); - cache.insert(token.to_string(), (response, expires_at)); + self.cache + .insert(token.to_string(), (response, expires_at)) + .await; } async fn clear(&self) { - let mut cache = self.cache.lock().unwrap(); - cache.clear(); + self.cache.invalidate_all(); } } @@ -76,8 +100,6 @@ mod tests { t.set("token1", response.clone()).await; t.set("token2", response.clone()).await; - assert_eq!(c.cache.lock().unwrap().len(), 2); - assert!(t.get("token1").await.is_some()); assert!(t.get("token2").await.is_some()); assert!(t.get("token3").await.is_none()); @@ -93,7 +115,8 @@ mod tests { t.set("token1", response.clone()).await; t.set("token2", response.clone()).await; - assert_eq!(c.cache.lock().unwrap().len(), 0); + assert!(t.get("token1").await.is_none()); + assert!(t.get("token2").await.is_none()); } #[tokio::test] @@ -107,11 +130,10 @@ mod tests { t.set("token1", response.clone()).await; t.set("token2", response.clone()).await; - assert_eq!(c.cache.lock().unwrap().len(), 2); - t.clear().await; - assert_eq!(c.cache.lock().unwrap().len(), 0); + assert!(t.get("token1").await.is_none()); + assert!(t.get("token2").await.is_none()); } #[tokio::test] @@ -125,11 +147,10 @@ mod tests { t.set("token1", response.clone()).await; t.set("token2", response.clone()).await; - assert_eq!(c.cache.lock().unwrap().len(), 2); - let _ = t.get("token1").await; let _ = t.get("token2").await; - assert_eq!(c.cache.lock().unwrap().len(), 0); + assert!(t.get("token1").await.is_none()); + assert!(t.get("token2").await.is_none()); } } diff --git a/src/oidc/introspection/cache/mod.rs b/src/oidc/introspection/cache/mod.rs index 41fed335..10139f48 100644 --- a/src/oidc/introspection/cache/mod.rs +++ b/src/oidc/introspection/cache/mod.rs @@ -3,6 +3,10 @@ //! ZITADEL server. Depending on the enabled features, the cache can be persisted //! or only be kept in memory. +use async_trait::async_trait; +use std::fmt::Debug; +use std::ops::Deref; + pub mod in_memory; type Response = super::ZitadelIntrospectionResponse; @@ -17,7 +21,7 @@ type Response = super::ZitadelIntrospectionResponse; /// ZITADEL will always set the `exp` field, if the token is "active". /// /// Non-active tokens SHOULD not be cached. -#[async_trait::async_trait] +#[async_trait] pub trait IntrospectionCache: Send + Sync + std::fmt::Debug { /// Retrieves the cached introspection result for the given token, if it exists. async fn get(&self, token: &str) -> Option; @@ -29,3 +33,22 @@ pub trait IntrospectionCache: Send + Sync + std::fmt::Debug { /// Clears the cache. async fn clear(&self); } + +#[async_trait] +impl IntrospectionCache for T +where + T: Deref + Send + Sync + Debug, + V: IntrospectionCache, +{ + async fn get(&self, token: &str) -> Option { + self.deref().get(token).await + } + + async fn set(&self, token: &str, response: Response) { + self.deref().set(token, response).await + } + + async fn clear(&self) { + self.deref().clear().await + } +} diff --git a/src/oidc/introspection/mod.rs b/src/oidc/introspection/mod.rs index ad0a28d8..1927094d 100644 --- a/src/oidc/introspection/mod.rs +++ b/src/oidc/introspection/mod.rs @@ -7,12 +7,9 @@ use openidconnect::{ }; use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; -use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use std::cmp::Eq; use std::collections::HashMap; use std::fmt::Debug; -use std::hash::Hash; use crate::credentials::{Application, ApplicationError}; @@ -39,11 +36,36 @@ custom_error! { /// `resource_owner_` are set. /// - When scope contains `urn:zitadel:iam:user:metadata`, the metadata hashmap will be /// filled with the user metadata. +/// +/// It can be used as a basis for further customized authorization checks, for example: +/// ``` +/// use zitadel::axum::introspection::IntrospectedUser; +/// use zitadel::oidc::introspection::ZitadelIntrospectionExtraTokenFields; +/// +/// enum Role { +/// Admin, +/// Client +/// } +/// +/// trait MyAuthorizationChecks { +/// fn has_role(&self, role: Role, org_id: &str) -> bool; +/// } +/// +/// impl MyAuthorizationChecks for ZitadelIntrospectionExtraTokenFields { +/// fn has_role(&self, role: Role, org_id: &str) -> bool { +/// let role = match role { +/// Role::Admin => "Admin", +/// Role::Client => "Client", +/// }; +/// self.project_roles.as_ref() +/// .and_then(|roles| roles.get(role)) +/// .map(|org_ids| org_ids.contains_key(org_id)) +/// .unwrap_or(false) +/// } +/// } +/// ``` #[derive(Clone, Debug, Serialize, Deserialize, Default)] -pub struct ZitadelIntrospectionExtraTokenFields -where - Role: Hash + Eq + Clone, -{ +pub struct ZitadelIntrospectionExtraTokenFields { pub name: Option, pub given_name: Option, pub family_name: Option, @@ -58,19 +80,16 @@ where #[serde(rename = "urn:zitadel:iam:user:resourceowner:primary_domain")] pub resource_owner_primary_domain: Option, #[serde(rename = "urn:zitadel:iam:org:project:roles")] - pub project_roles: Option>>, + pub project_roles: Option>>, #[serde(rename = "urn:zitadel:iam:user:metadata")] pub metadata: Option>, } -impl ExtraTokenFields - for ZitadelIntrospectionExtraTokenFields -{ -} +impl ExtraTokenFields for ZitadelIntrospectionExtraTokenFields {} /// Type alias for the ZITADEL introspection response. -pub type ZitadelIntrospectionResponse = - StandardTokenIntrospectionResponse, CoreTokenType>; +pub type ZitadelIntrospectionResponse = + StandardTokenIntrospectionResponse; /// Definition of the authentication scheme against the authority (or issuer). This authentication /// is required when performing actions like introspection against any ZITADEL instance. @@ -175,7 +194,7 @@ fn payload( /// let token = "dEnGhIFs3VnqcQU5D2zRSeiarB1nwH6goIKY0J8MWZbsnWcTuu1C59lW9DgCq1y096GYdXA"; /// let metadata = discover(authority).await?; /// -/// let result = introspect::( +/// let result = introspect( /// metadata.additional_metadata().introspection_endpoint.as_ref().unwrap(), /// authority, /// &auth, @@ -186,12 +205,12 @@ fn payload( /// # Ok(()) /// # } /// ``` -pub async fn introspect( +pub async fn introspect( introspection_uri: &str, authority: &str, authentication: &AuthorityAuthentication, token: &str, -) -> Result, IntrospectionError> { +) -> Result { let response = async_http_client(HttpRequest { url: Url::parse(introspection_uri) .map_err(|source| IntrospectionError::ParseUrl { source })?, @@ -202,19 +221,17 @@ pub async fn introspect = + let mut response: ZitadelIntrospectionResponse = serde_json::from_slice(response.body.as_slice()) .map_err(|source| IntrospectionError::ParseResponse { source })?; - decode_metadata::(&mut response)?; + decode_metadata(&mut response)?; Ok(response) } // Metadata values are base64 encoded. -fn decode_metadata( - response: &mut ZitadelIntrospectionResponse, -) -> Result<(), IntrospectionError> { +fn decode_metadata(response: &mut ZitadelIntrospectionResponse) -> Result<(), IntrospectionError> { if let Some(h) = &response.extra_fields().metadata { - let mut extra: ZitadelIntrospectionExtraTokenFields = response.extra_fields().clone(); + let mut extra: ZitadelIntrospectionExtraTokenFields = response.extra_fields().clone(); let mut metadata = HashMap::new(); for (k, v) in h { let decoded_v = base64::decode(v) @@ -243,7 +260,7 @@ mod tests { #[tokio::test] async fn introspect_fails_with_invalid_url() { - let result = introspect::( + let result = introspect( "foobar", "foobar", &AuthorityAuthentication::Basic { @@ -264,7 +281,7 @@ mod tests { #[tokio::test] async fn introspect_fails_with_invalid_endpoint() { let meta = discover(ZITADEL_URL).await.unwrap(); - let result = introspect::( + let result = introspect( &meta.token_endpoint().unwrap().to_string(), ZITADEL_URL, &AuthorityAuthentication::Basic { @@ -281,7 +298,7 @@ mod tests { #[tokio::test] async fn introspect_succeeds() { let meta = discover(ZITADEL_URL).await.unwrap(); - let result = introspect::( + let result = introspect( &meta .additional_metadata() .introspection_endpoint diff --git a/src/rocket/introspection/config.rs b/src/rocket/introspection/config.rs index 476207bc..9dff39ff 100644 --- a/src/rocket/introspection/config.rs +++ b/src/rocket/introspection/config.rs @@ -1,6 +1,8 @@ use openidconnect::IntrospectionUrl; -use crate::oidc::introspection::{cache::IntrospectionCache, AuthorityAuthentication}; +#[cfg(feature = "introspection_cache")] +use crate::oidc::introspection::cache::IntrospectionCache; +use crate::oidc::introspection::AuthorityAuthentication; /// Configuration that must be injected into /// [the managed global state](https://rocket.rs/v0.5-rc/guide/state/#managed-state) of the