1use 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 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
134pub 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 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}