Skip to content

Commit 229ed43

Browse files
domenkozarclaude
andcommitted
feat: simplify token introspection with flattened claims and RBAC methods
- 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 <[email protected]>
1 parent c349114 commit 229ed43

File tree

9 files changed

+732
-191
lines changed

9 files changed

+732
-191
lines changed

crates/zitadel/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,4 @@ path = "examples/rocket_webapi_oauth_interception_jwtprofile.rs"
176176
name = "service_account_authentication"
177177
required-features = ["credentials"]
178178
path = "examples/service_account_authentication.rs"
179+

crates/zitadel/src/actix/introspection/extractor.rs

Lines changed: 36 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@ use actix_web::dev::Payload;
44
use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized};
55
use actix_web::{Error, FromRequest, HttpRequest};
66
use custom_error::custom_error;
7-
use openidconnect::TokenIntrospectionResponse;
8-
use std::collections::HashMap;
97

108
use crate::actix::introspection::config::IntrospectionConfig;
11-
use crate::oidc::introspection::{introspect, IntrospectionError, ZitadelIntrospectionResponse};
9+
use crate::oidc::introspection::{claims::ZitadelClaims, introspect, IntrospectionError};
1210

1311
custom_error! {
1412
/// Error type for extractor related errors.
@@ -22,43 +20,31 @@ custom_error! {
2220
NoUserId = "introspection result contained no user id",
2321
}
2422

25-
/// Struct for the handler function that requires an authenticated user.
26-
/// Contains various information about the given token. The fields are optional
27-
/// since a machine user does not have a profile or (varying by scope) not all
28-
/// fields are returned from the introspection endpoint.
29-
#[derive(Debug)]
30-
pub struct IntrospectedUser {
31-
/// UserID of the introspected user (OIDC Field "sub").
32-
pub user_id: String,
33-
pub username: Option<String>,
34-
pub name: Option<String>,
35-
pub given_name: Option<String>,
36-
pub family_name: Option<String>,
37-
pub preferred_username: Option<String>,
38-
pub email: Option<String>,
39-
pub email_verified: Option<bool>,
40-
pub locale: Option<String>,
41-
pub project_roles: Option<HashMap<String, HashMap<String, String>>>,
42-
pub metadata: Option<HashMap<String, String>>,
43-
}
44-
45-
impl From<ZitadelIntrospectionResponse> for IntrospectedUser {
46-
fn from(response: ZitadelIntrospectionResponse) -> Self {
47-
Self {
48-
user_id: response.sub().unwrap().to_string(),
49-
username: response.username().map(|s| s.to_string()),
50-
name: response.extra_fields().name.clone(),
51-
given_name: response.extra_fields().given_name.clone(),
52-
family_name: response.extra_fields().family_name.clone(),
53-
preferred_username: response.extra_fields().preferred_username.clone(),
54-
email: response.extra_fields().email.clone(),
55-
email_verified: response.extra_fields().email_verified,
56-
locale: response.extra_fields().locale.clone(),
57-
project_roles: response.extra_fields().project_roles.clone(),
58-
metadata: response.extra_fields().metadata.clone(),
59-
}
60-
}
61-
}
23+
/// Type alias for the extracted user.
24+
///
25+
/// The extracted user will always be valid when fetched in request function arguments.
26+
/// If not, the API will return with an appropriate error.
27+
///
28+
/// # Example
29+
///
30+
/// ```
31+
/// use actix_web::{get, HttpResponse, Responder};
32+
/// use zitadel::actix::introspection::IntrospectedUser;
33+
///
34+
/// #[get("/protected")]
35+
/// async fn protected_route(user: IntrospectedUser) -> impl Responder {
36+
/// if !user.has_role("admin") {
37+
/// return HttpResponse::Forbidden().body("Admin access required");
38+
/// }
39+
///
40+
/// if user.has_role_in_project("project123", "editor") {
41+
/// return HttpResponse::Ok().body("Hello Editor");
42+
/// }
43+
///
44+
/// HttpResponse::Ok().body("Hello Admin")
45+
/// }
46+
/// ```
47+
pub type IntrospectedUser = ZitadelClaims;
6248

6349
impl FromRequest for IntrospectedUser {
6450
type Error = Error;
@@ -115,12 +101,17 @@ impl FromRequest for IntrospectedUser {
115101
));
116102
}
117103

118-
let result = result.unwrap();
119-
match result.active() {
120-
true if result.sub().is_some() => Ok(result.into()),
121-
false => Err(ErrorUnauthorized(IntrospectionExtractorError::Inactive)),
122-
_ => Err(ErrorUnauthorized(IntrospectionExtractorError::NoUserId)),
104+
let claims = result.unwrap();
105+
106+
if !claims.active {
107+
return Err(ErrorUnauthorized(IntrospectionExtractorError::Inactive));
108+
}
109+
110+
if claims.sub.is_empty() {
111+
return Err(ErrorUnauthorized(IntrospectionExtractorError::NoUserId));
123112
}
113+
114+
Ok(claims)
124115
})
125116
}
126117
}

