Skip to main content

faucet_auth/
flow.rs

1//! Composable multi-step auth **flow** provider (#511).
2//!
3//! Turns `AuthProvider` from a single-step credential source into a small
4//! declarative program: an optional login / pre-flight request chain whose
5//! responses are captured (by JSONPath into a JSON body, an XML dot-path, a
6//! response header, or a `Set-Cookie` value — #542), arbitrary credential
7//! *placement* (header / query / cookie / body), a pluggable HMAC request
8//! *signer* usable on both data requests **and** the login steps themselves
9//! (#541), and a dynamic per-session base-URL — on top of the existing
10//! single-flight machinery.
11//!
12//! ```yaml
13//! auth:
14//!   bullhorn:
15//!     type: flow
16//!     config:
17//!       steps:
18//!         - request: { method: POST, url: "https://auth/oauth/token",
19//!                      form: { grant_type: refresh_token, refresh_token: "..." } }
20//!           capture: { access_token: "$.access_token" }
21//!         - request: { method: GET, url: "https://login/rest/login",
22//!                      query: { access_token: "${access_token}" } }
23//!           capture: { bh_rest_token: "$.BhRestToken", base_url: "$.restUrl" }
24//!       apply:
25//!         - { into: query, name: BhRestToken, value: "${bh_rest_token}" }
26//!       base_url_from: "${base_url}"
27//!       ttl_secs: 86400
28//!       reauth_on: [401]
29//! ```
30
31use crate::auth_http_client;
32use async_trait::async_trait;
33use base64::Engine;
34use faucet_core::{AuthProvider, Credential, CredentialPlacement, FaucetError, RequestAuth};
35use hmac::{Hmac, Mac};
36use jsonpath_rust::JsonPath;
37use serde::Deserialize;
38use serde_json::Value;
39use sha2::Sha256;
40use std::collections::HashMap;
41use std::time::{SystemTime, UNIX_EPOCH};
42use tokio::sync::Mutex;
43use tokio::time::{Duration, Instant};
44
45type HmacSha256 = Hmac<Sha256>;
46
47// ── Config ──────────────────────────────────────────────────────────────────
48
49/// One HTTP request in the login / pre-flight chain.
50#[derive(Debug, Clone, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct FlowRequest {
53    #[serde(default = "default_method")]
54    method: String,
55    url: String,
56    #[serde(default)]
57    headers: HashMap<String, String>,
58    #[serde(default)]
59    query: HashMap<String, String>,
60    /// `application/x-www-form-urlencoded` body.
61    #[serde(default)]
62    form: Option<HashMap<String, String>>,
63    /// JSON body (mutually exclusive with `form`).
64    #[serde(default)]
65    json: Option<Value>,
66    /// Optional HMAC signature computed over `template` and attached to this
67    /// login/pre-flight request itself (#541). Uses a fresh `${ts}`/`${nonce}`
68    /// clock per step, the same semantics as an `apply` signer. On the **first**
69    /// step nothing has been captured yet, so its `template` may reference only
70    /// `${param.*}` / `${env:*}` / `${ts}` / `${nonce}` (later steps also see
71    /// values captured by earlier steps via `${name}`).
72    #[serde(default)]
73    sign: Option<SignSpec>,
74}
75
76fn default_method() -> String {
77    "GET".to_owned()
78}
79
80/// Where a login value is captured from (#542). Defaults to `json` for
81/// back-compat: `capture: { name: "$.jsonpath" }` (the bare-string form) still
82/// parses as a JSONPath into the JSON response body.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
84#[serde(rename_all = "snake_case")]
85enum CaptureFrom {
86    /// JSONPath into the JSON response body.
87    #[default]
88    Json,
89    /// Dot-path into an XML response body (element local names; namespace
90    /// prefixes are ignored — `ns:tag` matches `tag`).
91    Xml,
92    /// A response header value (looked up case-insensitively).
93    Header,
94    /// A specific `Set-Cookie` value, selected by cookie name.
95    SetCookie,
96}
97
98/// The structured (non-string) capture form: `{ from, name | path }`.
99#[derive(Debug, Clone, Deserialize)]
100#[serde(deny_unknown_fields)]
101struct CaptureSource {
102    from: CaptureFrom,
103    /// Header / cookie name (`from: header | set_cookie`).
104    #[serde(default)]
105    name: Option<String>,
106    /// JSONPath (`from: json`) or XML dot-path (`from: xml`).
107    #[serde(default)]
108    path: Option<String>,
109}
110
111/// How one login value is captured. Untagged so the historical bare-string
112/// JSONPath form stays valid (`{ name: "$.path" }` ⇒ `from: json`), while a
113/// struct form selects a richer source (#542).
114#[derive(Debug, Clone, Deserialize)]
115#[serde(untagged)]
116enum CaptureSpec {
117    /// `"$.jsonpath"` — JSONPath into the JSON response body (back-compat).
118    Json(String),
119    /// `{ from: json|xml|header|set_cookie, name|path }`.
120    Source(CaptureSource),
121}
122
123impl CaptureSpec {
124    fn kind(&self) -> CaptureFrom {
125        match self {
126            CaptureSpec::Json(_) => CaptureFrom::Json,
127            CaptureSpec::Source(s) => s.from,
128        }
129    }
130
131    /// Validate that the required selector field is present for the source.
132    fn validate(&self) -> Result<(), &'static str> {
133        match self {
134            CaptureSpec::Json(p) if p.trim().is_empty() => Err("empty JSONPath"),
135            CaptureSpec::Json(_) => Ok(()),
136            CaptureSpec::Source(s) => match s.from {
137                CaptureFrom::Json | CaptureFrom::Xml => match s.path.as_deref().map(str::trim) {
138                    Some(p) if !p.is_empty() => Ok(()),
139                    _ => Err("`from: json|xml` requires a non-empty `path`"),
140                },
141                CaptureFrom::Header | CaptureFrom::SetCookie => {
142                    match s.name.as_deref().map(str::trim) {
143                        Some(n) if !n.is_empty() => Ok(()),
144                        _ => Err("`from: header|set_cookie` requires a non-empty `name`"),
145                    }
146                }
147            },
148        }
149    }
150}
151
152/// A login step: a request plus the values to capture from its response.
153#[derive(Debug, Clone, Deserialize)]
154#[serde(deny_unknown_fields)]
155struct FlowStep {
156    request: FlowRequest,
157    /// Captured-name → capture source (JSONPath string for back-compat, or a
158    /// `{ from, name|path }` struct for header / Set-Cookie / XML sources).
159    #[serde(default)]
160    capture: HashMap<String, CaptureSpec>,
161}
162
163/// Where a captured credential is placed into data requests.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
165#[serde(rename_all = "snake_case")]
166enum PlaceTarget {
167    Header,
168    Query,
169    Cookie,
170    Body,
171}
172
173/// HMAC algorithm for the request signer.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
175#[serde(rename_all = "snake_case")]
176enum SignAlg {
177    HmacSha256,
178}
179
180/// Signature encoding.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
182#[serde(rename_all = "snake_case")]
183enum SigEncoding {
184    #[default]
185    Hex,
186    Base64,
187}
188
189/// Where a computed signature is placed (a header, with a value template).
190#[derive(Debug, Clone, Deserialize)]
191#[serde(deny_unknown_fields)]
192struct SignInto {
193    header: String,
194    /// Value template; `${sig}` is the computed signature. Defaults to `${sig}`.
195    #[serde(default = "default_sig_format")]
196    format: String,
197}
198
199fn default_sig_format() -> String {
200    "${sig}".to_owned()
201}
202
203/// A pluggable HMAC request signer.
204#[derive(Debug, Clone, Deserialize)]
205#[serde(deny_unknown_fields)]
206struct SignSpec {
207    alg: SignAlg,
208    /// HMAC key (resolve via `${env:...}` / `${vault:...}` in the catalog).
209    key: String,
210    /// The signature base string; `${captured}`, `${ts}`, `${nonce}` are
211    /// substituted before signing.
212    template: String,
213    #[serde(default)]
214    encoding: SigEncoding,
215    into: SignInto,
216}
217
218/// One `apply` entry: either a static placement or a computed signature.
219#[derive(Debug, Clone, Deserialize)]
220#[serde(untagged, deny_unknown_fields)]
221enum ApplySpec {
222    /// `{ into, name, value }` — place a (templated) value.
223    Place {
224        into: PlaceTarget,
225        name: String,
226        /// Value template; `${captured}` substituted.
227        value: String,
228    },
229    /// `{ sign: { ... } }` — compute and place an HMAC signature.
230    Sign { sign: SignSpec },
231}
232
233/// The `type: flow` provider config.
234#[derive(Debug, Clone, Deserialize)]
235#[serde(deny_unknown_fields)]
236struct FlowConfig {
237    /// Login / pre-flight chain, run in order; each captures values for later
238    /// steps and for `apply`.
239    #[serde(default)]
240    steps: Vec<FlowStep>,
241    /// Credential placements applied to every data request.
242    #[serde(default)]
243    apply: Vec<ApplySpec>,
244    /// Template (over captured values) yielding a per-session base-URL that
245    /// overrides the connector's configured `base_url`.
246    #[serde(default)]
247    base_url_from: Option<String>,
248    /// Re-run the login chain after this many seconds.
249    #[serde(default)]
250    ttl_secs: Option<u64>,
251    /// HTTP statuses that trigger a re-login (via `invalidate`). Advisory —
252    /// stored for connectors that wire status-based reauth.
253    #[serde(default)]
254    reauth_on: Vec<u16>,
255    // Future work (#542): an optional `cookie_jar: true` that shares a cookie
256    // store between the login client and the connector's HTTP client, so
257    // `Set-Cookie`s from login `steps` forward to data requests automatically.
258    // Out of scope here — the `capture: { from: set_cookie }` → `apply:
259    // { into: cookie }` path already expresses the same case (Acumatica).
260}
261
262impl FlowConfig {
263    fn validate(&self) -> Result<(), FaucetError> {
264        if self.steps.is_empty() && self.apply.is_empty() {
265            return Err(FaucetError::Config(
266                "flow auth: at least one of `steps` or `apply` is required".to_owned(),
267            ));
268        }
269        for (i, step) in self.steps.iter().enumerate() {
270            if step.request.url.trim().is_empty() {
271                return Err(FaucetError::Config(format!(
272                    "flow auth: step {i} has an empty `url`"
273                )));
274            }
275            if step.request.form.is_some() && step.request.json.is_some() {
276                return Err(FaucetError::Config(format!(
277                    "flow auth: step {i} sets both `form` and `json`; pick one"
278                )));
279            }
280            if let Some(sign) = &step.request.sign
281                && sign.into.header.trim().is_empty()
282            {
283                return Err(FaucetError::Config(format!(
284                    "flow auth: step {i} sign.into.header must not be empty"
285                )));
286            }
287            for (name, cap) in &step.capture {
288                if let Err(msg) = cap.validate() {
289                    return Err(FaucetError::Config(format!(
290                        "flow auth: step {i} capture '{name}': {msg}"
291                    )));
292                }
293            }
294        }
295        for (i, a) in self.apply.iter().enumerate() {
296            match a {
297                ApplySpec::Place { name, .. } if name.trim().is_empty() => {
298                    return Err(FaucetError::Config(format!(
299                        "flow auth: apply[{i}] has an empty `name`"
300                    )));
301                }
302                ApplySpec::Sign { sign } if sign.into.header.trim().is_empty() => {
303                    return Err(FaucetError::Config(format!(
304                        "flow auth: apply[{i}].sign.into.header must not be empty"
305                    )));
306                }
307                _ => {}
308            }
309        }
310        Ok(())
311    }
312}
313
314// ── Template rendering ───────────────────────────────────────────────────────
315
316/// Substitute `${key}` tokens from `ctx`. Unknown tokens are left verbatim.
317/// Single-pass — a substituted value is never re-scanned.
318fn render(template: &str, ctx: &HashMap<String, String>) -> String {
319    let mut out = String::with_capacity(template.len());
320    let mut rest = template;
321    while let Some(start) = rest.find("${") {
322        out.push_str(&rest[..start]);
323        let after = &rest[start + 2..];
324        if let Some(end) = after.find('}') {
325            let key = &after[..end];
326            match ctx.get(key) {
327                Some(v) => out.push_str(v),
328                None => {
329                    out.push_str("${");
330                    out.push_str(key);
331                    out.push('}');
332                }
333            }
334            rest = &after[end + 1..];
335        } else {
336            out.push_str(&rest[start..]);
337            rest = "";
338        }
339    }
340    out.push_str(rest);
341    out
342}
343
344/// A captured JSON value rendered to its string form for templating.
345fn value_to_string(v: &Value) -> String {
346    match v {
347        Value::String(s) => s.clone(),
348        Value::Bool(b) => b.to_string(),
349        Value::Number(n) => n.to_string(),
350        Value::Null => String::new(),
351        other => other.to_string(),
352    }
353}
354
355fn jsonpath_first(body: &Value, path: &str) -> Option<Value> {
356    let results = body.query(path).ok()?;
357    results.first().map(|v| (*v).clone())
358}
359
360fn to_hex(bytes: &[u8]) -> String {
361    let mut s = String::with_capacity(bytes.len() * 2);
362    for b in bytes {
363        s.push_str(&format!("{b:02x}"));
364    }
365    s
366}
367
368fn hmac_sign(key: &str, message: &str, encoding: SigEncoding) -> String {
369    let mut mac =
370        HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts a key of any length");
371    mac.update(message.as_bytes());
372    let bytes = mac.finalize().into_bytes();
373    match encoding {
374        SigEncoding::Hex => to_hex(&bytes),
375        SigEncoding::Base64 => base64::engine::general_purpose::STANDARD.encode(bytes),
376    }
377}
378
379/// Compute the `(header-name, header-value)` for a signer given the render
380/// context. `${captured}`, `${ts}`, `${nonce}` in `template` are substituted
381/// before signing; `${sig}` in `into.format` becomes the computed signature.
382fn sign_header(sign: &SignSpec, ctx: &HashMap<String, String>) -> (String, String) {
383    let base = render(&sign.template, ctx);
384    let sig = match sign.alg {
385        SignAlg::HmacSha256 => hmac_sign(&sign.key, &base, sign.encoding),
386    };
387    let mut sig_ctx = ctx.clone();
388    sig_ctx.insert("sig".to_owned(), sig);
389    let value = render(&sign.into.format, &sig_ctx);
390    (sign.into.header.clone(), value)
391}
392
393/// First response-header value (case-insensitive), as a string.
394fn header_value(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
395    headers
396        .get(name)
397        .and_then(|v| v.to_str().ok())
398        .map(|s| s.to_owned())
399}
400
401/// The value of a specific `Set-Cookie` cookie, selected by cookie name.
402fn set_cookie_value(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
403    for v in headers.get_all("set-cookie") {
404        let Ok(s) = v.to_str() else { continue };
405        // A Set-Cookie value is `name=value; attr; attr…`; the cookie pair is
406        // the first `;`-delimited segment.
407        let pair = s.split(';').next().unwrap_or("");
408        if let Some((k, val)) = pair.split_once('=')
409            && k.trim() == name
410        {
411            return Some(val.trim().to_owned());
412        }
413    }
414    None
415}
416
417// ── Minimal XML dot-path extraction (#542) ───────────────────────────────────
418//
419// A tiny, dependency-free XML walk: enough to pull a scalar (e.g. a session id)
420// out of a login response by element local name. Not a general XML parser — it
421// ignores attributes, namespace prefixes (`ns:tag` matches `tag`), and PIs, and
422// decodes only the five predefined entities. Deliberately kept in-crate rather
423// than depending on the XML *source* connector.
424
425#[derive(Debug)]
426struct XmlNode {
427    tag: String,
428    text: String,
429    children: Vec<XmlNode>,
430}
431
432enum XmlToken {
433    Start(String),
434    End,
435    SelfClose(String),
436    Text(String),
437}
438
439/// Local element name: strip a namespace prefix and any attributes.
440fn xml_local_name(raw: &str) -> String {
441    let name = raw.split_whitespace().next().unwrap_or("");
442    match name.split_once(':') {
443        Some((_, local)) => local.to_owned(),
444        None => name.to_owned(),
445    }
446}
447
448fn xml_unescape(s: &str) -> String {
449    s.replace("&lt;", "<")
450        .replace("&gt;", ">")
451        .replace("&quot;", "\"")
452        .replace("&apos;", "'")
453        .replace("&amp;", "&")
454}
455
456fn xml_tokenize(input: &str) -> Vec<XmlToken> {
457    let mut out = Vec::new();
458    let mut rest = input;
459    while let Some(lt) = rest.find('<') {
460        let text = &rest[..lt];
461        if !text.trim().is_empty() {
462            out.push(XmlToken::Text(xml_unescape(text)));
463        }
464        let after = &rest[lt..];
465        // CDATA: take its content verbatim as text.
466        if let Some(cdata) = after.strip_prefix("<![CDATA[") {
467            if let Some(end) = cdata.find("]]>") {
468                let content = &cdata[..end];
469                if !content.trim().is_empty() {
470                    out.push(XmlToken::Text(content.to_owned()));
471                }
472                rest = &cdata[end + 3..];
473                continue;
474            }
475            break;
476        }
477        let Some(gt_rel) = after.find('>') else { break };
478        let inner = &after[1..gt_rel]; // between '<' and '>'
479        rest = &after[gt_rel + 1..];
480        if inner.starts_with('?') || inner.starts_with('!') {
481            // XML declaration, comment, or doctype — skip.
482            continue;
483        }
484        if let Some(close) = inner.strip_prefix('/') {
485            let _ = close;
486            out.push(XmlToken::End);
487        } else if let Some(sc) = inner.strip_suffix('/') {
488            out.push(XmlToken::SelfClose(xml_local_name(sc)));
489        } else {
490            out.push(XmlToken::Start(xml_local_name(inner)));
491        }
492    }
493    out
494}
495
496fn xml_build_tree(tokens: Vec<XmlToken>) -> XmlNode {
497    let mut stack: Vec<XmlNode> = vec![XmlNode {
498        tag: String::new(),
499        text: String::new(),
500        children: Vec::new(),
501    }];
502    for tok in tokens {
503        match tok {
504            XmlToken::Start(tag) => stack.push(XmlNode {
505                tag,
506                text: String::new(),
507                children: Vec::new(),
508            }),
509            XmlToken::SelfClose(tag) => {
510                if let Some(parent) = stack.last_mut() {
511                    parent.children.push(XmlNode {
512                        tag,
513                        text: String::new(),
514                        children: Vec::new(),
515                    });
516                }
517            }
518            XmlToken::Text(t) => {
519                if let Some(node) = stack.last_mut() {
520                    node.text.push_str(&t);
521                }
522            }
523            XmlToken::End => {
524                if stack.len() > 1 {
525                    let node = stack.pop().unwrap();
526                    stack.last_mut().unwrap().children.push(node);
527                }
528            }
529        }
530    }
531    // Unwind any unclosed elements into the root.
532    while stack.len() > 1 {
533        let node = stack.pop().unwrap();
534        stack.last_mut().unwrap().children.push(node);
535    }
536    stack.pop().unwrap()
537}
538
539/// Walk a dot-path of element local names, returning the trimmed text of the
540/// first matching element. `path` = `a.b.c`; namespace prefixes are ignored.
541fn xml_dot_path(xml: &str, path: &str) -> Option<String> {
542    let root = xml_build_tree(xml_tokenize(xml));
543    let mut current = &root;
544    for seg in path.split('.') {
545        let seg = xml_local_name(seg.trim());
546        if seg.is_empty() {
547            continue;
548        }
549        current = current.children.iter().find(|c| c.tag == seg)?;
550    }
551    Some(current.text.trim().to_owned())
552}
553
554/// Build the placements + base-URL for one request from the captured context.
555/// Pure given `captured` (and the clock values injected by the caller).
556fn build_request_auth(
557    apply: &[ApplySpec],
558    base_url_from: &Option<String>,
559    ctx: &HashMap<String, String>,
560) -> RequestAuth {
561    let mut out = RequestAuth::new();
562    for a in apply {
563        match a {
564            ApplySpec::Place { into, name, value } => {
565                let value = render(value, ctx);
566                let name = name.clone();
567                let placement = match into {
568                    PlaceTarget::Header => CredentialPlacement::Header { name, value },
569                    PlaceTarget::Query => CredentialPlacement::Query { name, value },
570                    PlaceTarget::Cookie => CredentialPlacement::Cookie { name, value },
571                    PlaceTarget::Body => CredentialPlacement::BodyField { name, value },
572                };
573                out = out.with_placement(placement);
574            }
575            ApplySpec::Sign { sign } => {
576                let (name, value) = sign_header(sign, ctx);
577                out = out.with_placement(CredentialPlacement::Header { name, value });
578            }
579        }
580    }
581    if let Some(tmpl) = base_url_from {
582        let rendered = render(tmpl, ctx);
583        if !rendered.is_empty() {
584            out = out.with_base_url(rendered);
585        }
586    }
587    out
588}
589
590// ── Provider ────────────────────────────────────────────────────────────────
591
592struct Session {
593    /// Captured values (as strings, ready for templating) plus a fresh clock.
594    ctx: HashMap<String, String>,
595    expires_at: Option<Instant>,
596}
597
598impl Session {
599    fn valid(&self) -> bool {
600        match self.expires_at {
601            Some(exp) => Instant::now() < exp,
602            None => true,
603        }
604    }
605}
606
607/// A composable multi-step auth flow provider.
608pub struct FlowProvider {
609    http: reqwest::Client,
610    config: FlowConfig,
611    state: Mutex<Option<Session>>,
612}
613
614impl std::fmt::Debug for FlowProvider {
615    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
616        f.debug_struct("FlowProvider")
617            .field("steps", &self.config.steps.len())
618            .field("apply", &self.config.apply.len())
619            .finish()
620    }
621}
622
623impl FlowProvider {
624    /// Build a flow provider from its `config` block.
625    pub fn from_config(config: &Value) -> Result<Self, FaucetError> {
626        let config: FlowConfig = serde_json::from_value(config.clone())
627            .map_err(|e| FaucetError::Config(format!("flow auth: invalid config: {e}")))?;
628        config.validate()?;
629        Ok(Self {
630            http: auth_http_client(),
631            config,
632            state: Mutex::new(None),
633        })
634    }
635
636    /// A fresh clock context (`ts`, `nonce`) for signing.
637    fn clock_ctx() -> HashMap<String, String> {
638        let now = SystemTime::now()
639            .duration_since(UNIX_EPOCH)
640            .map(|d| d.as_secs())
641            .unwrap_or(0);
642        let nanos = SystemTime::now()
643            .duration_since(UNIX_EPOCH)
644            .map(|d| d.subsec_nanos())
645            .unwrap_or(0);
646        let mut ctx = HashMap::new();
647        ctx.insert("ts".to_owned(), now.to_string());
648        ctx.insert("nonce".to_owned(), format!("{now}{nanos}"));
649        ctx
650    }
651
652    /// Run the login chain, returning the captured context.
653    async fn run_login(&self) -> Result<HashMap<String, String>, FaucetError> {
654        let mut ctx: HashMap<String, String> = HashMap::new();
655        for (i, step) in self.config.steps.iter().enumerate() {
656            let req = &step.request;
657            let method = reqwest::Method::from_bytes(req.method.to_uppercase().as_bytes())
658                .map_err(|_| {
659                    FaucetError::Config(format!(
660                        "flow auth: step {i} has an invalid HTTP method '{}'",
661                        req.method
662                    ))
663                })?;
664            let url = render(&req.url, &ctx);
665            let mut builder = self.http.request(method, &url);
666            for (k, v) in &req.headers {
667                builder = builder.header(k.as_str(), render(v, &ctx));
668            }
669            if !req.query.is_empty() {
670                let q: Vec<(String, String)> = req
671                    .query
672                    .iter()
673                    .map(|(k, v)| (k.clone(), render(v, &ctx)))
674                    .collect();
675                builder = builder.query(&q);
676            }
677            if let Some(form) = &req.form {
678                let f: Vec<(String, String)> = form
679                    .iter()
680                    .map(|(k, v)| (k.clone(), render(v, &ctx)))
681                    .collect();
682                builder = builder.form(&f);
683            } else if let Some(json) = &req.json {
684                let rendered = render(&serde_json::to_string(json).unwrap_or_default(), &ctx);
685                let body: Value = serde_json::from_str(&rendered).unwrap_or_else(|_| json.clone());
686                builder = builder.json(&body);
687            }
688            // Sign this login/pre-flight step itself (#541): a fresh clock,
689            // captured values so far, then a header placement.
690            if let Some(sign) = &req.sign {
691                let mut sign_ctx = ctx.clone();
692                sign_ctx.extend(Self::clock_ctx());
693                let (name, value) = sign_header(sign, &sign_ctx);
694                builder = builder.header(name.as_str(), value);
695            }
696            let resp = builder.send().await.map_err(|e| {
697                FaucetError::Auth(format!("flow auth: step {i} request failed: {e}"))
698            })?;
699            let status = resp.status();
700            if !status.is_success() {
701                return Err(FaucetError::Auth(format!(
702                    "flow auth: step {i} returned HTTP {}",
703                    status.as_u16()
704                )));
705            }
706            // Decide which parts of the response the captures need. Headers are
707            // read first (they borrow), then the body is consumed at most once.
708            let headers = resp.headers().clone();
709            let needs_json = step.capture.values().any(|c| c.kind() == CaptureFrom::Json);
710            let needs_xml = step.capture.values().any(|c| c.kind() == CaptureFrom::Xml);
711            let text: Option<String> = if needs_json || needs_xml {
712                Some(resp.text().await.map_err(|e| {
713                    FaucetError::Auth(format!(
714                        "flow auth: step {i} failed to read response body: {e}"
715                    ))
716                })?)
717            } else {
718                None
719            };
720            let json_body: Option<Value> = if needs_json {
721                Some(
722                    serde_json::from_str(text.as_deref().unwrap_or("")).map_err(|e| {
723                        FaucetError::Auth(format!(
724                            "flow auth: step {i} response was not valid JSON: {e}"
725                        ))
726                    })?,
727                )
728            } else {
729                None
730            };
731            for (name, cap) in &step.capture {
732                let extracted = match cap {
733                    CaptureSpec::Json(path) => json_body
734                        .as_ref()
735                        .and_then(|b| jsonpath_first(b, path))
736                        .map(|v| value_to_string(&v)),
737                    CaptureSpec::Source(s) => match s.from {
738                        CaptureFrom::Json => json_body
739                            .as_ref()
740                            .and_then(|b| jsonpath_first(b, s.path.as_deref().unwrap_or("")))
741                            .map(|v| value_to_string(&v)),
742                        CaptureFrom::Xml => text
743                            .as_deref()
744                            .and_then(|t| xml_dot_path(t, s.path.as_deref().unwrap_or(""))),
745                        CaptureFrom::Header => {
746                            header_value(&headers, s.name.as_deref().unwrap_or(""))
747                        }
748                        CaptureFrom::SetCookie => {
749                            set_cookie_value(&headers, s.name.as_deref().unwrap_or(""))
750                        }
751                    },
752                };
753                match extracted {
754                    Some(v) => {
755                        ctx.insert(name.clone(), v);
756                    }
757                    None => {
758                        return Err(FaucetError::Auth(format!(
759                            "flow auth: step {i} capture '{name}' matched nothing"
760                        )));
761                    }
762                }
763            }
764        }
765        Ok(ctx)
766    }
767
768    /// Ensure a valid session, returning its captured context (single-flight:
769    /// the lock is held across the login network calls).
770    async fn ensure_ctx(&self) -> Result<HashMap<String, String>, FaucetError> {
771        let mut guard = self.state.lock().await;
772        if let Some(s) = guard.as_ref()
773            && s.valid()
774        {
775            return Ok(s.ctx.clone());
776        }
777        let ctx = self.run_login().await?;
778        let expires_at = self
779            .config
780            .ttl_secs
781            .map(|s| Instant::now() + Duration::from_secs(s));
782        *guard = Some(Session {
783            ctx: ctx.clone(),
784            expires_at,
785        });
786        Ok(ctx)
787    }
788
789    fn request_auth_from_ctx(&self, captured: &HashMap<String, String>) -> RequestAuth {
790        let mut ctx = captured.clone();
791        ctx.extend(Self::clock_ctx());
792        build_request_auth(&self.config.apply, &self.config.base_url_from, &ctx)
793    }
794}
795
796#[async_trait]
797impl AuthProvider for FlowProvider {
798    async fn credential(&self) -> Result<Credential, FaucetError> {
799        let ctx = self.ensure_ctx().await?;
800        let auth = self.request_auth_from_ctx(&ctx);
801        // Header/bearer fallback for connectors that only consume `credential()`
802        // (xml, graphql): return the first header placement as a header
803        // credential. Query/cookie/body placements need `request_auth`.
804        for p in &auth.placements {
805            if let CredentialPlacement::Header { name, value } = p {
806                return Ok(Credential::Header {
807                    name: name.clone(),
808                    value: value.clone(),
809                });
810            }
811        }
812        Err(FaucetError::Auth(
813            "flow auth: no header credential to apply via credential(); this flow places its \
814             credential in a query/cookie/body, which requires a connector that consumes \
815             request_auth() (e.g. the REST source)"
816                .to_owned(),
817        ))
818    }
819
820    async fn invalidate(&self, _stale: &Credential) -> Result<Credential, FaucetError> {
821        // Force a re-login on the next ensure_ctx.
822        *self.state.lock().await = None;
823        self.credential().await
824    }
825
826    async fn request_auth(
827        &self,
828        _method: &str,
829        _url: &str,
830        _query: &std::collections::BTreeMap<String, String>,
831    ) -> Result<RequestAuth, FaucetError> {
832        let ctx = self.ensure_ctx().await?;
833        Ok(self.request_auth_from_ctx(&ctx))
834    }
835
836    fn reauth_statuses(&self) -> &[u16] {
837        &self.config.reauth_on
838    }
839
840    fn provider_name(&self) -> &'static str {
841        "flow"
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use serde_json::json;
849
850    fn ctx(pairs: &[(&str, &str)]) -> HashMap<String, String> {
851        pairs
852            .iter()
853            .map(|(k, v)| (k.to_string(), v.to_string()))
854            .collect()
855    }
856
857    #[test]
858    fn render_substitutes_known_and_leaves_unknown() {
859        let c = ctx(&[("token", "abc"), ("region", "eu")]);
860        assert_eq!(render("Bearer ${token}", &c), "Bearer abc");
861        assert_eq!(render("https://${region}.api", &c), "https://eu.api");
862        assert_eq!(render("${missing}", &c), "${missing}");
863        assert_eq!(render("no tokens", &c), "no tokens");
864        assert_eq!(render("${token}${region}", &c), "abceu");
865    }
866
867    #[test]
868    fn render_handles_unterminated_and_utf8() {
869        let c = ctx(&[("x", "✓")]);
870        assert_eq!(render("prefix ${x} ünïcode", &c), "prefix ✓ ünïcode");
871        assert_eq!(render("dangling ${x", &c), "dangling ${x");
872    }
873
874    #[test]
875    fn value_to_string_covers_kinds() {
876        assert_eq!(value_to_string(&json!("s")), "s");
877        assert_eq!(value_to_string(&json!(7)), "7");
878        assert_eq!(value_to_string(&json!(true)), "true");
879        assert_eq!(value_to_string(&json!(null)), "");
880        assert_eq!(value_to_string(&json!([1, 2])), "[1,2]");
881    }
882
883    #[test]
884    fn jsonpath_first_extracts_scalar() {
885        let body = json!({"data": {"BhRestToken": "tok", "restUrl": "https://host/x"}});
886        assert_eq!(
887            jsonpath_first(&body, "$.data.BhRestToken"),
888            Some(json!("tok"))
889        );
890        assert_eq!(
891            jsonpath_first(&body, "$.data.restUrl"),
892            Some(json!("https://host/x"))
893        );
894        assert_eq!(jsonpath_first(&body, "$.data.missing"), None);
895    }
896
897    #[test]
898    fn hmac_hex_and_base64_are_deterministic() {
899        let hex = hmac_sign("key", "message", SigEncoding::Hex);
900        // Known HMAC-SHA256("key","message") hex vector.
901        assert_eq!(
902            hex,
903            "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"
904        );
905        let b64 = hmac_sign("key", "message", SigEncoding::Base64);
906        assert_eq!(b64, "bp7ym3X//Ft6uuUn1Y/a2y/kLnIZARl2kXNDBl9Y7Uo=");
907    }
908
909    #[test]
910    fn build_request_auth_places_header_query_cookie_body() {
911        let apply: Vec<ApplySpec> = serde_json::from_value(json!([
912            { "into": "header", "name": "X-Tok", "value": "${tok}" },
913            { "into": "query",  "name": "access_token", "value": "${tok}" },
914            { "into": "cookie", "name": "sid", "value": "${sid}" },
915            { "into": "body",   "name": "auth", "value": "${tok}" }
916        ]))
917        .unwrap();
918        let c = ctx(&[("tok", "T"), ("sid", "S")]);
919        let ra = build_request_auth(&apply, &None, &c);
920        assert_eq!(ra.placements.len(), 4);
921        assert!(
922            matches!(&ra.placements[0], CredentialPlacement::Header { name, value } if name=="X-Tok" && value=="T")
923        );
924        assert!(
925            matches!(&ra.placements[1], CredentialPlacement::Query { name, value } if name=="access_token" && value=="T")
926        );
927        assert!(
928            matches!(&ra.placements[2], CredentialPlacement::Cookie { name, value } if name=="sid" && value=="S")
929        );
930        assert!(
931            matches!(&ra.placements[3], CredentialPlacement::BodyField { name, value } if name=="auth" && value=="T")
932        );
933        assert!(ra.base_url.is_none());
934    }
935
936    #[test]
937    fn build_request_auth_signs_and_sets_base_url() {
938        let apply: Vec<ApplySpec> = serde_json::from_value(json!([
939            { "sign": { "alg": "hmac_sha256", "key": "secret", "template": "${client}:${ts}",
940                        "encoding": "hex", "into": { "header": "Authorization", "format": "SS ${sig}" } } }
941        ]))
942        .unwrap();
943        let mut c = ctx(&[
944            ("client", "abc"),
945            ("ts", "100"),
946            ("base_url", "https://eu.host"),
947        ]);
948        c.insert("base_url".to_owned(), "https://eu.host".to_owned());
949        let ra = build_request_auth(&apply, &Some("${base_url}".to_owned()), &c);
950        assert_eq!(ra.placements.len(), 1);
951        let expected = format!("SS {}", hmac_sign("secret", "abc:100", SigEncoding::Hex));
952        assert!(
953            matches!(&ra.placements[0], CredentialPlacement::Header { name, value } if name=="Authorization" && *value==expected)
954        );
955        assert_eq!(ra.base_url.as_deref(), Some("https://eu.host"));
956    }
957
958    #[test]
959    fn base_url_from_empty_render_is_ignored() {
960        let ra = build_request_auth(&[], &Some("${missing_and_stripped}".to_owned()), &ctx(&[]));
961        // The unknown token renders verbatim (non-empty), so it is set; an
962        // actually-empty render (empty template) is ignored.
963        assert!(ra.base_url.is_some());
964        let ra2 = build_request_auth(&[], &Some("${e}".to_owned()), &ctx(&[("e", "")]));
965        assert!(ra2.base_url.is_none());
966    }
967
968    #[test]
969    fn config_validate_requires_steps_or_apply() {
970        let empty: FlowConfig = serde_json::from_value(json!({})).unwrap();
971        assert!(empty.validate().is_err());
972    }
973
974    #[test]
975    fn config_rejects_form_and_json_together() {
976        let cfg: FlowConfig = serde_json::from_value(json!({
977            "steps": [ { "request": { "url": "https://x", "form": {"a":"b"}, "json": {"c":"d"} } } ]
978        }))
979        .unwrap();
980        assert!(cfg.validate().is_err());
981    }
982
983    #[test]
984    fn config_rejects_empty_place_name_and_sign_header() {
985        let bad_place: FlowConfig = serde_json::from_value(json!({
986            "apply": [ { "into": "query", "name": "", "value": "${x}" } ]
987        }))
988        .unwrap();
989        assert!(bad_place.validate().is_err());
990
991        let bad_sign: FlowConfig = serde_json::from_value(json!({
992            "apply": [ { "sign": { "alg": "hmac_sha256", "key": "k", "template": "${ts}",
993                                   "into": { "header": "" } } } ]
994        }))
995        .unwrap();
996        assert!(bad_sign.validate().is_err());
997    }
998
999    #[test]
1000    fn from_config_rejects_unknown_field() {
1001        assert!(FlowProvider::from_config(&json!({ "bogus": 1 })).is_err());
1002    }
1003
1004    #[test]
1005    fn build_provider_dispatches_flow() {
1006        let p = crate::build_provider(&json!({
1007            "type": "flow",
1008            "config": { "apply": [ { "into": "header", "name": "X", "value": "v" } ] }
1009        }))
1010        .unwrap();
1011        assert_eq!(p.provider_name(), "flow");
1012    }
1013
1014    #[test]
1015    fn debug_redacts_and_summarizes() {
1016        let p = FlowProvider::from_config(&json!({
1017            "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1018        }))
1019        .unwrap();
1020        let s = format!("{p:?}");
1021        assert!(s.contains("FlowProvider"));
1022        assert!(s.contains("apply"));
1023    }
1024
1025    use std::collections::BTreeMap;
1026    use std::sync::Arc;
1027    use std::sync::atomic::{AtomicUsize, Ordering};
1028    use wiremock::matchers::{body_json, header, method, path};
1029    use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
1030
1031    #[tokio::test]
1032    async fn login_chain_captures_placements_and_base_url() {
1033        let server = MockServer::start().await;
1034        Mock::given(method("POST"))
1035            .and(path("/token"))
1036            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"access_token": "AT"})))
1037            .mount(&server)
1038            .await;
1039        Mock::given(method("GET"))
1040            .and(path("/login"))
1041            .respond_with(
1042                ResponseTemplate::new(200).set_body_json(
1043                    json!({"BhRestToken": "BRT", "restUrl": "https://data.example"}),
1044                ),
1045            )
1046            .mount(&server)
1047            .await;
1048
1049        let cfg = json!({
1050            "steps": [
1051                { "request": { "method": "POST", "url": format!("{}/token", server.uri()),
1052                               "form": {"grant_type": "refresh_token"} },
1053                  "capture": { "access_token": "$.access_token" } },
1054                { "request": { "method": "GET", "url": format!("{}/login", server.uri()),
1055                               "query": {"access_token": "${access_token}"} },
1056                  "capture": { "bh_rest_token": "$.BhRestToken", "base_url": "$.restUrl" } }
1057            ],
1058            "apply": [ { "into": "query", "name": "BhRestToken", "value": "${bh_rest_token}" } ],
1059            "base_url_from": "${base_url}",
1060            "reauth_on": [401]
1061        });
1062        let p = FlowProvider::from_config(&cfg).unwrap();
1063        let ra = p
1064            .request_auth("GET", "https://data.example/x", &BTreeMap::new())
1065            .await
1066            .unwrap();
1067        assert_eq!(ra.base_url.as_deref(), Some("https://data.example"));
1068        assert!(
1069            matches!(&ra.placements[0], CredentialPlacement::Query { name, value } if name == "BhRestToken" && value == "BRT")
1070        );
1071        assert_eq!(p.reauth_statuses(), &[401]);
1072        // A query placement has no header credential to apply via credential().
1073        assert!(p.credential().await.is_err());
1074    }
1075
1076    struct CountingLogin(Arc<AtomicUsize>);
1077    impl Respond for CountingLogin {
1078        fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
1079            let n = self.0.fetch_add(1, Ordering::SeqCst) + 1;
1080            ResponseTemplate::new(200).set_body_json(json!({ "sid": format!("S{n}") }))
1081        }
1082    }
1083
1084    #[tokio::test]
1085    async fn header_credential_caches_then_reloads_on_invalidate() {
1086        let server = MockServer::start().await;
1087        let hits = Arc::new(AtomicUsize::new(0));
1088        Mock::given(method("POST"))
1089            .and(path("/login"))
1090            .respond_with(CountingLogin(hits.clone()))
1091            .mount(&server)
1092            .await;
1093        let cfg = json!({
1094            "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()) },
1095                         "capture": { "sid": "$.sid" } } ],
1096            "apply": [ { "into": "header", "name": "X-Session", "value": "${sid}" } ]
1097        });
1098        let p = FlowProvider::from_config(&cfg).unwrap();
1099
1100        let c1 = p.credential().await.unwrap();
1101        assert!(
1102            matches!(&c1, Credential::Header { name, value } if name == "X-Session" && value == "S1")
1103        );
1104        // Cached: no ttl → second call does not re-login.
1105        let _ = p.credential().await.unwrap();
1106        assert_eq!(hits.load(Ordering::SeqCst), 1);
1107        // invalidate forces a fresh login.
1108        let c2 = p.invalidate(&c1).await.unwrap();
1109        assert!(matches!(&c2, Credential::Header { value, .. } if value == "S2"));
1110        assert_eq!(hits.load(Ordering::SeqCst), 2);
1111    }
1112
1113    #[tokio::test]
1114    async fn login_capture_miss_is_an_error() {
1115        let server = MockServer::start().await;
1116        Mock::given(method("GET"))
1117            .and(path("/x"))
1118            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"other": 1})))
1119            .mount(&server)
1120            .await;
1121        let cfg = json!({
1122            "steps": [ { "request": { "url": format!("{}/x", server.uri()) },
1123                         "capture": { "tok": "$.access_token" } } ],
1124            "apply": [ { "into": "header", "name": "X", "value": "${tok}" } ]
1125        });
1126        let p = FlowProvider::from_config(&cfg).unwrap();
1127        assert!(p.credential().await.is_err());
1128    }
1129
1130    #[tokio::test]
1131    async fn login_non_success_is_an_error() {
1132        let server = MockServer::start().await;
1133        Mock::given(method("GET"))
1134            .and(path("/x"))
1135            .respond_with(ResponseTemplate::new(500))
1136            .mount(&server)
1137            .await;
1138        let cfg = json!({
1139            "steps": [ { "request": { "url": format!("{}/x", server.uri()) } } ],
1140            "apply": [ { "into": "header", "name": "X", "value": "static" } ]
1141        });
1142        let p = FlowProvider::from_config(&cfg).unwrap();
1143        assert!(
1144            p.request_auth("GET", "https://x", &BTreeMap::new())
1145                .await
1146                .is_err()
1147        );
1148    }
1149
1150    #[test]
1151    fn config_rejects_empty_step_url() {
1152        let cfg: FlowConfig = serde_json::from_value(json!({
1153            "steps": [ { "request": { "url": "  " } } ]
1154        }))
1155        .unwrap();
1156        assert!(cfg.validate().is_err());
1157    }
1158
1159    #[tokio::test]
1160    async fn session_ttl_caches_within_window() {
1161        let server = MockServer::start().await;
1162        let hits = Arc::new(AtomicUsize::new(0));
1163        Mock::given(method("POST"))
1164            .and(path("/login"))
1165            .respond_with(CountingLogin(hits.clone()))
1166            .mount(&server)
1167            .await;
1168        let cfg = json!({
1169            "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()) },
1170                         "capture": { "sid": "$.sid" } } ],
1171            "apply": [ { "into": "header", "name": "X", "value": "${sid}" } ],
1172            "ttl_secs": 3600
1173        });
1174        let p = FlowProvider::from_config(&cfg).unwrap();
1175        let _ = p.credential().await.unwrap();
1176        // Within the TTL window the session is reused (Session::valid == true).
1177        let _ = p.credential().await.unwrap();
1178        assert_eq!(hits.load(Ordering::SeqCst), 1);
1179    }
1180
1181    #[tokio::test]
1182    async fn login_sends_headers_and_json_body() {
1183        let server = MockServer::start().await;
1184        Mock::given(method("POST"))
1185            .and(path("/login"))
1186            .and(header("x-tenant", "acme"))
1187            .and(body_json(json!({"scope": "read"})))
1188            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"tok": "T"})))
1189            .mount(&server)
1190            .await;
1191        let cfg = json!({
1192            "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()),
1193                         "headers": {"X-Tenant": "acme"}, "json": {"scope": "read"} },
1194                         "capture": { "t": "$.tok" } } ],
1195            "apply": [ { "into": "header", "name": "Authorization", "value": "Bearer ${t}" } ]
1196        });
1197        let p = FlowProvider::from_config(&cfg).unwrap();
1198        assert_eq!(p.provider_name(), "flow");
1199        let c = p.credential().await.unwrap();
1200        assert!(
1201            matches!(&c, Credential::Header { name, value } if name == "Authorization" && value == "Bearer T")
1202        );
1203    }
1204
1205    #[tokio::test]
1206    async fn login_invalid_method_errors() {
1207        let cfg = json!({
1208            "steps": [ { "request": { "method": "BAD METHOD", "url": "https://x/login" } } ],
1209            "apply": [ { "into": "header", "name": "X", "value": "static" } ]
1210        });
1211        let p = FlowProvider::from_config(&cfg).unwrap();
1212        assert!(
1213            p.request_auth("GET", "https://x", &BTreeMap::new())
1214                .await
1215                .is_err()
1216        );
1217    }
1218
1219    #[tokio::test]
1220    async fn login_send_failure_errors() {
1221        // Port 1 is unassignable → the send fails at the transport layer.
1222        let cfg = json!({
1223            "steps": [ { "request": { "url": "http://127.0.0.1:1/x" }, "capture": { "t": "$.t" } } ],
1224            "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1225        });
1226        let p = FlowProvider::from_config(&cfg).unwrap();
1227        assert!(
1228            p.request_auth("GET", "https://x", &BTreeMap::new())
1229                .await
1230                .is_err()
1231        );
1232    }
1233
1234    #[tokio::test]
1235    async fn login_non_json_response_errors() {
1236        let server = MockServer::start().await;
1237        Mock::given(method("GET"))
1238            .and(path("/x"))
1239            .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
1240            .mount(&server)
1241            .await;
1242        let cfg = json!({
1243            "steps": [ { "request": { "url": format!("{}/x", server.uri()) }, "capture": { "t": "$.t" } } ],
1244            "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1245        });
1246        let p = FlowProvider::from_config(&cfg).unwrap();
1247        assert!(
1248            p.request_auth("GET", "https://x", &BTreeMap::new())
1249                .await
1250                .is_err()
1251        );
1252    }
1253
1254    #[tokio::test]
1255    async fn login_step_with_empty_capture_succeeds() {
1256        // A pre-flight step that captures nothing (e.g. establishes a cookie);
1257        // its non-JSON body is never parsed.
1258        let server = MockServer::start().await;
1259        Mock::given(method("GET"))
1260            .and(path("/ping"))
1261            .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
1262            .mount(&server)
1263            .await;
1264        Mock::given(method("GET"))
1265            .and(path("/token"))
1266            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"t": "T"})))
1267            .mount(&server)
1268            .await;
1269        let cfg = json!({
1270            "steps": [
1271                { "request": { "url": format!("{}/ping", server.uri()) } },
1272                { "request": { "url": format!("{}/token", server.uri()) }, "capture": { "t": "$.t" } }
1273            ],
1274            "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1275        });
1276        let p = FlowProvider::from_config(&cfg).unwrap();
1277        let c = p.credential().await.unwrap();
1278        assert!(matches!(&c, Credential::Header { value, .. } if value == "T"));
1279    }
1280
1281    #[tokio::test]
1282    async fn no_steps_apply_only_flow_works() {
1283        // A flow with only `apply` (a static signer/placement, no login) needs
1284        // no network.
1285        let cfg = json!({
1286            "apply": [ { "sign": { "alg": "hmac_sha256", "key": "k", "template": "msg",
1287                                   "into": { "header": "Authorization", "format": "HMAC ${sig}" } } } ]
1288        });
1289        let p = FlowProvider::from_config(&cfg).unwrap();
1290        let c = p.credential().await.unwrap();
1291        let expected = format!("HMAC {}", hmac_sign("k", "msg", SigEncoding::Hex));
1292        assert!(
1293            matches!(&c, Credential::Header { name, value } if name == "Authorization" && *value == expected)
1294        );
1295    }
1296
1297    // ── #541: HMAC sign on a login/pre-flight step ───────────────────────────
1298
1299    #[test]
1300    fn sign_header_computes_name_and_value_with_clock() {
1301        let mut c = ctx(&[("client", "abc")]);
1302        c.insert("ts".to_owned(), "999".to_owned());
1303        let sign: SignSpec = serde_json::from_value(json!({
1304            "alg": "hmac_sha256", "key": "k", "template": "${client}:${ts}",
1305            "encoding": "hex", "into": { "header": "Authorization", "format": "SS ${sig}" }
1306        }))
1307        .unwrap();
1308        let (name, value) = sign_header(&sign, &c);
1309        assert_eq!(name, "Authorization");
1310        assert_eq!(
1311            value,
1312            format!("SS {}", hmac_sign("k", "abc:999", SigEncoding::Hex))
1313        );
1314    }
1315
1316    #[tokio::test]
1317    async fn signed_login_step_sends_computed_signature_header() {
1318        let server = MockServer::start().await;
1319        let key = "secret_key";
1320        // Deterministic template (no ${ts}) so the header is assertable.
1321        let expected = format!(
1322            "SS access:{}",
1323            hmac_sign(key, "client:secret", SigEncoding::Base64)
1324        );
1325        Mock::given(method("POST"))
1326            .and(path("/auth/login"))
1327            .and(header("authorization", expected.as_str()))
1328            .respond_with(
1329                ResponseTemplate::new(200).set_body_json(json!({"session": {"token": "SESS"}})),
1330            )
1331            .mount(&server)
1332            .await;
1333        let cfg = json!({
1334            "steps": [ {
1335                "request": {
1336                    "method": "POST",
1337                    "url": format!("{}/auth/login", server.uri()),
1338                    "json": {"clientId": "client"},
1339                    "sign": { "alg": "hmac_sha256", "key": key, "template": "client:secret",
1340                              "encoding": "base64",
1341                              "into": { "header": "Authorization", "format": "SS access:${sig}" } }
1342                },
1343                "capture": { "session": "$.session.token" }
1344            } ],
1345            "apply": [ { "into": "header", "name": "Session", "value": "${session}" } ]
1346        });
1347        let p = FlowProvider::from_config(&cfg).unwrap();
1348        // Success only if the login request carried the exact signed header
1349        // (otherwise wiremock returns 404 and the step fails).
1350        let c = p.credential().await.unwrap();
1351        assert!(
1352            matches!(&c, Credential::Header { name, value } if name == "Session" && value == "SESS")
1353        );
1354    }
1355
1356    #[tokio::test]
1357    async fn skyslope_style_signed_login_then_signed_data_requests() {
1358        let server = MockServer::start().await;
1359        let key = "base64secret";
1360        let login_sig = format!(
1361            "SS access:{}",
1362            hmac_sign(key, "cid:csec", SigEncoding::Base64)
1363        );
1364        Mock::given(method("POST"))
1365            .and(path("/auth/login"))
1366            .and(header("authorization", login_sig.as_str()))
1367            .respond_with(
1368                ResponseTemplate::new(200)
1369                    .set_body_json(json!({"session": {"token": "SESSION-TOK"}})),
1370            )
1371            .mount(&server)
1372            .await;
1373        let signer = json!({ "alg": "hmac_sha256", "key": key, "template": "cid:csec",
1374                             "encoding": "base64",
1375                             "into": { "header": "Authorization", "format": "SS access:${sig}" } });
1376        let cfg = json!({
1377            "steps": [ {
1378                "request": { "method": "POST", "url": format!("{}/auth/login", server.uri()),
1379                             "json": {"clientId": "cid"}, "sign": signer },
1380                "capture": { "session": "$.session.token" }
1381            } ],
1382            "apply": [
1383                { "sign": signer },
1384                { "into": "header", "name": "Session", "value": "${session}" }
1385            ]
1386        });
1387        let p = FlowProvider::from_config(&cfg).unwrap();
1388        let ra = p
1389            .request_auth("GET", "https://data", &BTreeMap::new())
1390            .await
1391            .unwrap();
1392        let has_auth = ra.placements.iter().any(|pl| {
1393            matches!(pl, CredentialPlacement::Header { name, value } if name == "Authorization" && *value == login_sig)
1394        });
1395        let has_sess = ra.placements.iter().any(|pl| {
1396            matches!(pl, CredentialPlacement::Header { name, value } if name == "Session" && value == "SESSION-TOK")
1397        });
1398        assert!(has_auth, "data request carries the signer header");
1399        assert!(has_sess, "data request carries the captured Session header");
1400    }
1401
1402    #[test]
1403    fn config_rejects_empty_step_sign_header() {
1404        let cfg: FlowConfig = serde_json::from_value(json!({
1405            "steps": [ { "request": { "url": "https://x",
1406                "sign": { "alg": "hmac_sha256", "key": "k", "template": "${ts}",
1407                          "into": { "header": "" } } } } ]
1408        }))
1409        .unwrap();
1410        assert!(cfg.validate().is_err());
1411    }
1412
1413    // ── #542: capture from header / Set-Cookie / XML ─────────────────────────
1414
1415    #[test]
1416    fn xml_dot_path_extracts_nested_text() {
1417        let xml = "<operation><result><data><api><sessionid>ABC123</sessionid></api></data></result></operation>";
1418        assert_eq!(
1419            xml_dot_path(xml, "operation.result.data.api.sessionid").as_deref(),
1420            Some("ABC123")
1421        );
1422        assert_eq!(xml_dot_path(xml, "operation.result.missing"), None);
1423    }
1424
1425    #[test]
1426    fn xml_dot_path_ignores_declaration_attrs_and_namespaces() {
1427        let xml = r#"<?xml version="1.0"?><ns:root xmlns:ns="urn:x"><ns:child id="1">  hi  </ns:child></ns:root>"#;
1428        assert_eq!(xml_dot_path(xml, "root.child").as_deref(), Some("hi"));
1429        // A path segment may itself carry a prefix — it is stripped too.
1430        assert_eq!(xml_dot_path(xml, "ns:root.ns:child").as_deref(), Some("hi"));
1431    }
1432
1433    #[test]
1434    fn xml_dot_path_handles_cdata_and_entities() {
1435        let xml = "<r><a><![CDATA[a&b]]></a><b>x &amp; y</b></r>";
1436        assert_eq!(xml_dot_path(xml, "r.a").as_deref(), Some("a&b"));
1437        assert_eq!(xml_dot_path(xml, "r.b").as_deref(), Some("x & y"));
1438    }
1439
1440    #[test]
1441    fn set_cookie_and_header_helpers_select_by_name() {
1442        let mut h = reqwest::header::HeaderMap::new();
1443        h.append("set-cookie", "a=1; path=/".parse().unwrap());
1444        h.append(
1445            "set-cookie",
1446            "ASP.NET_SessionId=SID; path=/; HttpOnly".parse().unwrap(),
1447        );
1448        h.insert("location", "https://x/next".parse().unwrap());
1449        assert_eq!(
1450            set_cookie_value(&h, "ASP.NET_SessionId").as_deref(),
1451            Some("SID")
1452        );
1453        assert_eq!(set_cookie_value(&h, "missing"), None);
1454        assert_eq!(
1455            header_value(&h, "Location").as_deref(),
1456            Some("https://x/next")
1457        );
1458        assert_eq!(header_value(&h, "absent"), None);
1459    }
1460
1461    #[test]
1462    fn capture_backcompat_string_form_parses_as_json() {
1463        let step: FlowStep = serde_json::from_value(json!({
1464            "request": { "url": "https://x" },
1465            "capture": { "tok": "$.access_token" }
1466        }))
1467        .unwrap();
1468        assert_eq!(step.capture["tok"].kind(), CaptureFrom::Json);
1469        assert!(matches!(&step.capture["tok"], CaptureSpec::Json(p) if p == "$.access_token"));
1470    }
1471
1472    #[test]
1473    fn capture_struct_forms_parse_kinds() {
1474        let step: FlowStep = serde_json::from_value(json!({
1475            "request": { "url": "https://x" },
1476            "capture": {
1477                "h": { "from": "header", "name": "Location" },
1478                "c": { "from": "set_cookie", "name": "sid" },
1479                "x": { "from": "xml", "path": "a.b" },
1480                "j": { "from": "json", "path": "$.tok" }
1481            }
1482        }))
1483        .unwrap();
1484        assert_eq!(step.capture["h"].kind(), CaptureFrom::Header);
1485        assert_eq!(step.capture["c"].kind(), CaptureFrom::SetCookie);
1486        assert_eq!(step.capture["x"].kind(), CaptureFrom::Xml);
1487        assert_eq!(step.capture["j"].kind(), CaptureFrom::Json);
1488    }
1489
1490    #[test]
1491    fn config_rejects_capture_missing_selector() {
1492        let cfg: FlowConfig = serde_json::from_value(json!({
1493            "steps": [ { "request": { "url": "https://x" }, "capture": { "h": { "from": "header" } } } ]
1494        }))
1495        .unwrap();
1496        assert!(cfg.validate().is_err());
1497
1498        let cfg2: FlowConfig = serde_json::from_value(json!({
1499            "steps": [ { "request": { "url": "https://x" }, "capture": { "x": { "from": "xml" } } } ]
1500        }))
1501        .unwrap();
1502        assert!(cfg2.validate().is_err());
1503    }
1504
1505    #[tokio::test]
1506    async fn capture_from_header_works() {
1507        let server = MockServer::start().await;
1508        Mock::given(method("GET"))
1509            .and(path("/login"))
1510            .respond_with(
1511                ResponseTemplate::new(200)
1512                    .insert_header("Location", "https://redirect.example/next"),
1513            )
1514            .mount(&server)
1515            .await;
1516        let cfg = json!({
1517            "steps": [ { "request": { "url": format!("{}/login", server.uri()) },
1518                         "capture": { "loc": { "from": "header", "name": "Location" } } } ],
1519            "apply": [ { "into": "header", "name": "X-Loc", "value": "${loc}" } ]
1520        });
1521        let p = FlowProvider::from_config(&cfg).unwrap();
1522        let c = p.credential().await.unwrap();
1523        assert!(
1524            matches!(&c, Credential::Header { name, value } if name == "X-Loc" && value == "https://redirect.example/next")
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn capture_from_set_cookie_applies_as_cookie() {
1530        // Acumatica-style: POST /login → 204 empty body + Set-Cookie session.
1531        let server = MockServer::start().await;
1532        Mock::given(method("POST"))
1533            .and(path("/entity/auth/login"))
1534            .respond_with(
1535                ResponseTemplate::new(204)
1536                    .append_header("Set-Cookie", "other=nope; path=/")
1537                    .append_header("Set-Cookie", "ASP.NET_SessionId=SID123; path=/; HttpOnly"),
1538            )
1539            .mount(&server)
1540            .await;
1541        let cfg = json!({
1542            "steps": [ { "request": { "method": "POST",
1543                           "url": format!("{}/entity/auth/login", server.uri()),
1544                           "json": {"name": "u", "password": "p"} },
1545                         "capture": { "session_cookie": { "from": "set_cookie", "name": "ASP.NET_SessionId" } } } ],
1546            "apply": [ { "into": "cookie", "name": "ASP.NET_SessionId", "value": "${session_cookie}" } ]
1547        });
1548        let p = FlowProvider::from_config(&cfg).unwrap();
1549        let ra = p
1550            .request_auth("GET", "https://data", &BTreeMap::new())
1551            .await
1552            .unwrap();
1553        assert!(
1554            matches!(&ra.placements[0], CredentialPlacement::Cookie { name, value } if name == "ASP.NET_SessionId" && value == "SID123")
1555        );
1556    }
1557
1558    #[tokio::test]
1559    async fn capture_from_xml_body_works() {
1560        // Sage Intacct-style: session id lives in an XML response body.
1561        let server = MockServer::start().await;
1562        let xml = r#"<?xml version="1.0" encoding="UTF-8"?><response><operation><result><data><api><sessionid>XYZ-SESSION</sessionid></api></data></result></operation></response>"#;
1563        Mock::given(method("POST"))
1564            .and(path("/xml/xmlgw.phtml"))
1565            .respond_with(ResponseTemplate::new(200).set_body_string(xml))
1566            .mount(&server)
1567            .await;
1568        let cfg = json!({
1569            "steps": [ { "request": { "method": "POST",
1570                           "url": format!("{}/xml/xmlgw.phtml", server.uri()) },
1571                         "capture": { "sess_id": { "from": "xml",
1572                             "path": "response.operation.result.data.api.sessionid" } } } ],
1573            "apply": [ { "into": "header", "name": "X-Session", "value": "${sess_id}" } ]
1574        });
1575        let p = FlowProvider::from_config(&cfg).unwrap();
1576        let c = p.credential().await.unwrap();
1577        assert!(matches!(&c, Credential::Header { value, .. } if value == "XYZ-SESSION"));
1578    }
1579
1580    #[tokio::test]
1581    async fn capture_from_xml_miss_is_an_error() {
1582        let server = MockServer::start().await;
1583        Mock::given(method("POST"))
1584            .and(path("/x"))
1585            .respond_with(ResponseTemplate::new(200).set_body_string("<r><a>1</a></r>"))
1586            .mount(&server)
1587            .await;
1588        let cfg = json!({
1589            "steps": [ { "request": { "method": "POST", "url": format!("{}/x", server.uri()) },
1590                         "capture": { "s": { "from": "xml", "path": "r.missing" } } } ],
1591            "apply": [ { "into": "header", "name": "X", "value": "${s}" } ]
1592        });
1593        let p = FlowProvider::from_config(&cfg).unwrap();
1594        assert!(p.credential().await.is_err());
1595    }
1596}