Skip to main content

greentic_setup/
oauth_device.rs

1//! Provider-agnostic OAuth device-code setup helpers.
2
3use 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 _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
1599        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
1600        let temp = tempfile::tempdir().unwrap();
1601        let bundle = temp.path();
1602        let provider_id = "messaging-teams";
1603        let tenant = "demo";
1604        let team = "default";
1605        let action_id = "teams-device-code";
1606        let config_dir = bundle.join("state/config").join(provider_id);
1607        std::fs::create_dir_all(&config_dir).unwrap();
1608        std::fs::write(
1609            config_dir.join("setup-answers.json"),
1610            serde_json::to_string_pretty(&json!({
1611                "client_id": "client-123",
1612                "public_base_url": "https://tunnel.example"
1613            }))
1614            .unwrap(),
1615        )
1616        .unwrap();
1617        crate::setup_actions::persist_setup_actions(
1618            bundle,
1619            &[crate::setup_actions::SetupAction {
1620                id: action_id.into(),
1621                kind: crate::setup_actions::SetupActionKind::OauthDeviceCode,
1622                label: "Connect Teams".into(),
1623                provider_id: provider_id.into(),
1624                tenant: tenant.into(),
1625                team: Some(team.into()),
1626                authorize_url: None,
1627                callback_path: None,
1628                state: None,
1629                status: crate::setup_actions::SetupActionStatus::Pending,
1630                created_at: None,
1631                completed_at: None,
1632                extra: JsonMap::new(),
1633            }],
1634        )
1635        .unwrap();
1636
1637        let session = OAuthDeviceSessionState {
1638            session_id: "session-1".into(),
1639            provider_id: provider_id.into(),
1640            tenant: tenant.into(),
1641            team: team.into(),
1642            action_id: action_id.into(),
1643            device_code: "device-code".into(),
1644            client_id: "client-123".into(),
1645            interval: 5,
1646            expires_at: crate::setup_actions::current_epoch_secs() + 900,
1647            created_at: crate::setup_actions::current_epoch_secs(),
1648        };
1649        save_session(bundle, &session).unwrap();
1650
1651        let mut metadata = metadata();
1652        metadata
1653            .secrets_out
1654            .insert("access_token".into(), "MS_GRAPH_ACCESS_TOKEN".into());
1655        metadata.config_out = BTreeMap::from([
1656            ("tenant_id".into(), "tenant_id".into()),
1657            ("team_id".into(), "team_id".into()),
1658            ("team_name".into(), "team_name".into()),
1659            ("channel_id".into(), "channel_id".into()),
1660            ("channel_name".into(), "channel_name".into()),
1661        ]);
1662
1663        let report = poll_oauth_device_code_with_token_response(
1664            bundle,
1665            "dev",
1666            &session,
1667            &metadata,
1668            &json!({
1669                "refresh_token": "refresh-123",
1670                "access_token": "access-123",
1671                "tenant_id": "tenant-123",
1672                "team_id": "team-123",
1673                "team_name": "Support",
1674                "channel_id": "channel-123",
1675                "channel_name": "Greentic"
1676            }),
1677        )
1678        .await
1679        .unwrap();
1680
1681        assert_eq!(report.status, OAuthDevicePollStatus::Complete);
1682
1683        let answers = load_provider_setup_answers(bundle, provider_id).unwrap();
1684        assert_eq!(answers["client_id"], json!("client-123"));
1685        assert_eq!(answers["public_base_url"], json!("https://tunnel.example"));
1686        assert_eq!(answers["tenant_id"], json!("tenant-123"));
1687        assert_eq!(answers["team_id"], json!("team-123"));
1688        assert_eq!(answers["team_name"], json!("Support"));
1689        assert_eq!(answers["channel_id"], json!("channel-123"));
1690        assert_eq!(answers["channel_name"], json!("Greentic"));
1691        assert!(answers.get("MS_GRAPH_REFRESH_TOKEN").is_none());
1692        assert!(answers.get("MS_GRAPH_ACCESS_TOKEN").is_none());
1693
1694        let store = crate::secrets::open_dev_store_for_env(bundle, "dev").unwrap();
1695        let refresh_uri = crate::canonical_secret_uri(
1696            "dev",
1697            tenant,
1698            Some(team),
1699            provider_id,
1700            "MS_GRAPH_REFRESH_TOKEN",
1701        );
1702        let refresh = String::from_utf8(store.get(&refresh_uri).await.unwrap()).unwrap();
1703        assert_eq!(refresh, "refresh-123");
1704    }
1705
1706    #[tokio::test]
1707    async fn poll_invokes_apply_answers_before_completing_and_persists_provider_config() {
1708        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
1709        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
1710        let temp = tempfile::tempdir().unwrap();
1711        let bundle = temp.path();
1712        let provider_id = "messaging-teams";
1713        let tenant = "demo";
1714        let team = "default";
1715        let action_id = "teams-device-code";
1716        write_setup_answers(
1717            bundle,
1718            provider_id,
1719            json!({
1720                "client_id": "client-123",
1721                "public_base_url": "https://tunnel.example",
1722                "desired_channel_name": "hr onboarding"
1723            }),
1724        );
1725        setup_pending_oauth_action(bundle, provider_id, tenant, team, action_id);
1726        write_mock_provider_pack(
1727            bundle,
1728            provider_id,
1729            json!({
1730                "ok": true,
1731                "config": {
1732                    "client_id": "client-123",
1733                    "user_id": "user-123",
1734                    "tenant_id": "tenant-123",
1735                    "team_id": "team-123",
1736                    "team_name": "Greentic AI Ltd",
1737                    "channel_id": "hr-channel-123",
1738                    "channel_name": "hr onboarding",
1739                    "desired_channel_name": "hr onboarding",
1740                    "refresh_token": "refresh-123",
1741                    "access_token": "access-123"
1742                }
1743            }),
1744        )
1745        .unwrap();
1746
1747        let session = session_state(provider_id, tenant, team, action_id);
1748        save_session(bundle, &session).unwrap();
1749        let report = poll_oauth_device_code_with_token_response(
1750            bundle,
1751            "dev",
1752            &session,
1753            &metadata_with_apply_answers(),
1754            &json!({
1755                "refresh_token": "refresh-123",
1756                "access_token": "access-123",
1757                "id_token": unsigned_jwt_claims(json!({"tid": "tenant-123"})),
1758                "user_id": "user-123",
1759                "team_id": "team-123",
1760                "team_name": "Greentic AI Ltd",
1761                "channel_id": "general-channel-123",
1762                "channel_name": "General"
1763            }),
1764        )
1765        .await
1766        .unwrap();
1767
1768        assert_eq!(report.status, OAuthDevicePollStatus::Complete);
1769        let action =
1770            crate::setup_actions::load_setup_action(bundle, tenant, team, provider_id, action_id)
1771                .unwrap()
1772                .unwrap();
1773        assert_eq!(
1774            action.status,
1775            crate::setup_actions::SetupActionStatus::Complete
1776        );
1777
1778        let answers = load_provider_setup_answers(bundle, provider_id).unwrap();
1779        assert_eq!(answers["client_id"], json!("client-123"));
1780        assert_eq!(answers["user_id"], json!("user-123"));
1781        assert_eq!(answers["team_id"], json!("team-123"));
1782        assert_eq!(answers["team_name"], json!("Greentic AI Ltd"));
1783        assert_eq!(answers["channel_id"], json!("hr-channel-123"));
1784        assert_eq!(answers["channel_name"], json!("hr onboarding"));
1785        assert_eq!(answers["desired_channel_name"], json!("hr onboarding"));
1786        assert!(answers.get("refresh_token").is_none());
1787        assert!(answers.get("access_token").is_none());
1788        assert!(answers.get("MS_GRAPH_REFRESH_TOKEN").is_none());
1789        assert!(answers.get("MS_GRAPH_ACCESS_TOKEN").is_none());
1790
1791        let store = crate::secrets::open_dev_store_for_env(bundle, "dev").unwrap();
1792        let refresh_uri = crate::canonical_secret_uri(
1793            "dev",
1794            tenant,
1795            Some(team),
1796            provider_id,
1797            "MS_GRAPH_REFRESH_TOKEN",
1798        );
1799        let access_uri = crate::canonical_secret_uri(
1800            "dev",
1801            tenant,
1802            Some(team),
1803            provider_id,
1804            "MS_GRAPH_ACCESS_TOKEN",
1805        );
1806        let refresh = String::from_utf8(store.get(&refresh_uri).await.unwrap()).unwrap();
1807        let access = String::from_utf8(store.get(&access_uri).await.unwrap()).unwrap();
1808        assert_eq!(refresh, "refresh-123");
1809        assert_eq!(access, "access-123");
1810    }
1811
1812    #[tokio::test]
1813    async fn poll_does_not_complete_when_apply_answers_returns_not_ok() {
1814        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
1815        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
1816        let temp = tempfile::tempdir().unwrap();
1817        let bundle = temp.path();
1818        let provider_id = "messaging-teams";
1819        let tenant = "demo";
1820        let team = "default";
1821        let action_id = "teams-device-code";
1822        write_setup_answers(
1823            bundle,
1824            provider_id,
1825            json!({
1826                "client_id": "client-123",
1827                "desired_channel_name": "hr onboarding"
1828            }),
1829        );
1830        setup_pending_oauth_action(bundle, provider_id, tenant, team, action_id);
1831        write_mock_provider_pack(
1832            bundle,
1833            provider_id,
1834            json!({
1835                "ok": false,
1836                "error": "cannot create channel"
1837            }),
1838        )
1839        .unwrap();
1840
1841        let session = session_state(provider_id, tenant, team, action_id);
1842        save_session(bundle, &session).unwrap();
1843        let error = poll_oauth_device_code_with_token_response(
1844            bundle,
1845            "dev",
1846            &session,
1847            &metadata_with_apply_answers(),
1848            &json!({
1849                "refresh_token": "refresh-123",
1850                "access_token": "access-123",
1851                "tenant_id": "tenant-123",
1852                "user_id": "user-123",
1853                "team_id": "team-123",
1854                "team_name": "Greentic AI Ltd",
1855                "channel_id": "general-channel-123",
1856                "channel_name": "General"
1857            }),
1858        )
1859        .await
1860        .unwrap_err()
1861        .to_string();
1862
1863        assert!(error.contains("cannot create channel"));
1864        let action =
1865            crate::setup_actions::load_setup_action(bundle, tenant, team, provider_id, action_id)
1866                .unwrap()
1867                .unwrap();
1868        assert_eq!(
1869            action.status,
1870            crate::setup_actions::SetupActionStatus::Pending
1871        );
1872    }
1873
1874    #[test]
1875    fn apply_answers_request_includes_existing_answers_discovery_and_raw_tokens() {
1876        let request = json_apply_answers_request(
1877            &json!({
1878                "client_id": "client-123",
1879                "desired_channel_name": "hr onboarding"
1880            }),
1881            &metadata_with_apply_answers(),
1882            &json!({
1883                "refresh_token": "refresh-123",
1884                "access_token": "access-123",
1885                "tenant_id": "tenant-123",
1886                "team_id": "team-123",
1887                "channel_id": "general-channel-123",
1888                "channel_name": "General"
1889            }),
1890            "client-123",
1891            &BTreeMap::from([
1892                ("MS_GRAPH_REFRESH_TOKEN".into(), "refresh-123".into()),
1893                ("MS_GRAPH_ACCESS_TOKEN".into(), "access-123".into()),
1894                ("tenant_id".into(), "tenant-123".into()),
1895                ("team_id".into(), "team-123".into()),
1896                ("channel_id".into(), "general-channel-123".into()),
1897                ("channel_name".into(), "General".into()),
1898            ]),
1899        )
1900        .unwrap();
1901
1902        let answers = &request["answers"];
1903        assert_eq!(answers["desired_channel_name"], json!("hr onboarding"));
1904        assert_eq!(answers["refresh_token"], json!("refresh-123"));
1905        assert_eq!(answers["access_token"], json!("access-123"));
1906        assert_eq!(answers["MS_GRAPH_REFRESH_TOKEN"], json!("refresh-123"));
1907        assert_eq!(answers["team_id"], json!("team-123"));
1908        assert_eq!(answers["channel_name"], json!("General"));
1909    }
1910
1911    #[test]
1912    fn discovery_saves_scalars_and_selected_values() {
1913        let mut metadata = metadata();
1914        metadata.post_login_discovery = vec![
1915            DiscoveryStep {
1916                id: "me".into(),
1917                method: "GET".into(),
1918                url: Some("https://graph.example/me".into()),
1919                url_template: None,
1920                requires: Vec::new(),
1921                save: BTreeMap::from([("id".into(), "user_id".into())]),
1922                select: None,
1923            },
1924            DiscoveryStep {
1925                id: "teams".into(),
1926                method: "GET".into(),
1927                url: Some("https://graph.example/joinedTeams".into()),
1928                url_template: None,
1929                requires: Vec::new(),
1930                save: BTreeMap::new(),
1931                select: Some(DiscoverySelect {
1932                    from: "value".into(),
1933                    label: "displayName".into(),
1934                    value: "id".into(),
1935                    save_as: "team_id".into(),
1936                    label_save_as: None,
1937                    default_label: None,
1938                    default_filter: None,
1939                }),
1940            },
1941            DiscoveryStep {
1942                id: "channels".into(),
1943                method: "GET".into(),
1944                url: None,
1945                url_template: Some("https://graph.example/teams/{team_id}/channels".into()),
1946                requires: vec!["team_id".into()],
1947                save: BTreeMap::new(),
1948                select: Some(DiscoverySelect {
1949                    from: "value".into(),
1950                    label: "displayName".into(),
1951                    value: "id".into(),
1952                    save_as: "channel_id".into(),
1953                    label_save_as: None,
1954                    default_label: Some("Ops".into()),
1955                    default_filter: None,
1956                }),
1957            },
1958        ];
1959        let responses = BTreeMap::from([
1960            ("me".into(), json!({"id": "user-1"})),
1961            (
1962                "teams".into(),
1963                json!({"value": [
1964                    {"id": "team-1", "displayName": "One"},
1965                    {"id": "team-2", "displayName": "Two"}
1966                ]}),
1967            ),
1968            (
1969                "channels".into(),
1970                json!({"value": [
1971                    {"id": "channel-1", "displayName": "General"},
1972                    {"id": "channel-2", "displayName": "Ops"}
1973                ]}),
1974            ),
1975        ]);
1976        let values =
1977            execute_post_login_discovery_with_responses(&metadata, &responses, |step, _| {
1978                if step.id == "teams" { 1 } else { 0 }
1979            })
1980            .unwrap();
1981        assert_eq!(values.get("user_id").map(String::as_str), Some("user-1"));
1982        assert_eq!(values.get("team_id").map(String::as_str), Some("team-2"));
1983        assert_eq!(values.get("team_name").map(String::as_str), Some("Two"));
1984        assert_eq!(
1985            values.get("channel_id").map(String::as_str),
1986            Some("channel-2")
1987        );
1988        assert_eq!(values.get("channel_name").map(String::as_str), Some("Ops"));
1989    }
1990
1991    #[test]
1992    fn discovery_reports_missing_requirements_and_bad_selects() {
1993        let mut metadata = metadata();
1994        metadata.post_login_discovery = vec![DiscoveryStep {
1995            id: "channels".into(),
1996            method: "GET".into(),
1997            url: None,
1998            url_template: Some("https://graph.example/teams/{team_id}/channels".into()),
1999            requires: vec!["team_id".into()],
2000            save: BTreeMap::new(),
2001            select: None,
2002        }];
2003        let error =
2004            execute_post_login_discovery_with_responses(&metadata, &BTreeMap::new(), |_, _| 0)
2005                .unwrap_err()
2006                .to_string();
2007        assert!(error.contains("requires missing value team_id"));
2008
2009        let step = DiscoveryStep {
2010            id: "teams".into(),
2011            method: "GET".into(),
2012            url: Some("https://graph.example/joinedTeams".into()),
2013            url_template: None,
2014            requires: Vec::new(),
2015            save: BTreeMap::new(),
2016            select: Some(DiscoverySelect {
2017                from: "value".into(),
2018                label: "displayName".into(),
2019                value: "id".into(),
2020                save_as: "team_id".into(),
2021                label_save_as: None,
2022                default_label: None,
2023                default_filter: None,
2024            }),
2025        };
2026        let error = apply_discovery_step(&step, &json!({"value": []}), |_| 0)
2027            .unwrap_err()
2028            .to_string();
2029        assert!(error.contains("returned no selectable items"));
2030
2031        let error = apply_discovery_step(&step, &json!({"value": [{"displayName": "One"}]}), |_| 0)
2032            .unwrap_err()
2033            .to_string();
2034        assert!(error.contains("selected item missing value"));
2035    }
2036
2037    #[test]
2038    fn discovery_http_error_includes_step_status_url_and_body() {
2039        let step = DiscoveryStep {
2040            id: "joined_teams".into(),
2041            method: "GET".into(),
2042            url: Some("https://graph.example/me/joinedTeams".into()),
2043            url_template: None,
2044            requires: Vec::new(),
2045            save: BTreeMap::new(),
2046            select: None,
2047        };
2048
2049        let message = discovery_http_error_message(
2050            &step,
2051            "https://graph.example/me/joinedTeams",
2052            403,
2053            r#"{
2054                "error": {
2055                    "code": "Authorization_RequestDenied",
2056                    "message": "Insufficient privileges to complete the operation."
2057                }
2058            }"#,
2059        );
2060
2061        assert!(message.contains("joined_teams"));
2062        assert!(message.contains("HTTP 403"));
2063        assert!(message.contains("https://graph.example/me/joinedTeams"));
2064        assert!(message.contains("Authorization_RequestDenied"));
2065        assert!(message.contains("Insufficient privileges"));
2066    }
2067}