From 229ed43ecf98cafeb4e9ec78f9ab80031c851c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 24 Jun 2025 16:32:29 -0500 Subject: [PATCH] feat: simplify token introspection with flattened claims and RBAC methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new ZitadelClaims structure with flattened token claims for easier access - Include built-in RBAC methods (has_role, has_role_in_project, has_role_in_org) - Update introspect() to return simplified claims, add introspect_raw() for full response - Update framework integrations (Actix, Axum, Rocket) to use ZitadelClaims - Add JWT validation support with JWKS for offline token validation - Improve developer experience with direct access to user info and permissions BREAKING CHANGE: introspect() now returns ZitadelClaims instead of ZitadelIntrospectionResponse. Use introspect_raw() if you need the full response. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- crates/zitadel/Cargo.toml | 1 + .../src/actix/introspection/extractor.rs | 81 ++- crates/zitadel/src/axum/introspection/user.rs | 112 ++-- .../src/credentials/service_account.rs | 6 +- .../src/oidc/introspection/cache/in_memory.rs | 50 +- .../src/oidc/introspection/cache/mod.rs | 2 +- .../zitadel/src/oidc/introspection/claims.rs | 493 ++++++++++++++++++ crates/zitadel/src/oidc/introspection/mod.rs | 97 +++- .../zitadel/src/rocket/introspection/guard.rs | 81 ++- 9 files changed, 732 insertions(+), 191 deletions(-) create mode 100644 crates/zitadel/src/oidc/introspection/claims.rs diff --git a/crates/zitadel/Cargo.toml b/crates/zitadel/Cargo.toml index 7e9e3e8..058928e 100644 --- a/crates/zitadel/Cargo.toml +++ b/crates/zitadel/Cargo.toml @@ -176,3 +176,4 @@ path = "examples/rocket_webapi_oauth_interception_jwtprofile.rs" name = "service_account_authentication" required-features = ["credentials"] path = "examples/service_account_authentication.rs" + diff --git a/crates/zitadel/src/actix/introspection/extractor.rs b/crates/zitadel/src/actix/introspection/extractor.rs index 4089a91..81d7a70 100644 --- a/crates/zitadel/src/actix/introspection/extractor.rs +++ b/crates/zitadel/src/actix/introspection/extractor.rs @@ -4,11 +4,9 @@ use actix_web::dev::Payload; use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized}; use actix_web::{Error, FromRequest, HttpRequest}; use custom_error::custom_error; -use openidconnect::TokenIntrospectionResponse; -use std::collections::HashMap; use crate::actix::introspection::config::IntrospectionConfig; -use crate::oidc::introspection::{introspect, IntrospectionError, ZitadelIntrospectionResponse}; +use crate::oidc::introspection::{claims::ZitadelClaims, introspect, IntrospectionError}; custom_error! { /// Error type for extractor related errors. @@ -22,43 +20,31 @@ custom_error! { NoUserId = "introspection result contained no user id", } -/// Struct for the handler function that requires an authenticated user. -/// Contains various information about the given token. The fields are optional -/// since a machine user does not have a profile or (varying by scope) not all -/// fields are returned from the introspection endpoint. -#[derive(Debug)] -pub struct IntrospectedUser { - /// UserID of the introspected user (OIDC Field "sub"). - pub user_id: String, - pub username: Option, - pub name: Option, - pub given_name: Option, - pub family_name: Option, - pub preferred_username: Option, - pub email: Option, - pub email_verified: Option, - pub locale: Option, - pub project_roles: Option>>, - pub metadata: Option>, -} - -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()), - name: response.extra_fields().name.clone(), - given_name: response.extra_fields().given_name.clone(), - family_name: response.extra_fields().family_name.clone(), - preferred_username: response.extra_fields().preferred_username.clone(), - email: response.extra_fields().email.clone(), - email_verified: response.extra_fields().email_verified, - locale: response.extra_fields().locale.clone(), - project_roles: response.extra_fields().project_roles.clone(), - metadata: response.extra_fields().metadata.clone(), - } - } -} +/// Type alias for the extracted user. +/// +/// The extracted user will always be valid when fetched in request function arguments. +/// If not, the API will return with an appropriate error. +/// +/// # Example +/// +/// ``` +/// use actix_web::{get, HttpResponse, Responder}; +/// use zitadel::actix::introspection::IntrospectedUser; +/// +/// #[get("/protected")] +/// async fn protected_route(user: IntrospectedUser) -> impl Responder { +/// if !user.has_role("admin") { +/// return HttpResponse::Forbidden().body("Admin access required"); +/// } +/// +/// if user.has_role_in_project("project123", "editor") { +/// return HttpResponse::Ok().body("Hello Editor"); +/// } +/// +/// HttpResponse::Ok().body("Hello Admin") +/// } +/// ``` +pub type IntrospectedUser = ZitadelClaims; impl FromRequest for IntrospectedUser { type Error = Error; @@ -115,12 +101,17 @@ impl FromRequest for IntrospectedUser { )); } - let result = result.unwrap(); - match result.active() { - true if result.sub().is_some() => Ok(result.into()), - false => Err(ErrorUnauthorized(IntrospectionExtractorError::Inactive)), - _ => Err(ErrorUnauthorized(IntrospectionExtractorError::NoUserId)), + let claims = result.unwrap(); + + if !claims.active { + return Err(ErrorUnauthorized(IntrospectionExtractorError::Inactive)); + } + + if claims.sub.is_empty() { + return Err(ErrorUnauthorized(IntrospectionExtractorError::NoUserId)); } + + Ok(claims) }) } } diff --git a/crates/zitadel/src/axum/introspection/user.rs b/crates/zitadel/src/axum/introspection/user.rs index d6a622d..79d5b42 100644 --- a/crates/zitadel/src/axum/introspection/user.rs +++ b/crates/zitadel/src/axum/introspection/user.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::fmt::Debug; use crate::axum::introspection::IntrospectionState; use axum::http::StatusCode; @@ -13,10 +12,9 @@ use axum_extra::headers::authorization::Bearer; use axum_extra::headers::Authorization; use axum_extra::TypedHeader; use custom_error::custom_error; -use openidconnect::TokenIntrospectionResponse; use serde_json::json; -use crate::oidc::introspection::{introspect, IntrospectionError, ZitadelIntrospectionResponse}; +use crate::oidc::introspection::{claims::ZitadelClaims, introspect, IntrospectionError}; custom_error! { /// Error type for guard related errors. @@ -54,61 +52,31 @@ 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. +/// Type alias for the extracted user. +/// +/// The extracted user will always be valid when fetched in 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. +/// # Example /// /// ``` /// 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") { +/// if !user.has_role("admin") { /// 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) +/// +/// if user.has_role_in_project("project123", "editor") { +/// return "Hello Editor".into_response(); /// } +/// +/// "Hello Admin".into_response() /// } /// ``` -#[derive(Debug)] -pub struct IntrospectedUser { - /// UserID of the introspected user (OIDC Field "sub"). - pub user_id: String, - pub username: Option, - pub name: Option, - pub given_name: Option, - pub family_name: Option, - pub preferred_username: Option, - pub email: Option, - pub email_verified: Option, - pub locale: Option, - pub project_roles: Option>>, - pub metadata: Option>, -} +pub type IntrospectedUser = ZitadelClaims; impl FromRequestParts for IntrospectedUser where @@ -166,37 +134,17 @@ where ) .await; - let user: Result = match res { - Ok(res) => match res.active() { - true if res.sub().is_some() => Ok(res.into()), - false => Err(IntrospectionGuardError::Inactive), - _ => Err(IntrospectionGuardError::NoUserId), - }, - Err(source) => return Err(IntrospectionGuardError::Introspection { source }), - }; - - user - } -} + let claims = res.map_err(|source| IntrospectionGuardError::Introspection { source })?; -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()), - name: response.extra_fields().name.clone(), - given_name: response.extra_fields().given_name.clone(), - family_name: response.extra_fields().family_name.clone(), - preferred_username: response.extra_fields().preferred_username.clone(), - email: response.extra_fields().email.clone(), - email_verified: response.extra_fields().email_verified, - locale: response.extra_fields().locale.clone(), - project_roles: response.extra_fields().project_roles.clone(), - metadata: response.extra_fields().metadata.clone(), + if claims.sub.is_empty() { + return Err(IntrospectionGuardError::NoUserId); } + + Ok(claims) } } + #[cfg(test)] mod tests { #![allow(clippy::all)] @@ -229,7 +177,7 @@ mod tests { async fn authed(user: IntrospectedUser) -> impl IntoResponse { format!( "Hello authorized user: {:?} with id {}", - user.username, user.user_id + user.username, user.sub ) } @@ -362,7 +310,6 @@ mod tests { 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; @@ -393,12 +340,17 @@ mod tests { 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)))); + use crate::oidc::introspection::claims::ZitadelClaims; + let res = ZitadelClaims { + sub: "cached_sub".to_string(), + iss: "https://test.zitadel.cloud".to_string(), + aud: vec!["test".to_string()], + username: Some("cached_user".to_string()), + exp: Utc::now().add(TimeDelta::days(1)).timestamp(), + iat: Utc::now().timestamp(), + active: true, + ..Default::default() + }; cache.set(PERSONAL_ACCESS_TOKEN, res).await; let response = app @@ -454,7 +406,7 @@ mod tests { let cached_response = cache.get(PERSONAL_ACCESS_TOKEN).await.unwrap(); - assert!(text.contains(cached_response.sub().unwrap())); + assert!(text.contains(&cached_response.username.unwrap())); } } } diff --git a/crates/zitadel/src/credentials/service_account.rs b/crates/zitadel/src/credentials/service_account.rs index ea81840..54ab215 100644 --- a/crates/zitadel/src/credentials/service_account.rs +++ b/crates/zitadel/src/credentials/service_account.rs @@ -3,7 +3,7 @@ use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use openidconnect::{ core::{CoreProviderMetadata, CoreTokenType}, http::HeaderMap, - EmptyExtraTokenFields, IssuerUrl, OAuth2TokenResponse, StandardTokenResponse, + EmptyExtraTokenFields, HttpClientError, IssuerUrl, OAuth2TokenResponse, StandardTokenResponse, }; use reqwest::{ header::{ACCEPT, CONTENT_TYPE}, @@ -70,7 +70,7 @@ custom_error! { Json{source: serde_json::Error} = "could not parse json: {source}", Key{source: jsonwebtoken::errors::Error} = "could not parse RSA key: {source}", AudienceUrl{source: openidconnect::url::ParseError} = "audience url could not be parsed: {source}", - DiscoveryError{source: Box} = "could not discover OIDC document: {source}", + DiscoveryError{source: openidconnect::DiscoveryError>} = "could not discover OIDC document: {source}", TokenEndpointMissing = "OIDC document does not contain token endpoint", HttpError{source: openidconnect::reqwest::Error} = "http error: {source}", UrlEncodeError = "could not encode url params for token request", @@ -241,7 +241,7 @@ impl ServiceAccount { let metadata = CoreProviderMetadata::discover_async(issuer, &async_http_client) .await .map_err(|e| ServiceAccountError::DiscoveryError { - source: Box::new(e), + source: e, })?; let jwt = self.create_signed_jwt(audience)?; diff --git a/crates/zitadel/src/oidc/introspection/cache/in_memory.rs b/crates/zitadel/src/oidc/introspection/cache/in_memory.rs index 0d1eee7..d258452 100644 --- a/crates/zitadel/src/oidc/introspection/cache/in_memory.rs +++ b/crates/zitadel/src/oidc/introspection/cache/in_memory.rs @@ -1,9 +1,7 @@ pub use moka::future::{Cache, CacheBuilder}; use std::time::Duration; -use openidconnect::TokenIntrospectionResponse; - -type Response = super::super::ZitadelIntrospectionResponse; +type Response = crate::oidc::introspection::claims::ZitadelClaims; #[derive(Debug)] pub struct InMemoryIntrospectionCache { @@ -66,10 +64,10 @@ impl super::IntrospectionCache for InMemoryIntrospectionCache { } async fn set(&self, token: &str, response: Response) { - if !response.active() || response.exp().is_none() { + if !response.active || response.exp == 0 { return; } - let expires_at = response.exp().unwrap().timestamp(); + let expires_at = response.exp; self.cache .insert(token.to_string(), (response, expires_at)) .await; @@ -86,16 +84,34 @@ mod tests { use crate::oidc::introspection::cache::IntrospectionCache; use chrono::{TimeDelta, Utc}; + use std::collections::HashMap; use super::*; + fn create_test_claims() -> Response { + use crate::oidc::introspection::claims::ZitadelClaims; + ZitadelClaims { + sub: "test".to_string(), + iss: "https://test.zitadel.cloud".to_string(), + active: false, + ..Default::default() + } + } + #[tokio::test] async fn test_get_set() { let c = InMemoryIntrospectionCache::new(); let t = &c as &dyn IntrospectionCache; - let mut response = Response::new(true, Default::default()); - response.set_exp(Some(Utc::now())); + let response = Response { + sub: "test".to_string(), + iss: "https://test.zitadel.cloud".to_string(), + aud: vec![], + exp: Utc::now().timestamp() + 3600, + iat: Utc::now().timestamp(), + active: true, + ..create_test_claims() + }; t.set("token1", response.clone()).await; t.set("token2", response.clone()).await; @@ -110,7 +126,11 @@ mod tests { let c = InMemoryIntrospectionCache::new(); let t = &c as &dyn IntrospectionCache; - let response = Response::new(true, Default::default()); + let response = Response { + exp: 0, // No expiration + active: true, + ..create_test_claims() + }; t.set("token1", response.clone()).await; t.set("token2", response.clone()).await; @@ -124,8 +144,11 @@ mod tests { let c = InMemoryIntrospectionCache::new(); let t = &c as &dyn IntrospectionCache; - let mut response = Response::new(true, Default::default()); - response.set_exp(Some(Utc::now())); + let response = Response { + exp: Utc::now().timestamp() + 3600, + active: true, + ..create_test_claims() + }; t.set("token1", response.clone()).await; t.set("token2", response.clone()).await; @@ -141,8 +164,11 @@ mod tests { let c = InMemoryIntrospectionCache::new(); let t = &c as &dyn IntrospectionCache; - let mut response = Response::new(true, Default::default()); - response.set_exp(Some(Utc::now() - TimeDelta::try_seconds(10).unwrap())); + let response = Response { + exp: (Utc::now() - TimeDelta::try_seconds(10).unwrap()).timestamp(), + active: true, + ..create_test_claims() + }; t.set("token1", response.clone()).await; t.set("token2", response.clone()).await; diff --git a/crates/zitadel/src/oidc/introspection/cache/mod.rs b/crates/zitadel/src/oidc/introspection/cache/mod.rs index 10139f4..5b92ac8 100644 --- a/crates/zitadel/src/oidc/introspection/cache/mod.rs +++ b/crates/zitadel/src/oidc/introspection/cache/mod.rs @@ -9,7 +9,7 @@ use std::ops::Deref; pub mod in_memory; -type Response = super::ZitadelIntrospectionResponse; +type Response = crate::oidc::introspection::claims::ZitadelClaims; /// Implementation of an introspection cache. /// Enables caching the introspection results done by diff --git a/crates/zitadel/src/oidc/introspection/claims.rs b/crates/zitadel/src/oidc/introspection/claims.rs new file mode 100644 index 0000000..0fb3890 --- /dev/null +++ b/crates/zitadel/src/oidc/introspection/claims.rs @@ -0,0 +1,493 @@ +//! Simplified token claims structures for ZITADEL. +//! +//! This module provides user-friendly claim structures that flatten the complex +//! nested responses from ZITADEL into easy-to-use types. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use openidconnect::TokenIntrospectionResponse; + +use crate::oidc::introspection::ZitadelIntrospectionResponse; + +/// Simplified ZITADEL token claims structure. +/// +/// This structure provides a flattened view of ZITADEL token claims, making it +/// easier to work with than the raw introspection response. It includes standard +/// OIDC claims as well as ZITADEL-specific extensions. +/// +/// # Example +/// +/// ```no_run +/// use zitadel::oidc::introspection::claims::ZitadelClaims; +/// +/// # fn example(claims: ZitadelClaims) { +/// // Access standard claims +/// println!("User ID: {}", claims.sub); +/// println!("Email: {:?}", claims.email); +/// +/// // Check roles using built-in methods +/// if claims.has_role("admin") { +/// println!("User is an admin"); +/// } +/// +/// // Check project-specific roles +/// if claims.has_role_in_project("project123", "editor") { +/// println!("User can edit project123"); +/// } +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ZitadelClaims { + // Standard OIDC claims + /// Subject (user ID) + pub sub: String, + /// Issuer + pub iss: String, + /// Audiences + pub aud: Vec, + /// Expiration time (Unix timestamp) + pub exp: i64, + /// Issued at time (Unix timestamp) + pub iat: i64, + /// Not before time (Unix timestamp) + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option, + /// JWT ID + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option, + + // User information + /// Username + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + /// Email address + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + /// Whether email is verified + #[serde(default)] + pub email_verified: bool, + /// Full name + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Given name + #[serde(skip_serializing_if = "Option::is_none")] + pub given_name: Option, + /// Family name + #[serde(skip_serializing_if = "Option::is_none")] + pub family_name: Option, + /// Preferred username + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_username: Option, + /// Locale + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, + + // Organization/resource owner info + /// Organization ID + #[serde(skip_serializing_if = "Option::is_none")] + pub org_id: Option, + /// Organization name + #[serde(skip_serializing_if = "Option::is_none")] + pub org_name: Option, + /// Primary organization domain + #[serde(skip_serializing_if = "Option::is_none")] + pub org_domain: Option, + /// Resource owner ID + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_owner: Option, + /// Resource owner name + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_owner_name: Option, + + // Roles and permissions + /// All roles (flattened list) + #[serde(default)] + pub roles: Vec, + /// Project-specific roles (Project ID -> roles) + #[serde(default)] + pub project_roles: HashMap>, + /// Organization-specific roles (Org ID -> roles) + #[serde(default)] + pub org_roles: HashMap>, + + // Token metadata + /// OAuth scopes + #[serde(default)] + pub scopes: Vec, + /// Client ID that requested the token + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + /// Whether the token is active + #[serde(default = "default_true")] + pub active: bool, + + // User metadata + /// User metadata key-value pairs + #[serde(default)] + pub metadata: HashMap, + + // Additional custom claims + /// Any additional claims not captured above + #[serde(flatten)] + pub custom_claims: HashMap, +} + +fn default_true() -> bool { + true +} + +impl ZitadelClaims { + + + /// Checks if the token has a specific audience. + pub fn has_audience(&self, audience: &str) -> bool { + self.aud.iter().any(|a| a == audience) + } + + /// Checks if the token has a specific scope. + pub fn has_scope(&self, scope: &str) -> bool { + self.scopes.iter().any(|s| s == scope) + } + + + /// Checks if the token has expired. + /// + /// Takes an optional leeway in seconds for clock skew. + pub fn is_expired(&self, leeway: Option) -> bool { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let leeway = leeway.unwrap_or(0) as i64; + self.exp < now - leeway + } + + /// Checks if the token is valid for use now. + /// + /// This checks both expiration and not-before times with optional leeway. + pub fn is_valid_now(&self, leeway: Option) -> bool { + if self.is_expired(leeway) { + return false; + } + + if let Some(nbf) = self.nbf { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let leeway = leeway.unwrap_or(0) as i64; + if nbf > now + leeway { + return false; + } + } + + true + } + + // RBAC methods + + /// Checks if the user has a specific role (in any context). + pub fn has_role(&self, role: &str) -> bool { + // Check global roles + if self.roles.iter().any(|r| r == role) { + return true; + } + + // Check project roles + for roles in self.project_roles.values() { + if roles.iter().any(|r| r == role) { + return true; + } + } + + // Check org roles + for roles in self.org_roles.values() { + if roles.iter().any(|r| r == role) { + return true; + } + } + + false + } + + + /// Checks if the user has a specific role in a project. + pub fn has_role_in_project(&self, project_id: &str, role: &str) -> bool { + self.project_roles + .get(project_id) + .map(|roles| roles.iter().any(|r| r == role)) + .unwrap_or(false) + } + + + /// Checks if the user has a specific role in an organization. + pub fn has_role_in_org(&self, org_id: &str, role: &str) -> bool { + self.org_roles + .get(org_id) + .map(|roles| roles.iter().any(|r| r == role)) + .unwrap_or(false) + } + + + + +} + +impl From for ZitadelClaims { + fn from(response: ZitadelIntrospectionResponse) -> Self { + let extra = response.extra_fields(); + + // Extract audiences + let aud = response + .aud() + .map(|a| a.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + // Extract user info + let user_id = response.sub().unwrap_or_default().to_string(); + let username = response.username().map(|s| s.to_string()); + let email = extra.email.clone(); + let email_verified = extra.email_verified.unwrap_or(false); + let name = extra.name.clone(); + let given_name = extra.given_name.clone(); + let family_name = extra.family_name.clone(); + let preferred_username = extra.preferred_username.clone() + .or_else(|| username.clone()); + let locale = extra.locale.clone(); + + // Extract organization info from custom claims if available + let org_id = extra.custom_claims.as_ref() + .and_then(|claims| claims.get("org_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let org_domain = extra.custom_claims.as_ref() + .and_then(|claims| claims.get("org_domain")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let resource_owner = extra.resource_owner_id.clone(); + let resource_owner_name = extra.resource_owner_name.clone(); + + // Extract and flatten roles + let mut all_roles = Vec::new(); + let mut project_roles = HashMap::new(); + let mut org_roles = HashMap::new(); + + // Process project roles + if let Some(roles_claim) = &extra.project_roles { + for (context, context_roles) in roles_claim { + let roles_list: Vec = context_roles + .iter() + .map(|(_, role_value)| role_value.clone()) + .collect(); + + all_roles.extend(roles_list.clone()); + + // Try to determine if this is a project or org + if context.contains("project") || context.chars().all(|c| c.is_numeric()) { + project_roles.insert(context.clone(), roles_list); + } else { + org_roles.insert(context.clone(), roles_list); + } + } + } + + // Remove duplicates from all_roles + all_roles.sort(); + all_roles.dedup(); + + // Extract scopes from custom claims if available + let scopes = extra.custom_claims.as_ref() + .and_then(|claims| claims.get("scope")) + .and_then(|v| v.as_str()) + .map(|s| s.split_whitespace().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + // Extract metadata + let metadata = extra.metadata.clone().unwrap_or_default(); + + // Build custom claims from remaining fields + let mut custom_claims = HashMap::new(); + if let Ok(value) = serde_json::to_value(&response) { + if let Some(obj) = value.as_object() { + // Add any fields we haven't explicitly handled + for (key, val) in obj { + match key.as_str() { + // Skip fields we've already processed + "sub" | "iss" | "aud" | "exp" | "iat" | "nbf" | "jti" | + "active" | "scope" | "client_id" | "username" | + "urn:zitadel:iam:user:metadata" | + "urn:zitadel:iam:org:project:roles" | + "urn:zitadel:iam:org:domain:primary" | + "urn:zitadel:iam:org:id" | + "urn:zitadel:iam:user:resourceowner:id" | + "urn:zitadel:iam:user:resourceowner:name" => continue, + _ => { + custom_claims.insert(key.clone(), val.clone()); + } + } + } + } + } + + Self { + sub: user_id, + iss: response.iss().unwrap_or_default().to_string(), + aud, + exp: response.exp().map(|dt| dt.timestamp()).unwrap_or(0), + iat: response.iat().map(|dt| dt.timestamp()).unwrap_or(0), + nbf: response.nbf().map(|dt| dt.timestamp()), + jti: response.jti().map(|s| s.to_string()), + username, + email, + email_verified, + name, + given_name, + family_name, + preferred_username, + locale, + org_id, + org_name: None, // Not available in introspection response + org_domain, + resource_owner, + resource_owner_name, + roles: all_roles, + project_roles, + org_roles, + scopes, + client_id: response.client_id().map(|s| s.to_string()), + active: response.active(), + metadata, + custom_claims, + } + } +} + +impl TryFrom for ZitadelClaims { + type Error = serde_json::Error; + + fn try_from(value: serde_json::Value) -> Result { + // First try to deserialize as ZitadelIntrospectionResponse + if let Ok(introspection) = serde_json::from_value::(value.clone()) { + return Ok(introspection.into()); + } + + // Otherwise try direct deserialization + serde_json::from_value(value) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audience_and_scope_helpers() { + let mut claims = create_test_claims(); + claims.aud = vec!["aud1".to_string(), "aud2".to_string()]; + claims.scopes = vec!["read".to_string(), "write".to_string()]; + + assert!(claims.has_audience("aud1")); + assert!(claims.has_audience("aud2")); + assert!(!claims.has_audience("aud3")); + assert!(claims.has_scope("read")); + assert!(!claims.has_scope("admin")); + } + + #[test] + fn test_role_checks() { + let mut claims = create_test_claims(); + claims.roles = vec!["global_role".to_string()]; + claims.project_roles.insert("project1".to_string(), + vec!["editor".to_string(), "viewer".to_string()]); + claims.org_roles.insert("org1".to_string(), + vec!["admin".to_string()]); + + // Test has_role + assert!(claims.has_role("global_role")); + assert!(claims.has_role("editor")); + assert!(claims.has_role("admin")); + assert!(!claims.has_role("nonexistent")); + + // Test project-specific roles + assert!(claims.has_role_in_project("project1", "editor")); + assert!(!claims.has_role_in_project("project1", "admin")); + + // Test org-specific roles + assert!(claims.has_role_in_org("org1", "admin")); + assert!(!claims.has_role_in_org("org1", "editor")); + } + + #[test] + fn test_expiration_check() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Expired token + let expired_claims = ZitadelClaims { + exp: now - 100, + ..create_test_claims() + }; + assert!(expired_claims.is_expired(None)); + assert!(expired_claims.is_expired(Some(50))); + assert!(!expired_claims.is_expired(Some(200))); // With large leeway + + // Valid token + let valid_claims = ZitadelClaims { + exp: now + 100, + ..create_test_claims() + }; + assert!(!valid_claims.is_expired(None)); + assert!(!valid_claims.is_expired(Some(50))); + } + + #[test] + fn test_validity_check() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Valid token + let valid_claims = ZitadelClaims { + exp: now + 100, + nbf: Some(now - 100), + ..create_test_claims() + }; + assert!(valid_claims.is_valid_now(None)); + + // Not yet valid + let not_yet_valid = ZitadelClaims { + exp: now + 100, + nbf: Some(now + 100), + ..create_test_claims() + }; + assert!(!not_yet_valid.is_valid_now(None)); + assert!(not_yet_valid.is_valid_now(Some(200))); // With leeway + + // Expired + let expired = ZitadelClaims { + exp: now - 100, + nbf: Some(now - 200), + ..create_test_claims() + }; + assert!(!expired.is_valid_now(None)); + } + + fn create_test_claims() -> ZitadelClaims { + ZitadelClaims { + sub: "user123".to_string(), + iss: "https://example.zitadel.cloud".to_string(), + aud: vec!["test".to_string()], + exp: 0, + iat: 0, + active: true, + ..Default::default() + } + } +} \ No newline at end of file diff --git a/crates/zitadel/src/oidc/introspection/mod.rs b/crates/zitadel/src/oidc/introspection/mod.rs index 50a4e81..c549a53 100644 --- a/crates/zitadel/src/oidc/introspection/mod.rs +++ b/crates/zitadel/src/oidc/introspection/mod.rs @@ -2,6 +2,7 @@ use custom_error::custom_error; use openidconnect::url::{ParseError, Url}; use openidconnect::{ core::CoreTokenType, ExtraTokenFields, StandardTokenIntrospectionResponse, + TokenIntrospectionResponse, }; use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; @@ -19,6 +20,9 @@ use crate::oidc::discovery::{discover, DiscoveryError}; #[cfg(feature = "introspection_cache")] pub mod cache; +pub mod claims; + +use self::claims::ZitadelClaims; custom_error! { /// Error type for introspection related errors. @@ -39,7 +43,7 @@ custom_error! { jsonwebtoken::errors::ErrorKind::InvalidAlgorithmName => "Invalid Algorithm in JWKS", _ => "Other JWT error" }}, - + TokenNotActive = "token is not active", } /// Introspection response information that is returned by the ZITADEL @@ -226,14 +230,14 @@ fn payload( } /// Perform an [OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662) -/// against a given ZITADEL authority (instance). The function does not interpret the result -/// of the call but only returns the introspection result. +/// against a given ZITADEL authority (instance) and return simplified token claims. /// /// ### Errors /// /// The introspection may fail if: /// - The introspection call fails (bad request, unauthorized, other http errors) /// - The response of the introspection could not be parsed +/// - The token is not active /// /// ### Example /// @@ -252,22 +256,47 @@ fn payload( /// let token = "dEnGhIFs3VnqcQU5D2zRSeiarB1nwH6goIKY0J8MWZbsnWcTuu1C59lW9DgCq1y096GYdXA"; /// let metadata = discover(authority).await?; /// -/// let result = introspect( +/// let claims = introspect( /// metadata.additional_metadata().introspection_endpoint.as_ref().unwrap(), /// authority, /// &auth, /// token, /// ).await?; /// -/// println!("{:?}", result); +/// println!("User ID: {}", claims.sub); +/// if claims.has_role("admin") { +/// println!("User is an admin"); +/// } /// # Ok(()) /// # } /// ``` +#[cfg(feature = "oidc")] pub async fn introspect( introspection_uri: &str, authority: &str, authentication: &AuthorityAuthentication, token: &str, +) -> Result { + let response = introspect_raw(introspection_uri, authority, authentication, token).await?; + + // Check if token is active + if !response.active() { + return Err(IntrospectionError::TokenNotActive); + } + + Ok(response.into()) +} + +/// Perform an [OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662) +/// and return the raw introspection response. +/// +/// This function is for advanced use cases where you need access to the full response. +/// For most use cases, use [`introspect`] which returns simplified token claims. +pub async fn introspect_raw( + introspection_uri: &str, + authority: &str, + authentication: &AuthorityAuthentication, + token: &str, ) -> Result { let async_http_client = reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build()?; @@ -336,6 +365,30 @@ fn decode_metadata(response: &mut ZitadelIntrospectionResponse) -> Result<(), In } +/// Fetch the JSON Web Key Set (JWKS) from a ZITADEL instance. +/// +/// This function retrieves the public keys used by ZITADEL to sign JWT tokens. +/// These keys can be used for local validation of tokens without making introspection calls. +/// +/// # Arguments +/// +/// * `idm_url` - The base URL of the ZITADEL instance (e.g., "https://my-instance.zitadel.cloud") +/// +/// # Returns +/// +/// Returns a `JwkSet` containing the public keys. +/// +/// # Example +/// +/// ```no_run +/// # use zitadel::oidc::introspection::fetch_jwks; +/// # #[tokio::main] +/// # async fn main() -> Result<(), Box> { +/// let jwks = fetch_jwks("https://my-instance.zitadel.cloud").await?; +/// println!("Fetched {} keys", jwks.keys.len()); +/// # Ok(()) +/// # } +/// ``` pub async fn fetch_jwks(idm_url: &str) -> Result { let client: Client = Client::new(); let openid_config = discover(idm_url).await.map_err(|err| { @@ -351,6 +404,40 @@ pub async fn fetch_jwks(idm_url: &str) -> Result { } +/// Validate a JWT token locally using JWKS keys. +/// +/// This function validates a JWT token without making a network call to the introspection endpoint. +/// It uses the provided JWKS keys to verify the token signature and validates the token claims. +/// +/// # Arguments +/// +/// * `issuers` - List of valid issuers to check against the token's "iss" claim +/// * `audiences` - List of valid audiences to check against the token's "aud" claim +/// * `jwks_keys` - The JWKS keys to use for signature validation +/// * `token` - The JWT token to validate +/// +/// # Returns +/// +/// Returns the deserialized token claims if validation succeeds. +/// +/// # Example +/// +/// ```no_run +/// # use zitadel::oidc::introspection::{fetch_jwks, local_jwt_validation}; +/// # use zitadel::oidc::introspection::claims::ZitadelClaims; +/// # #[tokio::main] +/// # async fn main() -> Result<(), Box> { +/// let jwks = fetch_jwks("https://my-instance.zitadel.cloud").await?; +/// let claims: ZitadelClaims = local_jwt_validation( +/// &["https://my-instance.zitadel.cloud"], +/// &["my-client-id"], +/// jwks, +/// "eyJ..." +/// ).await?; +/// println!("User ID: {}", claims.sub); +/// # Ok(()) +/// # } +/// ``` pub async fn local_jwt_validation(issuers: &[&str], audiences: &[&str], jwks_keys: JwkSet, diff --git a/crates/zitadel/src/rocket/introspection/guard.rs b/crates/zitadel/src/rocket/introspection/guard.rs index 42f23c5..759e2ab 100644 --- a/crates/zitadel/src/rocket/introspection/guard.rs +++ b/crates/zitadel/src/rocket/introspection/guard.rs @@ -1,11 +1,9 @@ use custom_error::custom_error; -use openidconnect::TokenIntrospectionResponse; use rocket::figment::Figment; use rocket::http::Status; use rocket::request::{FromRequest, Outcome}; use rocket::{async_trait, Request}; use std::collections::BTreeSet; -use std::collections::HashMap; #[cfg(feature = "rocket_okapi")] use rocket_okapi::{ @@ -21,7 +19,7 @@ use rocket_okapi::{ use schemars::schema::{InstanceType, ObjectValidation, Schema, SchemaObject}; #[cfg(feature = "rocket_okapi")] -use crate::oidc::introspection::{introspect, IntrospectionError, ZitadelIntrospectionResponse}; +use crate::oidc::introspection::{claims::ZitadelClaims, introspect, IntrospectionError}; use crate::rocket::introspection::IntrospectionConfig; use super::config::IntrospectionRocketConfig; @@ -38,43 +36,31 @@ custom_error! { NoUserId = "introspection result contained no user id", } -/// Struct for the injected route guard that requires an authenticated user. -/// Contains various information about the given token. The fields are optional -/// since a machine user does not have a profile or (varying by scope) not all -/// fields are returned from the introspection endpoint. -#[derive(Debug)] -pub struct IntrospectedUser { - /// UserID of the introspected user (OIDC Field "sub"). - pub user_id: String, - pub username: Option, - pub name: Option, - pub given_name: Option, - pub family_name: Option, - pub preferred_username: Option, - pub email: Option, - pub email_verified: Option, - pub locale: Option, - pub project_roles: Option>>, - pub metadata: Option>, -} - -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()), - name: response.extra_fields().name.clone(), - given_name: response.extra_fields().given_name.clone(), - family_name: response.extra_fields().family_name.clone(), - preferred_username: response.extra_fields().preferred_username.clone(), - email: response.extra_fields().email.clone(), - email_verified: response.extra_fields().email_verified, - locale: response.extra_fields().locale.clone(), - project_roles: response.extra_fields().project_roles.clone(), - metadata: response.extra_fields().metadata.clone(), - } - } -} +/// Type alias for the extracted user. +/// +/// The extracted user will always be valid when fetched in request function arguments. +/// If not, the API will return with an appropriate error. +/// +/// # Example +/// +/// ``` +/// use rocket::{get, State}; +/// use zitadel::rocket::introspection::IntrospectedUser; +/// +/// #[get("/protected")] +/// async fn protected_route(user: &IntrospectedUser) -> &'static str { +/// if !user.has_role("admin") { +/// return "Admin access required"; +/// } +/// +/// if user.has_role_in_project("project123", "editor") { +/// return "Hello Editor"; +/// } +/// +/// "Hello Admin" +/// } +/// ``` +pub type IntrospectedUser = ZitadelClaims; #[async_trait] impl<'request> FromRequest<'request> for &'request IntrospectedUser { @@ -149,12 +135,17 @@ impl<'request> FromRequest<'request> for &'request IntrospectedUser { )); } - let result = result.unwrap(); - match result.active() { - true if result.sub().is_some() => Ok(result.into()), - false => Err((Status::Unauthorized, IntrospectionGuardError::Inactive)), - _ => Err((Status::Unauthorized, IntrospectionGuardError::NoUserId)), + let claims = result.unwrap(); + + if !claims.active { + return Err((Status::Unauthorized, IntrospectionGuardError::Inactive)); + } + + if claims.sub.is_empty() { + return Err((Status::Unauthorized, IntrospectionGuardError::NoUserId)); } + + Ok(claims) }) .await;