use async_trait::async_trait;
use dataflow_rs::engine::error::DataflowError;
use dataflow_rs::engine::functions::AsyncFunctionHandler;
use dataflow_rs::engine::task_context::TaskContext;
use dataflow_rs::engine::task_outcome::TaskOutcome;
use serde_json::Value;
use super::connector_helpers::{
apply_output, parse_duration_secs, resolve_duration_secs, resolve_value,
};
use super::schema::{FieldKind, FieldSchema};
const NAME: &str = "jwt_sign";
const MAX_EXPIRES_SECS: u64 = 315_360_000;
pub struct JwtSignHandler;
#[async_trait]
impl AsyncFunctionHandler for JwtSignHandler {
type Input = Value;
async fn execute(
&self,
ctx: &mut TaskContext<'_>,
input: &Value,
) -> dataflow_rs::Result<TaskOutcome> {
let algorithm = match input.get("algorithm").and_then(Value::as_str) {
Some(name) => crate::jwt::parse_algorithm(name).map_err(|e| validation(&e))?,
None => return Err(validation("requires 'algorithm'")),
};
let Some(key_ref) = input.get("key").and_then(Value::as_str) else {
return Err(validation(
"requires 'key' (a literal or a secret reference like env://NAME)",
));
};
let key_encoding = input.get("key_encoding").and_then(Value::as_str);
let kid = input.get("kid").and_then(Value::as_str).map(str::to_string);
let output = input
.get("output")
.and_then(Value::as_str)
.unwrap_or("data");
let mut claims = match input.get("claims") {
None => serde_json::Map::new(),
Some(raw) => match resolve_value(raw, ctx) {
Value::Object(map) => map,
_ => return Err(validation("'claims' must resolve to an object")),
},
};
let now = chrono::Utc::now().timestamp();
if let Some(iss) = input.get("issuer").and_then(Value::as_str) {
claims.insert("iss".to_string(), Value::String(iss.to_string()));
}
if let Some(aud) = input.get("audience")
&& !aud.is_null()
{
claims.insert("aud".to_string(), resolve_value(aud, ctx));
}
if let Some(nbf) = input.get("not_before") {
let offset = resolve_duration_secs(nbf, ctx, NAME, "not_before")?;
claims.insert("nbf".to_string(), Value::from(now + offset as i64));
}
if let Some(iat) = claims.get("iat") {
require_numeric_date(iat, "iat")?;
} else {
claims.insert("iat".to_string(), Value::from(now));
}
match input.get("expires_in") {
Some(raw) if !raw.is_null() => {
let secs = resolve_duration_secs(raw, ctx, NAME, "expires_in")?;
if secs == 0 || secs > MAX_EXPIRES_SECS {
return Err(validation(&format!(
"'expires_in' must be between 1 second and {MAX_EXPIRES_SECS} \
seconds (10 years)"
)));
}
claims.insert("exp".to_string(), Value::from(now + secs as i64));
}
_ if claims.contains_key("exp") => {
require_numeric_date(&claims["exp"], "exp")?;
}
_ => {
return Err(validation(
"requires 'expires_in' (or an explicit 'exp' claim — non-expiring \
tokens must be deliberate)",
));
}
}
let material = crate::connector::secrets::resolve_secret_string(key_ref, "jwt_sign.key")
.await
.map_err(|e| validation(&e))?;
let key = crate::jwt::encoding_key(algorithm, &material, key_encoding)
.map_err(|e| validation(&e))?;
let token = crate::jwt::sign(algorithm, &key, kid, &Value::Object(claims))
.map_err(|e| DataflowError::function_execution(format!("{NAME}: {e}"), None))?;
apply_output(ctx, output, Value::String(token));
Ok(TaskOutcome::Success)
}
}
fn validation(msg: &str) -> DataflowError {
DataflowError::Validation(format!("{NAME}: {msg}"))
}
fn require_numeric_date(value: &Value, claim: &'static str) -> Result<(), DataflowError> {
if value.is_number() {
return Ok(());
}
Err(validation(&format!(
"claims.{claim} must be a number of seconds since the Unix epoch \
(NumericDate, RFC 7519 §2), got {}",
super::http_common::json_type_name(value)
)))
}
pub(super) fn validate_static_input(
obj: &serde_json::Map<String, Value>,
) -> Vec<(&'static str, &'static str, String)> {
let mut errors: Vec<(&'static str, &'static str, String)> = Vec::new();
if let Some(name) = obj.get("algorithm").and_then(Value::as_str)
&& let Err(e) = crate::jwt::parse_algorithm(name)
{
errors.push(("algorithm", "INVALID", e));
}
if obj.get("expires_in").is_none_or(Value::is_null) {
let has_exp = obj
.get("claims")
.and_then(Value::as_object)
.is_some_and(|c| c.contains_key("exp"));
if !has_exp {
errors.push((
"expires_in",
"REQUIRED",
"jwt_sign requires 'expires_in' (or an explicit 'exp' claim — \
non-expiring tokens must be deliberate)"
.to_string(),
));
}
} else if let Some(Value::String(s)) = obj.get("expires_in")
&& let Err(e) = parse_duration_secs(s)
{
errors.push(("expires_in", "INVALID", e));
}
errors
}
pub(super) const JWT_SIGN_FIELDS: &[FieldSchema] = &[
FieldSchema {
name: "algorithm",
description: "Signing algorithm: HS/RS/PS 256-512, ES256/384, or EdDSA.",
kind: FieldKind::String,
required: true,
resolvable: false,
alias: None,
},
FieldSchema {
name: "key",
description: "HS secret or RS/ES/Ed private-key PEM; a literal or a secret \
reference (env://NAME). Never appears in traces or errors.",
kind: FieldKind::String,
required: true,
resolvable: false,
alias: None,
},
FieldSchema {
name: "key_encoding",
description: "How an HS secret becomes bytes: utf8 (default), base64, hex.",
kind: FieldKind::String,
required: false,
resolvable: false,
alias: None,
},
FieldSchema {
name: "claims",
description: "The claims object; values fold {\"var\": ..} nodes (compose \
computed claims with a map task first). iat is stamped \
automatically.",
kind: FieldKind::Object,
required: false,
resolvable: true,
alias: None,
},
FieldSchema {
name: "expires_in",
description: "Token lifetime: integer seconds or \"<n>s|m|h|d\"; sets exp from \
now. Required unless claims carries an explicit exp.",
kind: FieldKind::Any,
required: false,
resolvable: true,
alias: None,
},
FieldSchema {
name: "issuer",
description: "Convenience for the iss claim.",
kind: FieldKind::String,
required: false,
resolvable: false,
alias: None,
},
FieldSchema {
name: "audience",
description: "Convenience for the aud claim (string or array; resolvable).",
kind: FieldKind::Any,
required: false,
resolvable: true,
alias: None,
},
FieldSchema {
name: "not_before",
description: "Offset from now for the nbf claim: integer seconds or \
\"<n>s|m|h|d\".",
kind: FieldKind::Any,
required: false,
resolvable: true,
alias: None,
},
FieldSchema {
name: "kid",
description: "Key id stamped into the token header, for rotation-aware \
verifiers.",
kind: FieldKind::String,
required: false,
resolvable: false,
alias: None,
},
FieldSchema {
name: "output",
description: "Dotted path where the compact JWS (string) is stored. Defaults \
to \"data\".",
kind: FieldKind::String,
required: false,
resolvable: false,
alias: None,
},
];