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_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}