1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct CardSetupSession {
23 pub session_id: String,
25 pub bundle_path: PathBuf,
27 pub provider_id: String,
29 pub tenant: String,
31 #[serde(default)]
33 pub team: Option<String>,
34 pub answers: HashMap<String, Value>,
36 pub current_step: usize,
38 pub created_at: u64,
40 pub expires_at: u64,
42 pub completed: bool,
44}
45
46impl CardSetupSession {
47 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 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 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 pub fn answers_as_value(&self) -> Value {
95 serde_json::to_value(&self.answers).unwrap_or(Value::Object(Default::default()))
96 }
97}
98
99#[derive(Clone, Debug, Serialize, Deserialize)]
101pub struct SetupLinkConfig {
102 pub base_url: String,
104 #[serde(default = "default_ttl_secs")]
106 pub ttl_secs: u64,
107 #[serde(default)]
109 pub signing_key: Option<String>,
110}
111
112fn default_ttl_secs() -> u64 {
113 1800 }
115
116impl SetupLinkConfig {
117 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 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
148fn 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
167fn verify_session_token(key: &str, token: &str, session: &CardSetupSession) -> bool {
169 let expected = sign_session_token(key, session);
170 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#[derive(Clone, Debug, Serialize)]
184pub struct CardSetupResult {
185 pub complete: bool,
187 pub next_card: Option<Value>,
189 pub warnings: Vec<String>,
191 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 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}