1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result, anyhow, bail};
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use hmac::{Hmac, KeyInit, Mac};
11use serde::{Deserialize, Serialize};
12use serde_json::{Map as JsonMap, Value};
13use sha2::Sha256;
14
15type HmacSha256 = Hmac<Sha256>;
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum SetupActionKind {
20 OauthInstallButton,
21 OauthDeviceCode,
22 OpenUrl,
23 CopySecret,
24 ManualStep,
25 DownloadFile,
26 AdminConsentButton,
27 #[serde(untagged)]
28 Other(String),
29}
30
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum SetupActionStatus {
34 Pending,
35 Complete,
36 Failed,
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub struct SetupAction {
41 pub id: String,
42 pub kind: SetupActionKind,
43 pub label: String,
44 pub provider_id: String,
45 pub tenant: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub team: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub authorize_url: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub callback_path: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub state: Option<String>,
54 pub status: SetupActionStatus,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub created_at: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub completed_at: Option<String>,
59 #[serde(flatten)]
60 pub extra: JsonMap<String, Value>,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64pub struct SetupActionStateFile {
65 pub provider_id: String,
66 pub tenant: String,
67 pub team: String,
68 pub actions: Vec<SetupAction>,
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
72pub struct OAuthStatePayload {
73 pub provider_id: String,
74 pub tenant: String,
75 pub team: String,
76 pub action_id: String,
77 pub nonce: String,
78 pub expires_at: u64,
79}
80
81#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct OAuthMetadata {
83 #[serde(default)]
84 pub auth_type: Option<String>,
85 #[serde(default)]
86 pub authorize_url: Option<String>,
87 pub token_url: String,
88 #[serde(default)]
89 pub redirect_path: Option<String>,
90 #[serde(default)]
91 pub scopes: Vec<String>,
92 #[serde(default)]
93 pub secret_keys: Vec<String>,
94 #[serde(default)]
95 pub response_secret_map: BTreeMap<String, String>,
96}
97
98pub fn extract_setup_actions(
99 provider_id: &str,
100 tenant: &str,
101 team: Option<&str>,
102 value: &Value,
103) -> Result<Vec<SetupAction>> {
104 let Some(actions) = value.get("setup_actions").and_then(Value::as_array) else {
105 return Ok(Vec::new());
106 };
107
108 actions
109 .iter()
110 .map(|raw| parse_setup_action(provider_id, tenant, team, raw))
111 .collect()
112}
113
114pub fn strip_setup_actions(value: &Value) -> Value {
115 let mut cloned = value.clone();
116 if let Some(obj) = cloned.as_object_mut() {
117 obj.remove("setup_actions");
118 obj.remove("pending_setup_actions");
119 }
120 cloned
121}
122
123pub fn persist_setup_actions(bundle_root: &Path, actions: &[SetupAction]) -> Result<Vec<PathBuf>> {
124 let mut grouped: BTreeMap<(String, String, String), Vec<SetupAction>> = BTreeMap::new();
125 for action in actions {
126 grouped
127 .entry((
128 action.provider_id.clone(),
129 action.tenant.clone(),
130 team_segment(action.team.as_deref()).to_string(),
131 ))
132 .or_default()
133 .push(action.clone());
134 }
135
136 let mut paths = Vec::new();
137 for ((provider_id, tenant, team), new_actions) in grouped {
138 let path = setup_actions_state_path(bundle_root, &tenant, &team, &provider_id);
139 let mut file = if path.exists() {
140 let raw = std::fs::read_to_string(&path)
141 .with_context(|| format!("failed to read {}", path.display()))?;
142 serde_json::from_str::<SetupActionStateFile>(&raw)
143 .with_context(|| format!("failed to parse {}", path.display()))?
144 } else {
145 SetupActionStateFile {
146 provider_id: provider_id.clone(),
147 tenant: tenant.clone(),
148 team: team.clone(),
149 actions: Vec::new(),
150 }
151 };
152
153 for mut action in new_actions {
154 if action.created_at.is_none() {
155 action.created_at = Some(now_stamp());
156 }
157 if let Some(existing) = file.actions.iter_mut().find(|a| a.id == action.id) {
158 if existing.status == SetupActionStatus::Complete
167 && action.status == SetupActionStatus::Pending
168 {
169 continue;
170 }
171 let existing_has_redirect = existing
176 .authorize_url
177 .as_deref()
178 .is_some_and(|url| url.contains("redirect_uri="));
179 let new_has_redirect = action
180 .authorize_url
181 .as_deref()
182 .is_some_and(|url| url.contains("redirect_uri="));
183 let existing_state_live = existing
187 .state
188 .as_deref()
189 .is_some_and(|state| state_not_expired(state, current_epoch_secs()));
190 if existing_has_redirect && !new_has_redirect && existing_state_live {
191 action.authorize_url = existing.authorize_url.clone();
192 action.state = existing.state.clone();
193 }
194 let created_at = existing.created_at.clone().or(action.created_at.clone());
195 *existing = action;
196 existing.created_at = created_at;
197 } else {
198 file.actions.push(action);
199 }
200 }
201
202 if let Some(parent) = path.parent() {
203 std::fs::create_dir_all(parent)?;
204 }
205 let payload = serde_json::to_string_pretty(&file)?;
206 std::fs::write(&path, payload)
207 .with_context(|| format!("failed to write {}", path.display()))?;
208 paths.push(path);
209 }
210 Ok(paths)
211}
212
213fn state_not_expired(state: &str, now_epoch_secs: u64) -> bool {
217 let Some(payload_b64) = state.split('.').next() else {
218 return false;
219 };
220 let mut padded = payload_b64.to_string();
221 while padded.len() % 4 != 0 {
222 padded.push('=');
223 }
224 URL_SAFE_NO_PAD
225 .decode(payload_b64)
226 .ok()
227 .or_else(|| {
228 base64::engine::general_purpose::URL_SAFE
229 .decode(&padded)
230 .ok()
231 })
232 .and_then(|bytes| serde_json::from_slice::<OAuthStatePayload>(&bytes).ok())
233 .is_some_and(|payload| payload.expires_at > now_epoch_secs)
234}
235
236pub fn sign_pending_oauth_actions(bundle_root: &Path, actions: &mut [SetupAction]) -> Result<()> {
237 let key = load_or_create_signing_key(bundle_root)?;
238 for action in actions {
239 if action.status != SetupActionStatus::Pending
240 || action.kind != SetupActionKind::OauthInstallButton
241 || action.state.is_some()
242 {
243 continue;
244 }
245 let team = team_segment(action.team.as_deref()).to_string();
246 let payload = OAuthStatePayload {
247 provider_id: action.provider_id.clone(),
248 tenant: action.tenant.clone(),
249 team,
250 action_id: action.id.clone(),
251 nonce: URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>()),
252 expires_at: current_epoch_secs() + 15 * 60,
253 };
254 let state = sign_oauth_state(&payload, &key)?;
255 if let Some(authorize_url) = action.authorize_url.as_mut()
256 && !authorize_url_contains_state(authorize_url)
257 && let Ok(mut parsed) = url::Url::parse(authorize_url)
258 {
259 parsed.query_pairs_mut().append_pair("state", &state);
260 *authorize_url = parsed.to_string();
261 }
262 action.state = Some(state);
263 }
264 Ok(())
265}
266
267pub fn load_setup_action(
268 bundle_root: &Path,
269 tenant: &str,
270 team: &str,
271 provider_id: &str,
272 action_id: &str,
273) -> Result<Option<SetupAction>> {
274 let path = setup_actions_state_path(bundle_root, tenant, team, provider_id);
275 if !path.exists() {
276 return Ok(None);
277 }
278 let raw = std::fs::read_to_string(&path)
279 .with_context(|| format!("failed to read {}", path.display()))?;
280 let file: SetupActionStateFile = serde_json::from_str(&raw)
281 .with_context(|| format!("failed to parse {}", path.display()))?;
282 Ok(file.actions.into_iter().find(|a| a.id == action_id))
283}
284
285pub fn mark_setup_action_complete(
286 bundle_root: &Path,
287 tenant: &str,
288 team: &str,
289 provider_id: &str,
290 action_id: &str,
291) -> Result<()> {
292 let path = setup_actions_state_path(bundle_root, tenant, team, provider_id);
293 let raw = std::fs::read_to_string(&path)
294 .with_context(|| format!("failed to read {}", path.display()))?;
295 let mut file: SetupActionStateFile = serde_json::from_str(&raw)
296 .with_context(|| format!("failed to parse {}", path.display()))?;
297 let Some(action) = file.actions.iter_mut().find(|a| a.id == action_id) else {
298 bail!("setup action not found: {action_id}");
299 };
300 action.status = SetupActionStatus::Complete;
301 action.completed_at = Some(now_stamp());
302 let payload = serde_json::to_string_pretty(&file)?;
303 std::fs::write(&path, payload)
304 .with_context(|| format!("failed to write {}", path.display()))?;
305 Ok(())
306}
307
308pub fn setup_actions_state_path(
309 bundle_root: &Path,
310 tenant: &str,
311 team: &str,
312 provider_id: &str,
313) -> PathBuf {
314 bundle_root
315 .join("state")
316 .join("config")
317 .join("setup-actions")
318 .join(tenant)
319 .join(team_segment(Some(team)))
320 .join(format!("{provider_id}.json"))
321}
322
323pub fn signing_key_path(bundle_root: &Path) -> PathBuf {
324 bundle_root.join(".greentic").join("setup-oauth-state-key")
325}
326
327pub fn load_or_create_signing_key(bundle_root: &Path) -> Result<Vec<u8>> {
328 let path = signing_key_path(bundle_root);
329 if path.exists() {
330 let raw = std::fs::read_to_string(&path)
331 .with_context(|| format!("failed to read {}", path.display()))?;
332 return URL_SAFE_NO_PAD
333 .decode(raw.trim())
334 .context("failed to decode setup OAuth state signing key");
335 }
336 let bytes: [u8; 32] = rand::random();
337 if let Some(parent) = path.parent() {
338 std::fs::create_dir_all(parent)?;
339 }
340 std::fs::write(&path, URL_SAFE_NO_PAD.encode(bytes))
341 .with_context(|| format!("failed to write {}", path.display()))?;
342 Ok(bytes.to_vec())
343}
344
345pub fn sign_oauth_state(payload: &OAuthStatePayload, key: &[u8]) -> Result<String> {
346 let payload_json = serde_json::to_vec(payload)?;
347 let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json);
348 let mut mac = HmacSha256::new_from_slice(key).context("invalid HMAC key")?;
349 mac.update(payload_b64.as_bytes());
350 let sig = mac.finalize().into_bytes();
351 Ok(format!("{payload_b64}.{}", URL_SAFE_NO_PAD.encode(sig)))
352}
353
354pub fn validate_oauth_state(
355 token: &str,
356 key: &[u8],
357 expected_provider_id: Option<&str>,
358 expected_tenant: Option<&str>,
359 expected_team: Option<&str>,
360 now_epoch: u64,
361) -> Result<OAuthStatePayload> {
362 let (payload_b64, sig_b64) = token
363 .split_once('.')
364 .ok_or_else(|| anyhow!("invalid OAuth state format"))?;
365 let sig = URL_SAFE_NO_PAD
366 .decode(sig_b64)
367 .context("invalid OAuth state signature encoding")?;
368 let mut mac = HmacSha256::new_from_slice(key).context("invalid HMAC key")?;
369 mac.update(payload_b64.as_bytes());
370 mac.verify_slice(&sig)
371 .map_err(|_| anyhow!("invalid OAuth state signature"))?;
372 let payload_bytes = URL_SAFE_NO_PAD
373 .decode(payload_b64)
374 .context("invalid OAuth state payload encoding")?;
375 let payload: OAuthStatePayload =
376 serde_json::from_slice(&payload_bytes).context("invalid OAuth state payload")?;
377 if payload.expires_at <= now_epoch {
378 bail!("OAuth state has expired");
379 }
380 if let Some(expected) = expected_provider_id
381 && payload.provider_id != expected
382 {
383 bail!("OAuth state provider mismatch");
384 }
385 if let Some(expected) = expected_tenant
386 && payload.tenant != expected
387 {
388 bail!("OAuth state tenant mismatch");
389 }
390 if let Some(expected) = expected_team
391 && payload.team != expected
392 {
393 bail!("OAuth state team mismatch");
394 }
395 Ok(payload)
396}
397
398pub fn current_epoch_secs() -> u64 {
399 SystemTime::now()
400 .duration_since(UNIX_EPOCH)
401 .unwrap_or_default()
402 .as_secs()
403}
404
405pub fn map_oauth_token_response(
406 metadata: &OAuthMetadata,
407 response: &Value,
408) -> Result<BTreeMap<String, String>> {
409 let mut mapped = BTreeMap::new();
410 for (secret_key, response_key) in &metadata.response_secret_map {
411 if let Some(value) = response.get(response_key).and_then(value_to_string) {
412 mapped.insert(secret_key.clone(), value);
413 }
414 }
415 if mapped.is_empty()
416 && let Some(token) = response.get("access_token").and_then(value_to_string)
417 {
418 for key in &metadata.secret_keys {
419 mapped.insert(key.clone(), token.clone());
420 }
421 }
422 if mapped.is_empty() {
423 bail!("OAuth token response did not contain mappable secrets");
424 }
425 Ok(mapped)
426}
427
428fn parse_setup_action(
429 provider_id: &str,
430 tenant: &str,
431 team: Option<&str>,
432 raw: &Value,
433) -> Result<SetupAction> {
434 let mut obj = raw
435 .as_object()
436 .cloned()
437 .ok_or_else(|| anyhow!("setup action must be an object"))?;
438 let id = take_string(&mut obj, "id").ok_or_else(|| anyhow!("setup action missing id"))?;
439 let kind = match take_string(&mut obj, "kind")
440 .ok_or_else(|| anyhow!("setup action missing kind"))?
441 .as_str()
442 {
443 "oauth_install_button" => SetupActionKind::OauthInstallButton,
444 "oauth_device_code" => SetupActionKind::OauthDeviceCode,
445 "open_url" => SetupActionKind::OpenUrl,
446 "copy_secret" => SetupActionKind::CopySecret,
447 "manual_step" => SetupActionKind::ManualStep,
448 "download_file" => SetupActionKind::DownloadFile,
449 "admin_consent_button" => SetupActionKind::AdminConsentButton,
450 other => SetupActionKind::Other(other.to_string()),
451 };
452 let label = take_string(&mut obj, "label").unwrap_or_else(|| id.clone());
453 let provider_id =
454 take_string(&mut obj, "provider_id").unwrap_or_else(|| provider_id.to_string());
455 let tenant = take_string(&mut obj, "tenant").unwrap_or_else(|| tenant.to_string());
456 let team = take_string(&mut obj, "team").or_else(|| team.map(ToString::to_string));
457 let status = match take_string(&mut obj, "status").as_deref() {
458 Some("complete") => SetupActionStatus::Complete,
459 Some("failed") => SetupActionStatus::Failed,
460 _ => SetupActionStatus::Pending,
461 };
462 Ok(SetupAction {
463 id,
464 kind,
465 label,
466 provider_id,
467 tenant,
468 team,
469 authorize_url: take_string(&mut obj, "authorize_url"),
470 callback_path: take_string(&mut obj, "callback_path"),
471 state: take_string(&mut obj, "state"),
472 status,
473 created_at: take_string(&mut obj, "created_at"),
474 completed_at: take_string(&mut obj, "completed_at"),
475 extra: obj,
476 })
477}
478
479fn take_string(obj: &mut JsonMap<String, Value>, key: &str) -> Option<String> {
480 obj.remove(key).and_then(|value| match value {
481 Value::String(text) if !text.trim().is_empty() => Some(text),
482 Value::Number(number) => Some(number.to_string()),
483 Value::Bool(value) => Some(value.to_string()),
484 _ => None,
485 })
486}
487
488fn team_segment(team: Option<&str>) -> &str {
489 team.map(str::trim)
490 .filter(|value| !value.is_empty())
491 .unwrap_or("default")
492}
493
494fn now_stamp() -> String {
495 current_epoch_secs().to_string()
496}
497
498fn value_to_string(value: &Value) -> Option<String> {
499 match value {
500 Value::String(text) if !text.is_empty() => Some(text.clone()),
501 Value::Number(number) => Some(number.to_string()),
502 Value::Bool(value) => Some(value.to_string()),
503 _ => None,
504 }
505}
506
507fn authorize_url_contains_state(value: &str) -> bool {
508 url::Url::parse(value)
509 .ok()
510 .and_then(|url| {
511 url.query_pairs()
512 .any(|(key, _)| key == "state")
513 .then_some(())
514 })
515 .is_some()
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use serde_json::json;
522
523 fn oauth_action(id: &str, authorize_url: Option<&str>, state: Option<&str>) -> SetupAction {
524 SetupAction {
525 id: id.to_string(),
526 kind: SetupActionKind::OauthInstallButton,
527 label: "Install".to_string(),
528 provider_id: "messaging-example".to_string(),
529 tenant: "demo".to_string(),
530 team: Some("default".to_string()),
531 authorize_url: authorize_url.map(ToString::to_string),
532 callback_path: None,
533 state: state.map(ToString::to_string),
534 status: SetupActionStatus::Pending,
535 created_at: None,
536 completed_at: None,
537 extra: JsonMap::new(),
538 }
539 }
540
541 fn live_state(bundle: &Path) -> String {
542 let key = load_or_create_signing_key(bundle).unwrap();
543 sign_oauth_state(
544 &OAuthStatePayload {
545 provider_id: "messaging-example".into(),
546 tenant: "demo".into(),
547 team: "default".into(),
548 action_id: "install".into(),
549 nonce: "n".into(),
550 expires_at: current_epoch_secs() + 600,
551 },
552 &key,
553 )
554 .unwrap()
555 }
556
557 #[test]
558 fn persist_never_replaces_complete_authorize_url_with_stripped_one() {
559 let temp = tempfile::tempdir().unwrap();
560 let state = live_state(temp.path());
561 let complete_url = format!(
562 "https://slack.com/oauth/v2/authorize?client_id=c&redirect_uri=https%3A%2F%2Fx%2Fcb&state={state}"
563 );
564 persist_setup_actions(
565 temp.path(),
566 &[oauth_action("install", Some(&complete_url), Some(&state))],
567 )
568 .unwrap();
569 persist_setup_actions(
571 temp.path(),
572 &[oauth_action(
573 "install",
574 Some("https://slack.com/oauth/v2/authorize?client_id=c&state=other"),
575 Some("other"),
576 )],
577 )
578 .unwrap();
579 let action = load_setup_action(
580 temp.path(),
581 "demo",
582 "default",
583 "messaging-example",
584 "install",
585 )
586 .unwrap()
587 .unwrap();
588 assert_eq!(action.authorize_url.as_deref(), Some(complete_url.as_str()));
589 assert_eq!(action.state.as_deref(), Some(state.as_str()));
590 }
591
592 #[test]
593 fn persist_replaces_expired_complete_url_with_fresh_one() {
594 let temp = tempfile::tempdir().unwrap();
595 let key = load_or_create_signing_key(temp.path()).unwrap();
596 let expired = sign_oauth_state(
597 &OAuthStatePayload {
598 provider_id: "messaging-example".into(),
599 tenant: "demo".into(),
600 team: "default".into(),
601 action_id: "install".into(),
602 nonce: "n".into(),
603 expires_at: current_epoch_secs().saturating_sub(1),
604 },
605 &key,
606 )
607 .unwrap();
608 let stale_url =
609 format!("https://x/authorize?redirect_uri=https%3A%2F%2Fx%2Fcb&state={expired}");
610 persist_setup_actions(
611 temp.path(),
612 &[oauth_action("install", Some(&stale_url), Some(&expired))],
613 )
614 .unwrap();
615 persist_setup_actions(
616 temp.path(),
617 &[oauth_action(
618 "install",
619 Some("https://x/authorize?state=fresh"),
620 Some("fresh"),
621 )],
622 )
623 .unwrap();
624 let action = load_setup_action(
625 temp.path(),
626 "demo",
627 "default",
628 "messaging-example",
629 "install",
630 )
631 .unwrap()
632 .unwrap();
633 assert_eq!(
634 action.authorize_url.as_deref(),
635 Some("https://x/authorize?state=fresh")
636 );
637 }
638
639 #[test]
640 fn persist_never_downgrades_complete_action_to_pending() {
641 let temp = tempfile::tempdir().unwrap();
642 let mut done = oauth_action("install", Some("https://x/a"), None);
643 done.status = SetupActionStatus::Complete;
644 persist_setup_actions(temp.path(), &[done]).unwrap();
645 persist_setup_actions(
646 temp.path(),
647 &[oauth_action("install", Some("https://x/b"), None)],
648 )
649 .unwrap();
650 let action = load_setup_action(
651 temp.path(),
652 "demo",
653 "default",
654 "messaging-example",
655 "install",
656 )
657 .unwrap()
658 .unwrap();
659 assert_eq!(action.status, SetupActionStatus::Complete);
660 assert_eq!(action.authorize_url.as_deref(), Some("https://x/a"));
661 }
662
663 #[test]
664 fn extract_setup_actions_fills_scope_defaults() {
665 let value = json!({
666 "setup_actions": [{
667 "id": "install",
668 "kind": "oauth_install_button",
669 "label": "Add to Example",
670 "authorize_url": "https://example.com/auth"
671 }]
672 });
673 let actions =
674 extract_setup_actions("messaging-example", "demo", Some("default"), &value).unwrap();
675 assert_eq!(actions.len(), 1);
676 assert_eq!(actions[0].provider_id, "messaging-example");
677 assert_eq!(actions[0].tenant, "demo");
678 assert_eq!(actions[0].team.as_deref(), Some("default"));
679 }
680
681 #[test]
682 fn extract_setup_actions_supports_oauth_device_code() {
683 let value = json!({
684 "setup_actions": [{
685 "id": "connect",
686 "kind": "oauth_device_code",
687 "label": "Connect"
688 }]
689 });
690 let actions =
691 extract_setup_actions("messaging-teams", "demo", Some("default"), &value).unwrap();
692 assert_eq!(actions.len(), 1);
693 assert_eq!(actions[0].kind, SetupActionKind::OauthDeviceCode);
694 assert_eq!(actions[0].provider_id, "messaging-teams");
695 }
696
697 #[test]
698 fn persist_setup_actions_upserts_by_id() {
699 let temp = tempfile::tempdir().unwrap();
700 let mut action = SetupAction {
701 id: "install".into(),
702 kind: SetupActionKind::OauthInstallButton,
703 label: "Add".into(),
704 provider_id: "messaging-example".into(),
705 tenant: "demo".into(),
706 team: Some("default".into()),
707 authorize_url: Some("https://example.com/one".into()),
708 callback_path: None,
709 state: None,
710 status: SetupActionStatus::Pending,
711 created_at: None,
712 completed_at: None,
713 extra: JsonMap::new(),
714 };
715 persist_setup_actions(temp.path(), &[action.clone()]).unwrap();
716 action.authorize_url = Some("https://example.com/two".into());
717 persist_setup_actions(temp.path(), &[action]).unwrap();
718 let path = setup_actions_state_path(temp.path(), "demo", "default", "messaging-example");
719 let file: SetupActionStateFile =
720 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
721 assert_eq!(file.actions.len(), 1);
722 assert_eq!(
723 file.actions[0].authorize_url.as_deref(),
724 Some("https://example.com/two")
725 );
726 }
727
728 #[test]
729 fn oauth_state_rejects_bad_signature_and_expiry() {
730 let key = b"test-key";
731 let payload = OAuthStatePayload {
732 provider_id: "messaging-example".into(),
733 tenant: "demo".into(),
734 team: "default".into(),
735 action_id: "install".into(),
736 nonce: "n".into(),
737 expires_at: 100,
738 };
739 let token = sign_oauth_state(&payload, key).unwrap();
740 assert!(validate_oauth_state(&token, key, None, None, None, 99).is_ok());
741 assert!(validate_oauth_state(&token, b"other", None, None, None, 99).is_err());
742 assert!(validate_oauth_state(&token, key, None, None, None, 100).is_err());
743 }
744
745 #[test]
746 fn sign_pending_oauth_actions_adds_state_to_action_and_url() {
747 let temp = tempfile::tempdir().unwrap();
748 let mut actions = vec![SetupAction {
749 id: "install".into(),
750 kind: SetupActionKind::OauthInstallButton,
751 label: "Add".into(),
752 provider_id: "messaging-example".into(),
753 tenant: "demo".into(),
754 team: Some("default".into()),
755 authorize_url: Some("https://example.com/oauth?client_id=abc".into()),
756 callback_path: Some("/oauth/callback/example".into()),
757 state: None,
758 status: SetupActionStatus::Pending,
759 created_at: None,
760 completed_at: None,
761 extra: JsonMap::new(),
762 }];
763 sign_pending_oauth_actions(temp.path(), &mut actions).unwrap();
764 let state = actions[0].state.as_deref().unwrap();
765 assert!(
766 actions[0]
767 .authorize_url
768 .as_deref()
769 .unwrap()
770 .contains("state=")
771 );
772 let key = load_or_create_signing_key(temp.path()).unwrap();
773 let payload =
774 validate_oauth_state(state, &key, Some("messaging-example"), None, None, 0).unwrap();
775 assert_eq!(payload.action_id, "install");
776 }
777
778 #[test]
779 fn token_response_maps_access_token_to_secret_keys() {
780 let metadata = OAuthMetadata {
781 token_url: "https://example.com/token".into(),
782 secret_keys: vec!["EXAMPLE_TOKEN".into()],
783 ..Default::default()
784 };
785 let mapped = map_oauth_token_response(&metadata, &json!({"access_token": "xoxb"})).unwrap();
786 assert_eq!(
787 mapped.get("EXAMPLE_TOKEN").map(String::as_str),
788 Some("xoxb")
789 );
790 }
791}