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