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