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 {
pub credential_id: String,
#[serde(default)]
pub expires_in: Option<i64>,
#[serde(default)]
pub claims: Map<String, Value>,
#[serde(default)]
pub issuer: Option<String>,
#[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,
})
}
}
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)?;
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),
));
}
};
let mut claims_map = Map::new();
let mut public_keys: Vec<String> = Vec::new();
for (key, val) in &self.config.claims {
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);
}
if !public_keys.is_empty() {
claims_map.insert(
"_zf_public".to_string(),
Value::Array(
public_keys
.iter()
.map(|k| Value::String(k.clone()))
.collect(),
),
);
}
let profile = Value::Object(claims_map.clone());
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));
}
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
)],
})
}
}