Skip to main content

greentic_setup/
card_setup.rs

1//! Adaptive Card setup flow types.
2//!
3//! Provides types for driving setup/onboard workflows via adaptive cards
4//! in messaging channels. The actual card rendering uses greentic-qa's
5//! `render_card` function; this module adds security (signed tokens)
6//! and multi-step orchestration on top.
7
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::time::{Duration, SystemTime};
11
12use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
13use hmac::{Hmac, KeyInit, Mac};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use sha2::Sha256;
17
18type HmacSha256 = Hmac<Sha256>;
19
20/// A setup session that tracks multi-step card-based onboarding.
21#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct CardSetupSession {
23    /// Unique session ID.
24    pub session_id: String,
25    /// Bundle being configured.
26    pub bundle_path: PathBuf,
27    /// Provider being configured.
28    pub provider_id: String,
29    /// Tenant context.
30    pub tenant: String,
31    /// Team context.
32    #[serde(default)]
33    pub team: Option<String>,
34    /// Answers collected so far.
35    pub answers: HashMap<String, Value>,
36    /// Current step index.
37    pub current_step: usize,
38    /// When this session was created (Unix timestamp).
39    pub created_at: u64,
40    /// When this session expires (Unix timestamp).
41    pub expires_at: u64,
42    /// Whether this session has been completed.
43    pub completed: bool,
44}
45
46impl CardSetupSession {
47    /// Create a new session with the given TTL.
48    pub fn new(
49        bundle_path: PathBuf,
50        provider_id: String,
51        tenant: String,
52        team: Option<String>,
53        ttl: Duration,
54    ) -> Self {
55        let now = SystemTime::now()
56            .duration_since(SystemTime::UNIX_EPOCH)
57            .unwrap_or_default()
58            .as_secs();
59        Self {
60            session_id: generate_session_id(),
61            bundle_path,
62            provider_id,
63            tenant,
64            team,
65            answers: HashMap::new(),
66            current_step: 0,
67            created_at: now,
68            expires_at: now + ttl.as_secs(),
69            completed: false,
70        }
71    }
72
73    /// Check whether this session has expired.
74    pub fn is_expired(&self) -> bool {
75        let now = SystemTime::now()
76            .duration_since(SystemTime::UNIX_EPOCH)
77            .unwrap_or_default()
78            .as_secs();
79        now >= self.expires_at
80    }
81
82    /// Merge new answers into the session.
83    pub fn merge_answers(&mut self, new_answers: &Value) {
84        if let Some(obj) = new_answers.as_object() {
85            for (key, value) in obj {
86                if !value.is_null() {
87                    self.answers.insert(key.clone(), value.clone());
88                }
89            }
90        }
91    }
92
93    /// Get collected answers as a JSON Value.
94    pub fn answers_as_value(&self) -> Value {
95        serde_json::to_value(&self.answers).unwrap_or(Value::Object(Default::default()))
96    }
97}
98
99/// Configuration for setup link generation.
100#[derive(Clone, Debug, Serialize, Deserialize)]
101pub struct SetupLinkConfig {
102    /// Base URL for the setup endpoint.
103    pub base_url: String,
104    /// Default TTL for setup sessions.
105    #[serde(default = "default_ttl_secs")]
106    pub ttl_secs: u64,
107    /// Signing key for setup tokens (hex-encoded).
108    #[serde(default)]
109    pub signing_key: Option<String>,
110}
111
112fn default_ttl_secs() -> u64 {
113    1800 // 30 minutes
114}
115
116impl SetupLinkConfig {
117    /// Generate a setup URL for the given session.
118    ///
119    /// If a `signing_key` is configured, a signed JWT token is included.
120    /// Otherwise falls back to session ID.
121    pub fn generate_url(&self, session: &CardSetupSession) -> String {
122        let token = if let Some(ref key) = self.signing_key {
123            sign_session_token(key, session)
124        } else {
125            session.session_id.clone()
126        };
127        format!(
128            "{}/setup?session={}&token={}&provider={}",
129            self.base_url.trim_end_matches('/'),
130            session.session_id,
131            token,
132            session.provider_id,
133        )
134    }
135
136    /// Verify a token against a session.
137    ///
138    /// Returns `true` if the token is valid for this session.
139    pub fn verify_token(&self, token: &str, session: &CardSetupSession) -> bool {
140        if let Some(ref key) = self.signing_key {
141            verify_session_token(key, token, session)
142        } else {
143            token == session.session_id
144        }
145    }
146}
147
148/// Sign a session token using HMAC-SHA256.
149///
150/// Payload: `{session_id}.{expires_at}.{provider_id}`
151/// Token format: `{base64(payload)}.{base64(signature)}`
152fn sign_session_token(key: &str, session: &CardSetupSession) -> String {
153    let payload = format!(
154        "{}.{}.{}",
155        session.session_id, session.expires_at, session.provider_id
156    );
157    let payload_b64 = URL_SAFE_NO_PAD.encode(payload.as_bytes());
158
159    let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length");
160    mac.update(payload_b64.as_bytes());
161    let sig = mac.finalize().into_bytes();
162    let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
163
164    format!("{payload_b64}.{sig_b64}")
165}
166
167/// Verify a session token against a session.
168fn verify_session_token(key: &str, token: &str, session: &CardSetupSession) -> bool {
169    let expected = sign_session_token(key, session);
170    // Constant-time comparison
171    if token.len() != expected.len() {
172        return false;
173    }
174    token
175        .as_bytes()
176        .iter()
177        .zip(expected.as_bytes())
178        .fold(0u8, |acc, (a, b)| acc | (a ^ b))
179        == 0
180}
181
182/// Result of processing a card setup submission.
183#[derive(Clone, Debug, Serialize)]
184pub struct CardSetupResult {
185    /// Whether setup is complete (all steps answered).
186    pub complete: bool,
187    /// The next card to render (if not complete).
188    pub next_card: Option<Value>,
189    /// Warnings from the setup process.
190    pub warnings: Vec<String>,
191    /// Keys that were persisted.
192    pub persisted_keys: Vec<String>,
193}
194
195fn generate_session_id() -> String {
196    use std::time::SystemTime;
197    let nanos = SystemTime::now()
198        .duration_since(SystemTime::UNIX_EPOCH)
199        .unwrap_or_default()
200        .as_nanos();
201    format!("setup-{nanos:x}")
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn session_not_expired_within_ttl() {
210        let session = CardSetupSession::new(
211            PathBuf::from("/bundle"),
212            "telegram".into(),
213            "demo".into(),
214            None,
215            Duration::from_secs(3600),
216        );
217        assert!(!session.is_expired());
218        assert!(!session.completed);
219        assert!(session.session_id.starts_with("setup-"));
220    }
221
222    #[test]
223    fn session_expired_with_zero_ttl() {
224        let session = CardSetupSession::new(
225            PathBuf::from("/bundle"),
226            "telegram".into(),
227            "demo".into(),
228            None,
229            Duration::from_secs(0),
230        );
231        assert!(session.is_expired());
232    }
233
234    #[test]
235    fn merge_answers_accumulates() {
236        let mut session = CardSetupSession::new(
237            PathBuf::from("/bundle"),
238            "telegram".into(),
239            "demo".into(),
240            None,
241            Duration::from_secs(3600),
242        );
243        session.merge_answers(&serde_json::json!({"bot_token": "abc"}));
244        session.merge_answers(&serde_json::json!({"public_url": "https://example.com"}));
245        assert_eq!(session.answers.len(), 2);
246        assert_eq!(
247            session.answers.get("bot_token"),
248            Some(&Value::String("abc".into()))
249        );
250    }
251
252    #[test]
253    fn null_answers_not_merged() {
254        let mut session = CardSetupSession::new(
255            PathBuf::from("/bundle"),
256            "telegram".into(),
257            "demo".into(),
258            None,
259            Duration::from_secs(3600),
260        );
261        session.merge_answers(&serde_json::json!({"key": null}));
262        assert!(session.answers.is_empty());
263    }
264
265    #[test]
266    fn setup_link_generation_unsigned() {
267        let config = SetupLinkConfig {
268            base_url: "https://operator.example.com".into(),
269            ttl_secs: 1800,
270            signing_key: None,
271        };
272        let session = CardSetupSession::new(
273            PathBuf::from("/bundle"),
274            "telegram".into(),
275            "demo".into(),
276            None,
277            Duration::from_secs(1800),
278        );
279        let url = config.generate_url(&session);
280        assert!(url.starts_with("https://operator.example.com/setup?session="));
281        assert!(url.contains("provider=telegram"));
282        assert!(config.verify_token(&session.session_id, &session));
283    }
284
285    #[test]
286    fn setup_link_generation_signed() {
287        let config = SetupLinkConfig {
288            base_url: "https://operator.example.com".into(),
289            ttl_secs: 1800,
290            signing_key: Some("my-secret-key-256".into()),
291        };
292        let session = CardSetupSession::new(
293            PathBuf::from("/bundle"),
294            "telegram".into(),
295            "demo".into(),
296            None,
297            Duration::from_secs(1800),
298        );
299        let url = config.generate_url(&session);
300        assert!(url.contains("token="));
301        assert!(url.contains("provider=telegram"));
302
303        // Extract token from URL
304        let token = url
305            .split("token=")
306            .nth(1)
307            .unwrap()
308            .split('&')
309            .next()
310            .unwrap();
311        assert!(config.verify_token(token, &session));
312        assert!(!config.verify_token("bad-token", &session));
313    }
314}