1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow, bail};
7use serde::{Deserialize, Serialize};
8use serde_json::{Map as JsonMap, Value, json};
9
10use crate::setup_actions::{SetupActionKind, SetupActionStatus};
11
12pub const DEFAULT_EXTENSION_KEY: &str = "messaging.oauth_device_code.v1";
13
14#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
15pub struct OAuthDeviceMetadata {
16 #[serde(default)]
17 pub provider: Option<String>,
18 #[serde(default)]
19 pub label: Option<String>,
20 #[serde(default)]
21 pub tenant_alias: Option<String>,
22 pub device_code_url: String,
23 pub token_url: String,
24 #[serde(default)]
25 pub verification_uri: Option<String>,
26 #[serde(default = "default_client_id_config_key")]
27 pub client_id_config_key: String,
28 #[serde(default)]
29 pub client_id_secret_key: Option<String>,
30 #[serde(default)]
31 pub scopes: Vec<String>,
32 #[serde(default)]
33 pub secrets_out: BTreeMap<String, String>,
34 #[serde(default)]
35 pub config_out: BTreeMap<String, String>,
36 #[serde(default)]
37 pub post_login_discovery: Vec<DiscoveryStep>,
38 #[serde(default)]
39 pub setup_modes: BTreeMap<String, OAuthDeviceSetupMode>,
40 #[serde(default)]
41 pub error_checklist: Vec<String>,
42}
43
44#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub struct OAuthDeviceSetupMode {
46 #[serde(default)]
47 pub provisioning: BTreeMap<String, OAuthDeviceProvisioning>,
48}
49
50#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub struct OAuthDeviceProvisioning {
52 #[serde(default)]
53 pub component_ref: String,
54 #[serde(default)]
55 pub op: String,
56 #[serde(default)]
57 pub output_keys: BTreeMap<String, String>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct DiscoveryStep {
62 pub id: String,
63 #[serde(default = "default_method")]
64 pub method: String,
65 #[serde(default)]
66 pub url: Option<String>,
67 #[serde(default)]
68 pub url_template: Option<String>,
69 #[serde(default)]
70 pub requires: Vec<String>,
71 #[serde(default)]
72 pub save: BTreeMap<String, String>,
73 #[serde(default)]
74 pub select: Option<DiscoverySelect>,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct DiscoverySelect {
79 pub from: String,
80 pub label: String,
81 pub value: String,
82 pub save_as: String,
83 #[serde(default)]
84 pub label_save_as: Option<String>,
85 #[serde(default)]
86 pub default_label: Option<String>,
87 #[serde(default)]
88 pub default_filter: Option<String>,
89}
90
91#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct OAuthDeviceStartInput {
93 pub provider_id: String,
94 pub tenant: String,
95 #[serde(default)]
96 pub team: Option<String>,
97 pub action_id: String,
98}
99
100#[derive(Clone, Debug, Serialize, Deserialize)]
101pub struct OAuthDevicePollInput {
102 pub session_id: String,
103}
104
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106pub struct OAuthDeviceStartReport {
107 pub session_id: String,
108 pub provider_id: String,
109 pub tenant: String,
110 pub team: String,
111 pub action_id: String,
112 pub verification_uri: String,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub verification_uri_complete: Option<String>,
115 pub user_code: String,
116 pub expires_at: u64,
117 pub interval: u64,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub checklist: Vec<String>,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123pub struct OAuthDevicePollReport {
124 pub status: OAuthDevicePollStatus,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub message: Option<String>,
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub persisted_keys: Vec<String>,
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub checklist: Vec<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub interval: Option<u64>,
133}
134
135#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum OAuthDevicePollStatus {
138 Pending,
139 SlowDown,
140 Complete,
141 Failed,
142}
143
144#[derive(Clone, Debug, Serialize, Deserialize)]
145struct OAuthDeviceSessionState {
146 session_id: String,
147 provider_id: String,
148 tenant: String,
149 team: String,
150 action_id: String,
151 device_code: String,
152 client_id: String,
153 interval: u64,
154 expires_at: u64,
155 created_at: u64,
156}
157
158#[derive(Clone, Debug, Deserialize)]
159struct DeviceCodeResponse {
160 device_code: String,
161 user_code: String,
162 #[serde(default)]
163 verification_uri: Option<String>,
164 #[serde(default)]
165 verification_url: Option<String>,
166 #[serde(default)]
167 verification_uri_complete: Option<String>,
168 #[serde(default)]
169 expires_in: Option<u64>,
170 #[serde(default)]
171 interval: Option<u64>,
172}
173
174pub fn load_provider_device_metadata(
175 bundle_root: &Path,
176 provider_id: &str,
177 extension_key: &str,
178) -> Result<OAuthDeviceMetadata> {
179 let discovered = crate::discovery::discover(bundle_root)
180 .context("failed to discover providers for OAuth device-code setup")?;
181 let provider = discovered
182 .find_setup_target(provider_id)
183 .ok_or_else(|| anyhow!("provider not found for OAuth device-code setup: {provider_id}"))?;
184 let raw = crate::discovery::read_pack_extension(&provider.pack_path, extension_key)?
185 .ok_or_else(|| anyhow!("provider missing OAuth device-code metadata: {extension_key}"))?;
186 let metadata = raw.get("inline").cloned().unwrap_or(raw);
187 let mut metadata: OAuthDeviceMetadata = serde_json::from_value(metadata)
188 .context("failed to parse provider OAuth device-code metadata")?;
189 let bundle_name = crate::bundle::read_bundle_name(bundle_root).ok().flatten();
190 apply_bundle_name_templates(&mut metadata, bundle_name.as_deref());
191 Ok(metadata)
192}
193
194pub fn device_code_request_form<'a>(
195 metadata: &'a OAuthDeviceMetadata,
196 client_id: &'a str,
197) -> Vec<(&'a str, String)> {
198 vec![
199 ("client_id", client_id.to_string()),
200 ("scope", metadata.scopes.join(" ")),
201 ]
202}
203
204pub fn token_poll_request_form<'a>(
205 client_id: &'a str,
206 device_code: &'a str,
207) -> Vec<(&'a str, String)> {
208 vec![
209 ("client_id", client_id.to_string()),
210 (
211 "grant_type",
212 "urn:ietf:params:oauth:grant-type:device_code".to_string(),
213 ),
214 ("device_code", device_code.to_string()),
215 ]
216}
217
218pub fn start_oauth_device_code(
219 bundle_root: &Path,
220 input: &OAuthDeviceStartInput,
221 extension_key: &str,
222) -> Result<OAuthDeviceStartReport> {
223 let team = team_segment(input.team.as_deref()).to_string();
224 let action = crate::setup_actions::load_setup_action(
225 bundle_root,
226 &input.tenant,
227 &team,
228 &input.provider_id,
229 &input.action_id,
230 )?
231 .ok_or_else(|| anyhow!("setup action not found: {}", input.action_id))?;
232 if action.kind != SetupActionKind::OauthDeviceCode {
233 bail!("setup action is not oauth_device_code");
234 }
235 if action.status != SetupActionStatus::Pending {
236 bail!("setup action is not pending");
237 }
238
239 let metadata = load_provider_device_metadata(bundle_root, &input.provider_id, extension_key)?;
240 let setup_answers = load_provider_setup_answers(bundle_root, &input.provider_id)?;
241 let client_id = lookup_client_id(&metadata, &setup_answers)?;
242 let request_form = device_code_request_form(&metadata, &client_id);
243 let mut response = crate::http_client::api_agent()
244 .post(&metadata.device_code_url)
245 .send_form(request_form)
246 .context("OAuth device-code request failed")?;
247 let response = response
248 .body_mut()
249 .read_json::<Value>()
250 .context("failed to parse OAuth device-code response")?;
251 start_oauth_device_code_with_response(bundle_root, input, &metadata, &client_id, &response)
252}
253
254pub fn start_oauth_device_code_with_response(
255 bundle_root: &Path,
256 input: &OAuthDeviceStartInput,
257 metadata: &OAuthDeviceMetadata,
258 client_id: &str,
259 response: &Value,
260) -> Result<OAuthDeviceStartReport> {
261 let parsed: DeviceCodeResponse =
262 serde_json::from_value(response.clone()).context("invalid OAuth device-code response")?;
263 if parsed.device_code.trim().is_empty() {
264 bail!("OAuth device-code response missing device_code");
265 }
266 let verification_uri = parsed
267 .verification_uri
268 .or(parsed.verification_url)
269 .or_else(|| metadata.verification_uri.clone())
270 .ok_or_else(|| anyhow!("OAuth device-code response missing verification URI"))?;
271 let now = crate::setup_actions::current_epoch_secs();
272 let expires_in = parsed.expires_in.unwrap_or(900);
273 let interval = parsed.interval.unwrap_or(5).max(1);
274 let session_id = new_session_id();
275 let team = team_segment(input.team.as_deref()).to_string();
276 let state = OAuthDeviceSessionState {
277 session_id: session_id.clone(),
278 provider_id: input.provider_id.clone(),
279 tenant: input.tenant.clone(),
280 team: team.clone(),
281 action_id: input.action_id.clone(),
282 device_code: parsed.device_code,
283 client_id: client_id.to_string(),
284 interval,
285 expires_at: now + expires_in,
286 created_at: now,
287 };
288 save_session(bundle_root, &state)?;
289 Ok(OAuthDeviceStartReport {
290 session_id,
291 provider_id: input.provider_id.clone(),
292 tenant: input.tenant.clone(),
293 team,
294 action_id: input.action_id.clone(),
295 verification_uri,
296 verification_uri_complete: parsed.verification_uri_complete,
297 user_code: parsed.user_code,
298 expires_at: now + expires_in,
299 interval,
300 checklist: metadata.error_checklist.clone(),
301 })
302}
303
304pub async fn poll_oauth_device_code(
305 bundle_root: &Path,
306 env: &str,
307 input: &OAuthDevicePollInput,
308 extension_key: &str,
309) -> Result<OAuthDevicePollReport> {
310 let session = load_session(bundle_root, &input.session_id)?;
311 if crate::setup_actions::current_epoch_secs() >= session.expires_at {
312 return Ok(OAuthDevicePollReport {
313 status: OAuthDevicePollStatus::Failed,
314 message: Some("OAuth device code has expired; start the login again.".to_string()),
315 persisted_keys: Vec::new(),
316 checklist: Vec::new(),
317 interval: None,
318 });
319 }
320 let metadata = load_provider_device_metadata(bundle_root, &session.provider_id, extension_key)?;
321 let request_form = token_poll_request_form(&session.client_id, &session.device_code);
322 let agent = crate::http_client::api_agent_any_status();
323 let mut response = agent
324 .post(&metadata.token_url)
325 .send_form(request_form)
326 .context("OAuth device-code token polling failed")?;
327 let response = response
328 .body_mut()
329 .read_json::<Value>()
330 .context("failed to parse OAuth device-code token response")?;
331 poll_oauth_device_code_with_token_response(bundle_root, env, &session, &metadata, &response)
332 .await
333}
334
335async fn poll_oauth_device_code_with_token_response(
336 bundle_root: &Path,
337 env: &str,
338 session: &OAuthDeviceSessionState,
339 metadata: &OAuthDeviceMetadata,
340 response: &Value,
341) -> Result<OAuthDevicePollReport> {
342 if let Some(error) = response.get("error").and_then(Value::as_str) {
343 return handle_poll_error(bundle_root, session, metadata, error, response);
344 }
345
346 let mut mapped = map_device_token_response(metadata, &session.client_id, response)?;
347 if !metadata.post_login_discovery.is_empty() {
348 let access_token = response
349 .get("access_token")
350 .and_then(Value::as_str)
351 .map(str::trim)
352 .filter(|value| !value.is_empty())
353 .ok_or_else(|| anyhow!("OAuth device-code discovery requires access_token"))?;
354 let discovered = execute_post_login_discovery(metadata, access_token)?;
355 mapped.extend(discovered);
356 }
357 let config = Value::Object(
358 mapped
359 .iter()
360 .map(|(key, value)| (key.clone(), Value::String(value.clone())))
361 .collect::<JsonMap<_, _>>(),
362 );
363 crate::qa::persist::persist_all_config_as_secrets(
364 bundle_root,
365 env,
366 &session.tenant,
367 Some(&session.team),
368 &session.provider_id,
369 &config,
370 None,
371 )
372 .await?;
373 let final_mapped =
374 finalize_provider_apply_answers(bundle_root, env, session, metadata, response, &mapped)
375 .await?;
376 let final_mapped = final_mapped.as_ref().unwrap_or(&mapped);
377 persist_device_config_outputs(bundle_root, &session.provider_id, metadata, final_mapped)?;
378 crate::setup_actions::mark_setup_action_complete(
379 bundle_root,
380 &session.tenant,
381 &session.team,
382 &session.provider_id,
383 &session.action_id,
384 )?;
385 let _ = std::fs::remove_file(session_path(bundle_root, &session.session_id));
386
387 Ok(OAuthDevicePollReport {
388 status: OAuthDevicePollStatus::Complete,
389 message: None,
390 persisted_keys: final_mapped.keys().cloned().collect(),
391 checklist: Vec::new(),
392 interval: None,
393 })
394}
395
396async fn finalize_provider_apply_answers(
397 bundle_root: &Path,
398 env: &str,
399 session: &OAuthDeviceSessionState,
400 metadata: &OAuthDeviceMetadata,
401 response: &Value,
402 mapped: &BTreeMap<String, String>,
403) -> Result<Option<BTreeMap<String, String>>> {
404 let Some(provisioning) = apply_answers_provisioning(metadata) else {
405 return Ok(None);
406 };
407 let discovered = crate::discovery::discover(bundle_root)
408 .context("failed to discover providers for OAuth device-code apply-answers")?;
409 let provider = discovered
410 .find_setup_target(&session.provider_id)
411 .ok_or_else(|| {
412 anyhow!(
413 "provider not found for OAuth device-code apply-answers: {}",
414 session.provider_id
415 )
416 })?;
417 let answers = load_provider_setup_answers(bundle_root, &session.provider_id)?;
418 let request =
419 json_apply_answers_request(&answers, metadata, response, &session.client_id, mapped)?;
420 let config = crate::engine::SetupConfig {
421 tenant: session.tenant.clone(),
422 team: Some(session.team.clone()),
423 env: env.to_string(),
424 offline: false,
425 verbose: false,
426 };
427 let pack_path = provider.pack_path.clone();
428 let component_ref = provisioning.component_ref.clone();
429 let op = provisioning.op.clone();
430 let bundle_root_owned = bundle_root.to_path_buf();
431 let result = tokio::task::spawn_blocking(move || {
432 crate::engine::invoke_setup_component_operation(
433 &bundle_root_owned,
434 &pack_path,
435 &component_ref,
436 &op,
437 &request,
438 &config,
439 )
440 })
441 .await
442 .context("OAuth device-code apply-answers task failed")?
443 .with_context(|| {
444 format!(
445 "OAuth device-code apply-answers failed for {}",
446 session.provider_id
447 )
448 })?;
449 let Some(config) = apply_answers_result_config(&result)? else {
450 return Ok(None);
451 };
452 crate::qa::persist::persist_all_config_as_secrets(
453 bundle_root,
454 env,
455 &session.tenant,
456 Some(&session.team),
457 &session.provider_id,
458 &config,
459 Some(&provider.pack_path),
460 )
461 .await?;
462 Ok(Some(map_config_object(&config)))
463}
464
465fn apply_answers_provisioning(metadata: &OAuthDeviceMetadata) -> Option<&OAuthDeviceProvisioning> {
466 metadata
467 .setup_modes
468 .values()
469 .flat_map(|mode| mode.provisioning.values())
470 .find(|provisioning| {
471 provisioning.op == "apply-answers" && !provisioning.component_ref.trim().is_empty()
472 })
473}
474
475fn json_apply_answers_request(
476 existing_answers: &Value,
477 metadata: &OAuthDeviceMetadata,
478 response: &Value,
479 client_id: &str,
480 mapped: &BTreeMap<String, String>,
481) -> Result<Value> {
482 let mut answers = existing_answers.as_object().cloned().unwrap_or_default();
483 for (key, value) in mapped {
484 answers.insert(key.clone(), Value::String(value.clone()));
485 }
486 for response_key in metadata
487 .secrets_out
488 .keys()
489 .chain(metadata.config_out.keys())
490 {
491 let value = if response_key == "client_id" {
492 Some(client_id.to_string())
493 } else {
494 oauth_response_value(response, response_key)
495 };
496 if let Some(value) = value {
497 answers.insert(response_key.clone(), Value::String(value));
498 }
499 }
500 Ok(json!({
501 "mode": "setup",
502 "answers": Value::Object(answers)
503 }))
504}
505
506fn apply_answers_result_config(result: &Value) -> Result<Option<Value>> {
507 if result.get("ok").and_then(Value::as_bool) == Some(false) {
508 let message = result
509 .get("error")
510 .or_else(|| result.get("message"))
511 .and_then(Value::as_str)
512 .map(str::trim)
513 .filter(|value| !value.is_empty())
514 .unwrap_or("provider apply-answers returned ok:false");
515 bail!("OAuth device-code apply-answers failed: {message}");
516 }
517 Ok(result.get("config").cloned().or_else(|| {
518 result
519 .as_object()
520 .is_some_and(|object| !object.contains_key("ok"))
521 .then(|| result.clone())
522 }))
523}
524
525fn map_config_object(config: &Value) -> BTreeMap<String, String> {
526 config
527 .as_object()
528 .into_iter()
529 .flat_map(|object| object.iter())
530 .filter_map(|(key, value)| value_to_string(value).map(|value| (key.clone(), value)))
531 .collect()
532}
533
534fn oauth_response_value(response: &Value, key: &str) -> Option<String> {
535 response
536 .get(key)
537 .and_then(value_to_string)
538 .or_else(|| oauth_token_claim_value(response, key))
539}
540
541fn oauth_token_claim_value(response: &Value, key: &str) -> Option<String> {
542 for token_key in ["id_token", "access_token"] {
543 let Some(token) = response
544 .get(token_key)
545 .and_then(Value::as_str)
546 .map(str::trim)
547 .filter(|value| !value.is_empty())
548 else {
549 continue;
550 };
551 let Some(claims) = decode_unverified_jwt_claims(token) else {
552 continue;
553 };
554 if let Some(value) = claims.get(key).and_then(value_to_string) {
555 return Some(value);
556 }
557 for alias in oauth_claim_aliases(key) {
558 if let Some(value) = claims.get(alias).and_then(value_to_string) {
559 return Some(value);
560 }
561 }
562 }
563 None
564}
565
566fn oauth_claim_aliases(key: &str) -> &'static [&'static str] {
567 match key {
568 "tenant_id" => &["tid"],
569 "user_id" => &["oid", "sub"],
570 _ => &[],
571 }
572}
573
574fn decode_unverified_jwt_claims(token: &str) -> Option<Value> {
575 let claims = token.split('.').nth(1)?;
576 let bytes =
577 base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, claims).ok()?;
578 serde_json::from_slice(&bytes).ok()
579}
580
581fn handle_poll_error(
582 bundle_root: &Path,
583 session: &OAuthDeviceSessionState,
584 metadata: &OAuthDeviceMetadata,
585 error: &str,
586 response: &Value,
587) -> Result<OAuthDevicePollReport> {
588 match error {
589 "authorization_pending" => Ok(OAuthDevicePollReport {
590 status: OAuthDevicePollStatus::Pending,
591 message: response
592 .get("error_description")
593 .and_then(Value::as_str)
594 .map(ToString::to_string),
595 persisted_keys: Vec::new(),
596 checklist: Vec::new(),
597 interval: Some(session.interval),
598 }),
599 "slow_down" => {
600 let mut updated = session.clone();
601 updated.interval = updated.interval.saturating_add(5).max(1);
602 save_session(bundle_root, &updated)?;
603 Ok(OAuthDevicePollReport {
604 status: OAuthDevicePollStatus::SlowDown,
605 message: response
606 .get("error_description")
607 .and_then(Value::as_str)
608 .map(ToString::to_string),
609 persisted_keys: Vec::new(),
610 checklist: Vec::new(),
611 interval: Some(updated.interval),
612 })
613 }
614 "expired_token" | "authorization_declined" | "bad_verification_code" => {
615 Ok(OAuthDevicePollReport {
616 status: OAuthDevicePollStatus::Failed,
617 message: Some(poll_error_message(error, response)),
618 persisted_keys: Vec::new(),
619 checklist: metadata.error_checklist.clone(),
620 interval: None,
621 })
622 }
623 other => Ok(OAuthDevicePollReport {
624 status: OAuthDevicePollStatus::Failed,
625 message: Some(poll_error_message(other, response)),
626 persisted_keys: Vec::new(),
627 checklist: metadata.error_checklist.clone(),
628 interval: None,
629 }),
630 }
631}
632
633pub fn map_device_token_response(
634 metadata: &OAuthDeviceMetadata,
635 client_id: &str,
636 response: &Value,
637) -> Result<BTreeMap<String, String>> {
638 let mut mapped = BTreeMap::new();
639 for (response_key, output_key) in &metadata.secrets_out {
640 let value = if response_key == "client_id" {
641 Some(client_id.to_string())
642 } else {
643 oauth_response_value(response, response_key)
644 };
645 if let Some(value) = value {
646 mapped.insert(output_key.clone(), value);
647 }
648 }
649 for (response_key, output_key) in &metadata.config_out {
650 let value = if response_key == "client_id" {
651 Some(client_id.to_string())
652 } else {
653 oauth_response_value(response, response_key)
654 };
655 if let Some(value) = value {
656 mapped.insert(output_key.clone(), value);
657 }
658 }
659 if mapped.is_empty() {
660 bail!("OAuth device-code token response did not contain mappable values");
661 }
662 Ok(mapped)
663}
664
665fn persist_device_config_outputs(
666 bundle_root: &Path,
667 provider_id: &str,
668 metadata: &OAuthDeviceMetadata,
669 mapped: &BTreeMap<String, String>,
670) -> Result<()> {
671 let config_outputs: JsonMap<String, Value> = mapped
672 .iter()
673 .filter(|(key, _)| !is_sensitive_device_output_key(metadata, key))
674 .map(|(key, value)| (key.clone(), Value::String(value.clone())))
675 .collect();
676 if config_outputs.is_empty() {
677 return Ok(());
678 }
679
680 let path = provider_setup_answers_path(bundle_root, provider_id);
681 let mut answers = load_provider_setup_answers(bundle_root, provider_id)?;
682 let Some(answer_map) = answers.as_object_mut() else {
683 bail!(
684 "provider setup answers must be a JSON object: {}",
685 path.display()
686 );
687 };
688 for (key, value) in config_outputs {
689 answer_map.insert(key, value);
690 }
691
692 if let Some(parent) = path.parent() {
693 std::fs::create_dir_all(parent)?;
694 }
695 let payload = serde_json::to_string_pretty(&answers)?;
696 std::fs::write(&path, payload)
697 .with_context(|| format!("failed to write {}", path.display()))?;
698
699 let verified = load_provider_setup_answers(bundle_root, provider_id)?;
700 for (key, value) in mapped {
701 if is_sensitive_device_output_key(metadata, key) {
702 continue;
703 }
704 let actual = verified.get(key).and_then(Value::as_str);
705 if actual != Some(value.as_str()) {
706 bail!("failed to verify persisted device-code config output {key}");
707 }
708 }
709 Ok(())
710}
711
712fn is_sensitive_device_output_key(metadata: &OAuthDeviceMetadata, key: &str) -> bool {
713 metadata.secrets_out.values().any(|value| value == key)
714 || metadata
715 .secrets_out
716 .keys()
717 .any(|value| value == key && value != "client_id")
718 || matches!(
719 key.to_ascii_lowercase().as_str(),
720 "access_token" | "refresh_token" | "client_secret" | "ms_bot_app_password"
721 )
722}
723
724pub fn execute_post_login_discovery(
725 metadata: &OAuthDeviceMetadata,
726 access_token: &str,
727) -> Result<BTreeMap<String, String>> {
728 let mut responses = BTreeMap::new();
729 let mut context = BTreeMap::new();
730 for step in &metadata.post_login_discovery {
731 let url = resolve_discovery_url(step, &context)?;
732 let mut response = crate::http_client::api_agent()
733 .get(&url)
734 .header("Authorization", &format!("Bearer {access_token}"))
735 .config()
736 .http_status_as_error(false)
737 .build()
738 .call()
739 .with_context(|| format!("OAuth device-code discovery request failed: {}", step.id))?;
740 if !response.status().is_success() {
741 let status = response.status().as_u16();
742 let body = response.body_mut().read_to_string().unwrap_or_default();
743 bail!(
744 "{}",
745 discovery_http_error_message(step, &url, status, &body)
746 );
747 }
748 let json = response
749 .body_mut()
750 .read_json::<Value>()
751 .with_context(|| format!("failed to parse OAuth discovery response: {}", step.id))?;
752 let saved = apply_discovery_step(step, &json, |_| 0)?;
753 context.extend(saved.clone());
754 responses.extend(saved);
755 }
756 Ok(responses)
757}
758
759fn discovery_http_error_message(
760 step: &DiscoveryStep,
761 url: &str,
762 status: u16,
763 body: &str,
764) -> String {
765 let mut message = format!(
766 "OAuth device-code discovery request failed: {} (HTTP {status} from {url})",
767 step.id
768 );
769 let body = compact_error_body(body);
770 if !body.is_empty() {
771 message.push_str(": ");
772 message.push_str(&body);
773 }
774 message
775}
776
777fn compact_error_body(body: &str) -> String {
778 const MAX_BODY_CHARS: usize = 2000;
779 let compact = body.split_whitespace().collect::<Vec<_>>().join(" ");
780 if compact.chars().count() <= MAX_BODY_CHARS {
781 return compact;
782 }
783 let truncated = compact.chars().take(MAX_BODY_CHARS).collect::<String>();
784 format!("{truncated}...")
785}
786
787pub fn execute_post_login_discovery_with_responses<F>(
788 metadata: &OAuthDeviceMetadata,
789 responses: &BTreeMap<String, Value>,
790 mut select_index: F,
791) -> Result<BTreeMap<String, String>>
792where
793 F: FnMut(&DiscoveryStep, &[Value]) -> usize,
794{
795 let mut values = BTreeMap::new();
796 for step in &metadata.post_login_discovery {
797 for required in &step.requires {
798 if !values.contains_key(required) {
799 bail!(
800 "OAuth discovery step {} requires missing value {}",
801 step.id,
802 required
803 );
804 }
805 }
806 if step.url_template.is_some() {
807 let _ = resolve_discovery_url(step, &values)?;
808 }
809 let response = responses
810 .get(&step.id)
811 .ok_or_else(|| anyhow!("missing OAuth discovery response for step {}", step.id))?;
812 let saved = apply_discovery_step(step, response, |items| select_index(step, items))?;
813 values.extend(saved);
814 }
815 Ok(values)
816}
817
818fn apply_discovery_step<F>(
819 step: &DiscoveryStep,
820 response: &Value,
821 mut select_index: F,
822) -> Result<BTreeMap<String, String>>
823where
824 F: FnMut(&[Value]) -> usize,
825{
826 let mut saved = BTreeMap::new();
827 for (from, to) in &step.save {
828 if let Some(value) = get_json_path(response, from).and_then(value_to_string) {
829 saved.insert(to.clone(), value);
830 }
831 }
832 if let Some(select) = &step.select {
833 let items = get_json_path(response, &select.from)
834 .and_then(Value::as_array)
835 .ok_or_else(|| {
836 anyhow!(
837 "OAuth discovery step {} did not return selectable array",
838 step.id
839 )
840 })?;
841 if items.is_empty() {
842 bail!(
843 "OAuth discovery step {} returned no selectable items",
844 step.id
845 );
846 }
847 let index = select_index_with_default(select, items, &mut select_index);
848 let item = &items[index];
849 let value = get_json_path(item, &select.value)
850 .and_then(value_to_string)
851 .ok_or_else(|| {
852 anyhow!(
853 "OAuth discovery step {} selected item missing value",
854 step.id
855 )
856 })?;
857 saved.insert(select.save_as.clone(), value);
858 if let Some(label) = get_json_path(item, &select.label).and_then(value_to_string) {
859 saved.insert(discovery_label_save_key(select), label);
860 }
861 }
862 Ok(saved)
863}
864
865fn select_index_with_default<F>(
866 select: &DiscoverySelect,
867 items: &[Value],
868 select_index: &mut F,
869) -> usize
870where
871 F: FnMut(&[Value]) -> usize,
872{
873 if let Some(index) = preferred_discovery_item_index(select, items) {
874 return index;
875 }
876 select_index(items).min(items.len() - 1)
877}
878
879fn preferred_discovery_item_index(select: &DiscoverySelect, items: &[Value]) -> Option<usize> {
880 if let Some(default_label) = select
881 .default_label
882 .as_deref()
883 .map(str::trim)
884 .filter(|value| !value.is_empty())
885 {
886 let default_label = default_label.to_ascii_lowercase();
887 if let Some(index) = items.iter().position(|item| {
888 get_json_path(item, &select.label)
889 .and_then(value_to_string)
890 .is_some_and(|label| label.trim().eq_ignore_ascii_case(&default_label))
891 }) {
892 return Some(index);
893 }
894 }
895
896 let filter = select
897 .default_filter
898 .as_deref()
899 .map(str::trim)
900 .filter(|value| !value.is_empty())?
901 .to_ascii_lowercase();
902 items.iter().position(|item| {
903 get_json_path(item, &select.label)
904 .and_then(value_to_string)
905 .is_some_and(|label| label.to_ascii_lowercase().contains(&filter))
906 })
907}
908
909fn discovery_label_save_key(select: &DiscoverySelect) -> String {
910 select
911 .label_save_as
912 .clone()
913 .unwrap_or_else(|| inferred_label_save_key(&select.save_as))
914}
915
916fn inferred_label_save_key(save_as: &str) -> String {
917 save_as
918 .strip_suffix("_id")
919 .map(|prefix| format!("{prefix}_name"))
920 .unwrap_or_else(|| format!("{save_as}_label"))
921}
922
923fn apply_bundle_name_templates(metadata: &mut OAuthDeviceMetadata, bundle_name: Option<&str>) {
924 let Some(bundle_name) = bundle_name.map(str::trim).filter(|value| !value.is_empty()) else {
925 return;
926 };
927 for step in &mut metadata.post_login_discovery {
928 let Some(select) = step.select.as_mut() else {
929 continue;
930 };
931 if let Some(value) = select.default_label.as_mut() {
932 *value = render_bundle_name_template(value, bundle_name);
933 }
934 if let Some(value) = select.default_filter.as_mut() {
935 *value = render_bundle_name_template(value, bundle_name);
936 }
937 }
938}
939
940fn render_bundle_name_template(template: &str, bundle_name: &str) -> String {
941 template
942 .replace("{{ bundle_name }}", bundle_name)
943 .replace("{{bundle_name}}", bundle_name)
944 .replace("{bundle_name}", bundle_name)
945}
946
947fn resolve_discovery_url(
948 step: &DiscoveryStep,
949 context: &BTreeMap<String, String>,
950) -> Result<String> {
951 if let Some(url) = &step.url {
952 return Ok(url.clone());
953 }
954 let Some(template) = &step.url_template else {
955 bail!(
956 "OAuth discovery step {} missing url or url_template",
957 step.id
958 );
959 };
960 let mut resolved = template.clone();
961 for required in &step.requires {
962 let value = context.get(required).ok_or_else(|| {
963 anyhow!(
964 "OAuth discovery step {} requires missing value {}",
965 step.id,
966 required
967 )
968 })?;
969 resolved = resolved.replace(&format!("{{{required}}}"), value);
970 }
971 Ok(resolved)
972}
973
974fn get_json_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
975 let mut current = value;
976 for part in path.split('.') {
977 current = current.get(part)?;
978 }
979 Some(current)
980}
981
982fn lookup_client_id(metadata: &OAuthDeviceMetadata, setup_answers: &Value) -> Result<String> {
983 let keys = [
984 metadata.client_id_config_key.as_str(),
985 "client_id",
986 "oauth_client_id",
987 ];
988 if let Some(obj) = setup_answers.as_object() {
989 for key in keys {
990 if let Some(value) = obj
991 .get(key)
992 .and_then(Value::as_str)
993 .map(str::trim)
994 .filter(|value| !value.is_empty())
995 {
996 return Ok(value.to_string());
997 }
998 }
999 }
1000 bail!(
1001 "OAuth device-code client_id is missing from provider setup answers; configure {} first",
1002 metadata.client_id_config_key
1003 )
1004}
1005
1006fn poll_error_message(error: &str, response: &Value) -> String {
1007 response
1008 .get("error_description")
1009 .and_then(Value::as_str)
1010 .map(ToString::to_string)
1011 .unwrap_or_else(|| format!("OAuth device-code polling failed: {error}"))
1012}
1013
1014fn load_provider_setup_answers(bundle_root: &Path, provider_id: &str) -> Result<Value> {
1015 let path = provider_setup_answers_path(bundle_root, provider_id);
1016 if !path.exists() {
1017 return Ok(Value::Object(JsonMap::new()));
1018 }
1019 let raw = std::fs::read_to_string(&path)
1020 .with_context(|| format!("failed to read {}", path.display()))?;
1021 serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
1022}
1023
1024fn provider_setup_answers_path(bundle_root: &Path, provider_id: &str) -> PathBuf {
1025 bundle_root
1026 .join("state")
1027 .join("config")
1028 .join(provider_id)
1029 .join("setup-answers.json")
1030}
1031
1032fn save_session(bundle_root: &Path, state: &OAuthDeviceSessionState) -> Result<()> {
1033 let path = session_path(bundle_root, &state.session_id);
1034 if let Some(parent) = path.parent() {
1035 std::fs::create_dir_all(parent)?;
1036 }
1037 let payload = serde_json::to_string_pretty(state)?;
1038 std::fs::write(&path, payload).with_context(|| format!("failed to write {}", path.display()))
1039}
1040
1041fn load_session(bundle_root: &Path, session_id: &str) -> Result<OAuthDeviceSessionState> {
1042 let path = session_path(bundle_root, session_id);
1043 let raw = std::fs::read_to_string(&path)
1044 .with_context(|| format!("failed to read {}", path.display()))?;
1045 serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
1046}
1047
1048fn session_path(bundle_root: &Path, session_id: &str) -> PathBuf {
1049 bundle_root
1050 .join(".greentic")
1051 .join("oauth-device-sessions")
1052 .join(format!("{session_id}.json"))
1053}
1054
1055fn new_session_id() -> String {
1056 base64::Engine::encode(
1057 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1058 rand::random::<[u8; 16]>(),
1059 )
1060}
1061
1062fn team_segment(team: Option<&str>) -> &str {
1063 team.map(str::trim)
1064 .filter(|value| !value.is_empty())
1065 .unwrap_or("default")
1066}
1067
1068fn default_client_id_config_key() -> String {
1069 "client_id".to_string()
1070}
1071
1072fn default_method() -> String {
1073 "GET".to_string()
1074}
1075
1076fn value_to_string(value: &Value) -> Option<String> {
1077 match value {
1078 Value::String(text) if !text.is_empty() => Some(text.clone()),
1079 Value::Number(number) => Some(number.to_string()),
1080 Value::Bool(value) => Some(value.to_string()),
1081 _ => None,
1082 }
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087 use super::*;
1088 use greentic_secrets_lib::SecretsStore;
1089 use serde_json::json;
1090 use std::io::Write;
1091 use std::path::Path;
1092 use zip::write::{FileOptions, ZipWriter};
1093
1094 fn metadata() -> OAuthDeviceMetadata {
1095 OAuthDeviceMetadata {
1096 device_code_url: "https://login.example/devicecode".into(),
1097 token_url: "https://login.example/token".into(),
1098 scopes: vec!["offline_access".into(), "User.Read".into()],
1099 secrets_out: BTreeMap::from([
1100 ("refresh_token".into(), "MS_GRAPH_REFRESH_TOKEN".into()),
1101 ("client_id".into(), "MS_GRAPH_CLIENT_ID".into()),
1102 ]),
1103 ..Default::default()
1104 }
1105 }
1106
1107 fn metadata_with_apply_answers() -> OAuthDeviceMetadata {
1108 let mut metadata = metadata();
1109 metadata
1110 .secrets_out
1111 .insert("access_token".into(), "MS_GRAPH_ACCESS_TOKEN".into());
1112 metadata.config_out = BTreeMap::from([
1113 ("tenant_id".into(), "tenant_id".into()),
1114 ("user_id".into(), "user_id".into()),
1115 ("team_id".into(), "team_id".into()),
1116 ("team_name".into(), "team_name".into()),
1117 ("channel_id".into(), "channel_id".into()),
1118 ("channel_name".into(), "channel_name".into()),
1119 ("desired_channel_name".into(), "desired_channel_name".into()),
1120 ]);
1121 metadata.setup_modes = BTreeMap::from([(
1122 "graph_channel".into(),
1123 OAuthDeviceSetupMode {
1124 provisioning: BTreeMap::from([(
1125 "teams_channel".into(),
1126 OAuthDeviceProvisioning {
1127 component_ref: "provision".into(),
1128 op: "apply-answers".into(),
1129 output_keys: BTreeMap::from([
1130 ("channel_id".into(), "channel_id".into()),
1131 ("channel_name".into(), "channel_name".into()),
1132 ]),
1133 },
1134 )]),
1135 },
1136 )]);
1137 metadata
1138 }
1139
1140 fn unsigned_jwt_claims(claims: Value) -> String {
1141 let header = base64::Engine::encode(
1142 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1143 br#"{"alg":"none"}"#,
1144 );
1145 let claims = base64::Engine::encode(
1146 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1147 claims.to_string(),
1148 );
1149 format!("{header}.{claims}.")
1150 }
1151
1152 fn setup_pending_oauth_action(
1153 bundle: &Path,
1154 provider_id: &str,
1155 tenant: &str,
1156 team: &str,
1157 action_id: &str,
1158 ) {
1159 crate::setup_actions::persist_setup_actions(
1160 bundle,
1161 &[crate::setup_actions::SetupAction {
1162 id: action_id.into(),
1163 kind: crate::setup_actions::SetupActionKind::OauthDeviceCode,
1164 label: "Connect Teams".into(),
1165 provider_id: provider_id.into(),
1166 tenant: tenant.into(),
1167 team: Some(team.into()),
1168 authorize_url: None,
1169 callback_path: None,
1170 state: None,
1171 status: crate::setup_actions::SetupActionStatus::Pending,
1172 created_at: None,
1173 completed_at: None,
1174 extra: JsonMap::new(),
1175 }],
1176 )
1177 .unwrap();
1178 }
1179
1180 fn session_state(
1181 provider_id: &str,
1182 tenant: &str,
1183 team: &str,
1184 action_id: &str,
1185 ) -> OAuthDeviceSessionState {
1186 OAuthDeviceSessionState {
1187 session_id: "session-1".into(),
1188 provider_id: provider_id.into(),
1189 tenant: tenant.into(),
1190 team: team.into(),
1191 action_id: action_id.into(),
1192 device_code: "device-code".into(),
1193 client_id: "client-123".into(),
1194 interval: 5,
1195 expires_at: crate::setup_actions::current_epoch_secs() + 900,
1196 created_at: crate::setup_actions::current_epoch_secs(),
1197 }
1198 }
1199
1200 fn write_setup_answers(bundle: &Path, provider_id: &str, answers: Value) {
1201 let config_dir = bundle.join("state/config").join(provider_id);
1202 std::fs::create_dir_all(&config_dir).unwrap();
1203 std::fs::write(
1204 config_dir.join("setup-answers.json"),
1205 serde_json::to_string_pretty(&answers).unwrap(),
1206 )
1207 .unwrap();
1208 }
1209
1210 fn write_mock_provider_pack(
1211 bundle: &Path,
1212 provider_id: &str,
1213 result: Value,
1214 ) -> anyhow::Result<()> {
1215 std::fs::create_dir_all(bundle.join("providers/messaging"))?;
1216 write_provider_pack_with_files(
1217 &bundle
1218 .join("providers/messaging")
1219 .join(format!("{provider_id}.gtpack")),
1220 json!({
1221 "pack_id": provider_id,
1222 "extensions": {}
1223 }),
1224 [(
1225 "components/provision.json",
1226 json!({
1227 "operations": {
1228 "apply-answers": {
1229 "result": result
1230 }
1231 }
1232 }),
1233 )],
1234 )
1235 }
1236
1237 fn write_provider_pack_with_manifest(
1238 path: &Path,
1239 manifest: serde_json::Value,
1240 ) -> anyhow::Result<()> {
1241 write_provider_pack_with_files(
1242 path,
1243 manifest,
1244 std::iter::empty::<(&str, serde_json::Value)>(),
1245 )
1246 }
1247
1248 fn write_provider_pack_with_files<I, P>(
1249 path: &Path,
1250 manifest: serde_json::Value,
1251 files: I,
1252 ) -> anyhow::Result<()>
1253 where
1254 I: IntoIterator<Item = (P, serde_json::Value)>,
1255 P: AsRef<str>,
1256 {
1257 let file = std::fs::File::create(path)?;
1258 let mut writer = ZipWriter::new(file);
1259 let options: FileOptions<'_, ()> =
1260 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1261 writer.start_file("pack.manifest.json", options)?;
1262 writer.write_all(manifest.to_string().as_bytes())?;
1263 for (file_path, value) in files {
1264 writer.start_file(file_path.as_ref(), options)?;
1265 writer.write_all(value.to_string().as_bytes())?;
1266 }
1267 writer.finish()?;
1268 Ok(())
1269 }
1270
1271 #[test]
1272 fn device_code_request_form_omits_client_secret() {
1273 let metadata = metadata();
1274 let form = device_code_request_form(&metadata, "client-123");
1275 assert_eq!(
1276 form,
1277 vec![
1278 ("client_id", "client-123".to_string()),
1279 ("scope", "offline_access User.Read".to_string())
1280 ]
1281 );
1282 assert!(!form.iter().any(|(key, _)| *key == "client_secret"));
1283 }
1284
1285 #[test]
1286 fn token_poll_request_form_omits_client_secret() {
1287 let form = token_poll_request_form("client-123", "device-secret");
1288 assert_eq!(
1289 form,
1290 vec![
1291 ("client_id", "client-123".to_string()),
1292 (
1293 "grant_type",
1294 "urn:ietf:params:oauth:grant-type:device_code".to_string()
1295 ),
1296 ("device_code", "device-secret".to_string())
1297 ]
1298 );
1299 assert!(!form.iter().any(|(key, _)| *key == "client_secret"));
1300 }
1301
1302 #[test]
1303 fn token_response_maps_refresh_token_and_client_id() {
1304 let mapped =
1305 map_device_token_response(&metadata(), "client-123", &json!({"refresh_token": "rt"}))
1306 .unwrap();
1307 assert_eq!(
1308 mapped.get("MS_GRAPH_REFRESH_TOKEN").map(String::as_str),
1309 Some("rt")
1310 );
1311 assert_eq!(
1312 mapped.get("MS_GRAPH_CLIENT_ID").map(String::as_str),
1313 Some("client-123")
1314 );
1315 }
1316
1317 #[test]
1318 fn load_provider_device_metadata_accepts_inline_extension_wrapper() -> anyhow::Result<()> {
1319 let temp = tempfile::tempdir()?;
1320 let bundle = temp.path();
1321 crate::bundle::create_demo_bundle_structure(bundle, Some("Acme Support"))?;
1322 std::fs::create_dir_all(bundle.join("providers/messaging"))?;
1323 write_provider_pack_with_manifest(
1324 &bundle.join("providers/messaging/messaging-example.gtpack"),
1325 json!({
1326 "pack_id": "messaging-example",
1327 "extensions": {
1328 "messaging.oauth_device_code.v1": {
1329 "kind": "messaging.oauth_device_code.v1",
1330 "version": "1",
1331 "inline": {
1332 "device_code_url": "https://login.example/devicecode",
1333 "token_url": "https://login.example/token",
1334 "verification_uri": "https://login.example/device",
1335 "client_id_config_key": "client_id",
1336 "scopes": ["User.Read"],
1337 "post_login_discovery": [{
1338 "id": "rooms",
1339 "url": "https://api.example/rooms",
1340 "select": {
1341 "from": "value",
1342 "label": "displayName",
1343 "value": "id",
1344 "save_as": "room_id",
1345 "default_filter": "{{ bundle_name }}"
1346 }
1347 }]
1348 }
1349 }
1350 }
1351 }),
1352 )?;
1353
1354 let metadata = load_provider_device_metadata(
1355 bundle,
1356 "messaging-example",
1357 "messaging.oauth_device_code.v1",
1358 )?;
1359
1360 assert_eq!(metadata.device_code_url, "https://login.example/devicecode");
1361 assert_eq!(metadata.token_url, "https://login.example/token");
1362 assert_eq!(metadata.scopes, vec!["User.Read"]);
1363 assert_eq!(
1364 metadata.post_login_discovery[0]
1365 .select
1366 .as_ref()
1367 .and_then(|select| select.default_filter.as_deref()),
1368 Some("Acme Support")
1369 );
1370 Ok(())
1371 }
1372
1373 #[test]
1374 fn start_report_excludes_raw_device_code() {
1375 let temp = tempfile::tempdir().unwrap();
1376 let input = OAuthDeviceStartInput {
1377 provider_id: "messaging-teams".into(),
1378 tenant: "demo".into(),
1379 team: None,
1380 action_id: "connect".into(),
1381 };
1382 let report = start_oauth_device_code_with_response(
1383 temp.path(),
1384 &input,
1385 &OAuthDeviceMetadata {
1386 verification_uri: Some("https://microsoft.com/devicelogin".into()),
1387 ..metadata()
1388 },
1389 "client-123",
1390 &json!({
1391 "device_code": "raw-device-code",
1392 "user_code": "ABCD-EFGH",
1393 "expires_in": 900,
1394 "interval": 5
1395 }),
1396 )
1397 .unwrap();
1398 let serialized = serde_json::to_string(&report).unwrap();
1399 assert!(serialized.contains("ABCD-EFGH"));
1400 assert!(!serialized.contains("raw-device-code"));
1401 }
1402
1403 #[test]
1404 fn start_report_uses_response_verification_url_and_defaults() {
1405 let temp = tempfile::tempdir().unwrap();
1406 let input = OAuthDeviceStartInput {
1407 provider_id: "messaging-teams".into(),
1408 tenant: "demo".into(),
1409 team: Some("".into()),
1410 action_id: "connect".into(),
1411 };
1412
1413 let report = start_oauth_device_code_with_response(
1414 temp.path(),
1415 &input,
1416 &metadata(),
1417 "client-123",
1418 &json!({
1419 "device_code": "raw-device-code",
1420 "user_code": "ABCD-EFGH",
1421 "verification_url": "https://login.example/verify",
1422 "verification_uri_complete": "https://login.example/verify?code=ABCD-EFGH",
1423 "interval": 0
1424 }),
1425 )
1426 .unwrap();
1427
1428 assert_eq!(report.team, "default");
1429 assert_eq!(report.interval, 1);
1430 assert_eq!(report.verification_uri, "https://login.example/verify");
1431 assert_eq!(
1432 report.verification_uri_complete.as_deref(),
1433 Some("https://login.example/verify?code=ABCD-EFGH")
1434 );
1435 }
1436
1437 #[test]
1438 fn start_report_rejects_missing_device_code_or_verification_uri() {
1439 let temp = tempfile::tempdir().unwrap();
1440 let input = OAuthDeviceStartInput {
1441 provider_id: "messaging-teams".into(),
1442 tenant: "demo".into(),
1443 team: None,
1444 action_id: "connect".into(),
1445 };
1446
1447 let missing_device_code = start_oauth_device_code_with_response(
1448 temp.path(),
1449 &input,
1450 &metadata(),
1451 "client-123",
1452 &json!({
1453 "device_code": "",
1454 "user_code": "ABCD-EFGH",
1455 "verification_uri": "https://login.example/verify"
1456 }),
1457 )
1458 .unwrap_err()
1459 .to_string();
1460 assert!(missing_device_code.contains("missing device_code"));
1461
1462 let missing_verification_uri = start_oauth_device_code_with_response(
1463 temp.path(),
1464 &input,
1465 &metadata(),
1466 "client-123",
1467 &json!({
1468 "device_code": "raw-device-code",
1469 "user_code": "ABCD-EFGH"
1470 }),
1471 )
1472 .unwrap_err()
1473 .to_string();
1474 assert!(missing_verification_uri.contains("missing verification URI"));
1475 }
1476
1477 #[test]
1478 fn poll_error_states_are_provider_neutral() {
1479 let temp = tempfile::tempdir().unwrap();
1480 let mut metadata = metadata();
1481 metadata.error_checklist = vec!["Try again".into()];
1482 let session = OAuthDeviceSessionState {
1483 session_id: "session-1".into(),
1484 provider_id: "messaging-teams".into(),
1485 tenant: "demo".into(),
1486 team: "default".into(),
1487 action_id: "connect".into(),
1488 device_code: "device-code".into(),
1489 client_id: "client-123".into(),
1490 interval: 5,
1491 expires_at: crate::setup_actions::current_epoch_secs() + 900,
1492 created_at: crate::setup_actions::current_epoch_secs(),
1493 };
1494 save_session(temp.path(), &session).unwrap();
1495
1496 let pending = handle_poll_error(
1497 temp.path(),
1498 &session,
1499 &metadata,
1500 "authorization_pending",
1501 &json!({"error_description": "not ready"}),
1502 )
1503 .unwrap();
1504 assert_eq!(pending.status, OAuthDevicePollStatus::Pending);
1505 assert_eq!(pending.message.as_deref(), Some("not ready"));
1506 assert_eq!(pending.interval, Some(5));
1507
1508 let slow_down =
1509 handle_poll_error(temp.path(), &session, &metadata, "slow_down", &json!({})).unwrap();
1510 assert_eq!(slow_down.status, OAuthDevicePollStatus::SlowDown);
1511 assert_eq!(slow_down.interval, Some(10));
1512 assert_eq!(load_session(temp.path(), "session-1").unwrap().interval, 10);
1513
1514 let failed = handle_poll_error(
1515 temp.path(),
1516 &session,
1517 &metadata,
1518 "authorization_declined",
1519 &json!({}),
1520 )
1521 .unwrap();
1522 assert_eq!(failed.status, OAuthDevicePollStatus::Failed);
1523 assert_eq!(failed.checklist, vec!["Try again"]);
1524 assert_eq!(
1525 failed.message.as_deref(),
1526 Some("OAuth device-code polling failed: authorization_declined")
1527 );
1528 }
1529
1530 #[test]
1531 fn token_response_maps_config_scalars_and_rejects_empty_mapping() {
1532 let mut metadata = metadata();
1533 metadata.secrets_out.clear();
1534 metadata.config_out = BTreeMap::from([
1535 ("expires_in".into(), "token_expires_in".into()),
1536 ("enabled".into(), "token_enabled".into()),
1537 ("client_id".into(), "client_id_copy".into()),
1538 ]);
1539
1540 let mapped = map_device_token_response(
1541 &metadata,
1542 "client-123",
1543 &json!({"expires_in": 3600, "enabled": true}),
1544 )
1545 .unwrap();
1546
1547 assert_eq!(
1548 mapped.get("token_expires_in").map(String::as_str),
1549 Some("3600")
1550 );
1551 assert_eq!(
1552 mapped.get("token_enabled").map(String::as_str),
1553 Some("true")
1554 );
1555 assert_eq!(
1556 mapped.get("client_id_copy").map(String::as_str),
1557 Some("client-123")
1558 );
1559
1560 let mut empty_metadata = metadata;
1561 empty_metadata.config_out.clear();
1562 let error = map_device_token_response(&empty_metadata, "client-123", &json!({}))
1563 .unwrap_err()
1564 .to_string();
1565 assert!(error.contains("did not contain mappable values"));
1566 }
1567
1568 #[test]
1569 fn token_response_maps_oidc_claim_aliases() {
1570 let mut metadata = metadata();
1571 metadata.secrets_out.clear();
1572 metadata.config_out = BTreeMap::from([
1573 ("tenant_id".into(), "tenant_id".into()),
1574 ("user_id".into(), "user_id".into()),
1575 ]);
1576
1577 let mapped = map_device_token_response(
1578 &metadata,
1579 "client-123",
1580 &json!({
1581 "id_token": unsigned_jwt_claims(json!({
1582 "tid": "tenant-123",
1583 "oid": "user-123"
1584 }))
1585 }),
1586 )
1587 .unwrap();
1588
1589 assert_eq!(
1590 mapped.get("tenant_id").map(String::as_str),
1591 Some("tenant-123")
1592 );
1593 assert_eq!(mapped.get("user_id").map(String::as_str), Some("user-123"));
1594 }
1595
1596 #[tokio::test]
1597 async fn poll_persists_device_outputs_to_runtime_config_and_secrets() {
1598 let temp = tempfile::tempdir().unwrap();
1599 let bundle = temp.path();
1600 let provider_id = "messaging-teams";
1601 let tenant = "demo";
1602 let team = "default";
1603 let action_id = "teams-device-code";
1604 let config_dir = bundle.join("state/config").join(provider_id);
1605 std::fs::create_dir_all(&config_dir).unwrap();
1606 std::fs::write(
1607 config_dir.join("setup-answers.json"),
1608 serde_json::to_string_pretty(&json!({
1609 "client_id": "client-123",
1610 "public_base_url": "https://tunnel.example"
1611 }))
1612 .unwrap(),
1613 )
1614 .unwrap();
1615 crate::setup_actions::persist_setup_actions(
1616 bundle,
1617 &[crate::setup_actions::SetupAction {
1618 id: action_id.into(),
1619 kind: crate::setup_actions::SetupActionKind::OauthDeviceCode,
1620 label: "Connect Teams".into(),
1621 provider_id: provider_id.into(),
1622 tenant: tenant.into(),
1623 team: Some(team.into()),
1624 authorize_url: None,
1625 callback_path: None,
1626 state: None,
1627 status: crate::setup_actions::SetupActionStatus::Pending,
1628 created_at: None,
1629 completed_at: None,
1630 extra: JsonMap::new(),
1631 }],
1632 )
1633 .unwrap();
1634
1635 let session = OAuthDeviceSessionState {
1636 session_id: "session-1".into(),
1637 provider_id: provider_id.into(),
1638 tenant: tenant.into(),
1639 team: team.into(),
1640 action_id: action_id.into(),
1641 device_code: "device-code".into(),
1642 client_id: "client-123".into(),
1643 interval: 5,
1644 expires_at: crate::setup_actions::current_epoch_secs() + 900,
1645 created_at: crate::setup_actions::current_epoch_secs(),
1646 };
1647 save_session(bundle, &session).unwrap();
1648
1649 let mut metadata = metadata();
1650 metadata
1651 .secrets_out
1652 .insert("access_token".into(), "MS_GRAPH_ACCESS_TOKEN".into());
1653 metadata.config_out = BTreeMap::from([
1654 ("tenant_id".into(), "tenant_id".into()),
1655 ("team_id".into(), "team_id".into()),
1656 ("team_name".into(), "team_name".into()),
1657 ("channel_id".into(), "channel_id".into()),
1658 ("channel_name".into(), "channel_name".into()),
1659 ]);
1660
1661 let report = poll_oauth_device_code_with_token_response(
1662 bundle,
1663 "dev",
1664 &session,
1665 &metadata,
1666 &json!({
1667 "refresh_token": "refresh-123",
1668 "access_token": "access-123",
1669 "tenant_id": "tenant-123",
1670 "team_id": "team-123",
1671 "team_name": "Support",
1672 "channel_id": "channel-123",
1673 "channel_name": "Greentic"
1674 }),
1675 )
1676 .await
1677 .unwrap();
1678
1679 assert_eq!(report.status, OAuthDevicePollStatus::Complete);
1680
1681 let answers = load_provider_setup_answers(bundle, provider_id).unwrap();
1682 assert_eq!(answers["client_id"], json!("client-123"));
1683 assert_eq!(answers["public_base_url"], json!("https://tunnel.example"));
1684 assert_eq!(answers["tenant_id"], json!("tenant-123"));
1685 assert_eq!(answers["team_id"], json!("team-123"));
1686 assert_eq!(answers["team_name"], json!("Support"));
1687 assert_eq!(answers["channel_id"], json!("channel-123"));
1688 assert_eq!(answers["channel_name"], json!("Greentic"));
1689 assert!(answers.get("MS_GRAPH_REFRESH_TOKEN").is_none());
1690 assert!(answers.get("MS_GRAPH_ACCESS_TOKEN").is_none());
1691
1692 let store = crate::secrets::open_dev_store(bundle).unwrap();
1693 let refresh_uri = crate::canonical_secret_uri(
1694 "dev",
1695 tenant,
1696 Some(team),
1697 provider_id,
1698 "MS_GRAPH_REFRESH_TOKEN",
1699 );
1700 let refresh = String::from_utf8(store.get(&refresh_uri).await.unwrap()).unwrap();
1701 assert_eq!(refresh, "refresh-123");
1702 }
1703
1704 #[tokio::test]
1705 async fn poll_invokes_apply_answers_before_completing_and_persists_provider_config() {
1706 let temp = tempfile::tempdir().unwrap();
1707 let bundle = temp.path();
1708 let provider_id = "messaging-teams";
1709 let tenant = "demo";
1710 let team = "default";
1711 let action_id = "teams-device-code";
1712 write_setup_answers(
1713 bundle,
1714 provider_id,
1715 json!({
1716 "client_id": "client-123",
1717 "public_base_url": "https://tunnel.example",
1718 "desired_channel_name": "hr onboarding"
1719 }),
1720 );
1721 setup_pending_oauth_action(bundle, provider_id, tenant, team, action_id);
1722 write_mock_provider_pack(
1723 bundle,
1724 provider_id,
1725 json!({
1726 "ok": true,
1727 "config": {
1728 "client_id": "client-123",
1729 "user_id": "user-123",
1730 "tenant_id": "tenant-123",
1731 "team_id": "team-123",
1732 "team_name": "Greentic AI Ltd",
1733 "channel_id": "hr-channel-123",
1734 "channel_name": "hr onboarding",
1735 "desired_channel_name": "hr onboarding",
1736 "refresh_token": "refresh-123",
1737 "access_token": "access-123"
1738 }
1739 }),
1740 )
1741 .unwrap();
1742
1743 let session = session_state(provider_id, tenant, team, action_id);
1744 save_session(bundle, &session).unwrap();
1745 let report = poll_oauth_device_code_with_token_response(
1746 bundle,
1747 "dev",
1748 &session,
1749 &metadata_with_apply_answers(),
1750 &json!({
1751 "refresh_token": "refresh-123",
1752 "access_token": "access-123",
1753 "id_token": unsigned_jwt_claims(json!({"tid": "tenant-123"})),
1754 "user_id": "user-123",
1755 "team_id": "team-123",
1756 "team_name": "Greentic AI Ltd",
1757 "channel_id": "general-channel-123",
1758 "channel_name": "General"
1759 }),
1760 )
1761 .await
1762 .unwrap();
1763
1764 assert_eq!(report.status, OAuthDevicePollStatus::Complete);
1765 let action =
1766 crate::setup_actions::load_setup_action(bundle, tenant, team, provider_id, action_id)
1767 .unwrap()
1768 .unwrap();
1769 assert_eq!(
1770 action.status,
1771 crate::setup_actions::SetupActionStatus::Complete
1772 );
1773
1774 let answers = load_provider_setup_answers(bundle, provider_id).unwrap();
1775 assert_eq!(answers["client_id"], json!("client-123"));
1776 assert_eq!(answers["user_id"], json!("user-123"));
1777 assert_eq!(answers["team_id"], json!("team-123"));
1778 assert_eq!(answers["team_name"], json!("Greentic AI Ltd"));
1779 assert_eq!(answers["channel_id"], json!("hr-channel-123"));
1780 assert_eq!(answers["channel_name"], json!("hr onboarding"));
1781 assert_eq!(answers["desired_channel_name"], json!("hr onboarding"));
1782 assert!(answers.get("refresh_token").is_none());
1783 assert!(answers.get("access_token").is_none());
1784 assert!(answers.get("MS_GRAPH_REFRESH_TOKEN").is_none());
1785 assert!(answers.get("MS_GRAPH_ACCESS_TOKEN").is_none());
1786
1787 let store = crate::secrets::open_dev_store(bundle).unwrap();
1788 let refresh_uri = crate::canonical_secret_uri(
1789 "dev",
1790 tenant,
1791 Some(team),
1792 provider_id,
1793 "MS_GRAPH_REFRESH_TOKEN",
1794 );
1795 let access_uri = crate::canonical_secret_uri(
1796 "dev",
1797 tenant,
1798 Some(team),
1799 provider_id,
1800 "MS_GRAPH_ACCESS_TOKEN",
1801 );
1802 let refresh = String::from_utf8(store.get(&refresh_uri).await.unwrap()).unwrap();
1803 let access = String::from_utf8(store.get(&access_uri).await.unwrap()).unwrap();
1804 assert_eq!(refresh, "refresh-123");
1805 assert_eq!(access, "access-123");
1806 }
1807
1808 #[tokio::test]
1809 async fn poll_does_not_complete_when_apply_answers_returns_not_ok() {
1810 let temp = tempfile::tempdir().unwrap();
1811 let bundle = temp.path();
1812 let provider_id = "messaging-teams";
1813 let tenant = "demo";
1814 let team = "default";
1815 let action_id = "teams-device-code";
1816 write_setup_answers(
1817 bundle,
1818 provider_id,
1819 json!({
1820 "client_id": "client-123",
1821 "desired_channel_name": "hr onboarding"
1822 }),
1823 );
1824 setup_pending_oauth_action(bundle, provider_id, tenant, team, action_id);
1825 write_mock_provider_pack(
1826 bundle,
1827 provider_id,
1828 json!({
1829 "ok": false,
1830 "error": "cannot create channel"
1831 }),
1832 )
1833 .unwrap();
1834
1835 let session = session_state(provider_id, tenant, team, action_id);
1836 save_session(bundle, &session).unwrap();
1837 let error = poll_oauth_device_code_with_token_response(
1838 bundle,
1839 "dev",
1840 &session,
1841 &metadata_with_apply_answers(),
1842 &json!({
1843 "refresh_token": "refresh-123",
1844 "access_token": "access-123",
1845 "tenant_id": "tenant-123",
1846 "user_id": "user-123",
1847 "team_id": "team-123",
1848 "team_name": "Greentic AI Ltd",
1849 "channel_id": "general-channel-123",
1850 "channel_name": "General"
1851 }),
1852 )
1853 .await
1854 .unwrap_err()
1855 .to_string();
1856
1857 assert!(error.contains("cannot create channel"));
1858 let action =
1859 crate::setup_actions::load_setup_action(bundle, tenant, team, provider_id, action_id)
1860 .unwrap()
1861 .unwrap();
1862 assert_eq!(
1863 action.status,
1864 crate::setup_actions::SetupActionStatus::Pending
1865 );
1866 }
1867
1868 #[test]
1869 fn apply_answers_request_includes_existing_answers_discovery_and_raw_tokens() {
1870 let request = json_apply_answers_request(
1871 &json!({
1872 "client_id": "client-123",
1873 "desired_channel_name": "hr onboarding"
1874 }),
1875 &metadata_with_apply_answers(),
1876 &json!({
1877 "refresh_token": "refresh-123",
1878 "access_token": "access-123",
1879 "tenant_id": "tenant-123",
1880 "team_id": "team-123",
1881 "channel_id": "general-channel-123",
1882 "channel_name": "General"
1883 }),
1884 "client-123",
1885 &BTreeMap::from([
1886 ("MS_GRAPH_REFRESH_TOKEN".into(), "refresh-123".into()),
1887 ("MS_GRAPH_ACCESS_TOKEN".into(), "access-123".into()),
1888 ("tenant_id".into(), "tenant-123".into()),
1889 ("team_id".into(), "team-123".into()),
1890 ("channel_id".into(), "general-channel-123".into()),
1891 ("channel_name".into(), "General".into()),
1892 ]),
1893 )
1894 .unwrap();
1895
1896 let answers = &request["answers"];
1897 assert_eq!(answers["desired_channel_name"], json!("hr onboarding"));
1898 assert_eq!(answers["refresh_token"], json!("refresh-123"));
1899 assert_eq!(answers["access_token"], json!("access-123"));
1900 assert_eq!(answers["MS_GRAPH_REFRESH_TOKEN"], json!("refresh-123"));
1901 assert_eq!(answers["team_id"], json!("team-123"));
1902 assert_eq!(answers["channel_name"], json!("General"));
1903 }
1904
1905 #[test]
1906 fn discovery_saves_scalars_and_selected_values() {
1907 let mut metadata = metadata();
1908 metadata.post_login_discovery = vec![
1909 DiscoveryStep {
1910 id: "me".into(),
1911 method: "GET".into(),
1912 url: Some("https://graph.example/me".into()),
1913 url_template: None,
1914 requires: Vec::new(),
1915 save: BTreeMap::from([("id".into(), "user_id".into())]),
1916 select: None,
1917 },
1918 DiscoveryStep {
1919 id: "teams".into(),
1920 method: "GET".into(),
1921 url: Some("https://graph.example/joinedTeams".into()),
1922 url_template: None,
1923 requires: Vec::new(),
1924 save: BTreeMap::new(),
1925 select: Some(DiscoverySelect {
1926 from: "value".into(),
1927 label: "displayName".into(),
1928 value: "id".into(),
1929 save_as: "team_id".into(),
1930 label_save_as: None,
1931 default_label: None,
1932 default_filter: None,
1933 }),
1934 },
1935 DiscoveryStep {
1936 id: "channels".into(),
1937 method: "GET".into(),
1938 url: None,
1939 url_template: Some("https://graph.example/teams/{team_id}/channels".into()),
1940 requires: vec!["team_id".into()],
1941 save: BTreeMap::new(),
1942 select: Some(DiscoverySelect {
1943 from: "value".into(),
1944 label: "displayName".into(),
1945 value: "id".into(),
1946 save_as: "channel_id".into(),
1947 label_save_as: None,
1948 default_label: Some("Ops".into()),
1949 default_filter: None,
1950 }),
1951 },
1952 ];
1953 let responses = BTreeMap::from([
1954 ("me".into(), json!({"id": "user-1"})),
1955 (
1956 "teams".into(),
1957 json!({"value": [
1958 {"id": "team-1", "displayName": "One"},
1959 {"id": "team-2", "displayName": "Two"}
1960 ]}),
1961 ),
1962 (
1963 "channels".into(),
1964 json!({"value": [
1965 {"id": "channel-1", "displayName": "General"},
1966 {"id": "channel-2", "displayName": "Ops"}
1967 ]}),
1968 ),
1969 ]);
1970 let values =
1971 execute_post_login_discovery_with_responses(&metadata, &responses, |step, _| {
1972 if step.id == "teams" { 1 } else { 0 }
1973 })
1974 .unwrap();
1975 assert_eq!(values.get("user_id").map(String::as_str), Some("user-1"));
1976 assert_eq!(values.get("team_id").map(String::as_str), Some("team-2"));
1977 assert_eq!(values.get("team_name").map(String::as_str), Some("Two"));
1978 assert_eq!(
1979 values.get("channel_id").map(String::as_str),
1980 Some("channel-2")
1981 );
1982 assert_eq!(values.get("channel_name").map(String::as_str), Some("Ops"));
1983 }
1984
1985 #[test]
1986 fn discovery_reports_missing_requirements_and_bad_selects() {
1987 let mut metadata = metadata();
1988 metadata.post_login_discovery = vec![DiscoveryStep {
1989 id: "channels".into(),
1990 method: "GET".into(),
1991 url: None,
1992 url_template: Some("https://graph.example/teams/{team_id}/channels".into()),
1993 requires: vec!["team_id".into()],
1994 save: BTreeMap::new(),
1995 select: None,
1996 }];
1997 let error =
1998 execute_post_login_discovery_with_responses(&metadata, &BTreeMap::new(), |_, _| 0)
1999 .unwrap_err()
2000 .to_string();
2001 assert!(error.contains("requires missing value team_id"));
2002
2003 let step = DiscoveryStep {
2004 id: "teams".into(),
2005 method: "GET".into(),
2006 url: Some("https://graph.example/joinedTeams".into()),
2007 url_template: None,
2008 requires: Vec::new(),
2009 save: BTreeMap::new(),
2010 select: Some(DiscoverySelect {
2011 from: "value".into(),
2012 label: "displayName".into(),
2013 value: "id".into(),
2014 save_as: "team_id".into(),
2015 label_save_as: None,
2016 default_label: None,
2017 default_filter: None,
2018 }),
2019 };
2020 let error = apply_discovery_step(&step, &json!({"value": []}), |_| 0)
2021 .unwrap_err()
2022 .to_string();
2023 assert!(error.contains("returned no selectable items"));
2024
2025 let error = apply_discovery_step(&step, &json!({"value": [{"displayName": "One"}]}), |_| 0)
2026 .unwrap_err()
2027 .to_string();
2028 assert!(error.contains("selected item missing value"));
2029 }
2030
2031 #[test]
2032 fn discovery_http_error_includes_step_status_url_and_body() {
2033 let step = DiscoveryStep {
2034 id: "joined_teams".into(),
2035 method: "GET".into(),
2036 url: Some("https://graph.example/me/joinedTeams".into()),
2037 url_template: None,
2038 requires: Vec::new(),
2039 save: BTreeMap::new(),
2040 select: None,
2041 };
2042
2043 let message = discovery_http_error_message(
2044 &step,
2045 "https://graph.example/me/joinedTeams",
2046 403,
2047 r#"{
2048 "error": {
2049 "code": "Authorization_RequestDenied",
2050 "message": "Insufficient privileges to complete the operation."
2051 }
2052 }"#,
2053 );
2054
2055 assert!(message.contains("joined_teams"));
2056 assert!(message.contains("HTTP 403"));
2057 assert!(message.contains("https://graph.example/me/joinedTeams"));
2058 assert!(message.contains("Authorization_RequestDenied"));
2059 assert!(message.contains("Insufficient privileges"));
2060 }
2061}