crates/zitadel/src/axum/introspection/user.rs

Lines changed: 32 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use std::collections::HashMap;
2-
use std::fmt::Debug;
32

43
use crate::axum::introspection::IntrospectionState;
54
use axum::http::StatusCode;
@@ -13,10 +12,9 @@ use axum_extra::headers::authorization::Bearer;
1312
use axum_extra::headers::Authorization;
1413
use axum_extra::TypedHeader;
1514
use custom_error::custom_error;
16-
use openidconnect::TokenIntrospectionResponse;
1715
use serde_json::json;
1816

19-
use crate::oidc::introspection::{introspect, IntrospectionError, ZitadelIntrospectionResponse};
17+
use crate::oidc::introspection::{claims::ZitadelClaims, introspect, IntrospectionError};
2018

2119
custom_error! {
2220
/// Error type for guard related errors.
@@ -54,61 +52,31 @@ impl IntoResponse for IntrospectionGuardError {
5452
}
5553
}
5654

57-
/// Struct for the extracted user. The extracted user will always be valid, when fetched in a
58-
/// request function arguments. If not the api will return with an appropriate error.
55+
/// Type alias for the extracted user.
56+
///
57+
/// The extracted user will always be valid when fetched in request function arguments.
58+
/// If not, the API will return with an appropriate error.
5959
///
60-
/// It can be used as a basis for further customized authorization checks with a custom extractor
61-
/// or an extension trait.
60+
/// # Example
6261
///
6362
/// ```
6463
/// use axum::http::StatusCode;
6564
/// use axum::response::IntoResponse;
6665
/// use zitadel::axum::introspection::IntrospectedUser;
6766
///
68-
/// enum Role {
69-
/// Admin,
70-
/// Client
71-
/// }
72-
///
7367
/// async fn my_handler(user: IntrospectedUser) -> impl IntoResponse {
74-
/// if !user.has_role(Role::Admin, "MY-ORG-ID") {
68+
/// if !user.has_role("admin") {
7569
/// return StatusCode::FORBIDDEN.into_response();
7670
/// }
77-
/// "Hello Admin".into_response()
78-
/// }
79-
///
80-
/// trait MyAuthorizationChecks {
81-
/// fn has_role(&self, role: Role, org_id: &str) -> bool;
82-
/// }
83-
///
84-
/// impl MyAuthorizationChecks for IntrospectedUser {
85-
/// fn has_role(&self, role: Role, org_id: &str) -> bool {
86-
/// let role = match role {
87-
/// Role::Admin => "Admin",
88-
/// Role::Client => "Client",
89-
/// };
90-
/// self.project_roles.as_ref()
91-
/// .and_then(|roles| roles.get(role))
92-
/// .map(|org_ids| org_ids.contains_key(org_id))
93-
/// .unwrap_or(false)
71+
///
72+
/// if user.has_role_in_project("project123", "editor") {
73+
/// return "Hello Editor".into_response();
9474
/// }
75+
///
76+
/// "Hello Admin".into_response()
9577
/// }
9678
/// ```
97-
#[derive(Debug)]
98-
pub struct IntrospectedUser {
99-
/// UserID of the introspected user (OIDC Field "sub").
100-
pub user_id: String,
101-
pub username: Option<String>,
102-
pub name: Option<String>,
103-
pub given_name: Option<String>,
104-
pub family_name: Option<String>,
105-
pub preferred_username: Option<String>,
106-
pub email: Option<String>,
107-
pub email_verified: Option<bool>,
108-
pub locale: Option<String>,
109-
pub project_roles: Option<HashMap<String, HashMap<String, String>>>,
110-
pub metadata: Option<HashMap<String, String>>,
111-
}
79+
pub type IntrospectedUser = ZitadelClaims;
11280

11381
impl<S> FromRequestParts<S> for IntrospectedUser
11482
where
@@ -166,37 +134,17 @@ where
166134
)
167135
.await;
168136

169-
let user: Result<IntrospectedUser, IntrospectionGuardError> = match res {
170-
Ok(res) => match res.active() {
171-
true if res.sub().is_some() => Ok(res.into()),
172-
false => Err(IntrospectionGuardError::Inactive),
173-
_ => Err(IntrospectionGuardError::NoUserId),
174-
},
175-
Err(source) => return Err(IntrospectionGuardError::Introspection { source }),
176-
};
177-
178-
user
179-
}
180-
}
137+
let claims = res.map_err(|source| IntrospectionGuardError::Introspection { source })?;
181138

