zebflow 0.8.3

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
/// JWT token creation node — signs claims using a stored `jwt_signing_key` credential.
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use async_trait::async_trait;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};

use crate::pipeline::{
    NodeDefinition, PipelineError,
    nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
use crate::platform::services::CredentialService;

use super::util::metadata_scope;
use crate::pipeline::model::LayoutItem;

pub const NODE_KIND: &str = "n.auth.token.create";
const INPUT_PIN_IN: &str = "in";
const OUTPUT_PIN_OUT: &str = "out";

pub fn definition() -> NodeDefinition {
    NodeDefinition {
        kind: NODE_KIND.to_string(),
        title: "Create Auth Token".to_string(),
        description: "Signs a JWT access token from input data using a stored jwt_signing_key credential. Supports HS256 and RS256 algorithms. Claims marked with `:public` (e.g. `--claim name=$.fullname:public`) are the only ones exposed in the browser via `ctx.auth`; all others remain server-only.".to_string(),
        input_schema: json!({
            "type": "object",
            "description": "Input payload for claim extraction."
        }),
        output_schema: json!({
            "type": "object",
            "properties": {
                "access_token": { "type": "string" },
                "token_type": { "type": "string" },
                "expires_in": { "type": "integer" },
                "profile": { "type": "object" }
            }
        }),
        input_pins: vec![INPUT_PIN_IN.to_string()],
        output_pins: vec![OUTPUT_PIN_OUT.to_string()],
        script_available: false,
        script_bridge: None,
        config_schema: serde_json::json!({
        "type": "object",
        "properties": {
            "credential_id": { "type": "string", "description": "ID of the jwt_signing_key credential." },
            "expires_in": { "type": "integer", "description": "Token lifetime in seconds (default 900)." },
            "claims": { "type": "object", "description": "Map of claim_name → value. Append `:public` to expose a claim in the browser (e.g. `name=$.fullname:public`). Claims without `:public` are signed into the JWT but never reach the browser DOM." },
            "issuer": { "type": "string" },
            "audience": { "type": "string" }
        }
    }),
        dsl_flags: vec![
            crate::pipeline::model::DslFlag {
                flag: "--credential".to_string(),
                config_key: "credential_id".to_string(),
                description: "ID of the jwt_signing_key credential used to sign the token.".to_string(),
                kind: crate::pipeline::model::DslFlagKind::Scalar,
                required: true,
            },
            crate::pipeline::model::DslFlag {
                flag: "--expires-in".to_string(),
                config_key: "expires_in".to_string(),
                description: "Token lifetime in seconds (default 900).".to_string(),
                kind: crate::pipeline::model::DslFlagKind::Scalar,
                required: false,
            },
            crate::pipeline::model::DslFlag {
                flag: "--claim".to_string(),
                config_key: "claims".to_string(),
                description: "Map a JWT claim from the input payload. Repeat for each claim. Format: claim_name=$.field_path or claim_name=literal. Append :public to expose the claim in the browser via ctx.auth (e.g. --claim name=$.fullname:public). Claims without :public are signed but never reach the browser DOM. e.g. --claim sub=$.id --claim name=$.fullname:public --claim role=$.role:public".to_string(),
                kind: crate::pipeline::model::DslFlagKind::KeyValuePairs,
                required: false,
            },
            crate::pipeline::model::DslFlag {
                flag: "--issuer".to_string(),
                config_key: "issuer".to_string(),
                description: "JWT issuer claim (iss).".to_string(),
                kind: crate::pipeline::model::DslFlagKind::Scalar,
                required: false,
            },
            crate::pipeline::model::DslFlag {
                flag: "--audience".to_string(),
                config_key: "audience".to_string(),
                description: "JWT audience claim (aud).".to_string(),
                kind: crate::pipeline::model::DslFlagKind::Scalar,
                required: false,
            },
        ],
        fields: {
            use crate::pipeline::model::{NodeFieldDef, NodeFieldType, NodeFieldDataSource};
            vec![
                NodeFieldDef { name: "credential_id".to_string(), label: "Signing Credential".to_string(), field_type: NodeFieldType::Select, data_source: Some(NodeFieldDataSource::CredentialsJwt), help: Some("JWT signing key credential (kind: jwt_signing_key). Algorithm is determined by the credential.".to_string()), ..Default::default() },
                NodeFieldDef { name: "expires_in".to_string(), label: "Expires In (seconds)".to_string(), field_type: NodeFieldType::Text, placeholder: Some("900".to_string()), help: Some("Token lifetime in seconds. Defaults to 900 when omitted.".to_string()), ..Default::default() },
                NodeFieldDef { name: "issuer".to_string(), label: "Issuer (iss)".to_string(), field_type: NodeFieldType::Text, help: Some("Optional JWT issuer claim written as iss.".to_string()), ..Default::default() },
                NodeFieldDef { name: "audience".to_string(), label: "Audience (aud)".to_string(), field_type: NodeFieldType::Text, help: Some("Optional JWT audience claim written as aud.".to_string()), ..Default::default() },
                NodeFieldDef { name: "claims".to_string(), label: "Claims".to_string(), field_type: NodeFieldType::ClaimsPairs, help: Some("Map claim name → $.field_path or literal. Toggle \"Public\" to expose that claim in the browser via ctx.auth. Private claims (no toggle) are signed into the JWT but never reach the browser DOM.".to_string()), ..Default::default() },
            ]
        },
        layout: vec![
            LayoutItem::Field("expires_in".to_string()),
            LayoutItem::Row { row: vec![LayoutItem::Field("credential_id".to_string())] },
            LayoutItem::Row { row: vec![LayoutItem::Field("issuer".to_string()), LayoutItem::Field("audience".to_string())] },
            LayoutItem::Field("claims".to_string()),
        ],
        ai_tool: Default::default(),
        ..Default::default()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
    /// ID of the `jwt_signing_key` credential.
    pub credential_id: String,
    /// Token lifetime in seconds (default 900).
    #[serde(default)]
    pub expires_in: Option<i64>,
    /// Map of claim_name → JSON pointer path (`$.field`) or literal value.
    #[serde(default)]
    pub claims: Map<String, Value>,
    /// Optional JWT issuer (`iss`).
    #[serde(default)]
    pub issuer: Option<String>,
    /// Optional JWT audience (`aud`).
    #[serde(default)]
    pub audience: Option<String>,
}

pub struct Node {
    config: Config,
    credentials: Arc<CredentialService>,
}

impl Node {
    pub fn new(config: Config, credentials: Arc<CredentialService>) -> Result<Self, PipelineError> {
        if config.credential_id.trim().is_empty() {
            return Err(PipelineError::new(
                "FW_NODE_AUTH_TOKEN_CONFIG",
                "config.credential_id must not be empty",
            ));
        }
        Ok(Self {
            config,
            credentials,
        })
    }
}

/// Resolves a claim value: strings starting with `$.` are treated as JSON pointer paths
/// into the input payload; everything else is used as a literal.
fn resolve_claim(val: &Value, payload: &Value) -> Value {
    if let Value::String(expr) = val {
        if let Some(pointer) = expr.strip_prefix("$.") {
            let ptr = format!("/{}", pointer.replace('.', "/"));
            return payload.pointer(&ptr).cloned().unwrap_or(Value::Null);
        }
    }
    val.clone()
}

fn now_unix() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

#[async_trait]
impl NodeHandler for Node {
    fn kind(&self) -> &'static str {
        NODE_KIND
    }
    fn input_pins(&self) -> &'static [&'static str] {
        &[INPUT_PIN_IN]
    }
    fn output_pins(&self) -> &'static [&'static str] {
        &[OUTPUT_PIN_OUT]
    }

    async fn execute_async(
        &self,
        input: NodeExecutionInput,
    ) -> Result<NodeExecutionOutput, PipelineError> {
        let (owner, project, _pipeline, _request_id) = metadata_scope(&input.metadata)?;

        // --- Credential ---
        let credential = self
            .credentials
            .get_project_credential(owner, project, &self.config.credential_id)
            .map_err(|err| PipelineError::new("FW_NODE_AUTH_TOKEN_CREDENTIAL", err.to_string()))?
            .ok_or_else(|| {
                PipelineError::new(
                    "FW_NODE_AUTH_TOKEN_CREDENTIAL_MISSING",
                    format!("credential '{}' not found", self.config.credential_id),
                )
            })?;

        if credential.kind != "jwt_signing_key" {
            return Err(PipelineError::new(
                "FW_NODE_AUTH_TOKEN_CREDENTIAL_KIND",
                format!(
                    "credential '{}' is kind '{}', expected 'jwt_signing_key'",
                    credential.credential_id, credential.kind
                ),
            ));
        }

        let algorithm_str = credential
            .secret
            .get("algorithm")
            .and_then(|v| v.as_str())
            .unwrap_or("HS256");

        let algorithm = match algorithm_str {
            "HS256" | "hs256" => Algorithm::HS256,
            "HS384" | "hs384" => Algorithm::HS384,
            "HS512" | "hs512" => Algorithm::HS512,
            "RS256" | "rs256" => Algorithm::RS256,
            "RS384" | "rs384" => Algorithm::RS384,
            "RS512" | "rs512" => Algorithm::RS512,
            other => {
                return Err(PipelineError::new(
                    "FW_NODE_AUTH_TOKEN_ALGORITHM",
                    format!("unsupported JWT algorithm '{}'", other),
                ));
            }
        };

        // --- Build claims from input payload ---
        let mut claims_map = Map::new();
        let mut public_keys: Vec<String> = Vec::new();
        for (key, val) in &self.config.claims {
            // Check for `:public` suffix on the value string to mark this claim
            // as safe to expose in the browser via __rwe_payload.
            let (resolved_val, is_public) = if let Value::String(s) = val {
                if let Some(stripped) = s.strip_suffix(":public") {
                    (
                        resolve_claim(&Value::String(stripped.to_string()), &input.payload),
                        true,
                    )
                } else {
                    (resolve_claim(val, &input.payload), false)
                }
            } else {
                (resolve_claim(val, &input.payload), false)
            };
            if is_public {
                public_keys.push(key.clone());
            }
            claims_map.insert(key.clone(), resolved_val);
        }
        // Embed public claim list into the JWT so web.response can filter at render time.
        if !public_keys.is_empty() {
            claims_map.insert(
                "_zf_public".to_string(),
                Value::Array(
                    public_keys
                        .iter()
                        .map(|k| Value::String(k.clone()))
                        .collect(),
                ),
            );
        }

        // Copy profile before adding standard JWT fields
        let profile = Value::Object(claims_map.clone());

        // Add standard JWT claims
        let now = now_unix();
        let expires_in = self.config.expires_in.unwrap_or(900);
        claims_map.insert("iat".to_string(), json!(now));
        claims_map.insert("exp".to_string(), json!(now + expires_in));
        if let Some(iss) = &self.config.issuer {
            claims_map.insert("iss".to_string(), json!(iss));
        }
        if let Some(aud) = &self.config.audience {
            claims_map.insert("aud".to_string(), json!(aud));
        }

        // --- Sign JWT ---
        let header = Header::new(algorithm);
        let claims_val = Value::Object(claims_map);

        let token = match algorithm {
            Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
                let secret = credential
                    .secret
                    .get("secret")
                    .and_then(|v| v.as_str())
                    .ok_or_else(|| {
                        PipelineError::new(
                            "FW_NODE_AUTH_TOKEN_SECRET_MISSING",
                            "jwt_signing_key credential missing 'secret' field",
                        )
                    })?;
                jsonwebtoken::encode(
                    &header,
                    &claims_val,
                    &EncodingKey::from_secret(secret.as_bytes()),
                )
                .map_err(|err| PipelineError::new("FW_NODE_AUTH_TOKEN_SIGN", err.to_string()))?
            }
            Algorithm::RS256 | Algorithm::RS384 | Algorithm::RS512 => {
                let pem = credential
                    .secret
                    .get("private_key")
                    .and_then(|v| v.as_str())
                    .ok_or_else(|| {
                        PipelineError::new(
                            "FW_NODE_AUTH_TOKEN_KEY_MISSING",
                            "jwt_signing_key credential missing 'private_key' field",
                        )
                    })?;
                let key = EncodingKey::from_rsa_pem(pem.as_bytes()).map_err(|err| {
                    PipelineError::new("FW_NODE_AUTH_TOKEN_KEY_INVALID", err.to_string())
                })?;
                jsonwebtoken::encode(&header, &claims_val, &key)
                    .map_err(|err| PipelineError::new("FW_NODE_AUTH_TOKEN_SIGN", err.to_string()))?
            }
            _ => {
                return Err(PipelineError::new(
                    "FW_NODE_AUTH_TOKEN_ALGORITHM",
                    "unsupported JWT algorithm variant",
                ));
            }
        };

        let output = json!({
            "access_token": token,
            "token_type": "bearer",
            "expires_in": expires_in,
            "profile": profile,
        });

        Ok(NodeExecutionOutput {
            output_pins: vec![OUTPUT_PIN_OUT.to_string()],
            payload: output,
            trace: vec![format!(
                "n.auth.token.create: signed {} token, exp +{}s",
                algorithm_str, expires_in
            )],
        })
    }
}