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