182-
impl From<ZitadelIntrospectionResponse> for IntrospectedUser {
183-
fn from(response: ZitadelIntrospectionResponse) -> Self {
184-
Self {
185-
user_id: response.sub().unwrap().to_string(),
186-
username: response.username().map(|s| s.to_string()),
187-
name: response.extra_fields().name.clone(),
188-
given_name: response.extra_fields().given_name.clone(),
189-
family_name: response.extra_fields().family_name.clone(),
190-
preferred_username: response.extra_fields().preferred_username.clone(),
191-
email: response.extra_fields().email.clone(),
192-
email_verified: response.extra_fields().email_verified,
193-
locale: response.extra_fields().locale.clone(),
194-
project_roles: response.extra_fields().project_roles.clone(),
195-
metadata: response.extra_fields().metadata.clone(),
139+
if claims.sub.is_empty() {
140+
return Err(IntrospectionGuardError::NoUserId);
196141
}
142+
143+
Ok(claims)
197144
}
198145
}
199146

147+
200148
#[cfg(test)]
201149
mod tests {
202150
#![allow(clippy::all)]
@@ -229,7 +177,7 @@ mod tests {
229177
async fn authed(user: IntrospectedUser) -> impl IntoResponse {
230178
format!(
231179
"Hello authorized user: {:?} with id {}",
232-
user.username, user.user_id
180+
user.username, user.sub
233181
)
234182
}
235183

@@ -362,7 +310,6 @@ mod tests {
362310
use super::*;
363311
use crate::oidc::introspection::cache::in_memory::InMemoryIntrospectionCache;
364312
use crate::oidc::introspection::cache::IntrospectionCache;
365-
use crate::oidc::introspection::ZitadelIntrospectionExtraTokenFields;
366313
use chrono::{TimeDelta, Utc};
367314
use http_body_util::BodyExt;
368315
use std::ops::Add;
@@ -393,12 +340,17 @@ mod tests {
393340
let cache = Arc::new(InMemoryIntrospectionCache::default());
394341
let app = app_witch_cache(cache.clone()).await;
395342

396-
let mut res = ZitadelIntrospectionResponse::new(
397-
true,
398-
ZitadelIntrospectionExtraTokenFields::default(),
399-
);
400-
res.set_sub(Some("cached_sub".to_string()));
401-
res.set_exp(Some(Utc::now().add(TimeDelta::days(1))));
343+
use crate::oidc::introspection::claims::ZitadelClaims;
344+
let res = ZitadelClaims {
345+
sub: "cached_sub".to_string(),
346+
iss: "https://test.zitadel.cloud".to_string(),
347+
aud: vec!["test".to_string()],
348+
username: Some("cached_user".to_string()),
349+
exp: Utc::now().add(TimeDelta::days(1)).timestamp(),
350+
iat: Utc::now().timestamp(),
351+
active: true,
352+
..Default::default()
353+
};
402354
cache.set(PERSONAL_ACCESS_TOKEN, res).await;
403355

404356
let response = app
@@ -454,7 +406,7 @@ mod tests {
454406

455407
let cached_response = cache.get(PERSONAL_ACCESS_TOKEN).await.unwrap();
456408

457-
assert!(text.contains(cached_response.sub().unwrap()));
409+
assert!(text.contains(&cached_response.username.unwrap()));
458410
}
459411
}
460412
}

crates/zitadel/src/credentials/service_account.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
33
use openidconnect::{
44
core::{CoreProviderMetadata, CoreTokenType},
55
http::HeaderMap,
6-
EmptyExtraTokenFields, IssuerUrl, OAuth2TokenResponse, StandardTokenResponse,
6+
EmptyExtraTokenFields, HttpClientError, IssuerUrl, OAuth2TokenResponse, StandardTokenResponse,
77
};
88
use reqwest::{
99
header::{ACCEPT, CONTENT_TYPE},
@@ -70,7 +70,7 @@ custom_error! {
7070
Json{source: serde_json::Error} = "could not parse json: {source}",
7171
Key{source: jsonwebtoken::errors::Error} = "could not parse RSA key: {source}",
7272
AudienceUrl{source: openidconnect::url::ParseError} = "audience url could not be parsed: {source}",
73-
DiscoveryError{source: Box<dyn std::error::Error>} = "could not discover OIDC document: {source}",
73+
DiscoveryError{source: openidconnect::DiscoveryError<HttpClientError<openidconnect::reqwest::Error>>} = "could not discover OIDC document: {source}",
7474
TokenEndpointMissing = "OIDC document does not contain token endpoint",
7575
HttpError{source: openidconnect::reqwest::Error} = "http error: {source}",
7676
UrlEncodeError = "could not encode url params for token request",
@@ -241,7 +241,7 @@ impl ServiceAccount {
241241
let metadata = CoreProviderMetadata::discover_async(issuer, &async_http_client)
242242
.await
243243
.map_err(|e| ServiceAccountError::DiscoveryError {
244-
source: Box::new(e),
244+
source: e,
245245
})?;
246246

247247
let jwt = self.create_signed_jwt(audience)?;

0 commit comments

Comments
 (0)