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    // Seam-1: env store (the file the runtime reads), not bundle-local.
425    let store = crate::secrets::open_dev_store_for_env(bundle_root, env)?;
426    for key in keys {
427        let uri = crate::canonical_secret_uri(
428            env,
429            &state.tenant,
430            Some(&state.team),
431            &state.provider_id,
432            key,
433        );
434        if let Ok(bytes) = store.get(&uri).await {
435            let value = String::from_utf8(bytes).context("setup action secret is not utf-8")?;
436            if !value.trim().is_empty() {
437                return Ok(Some(value));
438            }
439        }
440    }
441    Ok(None)
442}
443
444fn setup_action_client_id_keys(action: &SetupAction) -> Vec<String> {
445    setup_action_key_candidates(
446        action,
447        &["client_id_field", "client_id_output"],
448        &["client_id", "oauth_client_id"],
449    )
450}
451
452fn setup_action_client_secret_keys(action: &SetupAction) -> Vec<String> {
453    setup_action_key_candidates(
454        action,
455        &["client_secret_field", "client_secret_output"],
456        &["client_secret", "oauth_client_secret"],
457    )
458}
459
460fn setup_action_key_candidates(
461    action: &SetupAction,
462    direct_keys: &[&str],
463    defaults: &[&str],
464) -> Vec<String> {
465    let mut keys = Vec::new();
466    for key in direct_keys {
467        if let Some(value) = setup_action_string(action, key) {
468            push_unique(&mut keys, value);
469        }
470        if let Some(value) = action
471            .extra
472            .get("registration")
473            .and_then(|registration| registration.get(*key))
474            .and_then(Value::as_str)
475            .map(str::trim)
476            .filter(|value| !value.is_empty())
477            .map(ToString::to_string)
478        {
479            push_unique(&mut keys, value);
480        }
481    }
482    for key in defaults {
483        push_unique(&mut keys, (*key).to_string());
484    }
485    keys
486}
487
488fn setup_action_string(action: &SetupAction, key: &str) -> Option<String> {
489    action
490        .extra
491        .get(key)
492        .and_then(Value::as_str)
493        .map(str::trim)
494        .filter(|value| !value.is_empty())
495        .map(ToString::to_string)
496}
497
498fn push_unique(values: &mut Vec<String>, value: String) {
499    if !values.iter().any(|existing| existing == &value) {
500        values.push(value);
501    }
502}
503
504#[cfg(test)]
505fn first_nonempty(value: &Value, keys: &[&str]) -> Option<String> {
506    let obj = value.as_object()?;
507    keys.iter().find_map(|key| {
508        obj.get(*key)
509            .and_then(Value::as_str)
510            .map(str::trim)
511            .filter(|value| !value.is_empty())
512            .map(ToString::to_string)
513    })
514}
515
516fn ensure_leading_slash(value: &str) -> String {
517    if value.starts_with('/') {
518        value.to_string()
519    } else {
520        format!("/{value}")
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use greentic_secrets_lib::SecretsStore;
528    use serde_json::json;
529    use std::io::{Read, Write};
530    use std::net::TcpListener;
531    use std::thread;
532    use std::time::Duration;
533    use zip::write::{FileOptions, ZipWriter};
534
535    fn write_provider_pack_with_manifest(
536        path: &Path,
537        manifest: serde_json::Value,
538    ) -> anyhow::Result<()> {
539        let file = std::fs::File::create(path)?;
540        let mut writer = ZipWriter::new(file);
541        let options: FileOptions<'_, ()> =
542            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
543        writer.start_file("pack.manifest.json", options)?;
544        writer.write_all(manifest.to_string().as_bytes())?;
545        writer.finish()?;
546        Ok(())
547    }
548
549    fn persist_provider_answers(
550        bundle: &Path,
551        provider_id: &str,
552        answers: serde_json::Value,
553    ) -> anyhow::Result<()> {
554        let dir = bundle.join("state/config").join(provider_id);
555        std::fs::create_dir_all(&dir)?;
556        std::fs::write(dir.join("setup-answers.json"), answers.to_string())?;
557        Ok(())
558    }
559
560    fn signed_state(bundle: &Path, action_id: &str) -> anyhow::Result<String> {
561        let key = crate::setup_actions::load_or_create_signing_key(bundle)?;
562        let state_payload = crate::setup_actions::OAuthStatePayload {
563            provider_id: "messaging-example".into(),
564            tenant: "demo".into(),
565            team: "default".into(),
566            action_id: action_id.into(),
567            nonce: "nonce".into(),
568            expires_at: crate::setup_actions::current_epoch_secs() + 60,
569        };
570        crate::setup_actions::sign_oauth_state(&state_payload, &key)
571    }
572
573    #[test]
574    fn load_provider_oauth_metadata_reports_missing_provider_and_extension() -> anyhow::Result<()> {
575        let temp = tempfile::tempdir()?;
576        let bundle = temp.path();
577        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
578        write_provider_pack_with_manifest(
579            &bundle.join("providers/messaging/messaging-example.gtpack"),
580            json!({"pack_id": "messaging-example"}),
581        )?;
582
583        let missing_provider =
584            load_provider_oauth_metadata(bundle, "messaging-missing", "messaging.oauth.v1")
585                .unwrap_err()
586                .to_string();
587        assert!(missing_provider.contains("provider not found"));
588
589        let missing_extension =
590            load_provider_oauth_metadata(bundle, "messaging-example", "messaging.oauth.v1")
591                .unwrap_err()
592                .to_string();
593        assert!(missing_extension.contains("missing OAuth metadata"));
594        Ok(())
595    }
596
597    #[test]
598    fn load_provider_oauth_metadata_accepts_inline_extension_wrapper() -> anyhow::Result<()> {
599        let temp = tempfile::tempdir()?;
600        let bundle = temp.path();
601        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
602        write_provider_pack_with_manifest(
603            &bundle.join("providers/messaging/messaging-example.gtpack"),
604            json!({
605                "pack_id": "messaging-example",
606                "extensions": {
607                    "messaging.oauth.v1": {
608                        "kind": "messaging.oauth.v1",
609                        "inline": {
610                            "token_url": "https://example.com/token",
611                            "secret_keys": ["EXAMPLE_TOKEN"]
612                        }
613                    }
614                }
615            }),
616        )?;
617
618        let metadata =
619            load_provider_oauth_metadata(bundle, "messaging-example", "messaging.oauth.v1")?;
620
621        assert_eq!(metadata.token_url, "https://example.com/token");
622        assert_eq!(metadata.secret_keys, vec!["EXAMPLE_TOKEN"]);
623        Ok(())
624    }
625
626    #[test]
627    fn resolve_public_base_url_prefers_provider_answer() -> anyhow::Result<()> {
628        let temp = tempfile::tempdir()?;
629        let bundle = temp.path();
630        persist_provider_answers(
631            bundle,
632            "messaging-example",
633            json!({"public_base_url": "https://provider.example.com"}),
634        )?;
635
636        let resolved =
637            resolve_public_base_url(bundle, "demo", Some("default"), "messaging-example")?;
638        assert_eq!(resolved, "https://provider.example.com");
639        Ok(())
640    }
641
642    #[test]
643    fn resolve_public_base_url_uses_static_routes_and_runtime_fallback() -> anyhow::Result<()> {
644        let temp = tempfile::tempdir()?;
645        let bundle = temp.path();
646        crate::platform_setup::persist_static_routes_artifact(
647            bundle,
648            &crate::platform_setup::StaticRoutesPolicy {
649                public_base_url: Some("https://static.example.com".into()),
650                ..crate::platform_setup::StaticRoutesPolicy::default()
651            },
652        )?;
653        let resolved =
654            resolve_public_base_url(bundle, "demo", Some("default"), "messaging-example")?;
655        assert_eq!(resolved, "https://static.example.com");
656
657        let temp = tempfile::tempdir()?;
658        let bundle = temp.path();
659        let runtime_dir = bundle.join("state/runtime/demo.default");
660        std::fs::create_dir_all(&runtime_dir)?;
661        std::fs::write(
662            runtime_dir.join("endpoints.json"),
663            json!({"public_base_url": "https://runtime.example.com"}).to_string(),
664        )?;
665        let resolved =
666            resolve_public_base_url(bundle, "demo", Some("default"), "messaging-example")?;
667        assert_eq!(resolved, "https://runtime.example.com");
668        Ok(())
669    }
670
671    #[test]
672    fn resolve_public_base_url_errors_when_missing() {
673        let temp = tempfile::tempdir().unwrap();
674        let err =
675            resolve_public_base_url(temp.path(), "demo", Some("default"), "messaging-example")
676                .unwrap_err()
677                .to_string();
678        assert!(err.contains("requires a public_base_url"));
679    }
680
681    #[test]
682    fn setup_answer_helpers_handle_missing_file_and_nonempty_aliases() -> anyhow::Result<()> {
683        let temp = tempfile::tempdir()?;
684        let empty = load_provider_setup_answers(temp.path(), "messaging-example")?;
685        assert_eq!(empty, Value::Object(JsonMap::new()));
686
687        let answers = json!({"client_id": "  ", "oauth_client_id": "client"});
688        assert_eq!(
689            first_nonempty(&answers, &["client_id", "oauth_client_id"]).as_deref(),
690            Some("client")
691        );
692        assert_eq!(ensure_leading_slash("oauth/callback"), "/oauth/callback");
693        assert_eq!(ensure_leading_slash("/oauth/callback"), "/oauth/callback");
694        Ok(())
695    }
696
697    #[tokio::test]
698    async fn callback_rejects_empty_code_and_missing_setup_machine() -> anyhow::Result<()> {
699        let temp = tempfile::tempdir()?;
700        let bundle = temp.path();
701        let state = signed_state(bundle, "missing")?;
702        let err = complete_oauth_callback_with_token_response(
703            bundle,
704            "dev",
705            &OAuthCallbackInput {
706                code: " ".into(),
707                state: state.clone(),
708            },
709            &json!({"access_token": "token"}),
710            "messaging.oauth.v1",
711        )
712        .await
713        .unwrap_err()
714        .to_string();
715        assert!(err.contains("missing code"));
716
717        let err = complete_oauth_callback_with_token_response(
718            bundle,
719            "dev",
720            &OAuthCallbackInput {
721                code: "code".into(),
722                state,
723            },
724            &json!({"access_token": "token"}),
725            "messaging.oauth.v1",
726        )
727        .await
728        .unwrap_err()
729        .to_string();
730        assert!(err.contains("provider not found for setup-machine OAuth callback"));
731        Ok(())
732    }
733
734    #[tokio::test]
735    async fn callback_without_legacy_action_completes_setup_machine_oauth_step()
736    -> anyhow::Result<()> {
737        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
738        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
739        let temp = tempfile::tempdir()?;
740        let bundle = temp.path();
741        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
742        write_provider_pack_with_manifest(
743            &bundle.join("providers/messaging/messaging-example.gtpack"),
744            json!({
745                "pack_id": "messaging-example",
746                "extensions": {
747                    "greentic.setup.machine.v1": {
748                        "inline": {
749                            "version": 1,
750                            "id": "example-machine",
751                            "entry_step": "oauth",
752                            "steps": [
753                                {
754                                    "id": "oauth",
755                                    "kind": "oauth_authorization_code",
756                                    "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
757                                    "token_url": "https://login.example.com/oauth2/v2.0/token",
758                                    "token_store_key": "EXAMPLE_TOKEN",
759                                    "output_key": "oauth_result",
760                                    "on_success": "complete"
761                                }
762                            ]
763                        }
764                    }
765                }
766            }),
767        )?;
768        let machine = crate::setup_machine::load_setup_machine_from_pack(
769            &bundle.join("providers/messaging/messaging-example.gtpack"),
770        )?
771        .expect("setup machine");
772        crate::setup_machine::write_setup_machine_state(
773            bundle,
774            &crate::setup_machine::SetupMachineState {
775                schema_version: 1,
776                provider_id: "messaging-example".to_string(),
777                tenant: "demo".to_string(),
778                team: "default".to_string(),
779                machine_id: machine.id.clone(),
780                machine_version: machine.version,
781                status: crate::setup_machine::SetupMachineStatus::Paused,
782                current_step: Some("oauth".to_string()),
783                completed_steps: Vec::new(),
784                failed_step: None,
785                answers_hash: None,
786                pack_fingerprint: None,
787                outputs: json!({}),
788                created_resources: Vec::new(),
789                last_error: None,
790                updated_at: None,
791            },
792        )?;
793        let state = signed_state(bundle, "oauth")?;
794
795        let report = complete_oauth_callback_with_token_response(
796            bundle,
797            "dev",
798            &OAuthCallbackInput {
799                code: "code".into(),
800                state,
801            },
802            &json!({"access_token": "token-value"}),
803            "messaging.oauth.v1",
804        )
805        .await?;
806
807        assert_eq!(report.persisted_secret_keys, vec!["EXAMPLE_TOKEN"]);
808        let state = crate::setup_machine::load_setup_machine_state(
809            &crate::setup_machine::setup_machine_state_path(
810                bundle,
811                "demo",
812                "default",
813                "messaging-example",
814            ),
815        )?;
816        assert_eq!(
817            state.status,
818            crate::setup_machine::SetupMachineStatus::Complete
819        );
820        let store = crate::secrets::open_dev_store_for_env(bundle, "dev")?;
821        let uri = crate::canonical_secret_uri(
822            "dev",
823            "demo",
824            Some("default"),
825            "messaging-example",
826            "EXAMPLE_TOKEN",
827        );
828        let bytes = store.get(&uri).await?;
829        assert_eq!(String::from_utf8(bytes)?, "token-value");
830        Ok(())
831    }
832
833    #[tokio::test]
834    async fn callback_completes_setup_action_oauth_install_without_setup_machine()
835    -> anyhow::Result<()> {
836        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
837        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
838        let temp = tempfile::tempdir()?;
839        let bundle = temp.path();
840        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
841        write_provider_pack_with_manifest(
842            &bundle.join("providers/messaging/messaging-example.gtpack"),
843            json!({
844                "pack_id": "messaging-example",
845                "extensions": {
846                    "messaging.oauth.v1": {
847                        "inline": {
848                            "token_url": "https://example.com/token",
849                            "secret_keys": ["EXAMPLE_TOKEN"]
850                        }
851                    }
852                }
853            }),
854        )?;
855        crate::setup_actions::persist_setup_actions(
856            bundle,
857            &[crate::setup_actions::SetupAction {
858                id: "install".to_string(),
859                kind: crate::setup_actions::SetupActionKind::OauthInstallButton,
860                label: "Install app".to_string(),
861                provider_id: "messaging-example".to_string(),
862                tenant: "demo".to_string(),
863                team: Some("default".to_string()),
864                authorize_url: None,
865                callback_path: None,
866                state: None,
867                status: crate::setup_actions::SetupActionStatus::Pending,
868                created_at: None,
869                completed_at: None,
870                extra: JsonMap::new(),
871            }],
872        )?;
873        let state = signed_state(bundle, "install")?;
874
875        let report = complete_oauth_callback_with_token_response(
876            bundle,
877            "dev",
878            &OAuthCallbackInput {
879                code: "code".into(),
880                state,
881            },
882            &json!({"access_token": "install-token"}),
883            "messaging.oauth.v1",
884        )
885        .await?;
886
887        assert_eq!(report.provider_id, "messaging-example");
888        assert_eq!(report.action_id, "install");
889        assert_eq!(report.persisted_secret_keys, vec!["EXAMPLE_TOKEN"]);
890        let action = crate::setup_actions::load_setup_action(
891            bundle,
892            "demo",
893            "default",
894            "messaging-example",
895            "install",
896        )?
897        .expect("setup action");
898        assert_eq!(
899            action.status,
900            crate::setup_actions::SetupActionStatus::Complete
901        );
902        let store = crate::secrets::open_dev_store_for_env(bundle, "dev")?;
903        let uri = crate::canonical_secret_uri(
904            "dev",
905            "demo",
906            Some("default"),
907            "messaging-example",
908            "EXAMPLE_TOKEN",
909        );
910        let bytes = store.get(&uri).await?;
911        assert_eq!(String::from_utf8(bytes)?, "install-token");
912        Ok(())
913    }
914
915    #[tokio::test]
916    async fn live_callback_without_legacy_action_exchanges_setup_machine_oauth_code()
917    -> anyhow::Result<()> {
918        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
919        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
920        let temp = tempfile::tempdir()?;
921        let bundle = temp.path();
922        let listener = TcpListener::bind("127.0.0.1:0")?;
923        let token_url = format!("http://{}/token", listener.local_addr()?);
924        let server_handle = thread::spawn(move || -> anyhow::Result<()> {
925            let (mut stream, _) = listener.accept()?;
926            stream.set_read_timeout(Some(Duration::from_secs(2)))?;
927            let mut buffer = [0_u8; 8192];
928            let mut bytes = Vec::new();
929            loop {
930                let read = match stream.read(&mut buffer) {
931                    Ok(read) => read,
932                    Err(err)
933                        if matches!(
934                            err.kind(),
935                            std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
936                        ) =>
937                    {
938                        break;
939                    }
940                    Err(err) => return Err(err.into()),
941                };
942                if read == 0 {
943                    break;
944                }
945                bytes.extend_from_slice(&buffer[..read]);
946                let request = String::from_utf8_lossy(&bytes);
947                let Some((headers, body)) = request.split_once("\r\n\r\n") else {
948                    continue;
949                };
950                let content_length = headers
951                    .lines()
952                    .find_map(|line| line.split_once(':'))
953                    .filter(|(name, _)| name.eq_ignore_ascii_case("content-length"))
954                    .and_then(|(_, value)| value.trim().parse::<usize>().ok())
955                    .unwrap_or(usize::MAX);
956                if body.len() >= content_length
957                    || (body.contains("grant_type=")
958                        && body.contains("code=callback-code")
959                        && body.contains("client_id=client-123"))
960                {
961                    break;
962                }
963            }
964            let request = String::from_utf8_lossy(&bytes);
965            assert!(request.starts_with("POST /token "));
966            assert!(request.contains("grant_type=authorization_code"));
967            assert!(request.contains("code=callback-code"));
968            assert!(request.contains("client_id=client-123"));
969            let body = r#"{"access_token":"live-token"}"#;
970            let response = format!(
971                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
972                body.len()
973            );
974            stream.write_all(response.as_bytes())?;
975            Ok(())
976        });
977
978        std::fs::create_dir_all(bundle.join("providers/messaging"))?;
979        write_provider_pack_with_manifest(
980            &bundle.join("providers/messaging/messaging-example.gtpack"),
981            json!({
982                "pack_id": "messaging-example",
983                "extensions": {
984                    "greentic.setup.machine.v1": {
985                        "inline": {
986                            "version": 1,
987                            "id": "example-machine",
988                            "entry_step": "oauth",
989                            "steps": [
990                                {
991                                    "id": "oauth",
992                                    "kind": "oauth_authorization_code",
993                                    "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
994                                    "token_url": token_url,
995                                    "client_id": "client-123",
996                                    "redirect_uri": "https://runtime.example.com/oauth/callback",
997                                    "token_store_key": "EXAMPLE_TOKEN",
998                                    "on_success": "complete"
999                                }
1000                            ]
1001                        }
1002                    }
1003                }
1004            }),
1005        )?;
1006        let machine = crate::setup_machine::load_setup_machine_from_pack(
1007            &bundle.join("providers/messaging/messaging-example.gtpack"),
1008        )?
1009        .expect("setup machine");
1010        crate::setup_machine::write_setup_machine_state(
1011            bundle,
1012            &crate::setup_machine::SetupMachineState {
1013                schema_version: 1,
1014                provider_id: "messaging-example".to_string(),
1015                tenant: "demo".to_string(),
1016                team: "default".to_string(),
1017                machine_id: machine.id.clone(),
1018                machine_version: machine.version,
1019                status: crate::setup_machine::SetupMachineStatus::Paused,
1020                current_step: Some("oauth".to_string()),
1021                completed_steps: Vec::new(),
1022                failed_step: None,
1023                answers_hash: None,
1024                pack_fingerprint: None,
1025                outputs: json!({}),
1026                created_resources: Vec::new(),
1027                last_error: None,
1028                updated_at: None,
1029            },
1030        )?;
1031        let state = signed_state(bundle, "oauth")?;
1032
1033        let report = complete_oauth_callback(
1034            bundle,
1035            "dev",
1036            &OAuthCallbackInput {
1037                code: "callback-code".into(),
1038                state,
1039            },
1040            "messaging.oauth.v1",
1041            None,
1042        )
1043        .await?;
1044        server_handle.join().unwrap()?;
1045
1046        assert_eq!(report.persisted_secret_keys, vec!["EXAMPLE_TOKEN"]);
1047        let store = crate::secrets::open_dev_store_for_env(bundle, "dev")?;
1048        let uri = crate::canonical_secret_uri(
1049            "dev",
1050            "demo",
1051            Some("default"),
1052            "messaging-example",
1053            "EXAMPLE_TOKEN",
1054        );
1055        let bytes = store.get(&uri).await?;
1056        assert_eq!(String::from_utf8(bytes)?, "live-token");
1057        Ok(())
1058    }
1059}