Skip to content

Commit be2e128

Browse files
domenkozarclaude
andcommitted
feat: add ZITADEL Actions v3 webhook support
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]>
1 parent 5a4689d commit be2e128

File tree

10 files changed

+1151
-0
lines changed

10 files changed

+1151
-0
lines changed

crates/zitadel/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ default = ["tls-roots"]
2121
## Feature that enables support for the [actix framework](https://actix.rs/).
2222
actix = ["credentials", "oidc", "dep:actix-web"]
2323

24+
## Feature that enables support for ZITADEL Actions v3 webhooks.
25+
actions-v3 = ["dep:async-trait", "dep:serde", "dep:serde_json", "dep:hmac", "dep:sha2", "dep:hex", "dep:thiserror"]
26+
2427
api-common = ["dep:prost", "dep:prost-types", "dep:tonic", "dep:tonic-types", "dep:pbjson-types", "dep:zitadel-gen" ]
2528

2629
## The API feature enables all gRPC service clients to access the ZITADEL API.
@@ -102,6 +105,8 @@ axum-extra = { version = "0.10.0", optional = true, features = ["typed-header"]
102105
base64-compat = { version = "1", optional = true }
103106
custom_error = "1.9.2"
104107
document-features = { version = "0.2.8", optional = true }
108+
hex = { version = "0.4", optional = true }
109+
hmac = { version = "0.12", optional = true }
105110
jsonwebtoken = { version = "9.3.0", optional = true }
106111
moka = { version = "0.12.8", features = ["future"], optional = true }
107112
openidconnect = { version = "4.0.0", optional = true }
@@ -113,6 +118,8 @@ rocket = { version = "0.5.0", optional = true }
113118
serde = { version = "1.0.200", features = ["derive"], optional = true }
114119
serde_json = { version = "1.0.116", optional = true }
115120
serde_urlencoded = { version = "0.7.1", optional = true }
121+
sha2 = { version = "0.10", optional = true }
122+
thiserror = { version = "1.0", optional = true }
116123
time = { version = "0.3.36", optional = true }
117124
tokio = { version = "1.37.0", optional = true, features = [
118125
"macros",
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//! Example of handling ZITADEL Actions v3 webhooks
2+
//!
3+
//! This example shows how to create a webhook handler for ZITADEL Actions v3
4+
//! that adds custom claims to tokens.
5+
6+
use async_trait::async_trait;
7+
use axum::{routing::post, Router};
8+
use std::net::SocketAddr;
9+
use zitadel::actions::{ActionHandler, ActionRequest, ActionResponse};
10+
11+
/// Custom webhook handler that adds job-specific claims
12+
struct JobLoggerHandler;
13+
14+
#[derive(Debug, thiserror::Error)]
15+
#[error("Handler error")]
16+
struct HandlerError;
17+
18+
#[async_trait]
19+
impl ActionHandler for JobLoggerHandler {
20+
type Error = HandlerError;
21+
22+
async fn complement_token(&self, req: &ActionRequest) -> Result<ActionResponse, Self::Error> {
23+
// Check if this is a service account requesting a token
24+
if let Some(service_account) = &req.service_account {
25+
println!("Service account {} is requesting a token", service_account.client_id);
26+
27+
// Look for job ID in the audience
28+
if let Some(token) = &req.token {
29+
for audience in &token.audience {
30+
if let Some(job_id) = audience.strip_prefix("logger:job:") {
31+
println!("Found job ID in audience: {}", job_id);
32+
33+
// In a real application, you would verify the service account
34+
// has access to this job by checking your database
35+
36+
return Ok(ActionResponse::default()
37+
.add_claim("urn:zitadel:iam:job:id", job_id)
38+
.add_claim("urn:zitadel:iam:job:permissions", vec!["log.read", "log.write"])
39+
.add_log(format!("Added job claims for job {}", job_id)));
40+
}
41+
}
42+
}
43+
}
44+
45+
// Return empty response if no custom claims needed
46+
Ok(ActionResponse::default())
47+
}
48+
49+
async fn pre_userinfo_creation(&self, req: &ActionRequest) -> Result<ActionResponse, Self::Error> {
50+
// You can also modify userinfo responses
51+
if let Some(user) = &req.user {
52+
println!("Modifying userinfo for user {}", user.id);
53+
54+
return Ok(ActionResponse::default()
55+
.add_claim("custom_userinfo", "value")
56+
.add_metadata("last_webhook_call", chrono::Utc::now().to_rfc3339()));
57+
}
58+
59+
Ok(ActionResponse::default())
60+
}
61+
}
62+
63+
#[tokio::main]
64+
async fn main() {
65+
// Create the webhook handler
66+
let handler = JobLoggerHandler;
67+
68+
// Get webhook secret from environment
69+
let webhook_secret = std::env::var("ZITADEL_WEBHOOK_SECRET")
70+
.unwrap_or_else(|_| "test-secret".to_string());
71+
72+
// Create the router with webhook endpoint
73+
let app = Router::new()
74+
.route(
75+
"/webhook",
76+
post(zitadel::axum::actions::webhook_handler(handler, webhook_secret))
77+
);
78+
79+
// Start the server
80+
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
81+
println!("Webhook server listening on {}", addr);
82+
83+
axum::Server::bind(&addr)
84+
.serve(app.into_make_service())
85+
.await
86+
.unwrap();
87+
}

crates/zitadel/src/actions/README.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# ZITADEL Actions v3 Webhook Support
2+
3+
This module provides comprehensive support for handling ZITADEL Actions v3 webhooks in Rust applications.
4+
5+
## Features
6+
7+
- **Type-safe webhook handling**: Strongly typed request and response structures
8+
- **Signature verification**: HMAC-SHA256 signature verification with timestamp validation
9+
- **Framework integration**: Built-in support for Axum (with Actix and Rocket coming soon)
10+
- **Builder pattern**: Fluent API for constructing responses
11+
- **Async trait**: Define your webhook logic with async/await support
12+
13+
## Usage
14+
15+
### 1. Enable the feature
16+
17+
Add the `actions-v3` feature to your `Cargo.toml`:
18+
19+
```toml
20+
[dependencies]
21+
zitadel = { version = "0.1", features = ["actions-v3", "axum"] }
22+
```
23+
24+
### 2. Implement the ActionHandler trait
25+
26+
```rust
27+
use async_trait::async_trait;
28+
use zitadel::actions::{ActionHandler, ActionRequest, ActionResponse};
29+
30+
struct MyHandler;
31+
32+
#[async_trait]
33+
impl ActionHandler for MyHandler {
34+
type Error = MyError;
35+
36+
async fn complement_token(&self, req: &ActionRequest) -> Result<ActionResponse, Self::Error> {
37+
// Add custom claims based on your business logic
38+
Ok(ActionResponse::default()
39+
.add_claim("custom_claim", "value")
40+
.add_metadata("key", "value"))
41+
}
42+
}
43+
```
44+
45+
### 3. Set up the webhook endpoint
46+
47+
```rust
48+
use axum::{routing::post, Router};
49+
use zitadel::axum::actions::webhook_handler;
50+
51+
let app = Router::new()
52+
.route("/webhook", post(webhook_handler(MyHandler, "your-webhook-secret")));
53+
```
54+
55+
### 4. Configure ZITADEL Action
56+
57+
In ZITADEL, create an Actions v3 with:
58+
- Trigger: `complement_token` (or other supported triggers)
59+
- Type: Webhook
60+
- URL: Your webhook endpoint
61+
- Secret: The same secret used in your handler
62+
63+
## Supported Triggers
64+
65+
- `complement_token`: Modify access token claims
66+
- `pre_userinfo_creation`: Modify userinfo endpoint response
67+
- `pre_access_token_creation`: Modify token before creation
68+
- `post_authentication`: React to successful authentication
69+
- `post_creation`: React to resource creation
70+
- `pre_creation`: Validate before resource creation
71+
72+
## Security
73+
74+
- Always use HTTPS in production
75+
- Keep your webhook secret secure
76+
- Implement proper error handling
77+
- Validate all inputs from the webhook payload
78+
- Use the built-in signature verification
79+
80+
## Example: Job-Specific Claims
81+
82+
```rust
83+
async fn complement_token(&self, req: &ActionRequest) -> Result<ActionResponse, Self::Error> {
84+
if let Some(service_account) = &req.service_account {
85+
// Extract job ID from audience
86+
if let Some(job_id) = extract_job_id(&req) {
87+
// Verify access in your database
88+
if self.verify_access(&service_account.client_id, &job_id).await? {
89+
return Ok(ActionResponse::default()
90+
.add_claim("job_id", job_id)
91+
.add_claim("permissions", vec!["read", "write"]));
92+
}
93+
}
94+
}
95+
Ok(ActionResponse::default())
96+
}
97+
```

crates/zitadel/src/actions/helpers.rs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//! Helper functions and constants for ZITADEL Actions v3
2+
3+
use serde::Serialize;
4+
use serde_json::Value;
5+
6+
use crate::actions::{ActionResponse, Claim, Metadata};
7+
8+
/// Common ZITADEL claim keys
9+
pub mod claims {
10+
/// Resource owner (organization) ID
11+
pub const RESOURCE_OWNER_ID: &str = "urn:zitadel:iam:user:resourceowner:id";
12+
13+
/// Resource owner name
14+
pub const RESOURCE_OWNER_NAME: &str = "urn:zitadel:iam:user:resourceowner:name";
15+
16+
/// Resource owner primary domain
17+
pub const RESOURCE_OWNER_PRIMARY_DOMAIN: &str = "urn:zitadel:iam:user:resourceowner:primary_domain";
18+
19+
/// Project roles
20+
pub const PROJECT_ROLES: &str = "urn:zitadel:iam:org:project:roles";
21+
22+
/// Project-specific roles format
23+
pub const PROJECT_ROLES_FORMAT: &str = "urn:zitadel:iam:org:project:%s:roles";
24+
25+
/// User metadata
26+
pub const USER_METADATA: &str = "urn:zitadel:iam:user:metadata";
27+
28+
/// Action log format
29+
pub const ACTION_LOG_FORMAT: &str = "urn:zitadel:iam:action:%s:log";
30+
}
31+
32+
impl ActionResponse {
33+
/// Add a claim to the response
34+
///
35+
/// # Example
36+
///
37+
/// ```
38+
/// use zitadel::actions::ActionResponse;
39+
///
40+
/// let response = ActionResponse::default()
41+
/// .add_claim("custom_claim", "value")
42+
/// .add_claim("roles", vec!["admin", "user"]);
43+
/// ```
44+
pub fn add_claim<K, V>(mut self, key: K, value: V) -> Self
45+
where
46+
K: Into<String>,
47+
V: Serialize,
48+
{
49+
self.append_claims.push(Claim {
50+
key: key.into(),
51+
value: serde_json::to_value(value).unwrap_or(Value::Null),
52+
});
53+
self
54+
}
55+
56+
/// Add user metadata
57+
///
58+
/// # Example
59+
///
60+
/// ```
61+
/// use zitadel::actions::ActionResponse;
62+
///
63+
/// let response = ActionResponse::default()
64+
/// .add_metadata("preference", "dark_mode")
65+
/// .add_metadata("last_login", "2024-01-01");
66+
/// ```
67+
pub fn add_metadata<K, V>(mut self, key: K, value: V) -> Self
68+
where
69+
K: Into<String>,
70+
V: Serialize,
71+
{
72+
self.set_user_metadata.push(Metadata {
73+
key: key.into(),
74+
value: serde_json::to_value(value).unwrap_or(Value::Null),
75+
});
76+
self
77+
}
78+
79+
/// Add a log entry
80+
///
81+
/// Log entries are added to the token as an array under a special claim key.
82+
///
83+
/// # Example
84+
///
85+
/// ```
86+
/// use zitadel::actions::ActionResponse;
87+
///
88+
/// let response = ActionResponse::default()
89+
/// .add_log("Custom claim added successfully")
90+
/// .add_log("User verified");
91+
/// ```
92+
pub fn add_log<S>(mut self, message: S) -> Self
93+
where
94+
S: Into<String>,
95+
{
96+
self.append_log_claims.push(message.into());
97+
self
98+
}
99+
100+
/// Check if the response is empty (no modifications)
101+
pub fn is_empty(&self) -> bool {
102+
self.append_claims.is_empty()
103+
&& self.set_user_metadata.is_empty()
104+
&& self.append_log_claims.is_empty()
105+
}
106+
107+
/// Create a response with a single claim
108+
pub fn with_claim<K, V>(key: K, value: V) -> Self
109+
where
110+
K: Into<String>,
111+
V: Serialize,
112+
{
113+
Self::default().add_claim(key, value)
114+
}
115+
116+
/// Create a response with a single metadata entry
117+
pub fn with_metadata<K, V>(key: K, value: V) -> Self
118+
where
119+
K: Into<String>,
120+
V: Serialize,
121+
{
122+
Self::default().add_metadata(key, value)
123+
}
124+
}
125+
126+
/// Helper to create a claim
127+
pub fn claim<K, V>(key: K, value: V) -> Claim
128+
where
129+
K: Into<String>,
130+
V: Serialize,
131+
{
132+
Claim {
133+
key: key.into(),
134+
value: serde_json::to_value(value).unwrap_or(Value::Null),
135+
}
136+
}
137+
138+
/// Helper to create metadata
139+
pub fn metadata<K, V>(key: K, value: V) -> Metadata
140+
where
141+
K: Into<String>,
142+
V: Serialize,
143+
{
144+
Metadata {
145+
key: key.into(),
146+
value: serde_json::to_value(value).unwrap_or(Value::Null),
147+
}
148+
}
149+
150+
#[cfg(test)]
151+
mod tests {
152+
use super::*;
153+
154+
#[test]
155+
fn test_response_builder() {
156+
let response = ActionResponse::default()
157+
.add_claim("test", "value")
158+
.add_metadata("key", 123)
159+
.add_log("Test log");
160+
161+
assert_eq!(response.append_claims.len(), 1);
162+
assert_eq!(response.set_user_metadata.len(), 1);
163+
assert_eq!(response.append_log_claims.len(), 1);
164+
}
165+
166+
#[test]
167+
fn test_empty_response() {
168+
let response = ActionResponse::default();
169+
assert!(response.is_empty());
170+
171+
let response = response.add_claim("test", "value");
172+
assert!(!response.is_empty());
173+
}
174+
}

0 commit comments

Comments
 (0)