Skip to main content

greentic_setup/
oauth_callback.rs

1//! Provider-agnostic OAuth callback completion helpers.
2
3use std::path::Path;
4
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7use serde_json::{Map as JsonMap, Value};
8
9use crate::setup_actions::{OAuthMetadata, OAuthStatePayload, SetupAction, SetupActionKind};
10
11#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct OAuthCallbackInput {
13    pub code: String,
14    pub state: String,
15}
16
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub struct OAuthCallbackReport {
19    pub provider_id: String,
20    pub tenant: String,
21    pub team: String,
22    pub action_id: String,
23    pub persisted_secret_keys: Vec<String>,
24}
25
26pub fn load_provider_oauth_metadata(
27    bundle_root: &Path,
28    provider_id: &str,
29    extension_key: &str,
30) -> Result<OAuthMetadata> {
31    let discovered = crate::discovery::discover(bundle_root)
32        .context("failed to discover providers for OAuth callback")?;
33    let provider = discovered
34        .find_setup_target(provider_id)
35        .ok_or_else(|| anyhow::anyhow!("provider not found for OAuth callback: {provider_id}"))?;
36    let raw = crate::discovery::read_pack_extension(&provider.pack_path, extension_key)?
37        .ok_or_else(|| anyhow::anyhow!("provider missing OAuth metadata: {extension_key}"))?;
38    let metadata = raw.get("inline").cloned().unwrap_or(raw);
39    serde_json::from_value(metadata).context("failed to parse provider OAuth metadata")
40}
41
42pub async fn complete_oauth_callback_with_token_response(
43    bundle_root: &Path,
44    env: &str,
45    input: &OAuthCallbackInput,
46    token_response: &Value,
47    extension_key: &str,
48) -> Result<OAuthCallbackReport> {
49    if input.code.trim().is_empty() {
50        bail!("OAuth callback missing code");
51    }
52    let key = crate::setup_actions::load_or_create_signing_key(bundle_root)?;
53    let state = crate::setup_actions::validate_oauth_state(
54        &input.state,
55        &key,
56        None,
57        None,
58        None,
59        crate::setup_actions::current_epoch_secs(),
60    )?;
61    let machine_result =
62        crate::setup_machine::complete_setup_machine_oauth_authorization_code_with_token_response(
63            bundle_root,
64            env,
65            &state,
66            token_response,
67        )
68        .await;
69    match machine_result {
70        Ok(report) => Ok(report),
71        Err(machine_err) => match complete_setup_action_oauth_callback_with_token_response(
72            bundle_root,
73            env,
74            &state,
75            token_response,
76            extension_key,
77        )
78        .await?
79        {
80            Some(report) => Ok(report),
81            None => Err(machine_err),
82        },
83    }
84}
85
86pub async fn complete_setup_action_oauth_callback_with_token_response(
87    bundle_root: &Path,
88    env: &str,
89    state: &OAuthStatePayload,
90    token_response: &Value,
91    extension_key: &str,
92) -> Result<Option<OAuthCallbackReport>> {
93    let Some(action) = crate::setup_actions::load_setup_action(
94        bundle_root,
95        &state.tenant,
96        &state.team,
97        &state.provider_id,
98        &state.action_id,
99    )?
100    else {
101        return Ok(None);
102    };
103    if action.kind != SetupActionKind::OauthInstallButton {
104        bail!("setup action is not an oauth_install_button: {}", action.id);
105    }
106
107    let metadata = load_provider_oauth_metadata(bundle_root, &state.provider_id, extension_key)?;
108    persist_setup_action_oauth_token_response(
109        bundle_root,
110        env,
111        state,
112        &action,
113        &metadata,
114        token_response,
115    )
116    .await
117    .map(Some)
118}
119
120pub async fn complete_oauth_callback(
121    bundle_root: &Path,
122    env: &str,
123    input: &OAuthCallbackInput,
124    extension_key: &str,
125) -> Result<OAuthCallbackReport> {
126    if input.code.trim().is_empty() {
127        bail!("OAuth callback missing code");
128    }
129    let key = crate::setup_actions::load_or_create_signing_key(bundle_root)?;
130    let state = crate::setup_actions::validate_oauth_state(
131        &input.state,
132        &key,
133        None,
134        None,
135        None,
136        crate::setup_actions::current_epoch_secs(),
137    )?;
138    let machine_result = crate::setup_machine::complete_setup_machine_oauth_authorization_code(
139        bundle_root,
140        env,
141        &state,
142        input.code.trim(),
143    )
144    .await;
145    match machine_result {
146        Ok(report) => Ok(report),
147        Err(machine_err) => match complete_setup_action_oauth_callback(
148            bundle_root,
149            env,
150            &state,
151            input.code.trim(),
152            extension_key,
153        )
154        .await?
155        {
156            Some(report) => Ok(report),
157            None => Err(machine_err),
158        },
159    }
160}
161
162pub async fn complete_setup_action_oauth_callback(
163    bundle_root: &Path,
164    env: &str,
165    state: &OAuthStatePayload,
166    code: &str,
167    extension_key: &str,
168) -> Result<Option<OAuthCallbackReport>> {
169    let Some(action) = crate::setup_actions::load_setup_action(
170        bundle_root,
171        &state.tenant,
172        &state.team,
173        &state.provider_id,
174        &state.action_id,
175    )?
176    else {
177        return Ok(None);
178    };
179    if action.kind != SetupActionKind::OauthInstallButton {
180        bail!("setup action is not an oauth_install_button: {}", action.id);
181    }
182
183    let metadata = load_provider_oauth_metadata(bundle_root, &state.provider_id, extension_key)?;
184    let client_id = load_setup_action_secret(
185        bundle_root,
186        env,
187        state,
188        &setup_action_client_id_keys(&action),
189    )
190    .await?
191    .ok_or_else(|| anyhow::anyhow!("setup action OAuth callback missing client id"))?;
192    let client_secret = load_setup_action_secret(
193        bundle_root,
194        env,
195        state,
196        &setup_action_client_secret_keys(&action),
197    )
198    .await?
199    .ok_or_else(|| anyhow::anyhow!("setup action OAuth callback missing client secret"))?;
200    let public_base_url =
201        match resolve_public_base_url(bundle_root, &state.tenant, Some(&state.team), &state.provider_id)
202        {
203            Ok(value) => value,
204            Err(_) => load_setup_action_secret(
205                bundle_root,
206                env,
207                state,
208                &["public_base_url".to_string()],
209            )
210            .await?
211            .ok_or_else(|| {
212                anyhow::anyhow!(
213                    "This provider requires a public_base_url to generate OAuth callback and webhook URLs."
214                )
215            })?,
216        };
217    let redirect_path = setup_action_string(&action, "redirect_path")
218        .or_else(|| action.callback_path.clone())
219        .or_else(|| metadata.redirect_path.clone())
220        .ok_or_else(|| anyhow::anyhow!("setup action OAuth callback missing redirect path"))?;
221    let redirect_uri = format!(
222        "{}{}",
223        public_base_url.trim().trim_end_matches('/'),
224        ensure_leading_slash(&redirect_path)
225    );
226    let token_response = exchange_oauth_code(
227        &metadata,
228        code.trim(),
229        &redirect_uri,
230        client_id.trim(),
231        client_secret.trim(),
232    )?;
233    persist_setup_action_oauth_token_response(
234        bundle_root,
235        env,
236        state,
237        &action,
238        &metadata,
239        &token_response,
240    )
241    .await
242    .map(Some)
243}
244
245pub fn exchange_oauth_code(
246    metadata: &OAuthMetadata,
247    code: &str,
248    redirect_uri: &str,
249    client_id: &str,
250    client_secret: &str,
251) -> Result<Value> {
252    let mut response = ureq::post(&metadata.token_url)
253        .send_form([
254            ("grant_type", "authorization_code"),
255            ("code", code),
256            ("redirect_uri", redirect_uri),
257            ("client_id", client_id),
258            ("client_secret", client_secret),
259        ])
260        .context("OAuth token exchange failed")?;
261    response
262        .body_mut()
263        .read_json::<Value>()
264        .context("failed to parse OAuth token response")
265}
266
267pub fn resolve_public_base_url(
268    bundle_root: &Path,
269    tenant: &str,
270    team: Option<&str>,
271    provider_id: &str,
272) -> Result<String> {
273    if let Some(value) = load_provider_setup_answers(bundle_root, provider_id)?
274        .get("public_base_url")
275        .and_then(Value::as_str)
276        .map(str::trim)
277        .filter(|value| !value.is_empty())
278    {
279        return Ok(value.to_string());
280    }
281
282    if let Some(policy) =
283        crate::platform_setup::load_effective_static_routes_defaults(bundle_root, tenant, team)?
284        && let Some(value) = policy.public_base_url
285    {
286        return Ok(value);
287    }
288
289    bail!("This provider requires a public_base_url to generate OAuth callback and webhook URLs.")
290}
291
292fn load_provider_setup_answers(bundle_root: &Path, provider_id: &str) -> Result<Value> {
293    let path = bundle_root
294        .join("state")
295        .join("config")
296        .join(provider_id)
297        .join("setup-answers.json");
298    if !path.exists() {
299        return Ok(Value::Object(JsonMap::new()));
300    }
301    let raw = std::fs::read_to_string(&path)
302        .with_context(|| format!("failed to read {}", path.display()))?;
303    serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
304}
305
306async fn persist_setup_action_oauth_token_response(
307    bundle_root: &Path,
308    env: &str,
309    state: &OAuthStatePayload,
310    action: &SetupAction,
311    metadata: &OAuthMetadata,
312    token_response: &Value,
313) -> Result<OAuthCallbackReport> {
314    let mapped = crate::setup_actions::map_oauth_token_response(metadata, token_response)?;
315    let config = mapped
316        .into_iter()
317        .map(|(key, value)| (key, Value::String(value)))
318        .collect::<JsonMap<String, Value>>();
319    let persisted_keys = crate::qa::persist::persist_all_config_as_secrets(
320        bundle_root,
321        env,
322        &state.tenant,
323        Some(&state.team),
324        &state.provider_id,
325        &Value::Object(config),
326        None,
327    )
328    .await?;
329    crate::setup_actions::mark_setup_action_complete(
330        bundle_root,
331        &state.tenant,
332        &state.team,
333        &state.provider_id,
334        &action.id,
335    )?;
336    Ok(OAuthCallbackReport {
337        provider_id: state.provider_id.clone(),
338        tenant: state.tenant.clone(),
339        team: state.team.clone(),
340        action_id: action.id.clone(),
341        persisted_secret_keys: persisted_keys,
342    })
343}
344
345async fn load_setup_action_secret(
346    bundle_root: &Path,
347    env: &str,
348    state: &OAuthStatePayload,
349    keys: &[String],
350) -> Result<Option<String>> {
351    use greentic_secrets_lib::SecretsStore;
352
353    let store = crate::secrets::open_dev_store(bundle_root)?;
354    for key in keys {
355        let uri = crate::canonical_secret_uri(
356            env,
357            &state.tenant,
358            Some(&state.team),
359            &state.provider_id,
360            key,
361        );
362        if let Ok(bytes) = store.get(&uri).await {
363            let value = String::from_utf8(bytes).context("setup action secret is not utf-8")?;
364            if !value.trim().is_empty() {
365                return Ok(Some(value));
366            }
367        }
368    }
369    Ok(None)
370}
371
372fn setup_action_client_id_keys(action: &SetupAction) -> Vec<String> {
373    setup_action_key_candidates(
374        action,
375        &["client_id_field", "client_id_output"],
376        &["client_id", "oauth_client_id"],
377    )
378}
379
380fn setup_action_client_secret_keys(action: &SetupAction) -> Vec<String> {
381    setup_action_key_candidates(
382        action,
383        &["client_secret_field", "client_secret_output"],
384        &["client_secret", "oauth_client_secret"],
385    )
386}
387
388fn setup_action_key_candidates(
389    action: &SetupAction,
390    direct_keys: &[&str],
391    defaults: &[&str],
392) -> Vec<String> {
393    let mut keys = Vec::new();
394    for key in direct_keys {
395        if let Some(value) = setup_action_string(action, key) {
396            push_unique(&mut keys, value);
397        }
398        if let Some(value) = action
399            .extra
400            .get("registration")
401            .and_then(|registration| registration.get(*key))
402            .and_then(Value::as_str)
403            .map(str::trim)
404            .filter(|value| !value.is_empty())
405            .map(ToString::to_string)
406        {
407            push_unique(&mut keys, value);
408        }
409    }
410    for key in defaults {
411        push_unique(&mut keys, (*key).to_string());
412    }
413    keys
414}
415
416fn setup_action_string(action: &SetupAction, key: &str) -> Option<String> {
417    action
418        .extra
419        .get(key)
420        .and_then(Value::as_str)
421        .map(str::trim)
422        .filter(|value| !value.is_empty())
423        .map(ToString::to_string)
424}
425
426fn push_unique(values: &mut Vec<String>, value: String) {
427    if !values.iter().any(|existing| existing == &value) {
428        values.push(value);
429    }
430}
431
432#[cfg(test)]
433fn first_nonempty(value: &Value, keys: &[&str]) -> Option<String> {
434    let obj = value.as_object()?;
435    keys.iter().find_map(|key| {
436        obj.get(*key)
437            .and_then(Value::as_str)
438            .map(str::trim)
439            .filter(|value| !value.is_empty())
440            .map(ToString::to_string)
441    })
442}
443
444fn ensure_leading_slash(value: &str) -> String {
445    if value.starts_with('/') {
446        value.to_string()
447    } else {
448        format!("/{value}")
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use greentic_secrets_lib::SecretsStore;
456    use serde_json::json;
457    use std::io::{Read, Write};
458    use std::net::TcpListener;
459    use std::thread;
460    use std::time::Duration;
461    use zip::write::{FileOptions, ZipWriter};
462
463    fn write_provider_pack_with_manifest(
464        path: &Path,
465        manifest: serde_json::Value,
466    ) -> anyhow::Result<()> {
467        let file = std::fs::File::create(path)?;
468        let mut writer = ZipWriter::new(file);
469        let options: FileOptions<'_, ()> =
470            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
471        writer.start_file("pack.manifest.json", options)?;
472        writer.write_all(manifest.to_string().as_bytes())?;
473        writer.finish()?;
474        Ok(())
475    }
476
477    fn persist_provider_answers(
478        bundle: &Path,
479        provider_id: &str,
480        answers: serde_json::Value,
481    ) -> anyhow::Result<()> {
482        let dir = bundle.join("state/config").join(provider_id);
483        std::fs::create_dir_all(&dir)?;
484        std::fs::write(dir.join("setup-answers.json"), answers.to_string())?;
485        Ok(())
486    }
487
488    fn signed_state(bundle: &Path, action_id: &str) -> anyhow::Result<String> {
489        let key = crate::setup_actions::load_or_create_signing_key(bundle)?;
490        let state_payload = crate::setup_actions::OAuthStatePayload {
491            provider_id: "messaging-example".into(),
492            tenant: "demo".into(),
493            team: "default".into(),
494            action_id: action_id.into(),
495            nonce: "nonce".into(),
496            expires_at: crate::setup_actions::current_epoch_secs() + 60,
497        };
498        crate::setup_actions::sign_oauth_state(&state_payload, &key)
499    }
500
501    #[test]
502    fn load_provider_oauth_metadata_reports_missing_provider_and_extension() -> anyhow::Result<()> {
503        let temp = tempfile::tempdir()?;
504        let bundle = temp.path();
505        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
506        write_provider_pack_with_manifest(
507            &bundle.join("providers/messaging/messaging-example.gtpack"),
508            json!({"pack_id": "messaging-example"}),
509        )?;
510
511        let missing_provider =
512            load_provider_oauth_metadata(bundle, "messaging-missing", "messaging.oauth.v1")
513                .unwrap_err()
514                .to_string();
515        assert!(missing_provider.contains("provider not found"));
516
517        let missing_extension =
518            load_provider_oauth_metadata(bundle, "messaging-example", "messaging.oauth.v1")
519                .unwrap_err()
520                .to_string();
521        assert!(missing_extension.contains("missing OAuth metadata"));
522        Ok(())
523    }
524
525    #[test]
526    fn load_provider_oauth_metadata_accepts_inline_extension_wrapper() -> anyhow::Result<()> {
527        let temp = tempfile::tempdir()?;
528        let bundle = temp.path();
529        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
530        write_provider_pack_with_manifest(
531            &bundle.join("providers/messaging/messaging-example.gtpack"),
532            json!({
533                "pack_id": "messaging-example",
534                "extensions": {
535                    "messaging.oauth.v1": {
536                        "kind": "messaging.oauth.v1",
537                        "inline": {
538                            "token_url": "https://example.com/token",
539                            "secret_keys": ["EXAMPLE_TOKEN"]
540                        }
541                    }
542                }
543            }),
544        )?;
545
546        let metadata =
547            load_provider_oauth_metadata(bundle, "messaging-example", "messaging.oauth.v1")?;
548
549        assert_eq!(metadata.token_url, "https://example.com/token");
550        assert_eq!(metadata.secret_keys, vec!["EXAMPLE_TOKEN"]);
551        Ok(())
552    }
553
554    #[test]
555    fn resolve_public_base_url_prefers_provider_answer() -> anyhow::Result<()> {
556        let temp = tempfile::tempdir()?;
557        let bundle = temp.path();
558        persist_provider_answers(
559            bundle,
560            "messaging-example",
561            json!({"public_base_url": "https://provider.example.com"}),
562        )?;
563
564        let resolved =
565            resolve_public_base_url(bundle, "demo", Some("default"), "messaging-example")?;
566        assert_eq!(resolved, "https://provider.example.com");
567        Ok(())
568    }
569
570    #[test]
571    fn resolve_public_base_url_uses_static_routes_and_runtime_fallback() -> anyhow::Result<()> {
572        let temp = tempfile::tempdir()?;
573        let bundle = temp.path();
574        crate::platform_setup::persist_static_routes_artifact(
575            bundle,
576            &crate::platform_setup::StaticRoutesPolicy {
577                public_base_url: Some("https://static.example.com".into()),
578                ..crate::platform_setup::StaticRoutesPolicy::default()
579            },
580        )?;
581        let resolved =
582            resolve_public_base_url(bundle, "demo", Some("default"), "messaging-example")?;
583        assert_eq!(resolved, "https://static.example.com");
584
585        let temp = tempfile::tempdir()?;
586        let bundle = temp.path();
587        let runtime_dir = bundle.join("state/runtime/demo.default");
588        std::fs::create_dir_all(&runtime_dir)?;
589        std::fs::write(
590            runtime_dir.join("endpoints.json"),
591            json!({"public_base_url": "https://runtime.example.com"}).to_string(),
592        )?;
593        let resolved =
594            resolve_public_base_url(bundle, "demo", Some("default"), "messaging-example")?;
595        assert_eq!(resolved, "https://runtime.example.com");
596        Ok(())
597    }
598
599    #[test]
600    fn resolve_public_base_url_errors_when_missing() {
601        let temp = tempfile::tempdir().unwrap();
602        let err =
603            resolve_public_base_url(temp.path(), "demo", Some("default"), "messaging-example")
604                .unwrap_err()
605                .to_string();
606        assert!(err.contains("requires a public_base_url"));
607    }
608
609    #[test]
610    fn setup_answer_helpers_handle_missing_file_and_nonempty_aliases() -> anyhow::Result<()> {
611        let temp = tempfile::tempdir()?;
612        let empty = load_provider_setup_answers(temp.path(), "messaging-example")?;
613        assert_eq!(empty, Value::Object(JsonMap::new()));
614
615        let answers = json!({"client_id": "  ", "oauth_client_id": "client"});
616        assert_eq!(
617            first_nonempty(&answers, &["client_id", "oauth_client_id"]).as_deref(),
618            Some("client")
619        );
620        assert_eq!(ensure_leading_slash("oauth/callback"), "/oauth/callback");
621        assert_eq!(ensure_leading_slash("/oauth/callback"), "/oauth/callback");
622        Ok(())
623    }
624
625    #[tokio::test]
626    async fn callback_rejects_empty_code_and_missing_setup_machine() -> anyhow::Result<()> {
627        let temp = tempfile::tempdir()?;
628        let bundle = temp.path();
629        let state = signed_state(bundle, "missing")?;
630        let err = complete_oauth_callback_with_token_response(
631            bundle,
632            "dev",
633            &OAuthCallbackInput {
634                code: " ".into(),
635                state: state.clone(),
636            },
637            &json!({"access_token": "token"}),
638            "messaging.oauth.v1",
639        )
640        .await
641        .unwrap_err()
642        .to_string();
643        assert!(err.contains("missing code"));
644
645        let err = complete_oauth_callback_with_token_response(
646            bundle,
647            "dev",
648            &OAuthCallbackInput {
649                code: "code".into(),
650                state,
651            },
652            &json!({"access_token": "token"}),
653            "messaging.oauth.v1",
654        )
655        .await
656        .unwrap_err()
657        .to_string();
658        assert!(err.contains("provider not found for setup-machine OAuth callback"));
659        Ok(())
660    }
661
662    #[tokio::test]
663    async fn callback_without_legacy_action_completes_setup_machine_oauth_step()
664    -> anyhow::Result<()> {
665        let temp = tempfile::tempdir()?;
666        let bundle = temp.path();
667        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
668        write_provider_pack_with_manifest(
669            &bundle.join("providers/messaging/messaging-example.gtpack"),
670            json!({
671                "pack_id": "messaging-example",
672                "extensions": {
673                    "greentic.setup.machine.v1": {
674                        "inline": {
675                            "version": 1,
676                            "id": "example-machine",
677                            "entry_step": "oauth",
678                            "steps": [
679                                {
680                                    "id": "oauth",
681                                    "kind": "oauth_authorization_code",
682                                    "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
683                                    "token_url": "https://login.example.com/oauth2/v2.0/token",
684                                    "token_store_key": "EXAMPLE_TOKEN",
685                                    "output_key": "oauth_result",
686                                    "on_success": "complete"
687                                }
688                            ]
689                        }
690                    }
691                }
692            }),
693        )?;
694        let machine = crate::setup_machine::load_setup_machine_from_pack(
695            &bundle.join("providers/messaging/messaging-example.gtpack"),
696        )?
697        .expect("setup machine");
698        crate::setup_machine::write_setup_machine_state(
699            bundle,
700            &crate::setup_machine::SetupMachineState {
701                schema_version: 1,
702                provider_id: "messaging-example".to_string(),
703                tenant: "demo".to_string(),
704                team: "default".to_string(),
705                machine_id: machine.id.clone(),
706                machine_version: machine.version,
707                status: crate::setup_machine::SetupMachineStatus::Paused,
708                current_step: Some("oauth".to_string()),
709                completed_steps: Vec::new(),
710                failed_step: None,
711                answers_hash: None,
712                pack_fingerprint: None,
713                outputs: json!({}),
714                created_resources: Vec::new(),
715                last_error: None,
716                updated_at: None,
717            },
718        )?;
719        let state = signed_state(bundle, "oauth")?;
720
721        let report = complete_oauth_callback_with_token_response(
722            bundle,
723            "dev",
724            &OAuthCallbackInput {
725                code: "code".into(),
726                state,
727            },
728            &json!({"access_token": "token-value"}),
729            "messaging.oauth.v1",
730        )
731        .await?;
732
733        assert_eq!(report.persisted_secret_keys, vec!["EXAMPLE_TOKEN"]);
734        let state = crate::setup_machine::load_setup_machine_state(
735            &crate::setup_machine::setup_machine_state_path(
736                bundle,
737                "demo",
738                "default",
739                "messaging-example",
740            ),
741        )?;
742        assert_eq!(
743            state.status,
744            crate::setup_machine::SetupMachineStatus::Complete
745        );
746        let store = crate::secrets::open_dev_store(bundle)?;
747        let uri = crate::canonical_secret_uri(
748            "dev",
749            "demo",
750            Some("default"),
751            "messaging-example",
752            "EXAMPLE_TOKEN",
753        );
754        let bytes = store.get(&uri).await?;
755        assert_eq!(String::from_utf8(bytes)?, "token-value");
756        Ok(())
757    }
758
759    #[tokio::test]
760    async fn callback_completes_setup_action_oauth_install_without_setup_machine()
761    -> anyhow::Result<()> {
762        let temp = tempfile::tempdir()?;
763        let bundle = temp.path();
764        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
765        write_provider_pack_with_manifest(
766            &bundle.join("providers/messaging/messaging-example.gtpack"),
767            json!({
768                "pack_id": "messaging-example",
769                "extensions": {
770                    "messaging.oauth.v1": {
771                        "inline": {
772                            "token_url": "https://example.com/token",
773                            "secret_keys": ["EXAMPLE_TOKEN"]
774                        }
775                    }
776                }
777            }),
778        )?;
779        crate::setup_actions::persist_setup_actions(
780            bundle,
781            &[crate::setup_actions::SetupAction {
782                id: "install".to_string(),
783                kind: crate::setup_actions::SetupActionKind::OauthInstallButton,
784                label: "Install app".to_string(),
785                provider_id: "messaging-example".to_string(),
786                tenant: "demo".to_string(),
787                team: Some("default".to_string()),
788                authorize_url: None,
789                callback_path: None,
790                state: None,
791                status: crate::setup_actions::SetupActionStatus::Pending,
792                created_at: None,
793                completed_at: None,
794                extra: JsonMap::new(),
795            }],
796        )?;
797        let state = signed_state(bundle, "install")?;
798
799        let report = complete_oauth_callback_with_token_response(
800            bundle,
801            "dev",
802            &OAuthCallbackInput {
803                code: "code".into(),
804                state,
805            },
806            &json!({"access_token": "install-token"}),
807            "messaging.oauth.v1",
808        )
809        .await?;
810
811        assert_eq!(report.provider_id, "messaging-example");
812        assert_eq!(report.action_id, "install");
813        assert_eq!(report.persisted_secret_keys, vec!["EXAMPLE_TOKEN"]);
814        let action = crate::setup_actions::load_setup_action(
815            bundle,
816            "demo",
817            "default",
818            "messaging-example",
819            "install",
820        )?
821        .expect("setup action");
822        assert_eq!(
823            action.status,
824            crate::setup_actions::SetupActionStatus::Complete
825        );
826        let store = crate::secrets::open_dev_store(bundle)?;
827        let uri = crate::canonical_secret_uri(
828            "dev",
829            "demo",
830            Some("default"),
831            "messaging-example",
832            "EXAMPLE_TOKEN",
833        );
834        let bytes = store.get(&uri).await?;
835        assert_eq!(String::from_utf8(bytes)?, "install-token");
836        Ok(())
837    }
838
839    #[tokio::test]
840    async fn live_callback_without_legacy_action_exchanges_setup_machine_oauth_code()
841    -> anyhow::Result<()> {
842        let temp = tempfile::tempdir()?;
843        let bundle = temp.path();
844        let listener = TcpListener::bind("127.0.0.1:0")?;
845        let token_url = format!("http://{}/token", listener.local_addr()?);
846        let server_handle = thread::spawn(move || -> anyhow::Result<()> {
847            let (mut stream, _) = listener.accept()?;
848            stream.set_read_timeout(Some(Duration::from_secs(2)))?;
849            let mut buffer = [0_u8; 8192];
850            let mut bytes = Vec::new();
851            loop {
852                let read = match stream.read(&mut buffer) {
853                    Ok(read) => read,
854                    Err(err)
855                        if matches!(
856                            err.kind(),
857                            std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
858                        ) =>
859                    {
860                        break;
861                    }
862                    Err(err) => return Err(err.into()),
863                };
864                if read == 0 {
865                    break;
866                }
867                bytes.extend_from_slice(&buffer[..read]);
868                let request = String::from_utf8_lossy(&bytes);
869                let Some((headers, body)) = request.split_once("\r\n\r\n") else {
870                    continue;
871                };
872                let content_length = headers
873                    .lines()
874                    .find_map(|line| line.split_once(':'))
875                    .filter(|(name, _)| name.eq_ignore_ascii_case("content-length"))
876                    .and_then(|(_, value)| value.trim().parse::<usize>().ok())
877                    .unwrap_or(usize::MAX);
878                if body.len() >= content_length
879                    || (body.contains("grant_type=")
880                        && body.contains("code=callback-code")
881                        && body.contains("client_id=client-123"))
882                {
883                    break;
884                }
885            }
886            let request = String::from_utf8_lossy(&bytes);
887            assert!(request.starts_with("POST /token "));
888            assert!(request.contains("grant_type=authorization_code"));
889            assert!(request.contains("code=callback-code"));
890            assert!(request.contains("client_id=client-123"));
891            let body = r#"{"access_token":"live-token"}"#;
892            let response = format!(
893                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
894                body.len()
895            );
896            stream.write_all(response.as_bytes())?;
897            Ok(())
898        });
899
900        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
901        write_provider_pack_with_manifest(
902            &bundle.join("providers/messaging/messaging-example.gtpack"),
903            json!({
904                "pack_id": "messaging-example",
905                "extensions": {
906                    "greentic.setup.machine.v1": {
907                        "inline": {
908                            "version": 1,
909                            "id": "example-machine",
910                            "entry_step": "oauth",
911                            "steps": [
912                                {
913                                    "id": "oauth",
914                                    "kind": "oauth_authorization_code",
915                                    "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
916                                    "token_url": token_url,
917                                    "client_id": "client-123",
918                                    "redirect_uri": "https://runtime.example.com/oauth/callback",
919                                    "token_store_key": "EXAMPLE_TOKEN",
920                                    "on_success": "complete"
921                                }
922                            ]
923                        }
924                    }
925                }
926            }),
927        )?;
928        let machine = crate::setup_machine::load_setup_machine_from_pack(
929            &bundle.join("providers/messaging/messaging-example.gtpack"),
930        )?
931        .expect("setup machine");
932        crate::setup_machine::write_setup_machine_state(
933            bundle,
934            &crate::setup_machine::SetupMachineState {
935                schema_version: 1,
936                provider_id: "messaging-example".to_string(),
937                tenant: "demo".to_string(),
938                team: "default".to_string(),
939                machine_id: machine.id.clone(),
940                machine_version: machine.version,
941                status: crate::setup_machine::SetupMachineStatus::Paused,
942                current_step: Some("oauth".to_string()),
943                completed_steps: Vec::new(),
944                failed_step: None,
945                answers_hash: None,
946                pack_fingerprint: None,
947                outputs: json!({}),
948                created_resources: Vec::new(),
949                last_error: None,
950                updated_at: None,
951            },
952        )?;
953        let state = signed_state(bundle, "oauth")?;
954
955        let report = complete_oauth_callback(
956            bundle,
957            "dev",
958            &OAuthCallbackInput {
959                code: "callback-code".into(),
960                state,
961            },
962            "messaging.oauth.v1",
963        )
964        .await?;
965        server_handle.join().unwrap()?;
966
967        assert_eq!(report.persisted_secret_keys, vec!["EXAMPLE_TOKEN"]);
968        let store = crate::secrets::open_dev_store(bundle)?;
969        let uri = crate::canonical_secret_uri(
970            "dev",
971            "demo",
972            Some("default"),
973            "messaging-example",
974            "EXAMPLE_TOKEN",
975        );
976        let bytes = store.get(&uri).await?;
977        assert_eq!(String::from_utf8(bytes)?, "live-token");
978        Ok(())
979    }
980}