zitadel-rust icon indicating copy to clipboard operation
zitadel-rust copied to clipboard

Support offline validation of JWTs and RBAC, actions v3

Open domenkozar opened this issue 6 months ago • 2 comments

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

domenkozar avatar Jun 24 '25 21:06 domenkozar

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

domenkozar avatar Jun 27 '25 15:06 domenkozar

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.

buehler avatar Jun 27 '25 21:06 buehler