Skip to content

Support offline validation of JWTs and RBAC #602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

domenkozar
Copy link

@domenkozar domenkozar commented Jun 24, 2025

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 ZitadelTokenClaims with 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

  1. Simplified Access: Direct field access without nested Option types
  2. Type Safety: Required fields are non-optional in the struct
  3. Built-in Authorization: No need to write custom RBAC logic
  4. Better Performance: Pre-processed role mappings
  5. 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

…hods

- 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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

1 participant