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 5 commits 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]>
@domenkozar
Copy link
Author

@buehler would this kind of API changes be welcome? I think the current API has outlived itself.

@buehler
Copy link
Collaborator

buehler commented Jun 27, 2025

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.

domenkozar and others added 2 commits July 1, 2025 12:15
Add `create_signed_jwt_with_expiry` method to ServiceAccount that allows creating JWTs with custom expiration dates. This enables offline token validation scenarios and flexible token lifetime management.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
Add comprehensive support for handling ZITADEL Actions v3 webhooks with:
- Type-safe webhook handling with ActionHandler trait
- HMAC-SHA256 signature verification with timestamp validation
- Axum integration with webhook_handler function
- Builder pattern for ActionResponse construction
- Complete example showing job-specific claims
- Documentation and helper functions for common use cases

🤖 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.

2 participants