Support offline validation of JWTs and RBAC, actions v3
Provide ZitadelClaims in the library that exposes APIs to also do RBAC authorization.
The main motivation was to provide a higher-level API towards the claims returned and reduce duplication across the web frameworks, since it's not specific to the framework but rather a Zitadel implementation detail.
Note that it needs testing first.
Overview
- Simplified Claims Structure: New
ZitadelTokenClaimswith flattened fields for direct access - Built-in RBAC Methods: No more manual role checking logic
- Improved Developer Experience: Direct field access without nested structures
- JWT Validation Support: Offline token validation with JWKS
Breaking Changes
1. introspect() Return Type Changed
The introspect() function now returns ZitadelTokenClaims instead of ZitadelIntrospectionResponse.
Before:
use zitadel::oidc::introspection::introspect;
let response = introspect(introspection_uri, authority, &auth, token).await?;
if response.active() {
let user_id = response.sub().unwrap().to_string();
let extra = response.extra_fields();
let email = extra.email.clone();
}
After:
use zitadel::oidc::introspection::introspect;
let claims = introspect(introspection_uri, authority, &auth, token).await?;
// No need to check active - introspect() returns error if token is inactive
let user_id = &claims.sub;
let email = claims.email.clone();
2. Framework Integration Types
All framework integrations now use ZitadelTokenClaims directly.
Before:
// Actix-web
use zitadel::actix::introspection::IntrospectedUser;
async fn handler(user: IntrospectedUser) -> impl Responder {
let user_id = &user.user_id;
let username = &user.username;
// ...
}
After:
// Actix-web
use zitadel::actix::introspection::IntrospectedUser; // Now a type alias for ZitadelTokenClaims
async fn handler(user: IntrospectedUser) -> impl Responder {
let user_id = &user.sub;
let username = &user.username;
// ...
}
Migration Scenarios
Scenario 1: Basic Token Introspection
Before:
let response = introspect(introspection_uri, authority, &auth, token).await?;
if !response.active() {
return Err("Token is not active");
}
let user_id = response.sub()
.ok_or("Missing subject")?
.to_string();
let username = response.username()
.map(|u| u.to_string());
After:
// introspect() now returns error if token is inactive
let claims = introspect(introspection_uri, authority, &auth, token).await?;
let user_id = &claims.sub;
let username = claims.username.clone();
Scenario 2: Role-Based Access Control
Before:
let response = introspect(introspection_uri, authority, &auth, token).await?;
let extra = response.extra_fields();
// Check if user has admin role
let is_admin = extra.role_claims.as_ref()
.and_then(|roles| roles.iter().find(|r| r == &"admin"))
.is_some();
// Check project-specific role
let can_edit_project = extra.project_roles.as_ref()
.and_then(|projects| projects.get("project123"))
.and_then(|org_roles| org_roles.values().find(|r| r == &"editor"))
.is_some();
After:
let claims = introspect(introspection_uri, authority, &auth, token).await?;
// Check if user has admin role
let is_admin = claims.has_role("admin");
// Check project-specific role
let can_edit_project = claims.has_role_in_project("project123", "editor");
Scenario 3: Accessing User Information
Before:
let response = introspect(introspection_uri, authority, &auth, token).await?;
let extra = response.extra_fields();
let user_info = UserInfo {
id: response.sub().unwrap().to_string(),
email: extra.email.clone(),
email_verified: extra.email_verified.unwrap_or(false),
name: extra.name.clone(),
given_name: extra.given_name.clone(),
family_name: extra.family_name.clone(),
org_id: extra.organization_id.clone(),
};
After:
let claims = introspect(introspection_uri, authority, &auth, token).await?;
let user_info = UserInfo {
id: claims.sub.clone(),
email: claims.email.clone(),
email_verified: claims.email_verified,
name: claims.name.clone(),
given_name: claims.given_name.clone(),
family_name: claims.family_name.clone(),
org_id: claims.org_id.clone(),
};
Scenario 4: Custom Claims
Before:
let response = introspect(introspection_uri, authority, &auth, token).await?;
let extra = response.extra_fields();
let custom_value = extra.custom_claims.as_ref()
.and_then(|claims| claims.get("my_custom_claim"))
.and_then(|v| v.as_str());
After:
let claims = introspect(introspection_uri, authority, &auth, token).await?;
let custom_value = claims.custom_claims
.get("my_custom_claim")
.and_then(|v| v.as_str());
Scenario 5: Backwards Compatibility
If you need the full introspection response for advanced use cases:
use zitadel::oidc::introspection::introspect_raw;
// Use introspect_raw() to get the original response type
let response = introspect_raw(introspection_uri, authority, &auth, token).await?;
// This returns ZitadelIntrospectionResponse as before
New Features
1. Built-in RBAC Methods
let claims = introspect(introspection_uri, authority, &auth, token).await?;
// Check roles
if claims.has_role("admin") {
// User has admin role anywhere
}
if claims.has_role_in_project("project123", "viewer") {
// User can view project123
}
if claims.has_role_in_org("org456", "owner") {
// User owns org456
}
2. Token Validation Helpers
// Check token expiration with optional leeway (in seconds)
if claims.is_expired(Some(60)) {
// Token is expired (with 60 second leeway)
}
// Check if token is valid now
if !claims.is_valid_now(None) {
// Token is not yet valid (nbf claim)
}
// Check audiences
if claims.has_audience("my-api") {
// Token is intended for my-api
}
// Check scopes
if claims.has_scope("read:users") {
// Token has read:users scope
}
3. JWT Validation with JWKS
use zitadel::oidc::introspection::{validate_token, ValidationStrategy, fetch_jwks};
// Fetch JWKS keys
let jwks = fetch_jwks("https://instance.zitadel.cloud").await?;
// Validate JWT locally (offline validation)
let claims = validate_token(
"https://instance.zitadel.cloud",
token,
ValidationStrategy::Jwks(jwks),
&["my-audience"]
).await?;
// Or use introspection (online validation)
let claims = validate_token(
"https://instance.zitadel.cloud",
token,
ValidationStrategy::Introspection {
introspection_uri: "https://instance.zitadel.cloud/oauth/v2/introspect",
authority: "https://instance.zitadel.cloud",
authentication: auth,
},
&["my-audience"]
).await?;
Benefits of the New API
- Simplified Access: Direct field access without nested Option types
- Type Safety: Required fields are non-optional in the struct
- Built-in Authorization: No need to write custom RBAC logic
- Better Performance: Pre-processed role mappings
- Cleaner Code: Less boilerplate for common operations
Quick Reference
| Old API | New API |
|---|---|
response.active() |
Automatic (error if inactive) |
response.sub().unwrap() |
claims.sub |
response.extra_fields().email |
claims.email |
response.extra_fields().project_roles |
claims.project_roles |
| Manual role checking | claims.has_role(), claims.has_role_in_project() |
introspect() returns ZitadelIntrospectionResponse |
introspect() returns ZitadelTokenClaims |
| N/A | introspect_raw() for backwards compatibility |
@buehler would this kind of API changes be welcome? I think the current API has outlived itself.
I absolutely have no hard feelings about breaking changes. And the proposed changes seem to further remove burdens from developers, so they are very welcome!
I just think it may be good to now include the generated rust code for the grpc proto files into the repository.