Skip to main content

greentic_setup/
setup_actions.rs

1//! Provider-agnostic setup actions and OAuth setup helpers.
2
3use 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                // Persisted action state must never get POORER. Multiple writers
159                // persist here (the UI action handler with a complete OAuth
160                // authorize URL, and the engine apply/finish flow which
161                // regenerates actions without a redirect_uri); writer order must
162                // not decide which survives.
163                //
164                // 1. A `complete` action stays complete — a captured credential
165                //    is never rolled back to pending by a later re-apply.
166                if existing.status == SetupActionStatus::Complete
167                    && action.status == SetupActionStatus::Pending
168                {
169                    continue;
170                }
171                // 2. An authorize URL carrying redirect_uri (and its matching
172                //    signed state) is never replaced by one without it — a
173                //    stripped URL sends the user through the provider's consent
174                //    flow with no way back to our callback.
175                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                // ...unless the preserved URL's signed state has expired — an
184                // expired state fails callback validation, so a fresh (if
185                // stripped) URL is strictly better than a dead complete one.
186                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
213/// Whether a signed OAuth `state` token's embedded `expires_at` is still in the
214/// future. Signature is NOT checked here — this is only used to decide whether a
215/// persisted authorize URL is still clickable; the callback re-validates fully.
216fn 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        // Engine re-apply persists a stripped regeneration (no redirect_uri).
570        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}