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