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    // Expose the captured context so a connector can substitute `${name}` into a
588    // raw body/header string (an XML/SOAP `sessionid`, #567) — the same names the
589    // `apply` placements above draw from.
590    out = out.with_captured(ctx.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
591    out
592}
593
594// ── Provider ────────────────────────────────────────────────────────────────
595
596struct Session {
597    /// Captured values (as strings, ready for templating) plus a fresh clock.
598    ctx: HashMap<String, String>,
599    expires_at: Option<Instant>,
600}
601
602impl Session {
603    fn valid(&self) -> bool {
604        match self.expires_at {
605            Some(exp) => Instant::now() < exp,
606            None => true,
607        }
608    }
609}
610
611/// A composable multi-step auth flow provider.
612pub struct FlowProvider {
613    http: reqwest::Client,
614    config: FlowConfig,
615    state: Mutex<Option<Session>>,
616}
617
618impl std::fmt::Debug for FlowProvider {
619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620        f.debug_struct("FlowProvider")
621            .field("steps", &self.config.steps.len())
622            .field("apply", &self.config.apply.len())
623            .finish()
624    }
625}
626
627impl FlowProvider {
628    /// Build a flow provider from its `config` block.
629    pub fn from_config(config: &Value) -> Result<Self, FaucetError> {
630        let config: FlowConfig = serde_json::from_value(config.clone())
631            .map_err(|e| FaucetError::Config(format!("flow auth: invalid config: {e}")))?;
632        config.validate()?;
633        Ok(Self {
634            http: auth_http_client(),
635            config,
636            state: Mutex::new(None),
637        })
638    }
639
640    /// A fresh clock context (`ts`, `nonce`) for signing.
641    fn clock_ctx() -> HashMap<String, String> {
642        let now = SystemTime::now()
643            .duration_since(UNIX_EPOCH)
644            .map(|d| d.as_secs())
645            .unwrap_or(0);
646        let nanos = SystemTime::now()
647            .duration_since(UNIX_EPOCH)
648            .map(|d| d.subsec_nanos())
649            .unwrap_or(0);
650        let mut ctx = HashMap::new();
651        ctx.insert("ts".to_owned(), now.to_string());
652        ctx.insert("nonce".to_owned(), format!("{now}{nanos}"));
653        ctx
654    }
655
656    /// Run the login chain, returning the captured context.
657    async fn run_login(&self) -> Result<HashMap<String, String>, FaucetError> {
658        let mut ctx: HashMap<String, String> = HashMap::new();
659        for (i, step) in self.config.steps.iter().enumerate() {
660            let req = &step.request;
661            let method = reqwest::Method::from_bytes(req.method.to_uppercase().as_bytes())
662                .map_err(|_| {
663                    FaucetError::Config(format!(
664                        "flow auth: step {i} has an invalid HTTP method '{}'",
665                        req.method
666                    ))
667                })?;
668            let url = render(&req.url, &ctx);
669            let mut builder = self.http.request(method, &url);
670            for (k, v) in &req.headers {
671                builder = builder.header(k.as_str(), render(v, &ctx));
672            }
673            if !req.query.is_empty() {
674                let q: Vec<(String, String)> = req
675                    .query
676                    .iter()
677                    .map(|(k, v)| (k.clone(), render(v, &ctx)))
678                    .collect();
679                builder = builder.query(&q);
680            }
681            if let Some(form) = &req.form {
682                let f: Vec<(String, String)> = form
683                    .iter()
684                    .map(|(k, v)| (k.clone(), render(v, &ctx)))
685                    .collect();
686                builder = builder.form(&f);
687            } else if let Some(json) = &req.json {
688                let rendered = render(&serde_json::to_string(json).unwrap_or_default(), &ctx);
689                let body: Value = serde_json::from_str(&rendered).unwrap_or_else(|_| json.clone());
690                builder = builder.json(&body);
691            }
692            // Sign this login/pre-flight step itself (#541): a fresh clock,
693            // captured values so far, then a header placement.
694            if let Some(sign) = &req.sign {
695                let mut sign_ctx = ctx.clone();
696                sign_ctx.extend(Self::clock_ctx());
697                let (name, value) = sign_header(sign, &sign_ctx);
698                builder = builder.header(name.as_str(), value);
699            }
700            let resp = builder.send().await.map_err(|e| {
701                FaucetError::Auth(format!("flow auth: step {i} request failed: {e}"))
702            })?;
703            let status = resp.status();
704            if !status.is_success() {
705                return Err(FaucetError::Auth(format!(
706                    "flow auth: step {i} returned HTTP {}",
707                    status.as_u16()
708                )));
709            }
710            // Decide which parts of the response the captures need. Headers are
711            // read first (they borrow), then the body is consumed at most once.
712            let headers = resp.headers().clone();
713            let needs_json = step.capture.values().any(|c| c.kind() == CaptureFrom::Json);
714            let needs_xml = step.capture.values().any(|c| c.kind() == CaptureFrom::Xml);
715            let text: Option<String> = if needs_json || needs_xml {
716                Some(resp.text().await.map_err(|e| {
717                    FaucetError::Auth(format!(
718                        "flow auth: step {i} failed to read response body: {e}"
719                    ))
720                })?)
721            } else {
722                None
723            };
724            let json_body: Option<Value> = if needs_json {
725                Some(
726                    serde_json::from_str(text.as_deref().unwrap_or("")).map_err(|e| {
727                        FaucetError::Auth(format!(
728                            "flow auth: step {i} response was not valid JSON: {e}"
729                        ))
730                    })?,
731                )
732            } else {
733                None
734            };
735            for (name, cap) in &step.capture {
736                let extracted = match cap {
737                    CaptureSpec::Json(path) => json_body
738                        .as_ref()
739                        .and_then(|b| jsonpath_first(b, path))
740                        .map(|v| value_to_string(&v)),
741                    CaptureSpec::Source(s) => match s.from {
742                        CaptureFrom::Json => json_body
743                            .as_ref()
744                            .and_then(|b| jsonpath_first(b, s.path.as_deref().unwrap_or("")))
745                            .map(|v| value_to_string(&v)),
746                        CaptureFrom::Xml => text
747                            .as_deref()
748                            .and_then(|t| xml_dot_path(t, s.path.as_deref().unwrap_or(""))),
749                        CaptureFrom::Header => {
750                            header_value(&headers, s.name.as_deref().unwrap_or(""))
751                        }
752                        CaptureFrom::SetCookie => {
753                            set_cookie_value(&headers, s.name.as_deref().unwrap_or(""))
754                        }
755                    },
756                };
757                match extracted {
758                    Some(v) => {
759                        ctx.insert(name.clone(), v);
760                    }
761                    None => {
762                        return Err(FaucetError::Auth(format!(
763                            "flow auth: step {i} capture '{name}' matched nothing"
764                        )));
765                    }
766                }
767            }
768        }
769        Ok(ctx)
770    }
771
772    /// Ensure a valid session, returning its captured context (single-flight:
773    /// the lock is held across the login network calls).
774    async fn ensure_ctx(&self) -> Result<HashMap<String, String>, FaucetError> {
775        let mut guard = self.state.lock().await;
776        if let Some(s) = guard.as_ref()
777            && s.valid()
778        {
779            return Ok(s.ctx.clone());
780        }
781        let ctx = self.run_login().await?;
782        let expires_at = self
783            .config
784            .ttl_secs
785            .map(|s| Instant::now() + Duration::from_secs(s));
786        *guard = Some(Session {
787            ctx: ctx.clone(),
788            expires_at,
789        });
790        Ok(ctx)
791    }
792
793    fn request_auth_from_ctx(&self, captured: &HashMap<String, String>) -> RequestAuth {
794        let mut ctx = captured.clone();
795        ctx.extend(Self::clock_ctx());
796        build_request_auth(&self.config.apply, &self.config.base_url_from, &ctx)
797    }
798}
799
800#[async_trait]
801impl AuthProvider for FlowProvider {
802    async fn credential(&self) -> Result<Credential, FaucetError> {
803        let ctx = self.ensure_ctx().await?;
804        let auth = self.request_auth_from_ctx(&ctx);
805        // Header/bearer fallback for connectors that only consume `credential()`
806        // (xml, graphql): return the first header placement as a header
807        // credential. Query/cookie/body placements need `request_auth`.
808        for p in &auth.placements {
809            if let CredentialPlacement::Header { name, value } = p {
810                return Ok(Credential::Header {
811                    name: name.clone(),
812                    value: value.clone(),
813                });
814            }
815        }
816        Err(FaucetError::Auth(
817            "flow auth: no header credential to apply via credential(); this flow places its \
818             credential in a query/cookie/body, which requires a connector that consumes \
819             request_auth() (e.g. the REST source)"
820                .to_owned(),
821        ))
822    }
823
824    async fn invalidate(&self, _stale: &Credential) -> Result<Credential, FaucetError> {
825        // Force a re-login on the next ensure_ctx.
826        *self.state.lock().await = None;
827        self.credential().await
828    }
829
830    async fn request_auth(
831        &self,
832        _method: &str,
833        _url: &str,
834        _query: &std::collections::BTreeMap<String, String>,
835    ) -> Result<RequestAuth, FaucetError> {
836        let ctx = self.ensure_ctx().await?;
837        Ok(self.request_auth_from_ctx(&ctx))
838    }
839
840    fn reauth_statuses(&self) -> &[u16] {
841        &self.config.reauth_on
842    }
843
844    fn provider_name(&self) -> &'static str {
845        "flow"
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use serde_json::json;
853
854    fn ctx(pairs: &[(&str, &str)]) -> HashMap<String, String> {
855        pairs
856            .iter()
857            .map(|(k, v)| (k.to_string(), v.to_string()))
858            .collect()
859    }
860
861    #[test]
862    fn render_substitutes_known_and_leaves_unknown() {
863        let c = ctx(&[("token", "abc"), ("region", "eu")]);
864        assert_eq!(render("Bearer ${token}", &c), "Bearer abc");
865        assert_eq!(render("https://${region}.api", &c), "https://eu.api");
866        assert_eq!(render("${missing}", &c), "${missing}");
867        assert_eq!(render("no tokens", &c), "no tokens");
868        assert_eq!(render("${token}${region}", &c), "abceu");
869    }
870
871    #[test]
872    fn render_handles_unterminated_and_utf8() {
873        let c = ctx(&[("x", "✓")]);
874        assert_eq!(render("prefix ${x} ünïcode", &c), "prefix ✓ ünïcode");
875        assert_eq!(render("dangling ${x", &c), "dangling ${x");
876    }
877
878    #[test]
879    fn value_to_string_covers_kinds() {
880        assert_eq!(value_to_string(&json!("s")), "s");
881        assert_eq!(value_to_string(&json!(7)), "7");
882        assert_eq!(value_to_string(&json!(true)), "true");
883        assert_eq!(value_to_string(&json!(null)), "");
884        assert_eq!(value_to_string(&json!([1, 2])), "[1,2]");
885    }
886
887    #[test]
888    fn jsonpath_first_extracts_scalar() {
889        let body = json!({"data": {"BhRestToken": "tok", "restUrl": "https://host/x"}});
890        assert_eq!(
891            jsonpath_first(&body, "$.data.BhRestToken"),
892            Some(json!("tok"))
893        );
894        assert_eq!(
895            jsonpath_first(&body, "$.data.restUrl"),
896            Some(json!("https://host/x"))
897        );
898        assert_eq!(jsonpath_first(&body, "$.data.missing"), None);
899    }
900
901    #[test]
902    fn hmac_hex_and_base64_are_deterministic() {
903        let hex = hmac_sign("key", "message", SigEncoding::Hex);
904        // Known HMAC-SHA256("key","message") hex vector.
905        assert_eq!(
906            hex,
907            "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"
908        );
909        let b64 = hmac_sign("key", "message", SigEncoding::Base64);
910        assert_eq!(b64, "bp7ym3X//Ft6uuUn1Y/a2y/kLnIZARl2kXNDBl9Y7Uo=");
911    }
912
913    #[test]
914    fn build_request_auth_places_header_query_cookie_body() {
915        let apply: Vec<ApplySpec> = serde_json::from_value(json!([
916            { "into": "header", "name": "X-Tok", "value": "${tok}" },
917            { "into": "query",  "name": "access_token", "value": "${tok}" },
918            { "into": "cookie", "name": "sid", "value": "${sid}" },
919            { "into": "body",   "name": "auth", "value": "${tok}" }
920        ]))
921        .unwrap();
922        let c = ctx(&[("tok", "T"), ("sid", "S")]);
923        let ra = build_request_auth(&apply, &None, &c);
924        assert_eq!(ra.placements.len(), 4);
925        assert!(
926            matches!(&ra.placements[0], CredentialPlacement::Header { name, value } if name=="X-Tok" && value=="T")
927        );
928        assert!(
929            matches!(&ra.placements[1], CredentialPlacement::Query { name, value } if name=="access_token" && value=="T")
930        );
931        assert!(
932            matches!(&ra.placements[2], CredentialPlacement::Cookie { name, value } if name=="sid" && value=="S")
933        );
934        assert!(
935            matches!(&ra.placements[3], CredentialPlacement::BodyField { name, value } if name=="auth" && value=="T")
936        );
937        assert!(ra.base_url.is_none());
938        // #567: the captured context is exposed for `${name}` body/header
939        // substitution by connectors (e.g. an XML `sessionid`).
940        assert_eq!(ra.captured.get("tok").map(String::as_str), Some("T"));
941        assert_eq!(ra.captured.get("sid").map(String::as_str), Some("S"));
942    }
943
944    #[test]
945    fn build_request_auth_signs_and_sets_base_url() {
946        let apply: Vec<ApplySpec> = serde_json::from_value(json!([
947            { "sign": { "alg": "hmac_sha256", "key": "secret", "template": "${client}:${ts}",
948                        "encoding": "hex", "into": { "header": "Authorization", "format": "SS ${sig}" } } }
949        ]))
950        .unwrap();
951        let mut c = ctx(&[
952            ("client", "abc"),
953            ("ts", "100"),
954            ("base_url", "https://eu.host"),
955        ]);
956        c.insert("base_url".to_owned(), "https://eu.host".to_owned());
957        let ra = build_request_auth(&apply, &Some("${base_url}".to_owned()), &c);
958        assert_eq!(ra.placements.len(), 1);
959        let expected = format!("SS {}", hmac_sign("secret", "abc:100", SigEncoding::Hex));
960        assert!(
961            matches!(&ra.placements[0], CredentialPlacement::Header { name, value } if name=="Authorization" && *value==expected)
962        );
963        assert_eq!(ra.base_url.as_deref(), Some("https://eu.host"));
964    }
965
966    #[test]
967    fn base_url_from_empty_render_is_ignored() {
968        let ra = build_request_auth(&[], &Some("${missing_and_stripped}".to_owned()), &ctx(&[]));
969        // The unknown token renders verbatim (non-empty), so it is set; an
970        // actually-empty render (empty template) is ignored.
971        assert!(ra.base_url.is_some());
972        let ra2 = build_request_auth(&[], &Some("${e}".to_owned()), &ctx(&[("e", "")]));
973        assert!(ra2.base_url.is_none());
974    }
975
976    #[test]
977    fn config_validate_requires_steps_or_apply() {
978        let empty: FlowConfig = serde_json::from_value(json!({})).unwrap();
979        assert!(empty.validate().is_err());
980    }
981
982    #[test]
983    fn config_rejects_form_and_json_together() {
984        let cfg: FlowConfig = serde_json::from_value(json!({
985            "steps": [ { "request": { "url": "https://x", "form": {"a":"b"}, "json": {"c":"d"} } } ]
986        }))
987        .unwrap();
988        assert!(cfg.validate().is_err());
989    }
990
991    #[test]
992    fn config_rejects_empty_place_name_and_sign_header() {
993        let bad_place: FlowConfig = serde_json::from_value(json!({
994            "apply": [ { "into": "query", "name": "", "value": "${x}" } ]
995        }))
996        .unwrap();
997        assert!(bad_place.validate().is_err());
998
999        let bad_sign: FlowConfig = serde_json::from_value(json!({
1000            "apply": [ { "sign": { "alg": "hmac_sha256", "key": "k", "template": "${ts}",
1001                                   "into": { "header": "" } } } ]
1002        }))
1003        .unwrap();
1004        assert!(bad_sign.validate().is_err());
1005    }
1006
1007    #[test]
1008    fn from_config_rejects_unknown_field() {
1009        assert!(FlowProvider::from_config(&json!({ "bogus": 1 })).is_err());
1010    }
1011
1012    #[test]
1013    fn build_provider_dispatches_flow() {
1014        let p = crate::build_provider(&json!({
1015            "type": "flow",
1016            "config": { "apply": [ { "into": "header", "name": "X", "value": "v" } ] }
1017        }))
1018        .unwrap();
1019        assert_eq!(p.provider_name(), "flow");
1020    }
1021
1022    #[test]
1023    fn debug_redacts_and_summarizes() {
1024        let p = FlowProvider::from_config(&json!({
1025            "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1026        }))
1027        .unwrap();
1028        let s = format!("{p:?}");
1029        assert!(s.contains("FlowProvider"));
1030        assert!(s.contains("apply"));
1031    }
1032
1033    use std::collections::BTreeMap;
1034    use std::sync::Arc;
1035    use std::sync::atomic::{AtomicUsize, Ordering};
1036    use wiremock::matchers::{body_json, header, method, path};
1037    use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
1038
1039    #[tokio::test]
1040    async fn login_chain_captures_placements_and_base_url() {
1041        let server = MockServer::start().await;
1042        Mock::given(method("POST"))
1043            .and(path("/token"))
1044            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"access_token": "AT"})))
1045            .mount(&server)
1046            .await;
1047        Mock::given(method("GET"))
1048            .and(path("/login"))
1049            .respond_with(
1050                ResponseTemplate::new(200).set_body_json(
1051                    json!({"BhRestToken": "BRT", "restUrl": "https://data.example"}),
1052                ),
1053            )
1054            .mount(&server)
1055            .await;
1056
1057        let cfg = json!({
1058            "steps": [
1059                { "request": { "method": "POST", "url": format!("{}/token", server.uri()),
1060                               "form": {"grant_type": "refresh_token"} },
1061                  "capture": { "access_token": "$.access_token" } },
1062                { "request": { "method": "GET", "url": format!("{}/login", server.uri()),
1063                               "query": {"access_token": "${access_token}"} },
1064                  "capture": { "bh_rest_token": "$.BhRestToken", "base_url": "$.restUrl" } }
1065            ],
1066            "apply": [ { "into": "query", "name": "BhRestToken", "value": "${bh_rest_token}" } ],
1067            "base_url_from": "${base_url}",
1068            "reauth_on": [401]
1069        });
1070        let p = FlowProvider::from_config(&cfg).unwrap();
1071        let ra = p
1072            .request_auth("GET", "https://data.example/x", &BTreeMap::new())
1073            .await
1074            .unwrap();
1075        assert_eq!(ra.base_url.as_deref(), Some("https://data.example"));
1076        assert!(
1077            matches!(&ra.placements[0], CredentialPlacement::Query { name, value } if name == "BhRestToken" && value == "BRT")
1078        );
1079        assert_eq!(p.reauth_statuses(), &[401]);
1080        // A query placement has no header credential to apply via credential().
1081        assert!(p.credential().await.is_err());
1082    }
1083
1084    struct CountingLogin(Arc<AtomicUsize>);
1085    impl Respond for CountingLogin {
1086        fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
1087            let n = self.0.fetch_add(1, Ordering::SeqCst) + 1;
1088            ResponseTemplate::new(200).set_body_json(json!({ "sid": format!("S{n}") }))
1089        }
1090    }
1091
1092    #[tokio::test]
1093    async fn header_credential_caches_then_reloads_on_invalidate() {
1094        let server = MockServer::start().await;
1095        let hits = Arc::new(AtomicUsize::new(0));
1096        Mock::given(method("POST"))
1097            .and(path("/login"))
1098            .respond_with(CountingLogin(hits.clone()))
1099            .mount(&server)
1100            .await;
1101        let cfg = json!({
1102            "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()) },
1103                         "capture": { "sid": "$.sid" } } ],
1104            "apply": [ { "into": "header", "name": "X-Session", "value": "${sid}" } ]
1105        });
1106        let p = FlowProvider::from_config(&cfg).unwrap();
1107
1108        let c1 = p.credential().await.unwrap();
1109        assert!(
1110            matches!(&c1, Credential::Header { name, value } if name == "X-Session" && value == "S1")
1111        );
1112        // Cached: no ttl → second call does not re-login.
1113        let _ = p.credential().await.unwrap();
1114        assert_eq!(hits.load(Ordering::SeqCst), 1);
1115        // invalidate forces a fresh login.
1116        let c2 = p.invalidate(&c1).await.unwrap();
1117        assert!(matches!(&c2, Credential::Header { value, .. } if value == "S2"));
1118        assert_eq!(hits.load(Ordering::SeqCst), 2);
1119    }
1120
1121    #[tokio::test]
1122    async fn login_capture_miss_is_an_error() {
1123        let server = MockServer::start().await;
1124        Mock::given(method("GET"))
1125            .and(path("/x"))
1126            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"other": 1})))
1127            .mount(&server)
1128            .await;
1129        let cfg = json!({
1130            "steps": [ { "request": { "url": format!("{}/x", server.uri()) },
1131                         "capture": { "tok": "$.access_token" } } ],
1132            "apply": [ { "into": "header", "name": "X", "value": "${tok}" } ]
1133        });
1134        let p = FlowProvider::from_config(&cfg).unwrap();
1135        assert!(p.credential().await.is_err());
1136    }
1137
1138    #[tokio::test]
1139    async fn login_non_success_is_an_error() {
1140        let server = MockServer::start().await;
1141        Mock::given(method("GET"))
1142            .and(path("/x"))
1143            .respond_with(ResponseTemplate::new(500))
1144            .mount(&server)
1145            .await;
1146        let cfg = json!({
1147            "steps": [ { "request": { "url": format!("{}/x", server.uri()) } } ],
1148            "apply": [ { "into": "header", "name": "X", "value": "static" } ]
1149        });
1150        let p = FlowProvider::from_config(&cfg).unwrap();
1151        assert!(
1152            p.request_auth("GET", "https://x", &BTreeMap::new())
1153                .await
1154                .is_err()
1155        );
1156    }
1157
1158    #[test]
1159    fn config_rejects_empty_step_url() {
1160        let cfg: FlowConfig = serde_json::from_value(json!({
1161            "steps": [ { "request": { "url": "  " } } ]
1162        }))
1163        .unwrap();
1164        assert!(cfg.validate().is_err());
1165    }
1166
1167    #[tokio::test]
1168    async fn session_ttl_caches_within_window() {
1169        let server = MockServer::start().await;
1170        let hits = Arc::new(AtomicUsize::new(0));
1171        Mock::given(method("POST"))
1172            .and(path("/login"))
1173            .respond_with(CountingLogin(hits.clone()))
1174            .mount(&server)
1175            .await;
1176        let cfg = json!({
1177            "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()) },
1178                         "capture": { "sid": "$.sid" } } ],
1179            "apply": [ { "into": "header", "name": "X", "value": "${sid}" } ],
1180            "ttl_secs": 3600
1181        });
1182        let p = FlowProvider::from_config(&cfg).unwrap();
1183        let _ = p.credential().await.unwrap();
1184        // Within the TTL window the session is reused (Session::valid == true).
1185        let _ = p.credential().await.unwrap();
1186        assert_eq!(hits.load(Ordering::SeqCst), 1);
1187    }
1188
1189    #[tokio::test]
1190    async fn login_sends_headers_and_json_body() {
1191        let server = MockServer::start().await;
1192        Mock::given(method("POST"))
1193            .and(path("/login"))
1194            .and(header("x-tenant", "acme"))
1195            .and(body_json(json!({"scope": "read"})))
1196            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"tok": "T"})))
1197            .mount(&server)
1198            .await;
1199        let cfg = json!({
1200            "steps": [ { "request": { "method": "POST", "url": format!("{}/login", server.uri()),
1201                         "headers": {"X-Tenant": "acme"}, "json": {"scope": "read"} },
1202                         "capture": { "t": "$.tok" } } ],
1203            "apply": [ { "into": "header", "name": "Authorization", "value": "Bearer ${t}" } ]
1204        });
1205        let p = FlowProvider::from_config(&cfg).unwrap();
1206        assert_eq!(p.provider_name(), "flow");
1207        let c = p.credential().await.unwrap();
1208        assert!(
1209            matches!(&c, Credential::Header { name, value } if name == "Authorization" && value == "Bearer T")
1210        );
1211    }
1212
1213    #[tokio::test]
1214    async fn login_invalid_method_errors() {
1215        let cfg = json!({
1216            "steps": [ { "request": { "method": "BAD METHOD", "url": "https://x/login" } } ],
1217            "apply": [ { "into": "header", "name": "X", "value": "static" } ]
1218        });
1219        let p = FlowProvider::from_config(&cfg).unwrap();
1220        assert!(
1221            p.request_auth("GET", "https://x", &BTreeMap::new())
1222                .await
1223                .is_err()
1224        );
1225    }
1226
1227    #[tokio::test]
1228    async fn login_send_failure_errors() {
1229        // Port 1 is unassignable → the send fails at the transport layer.
1230        let cfg = json!({
1231            "steps": [ { "request": { "url": "http://127.0.0.1:1/x" }, "capture": { "t": "$.t" } } ],
1232            "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1233        });
1234        let p = FlowProvider::from_config(&cfg).unwrap();
1235        assert!(
1236            p.request_auth("GET", "https://x", &BTreeMap::new())
1237                .await
1238                .is_err()
1239        );
1240    }
1241
1242    #[tokio::test]
1243    async fn login_non_json_response_errors() {
1244        let server = MockServer::start().await;
1245        Mock::given(method("GET"))
1246            .and(path("/x"))
1247            .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
1248            .mount(&server)
1249            .await;
1250        let cfg = json!({
1251            "steps": [ { "request": { "url": format!("{}/x", server.uri()) }, "capture": { "t": "$.t" } } ],
1252            "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1253        });
1254        let p = FlowProvider::from_config(&cfg).unwrap();
1255        assert!(
1256            p.request_auth("GET", "https://x", &BTreeMap::new())
1257                .await
1258                .is_err()
1259        );
1260    }
1261
1262    #[tokio::test]
1263    async fn login_step_with_empty_capture_succeeds() {
1264        // A pre-flight step that captures nothing (e.g. establishes a cookie);
1265        // its non-JSON body is never parsed.
1266        let server = MockServer::start().await;
1267        Mock::given(method("GET"))
1268            .and(path("/ping"))
1269            .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
1270            .mount(&server)
1271            .await;
1272        Mock::given(method("GET"))
1273            .and(path("/token"))
1274            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"t": "T"})))
1275            .mount(&server)
1276            .await;
1277        let cfg = json!({
1278            "steps": [
1279                { "request": { "url": format!("{}/ping", server.uri()) } },
1280                { "request": { "url": format!("{}/token", server.uri()) }, "capture": { "t": "$.t" } }
1281            ],
1282            "apply": [ { "into": "header", "name": "X", "value": "${t}" } ]
1283        });
1284        let p = FlowProvider::from_config(&cfg).unwrap();
1285        let c = p.credential().await.unwrap();
1286        assert!(matches!(&c, Credential::Header { value, .. } if value == "T"));
1287    }
1288
1289    #[tokio::test]
1290    async fn no_steps_apply_only_flow_works() {
1291        // A flow with only `apply` (a static signer/placement, no login) needs
1292        // no network.
1293        let cfg = json!({
1294            "apply": [ { "sign": { "alg": "hmac_sha256", "key": "k", "template": "msg",
1295                                   "into": { "header": "Authorization", "format": "HMAC ${sig}" } } } ]
1296        });
1297        let p = FlowProvider::from_config(&cfg).unwrap();
1298        let c = p.credential().await.unwrap();
1299        let expected = format!("HMAC {}", hmac_sign("k", "msg", SigEncoding::Hex));
1300        assert!(
1301            matches!(&c, Credential::Header { name, value } if name == "Authorization" && *value == expected)
1302        );
1303    }
1304
1305    // ── #541: HMAC sign on a login/pre-flight step ───────────────────────────
1306
1307    #[test]
1308    fn sign_header_computes_name_and_value_with_clock() {
1309        let mut c = ctx(&[("client", "abc")]);
1310        c.insert("ts".to_owned(), "999".to_owned());
1311        let sign: SignSpec = serde_json::from_value(json!({
1312            "alg": "hmac_sha256", "key": "k", "template": "${client}:${ts}",
1313            "encoding": "hex", "into": { "header": "Authorization", "format": "SS ${sig}" }
1314        }))
1315        .unwrap();
1316        let (name, value) = sign_header(&sign, &c);
1317        assert_eq!(name, "Authorization");
1318        assert_eq!(
1319            value,
1320            format!("SS {}", hmac_sign("k", "abc:999", SigEncoding::Hex))
1321        );
1322    }
1323
1324    #[tokio::test]
1325    async fn signed_login_step_sends_computed_signature_header() {
1326        let server = MockServer::start().await;
1327        let key = "secret_key";
1328        // Deterministic template (no ${ts}) so the header is assertable.
1329        let expected = format!(
1330            "SS access:{}",
1331            hmac_sign(key, "client:secret", SigEncoding::Base64)
1332        );
1333        Mock::given(method("POST"))
1334            .and(path("/auth/login"))
1335            .and(header("authorization", expected.as_str()))
1336            .respond_with(
1337                ResponseTemplate::new(200).set_body_json(json!({"session": {"token": "SESS"}})),
1338            )
1339            .mount(&server)
1340            .await;
1341        let cfg = json!({
1342            "steps": [ {
1343                "request": {
1344                    "method": "POST",
1345                    "url": format!("{}/auth/login", server.uri()),
1346                    "json": {"clientId": "client"},
1347                    "sign": { "alg": "hmac_sha256", "key": key, "template": "client:secret",
1348                              "encoding": "base64",
1349                              "into": { "header": "Authorization", "format": "SS access:${sig}" } }
1350                },
1351                "capture": { "session": "$.session.token" }
1352            } ],
1353            "apply": [ { "into": "header", "name": "Session", "value": "${session}" } ]
1354        });
1355        let p = FlowProvider::from_config(&cfg).unwrap();
1356        // Success only if the login request carried the exact signed header
1357        // (otherwise wiremock returns 404 and the step fails).
1358        let c = p.credential().await.unwrap();
1359        assert!(
1360            matches!(&c, Credential::Header { name, value } if name == "Session" && value == "SESS")
1361        );
1362    }
1363
1364    #[tokio::test]
1365    async fn skyslope_style_signed_login_then_signed_data_requests() {
1366        let server = MockServer::start().await;
1367        let key = "base64secret";
1368        let login_sig = format!(
1369            "SS access:{}",
1370            hmac_sign(key, "cid:csec", SigEncoding::Base64)
1371        );
1372        Mock::given(method("POST"))
1373            .and(path("/auth/login"))
1374            .and(header("authorization", login_sig.as_str()))
1375            .respond_with(
1376                ResponseTemplate::new(200)
1377                    .set_body_json(json!({"session": {"token": "SESSION-TOK"}})),
1378            )
1379            .mount(&server)
1380            .await;
1381        let signer = json!({ "alg": "hmac_sha256", "key": key, "template": "cid:csec",
1382                             "encoding": "base64",
1383                             "into": { "header": "Authorization", "format": "SS access:${sig}" } });
1384        let cfg = json!({
1385            "steps": [ {
1386                "request": { "method": "POST", "url": format!("{}/auth/login", server.uri()),
1387                             "json": {"clientId": "cid"}, "sign": signer },
1388                "capture": { "session": "$.session.token" }
1389            } ],
1390            "apply": [
1391                { "sign": signer },
1392                { "into": "header", "name": "Session", "value": "${session}" }
1393            ]
1394        });
1395        let p = FlowProvider::from_config(&cfg).unwrap();
1396        let ra = p
1397            .request_auth("GET", "https://data", &BTreeMap::new())
1398            .await
1399            .unwrap();
1400        let has_auth = ra.placements.iter().any(|pl| {
1401            matches!(pl, CredentialPlacement::Header { name, value } if name == "Authorization" && *value == login_sig)
1402        });
1403        let has_sess = ra.placements.iter().any(|pl| {
1404            matches!(pl, CredentialPlacement::Header { name, value } if name == "Session" && value == "SESSION-TOK")
1405        });
1406        assert!(has_auth, "data request carries the signer header");
1407        assert!(has_sess, "data request carries the captured Session header");
1408    }
1409
1410    #[test]
1411    fn config_rejects_empty_step_sign_header() {
1412        let cfg: FlowConfig = serde_json::from_value(json!({
1413            "steps": [ { "request": { "url": "https://x",
1414                "sign": { "alg": "hmac_sha256", "key": "k", "template": "${ts}",
1415                          "into": { "header": "" } } } } ]
1416        }))
1417        .unwrap();
1418        assert!(cfg.validate().is_err());
1419    }
1420
1421    // ── #542: capture from header / Set-Cookie / XML ─────────────────────────
1422
1423    #[test]
1424    fn xml_dot_path_extracts_nested_text() {
1425        let xml = "<operation><result><data><api><sessionid>ABC123</sessionid></api></data></result></operation>";
1426        assert_eq!(
1427            xml_dot_path(xml, "operation.result.data.api.sessionid").as_deref(),
1428            Some("ABC123")
1429        );
1430        assert_eq!(xml_dot_path(xml, "operation.result.missing"), None);
1431    }
1432
1433    #[test]
1434    fn xml_dot_path_ignores_declaration_attrs_and_namespaces() {
1435        let xml = r#"<?xml version="1.0"?><ns:root xmlns:ns="urn:x"><ns:child id="1">  hi  </ns:child></ns:root>"#;
1436        assert_eq!(xml_dot_path(xml, "root.child").as_deref(), Some("hi"));
1437        // A path segment may itself carry a prefix — it is stripped too.
1438        assert_eq!(xml_dot_path(xml, "ns:root.ns:child").as_deref(), Some("hi"));
1439    }
1440
1441    #[test]
1442    fn xml_dot_path_handles_cdata_and_entities() {
1443        let xml = "<r><a><![CDATA[a&b]]></a><b>x &amp; y</b></r>";
1444        assert_eq!(xml_dot_path(xml, "r.a").as_deref(), Some("a&b"));
1445        assert_eq!(xml_dot_path(xml, "r.b").as_deref(), Some("x & y"));
1446    }
1447
1448    #[test]
1449    fn set_cookie_and_header_helpers_select_by_name() {
1450        let mut h = reqwest::header::HeaderMap::new();
1451        h.append("set-cookie", "a=1; path=/".parse().unwrap());
1452        h.append(
1453            "set-cookie",
1454            "ASP.NET_SessionId=SID; path=/; HttpOnly".parse().unwrap(),
1455        );
1456        h.insert("location", "https://x/next".parse().unwrap());
1457        assert_eq!(
1458            set_cookie_value(&h, "ASP.NET_SessionId").as_deref(),
1459            Some("SID")
1460        );
1461        assert_eq!(set_cookie_value(&h, "missing"), None);
1462        assert_eq!(
1463            header_value(&h, "Location").as_deref(),
1464            Some("https://x/next")
1465        );
1466        assert_eq!(header_value(&h, "absent"), None);
1467    }
1468
1469    #[test]
1470    fn capture_backcompat_string_form_parses_as_json() {
1471        let step: FlowStep = serde_json::from_value(json!({
1472            "request": { "url": "https://x" },
1473            "capture": { "tok": "$.access_token" }
1474        }))
1475        .unwrap();
1476        assert_eq!(step.capture["tok"].kind(), CaptureFrom::Json);
1477        assert!(matches!(&step.capture["tok"], CaptureSpec::Json(p) if p == "$.access_token"));
1478    }
1479
1480    #[test]
1481    fn capture_struct_forms_parse_kinds() {
1482        let step: FlowStep = serde_json::from_value(json!({
1483            "request": { "url": "https://x" },
1484            "capture": {
1485                "h": { "from": "header", "name": "Location" },
1486                "c": { "from": "set_cookie", "name": "sid" },
1487                "x": { "from": "xml", "path": "a.b" },
1488                "j": { "from": "json", "path": "$.tok" }
1489            }
1490        }))
1491        .unwrap();
1492        assert_eq!(step.capture["h"].kind(), CaptureFrom::Header);
1493        assert_eq!(step.capture["c"].kind(), CaptureFrom::SetCookie);
1494        assert_eq!(step.capture["x"].kind(), CaptureFrom::Xml);
1495        assert_eq!(step.capture["j"].kind(), CaptureFrom::Json);
1496    }
1497
1498    #[test]
1499    fn config_rejects_capture_missing_selector() {
1500        let cfg: FlowConfig = serde_json::from_value(json!({
1501            "steps": [ { "request": { "url": "https://x" }, "capture": { "h": { "from": "header" } } } ]
1502        }))
1503        .unwrap();
1504        assert!(cfg.validate().is_err());
1505
1506        let cfg2: FlowConfig = serde_json::from_value(json!({
1507            "steps": [ { "request": { "url": "https://x" }, "capture": { "x": { "from": "xml" } } } ]
1508        }))
1509        .unwrap();
1510        assert!(cfg2.validate().is_err());
1511    }
1512
1513    #[tokio::test]
1514    async fn capture_from_header_works() {
1515        let server = MockServer::start().await;
1516        Mock::given(method("GET"))
1517            .and(path("/login"))
1518            .respond_with(
1519                ResponseTemplate::new(200)
1520                    .insert_header("Location", "https://redirect.example/next"),
1521            )
1522            .mount(&server)
1523            .await;
1524        let cfg = json!({
1525            "steps": [ { "request": { "url": format!("{}/login", server.uri()) },
1526                         "capture": { "loc": { "from": "header", "name": "Location" } } } ],
1527            "apply": [ { "into": "header", "name": "X-Loc", "value": "${loc}" } ]
1528        });
1529        let p = FlowProvider::from_config(&cfg).unwrap();
1530        let c = p.credential().await.unwrap();
1531        assert!(
1532            matches!(&c, Credential::Header { name, value } if name == "X-Loc" && value == "https://redirect.example/next")
1533        );
1534    }
1535
1536    #[tokio::test]
1537    async fn capture_from_set_cookie_applies_as_cookie() {
1538        // Acumatica-style: POST /login → 204 empty body + Set-Cookie session.
1539        let server = MockServer::start().await;
1540        Mock::given(method("POST"))
1541            .and(path("/entity/auth/login"))
1542            .respond_with(
1543                ResponseTemplate::new(204)
1544                    .append_header("Set-Cookie", "other=nope; path=/")
1545                    .append_header("Set-Cookie", "ASP.NET_SessionId=SID123; path=/; HttpOnly"),
1546            )
1547            .mount(&server)
1548            .await;
1549        let cfg = json!({
1550            "steps": [ { "request": { "method": "POST",
1551                           "url": format!("{}/entity/auth/login", server.uri()),
1552                           "json": {"name": "u", "password": "p"} },
1553                         "capture": { "session_cookie": { "from": "set_cookie", "name": "ASP.NET_SessionId" } } } ],
1554            "apply": [ { "into": "cookie", "name": "ASP.NET_SessionId", "value": "${session_cookie}" } ]
1555        });
1556        let p = FlowProvider::from_config(&cfg).unwrap();
1557        let ra = p
1558            .request_auth("GET", "https://data", &BTreeMap::new())
1559            .await
1560            .unwrap();
1561        assert!(
1562            matches!(&ra.placements[0], CredentialPlacement::Cookie { name, value } if name == "ASP.NET_SessionId" && value == "SID123")
1563        );
1564    }
1565
1566    #[tokio::test]
1567    async fn capture_from_xml_body_works() {
1568        // Sage Intacct-style: session id lives in an XML response body.
1569        let server = MockServer::start().await;
1570        let xml = r#"<?xml version="1.0" encoding="UTF-8"?><response><operation><result><data><api><sessionid>XYZ-SESSION</sessionid></api></data></result></operation></response>"#;
1571        Mock::given(method("POST"))
1572            .and(path("/xml/xmlgw.phtml"))
1573            .respond_with(ResponseTemplate::new(200).set_body_string(xml))
1574            .mount(&server)
1575            .await;
1576        let cfg = json!({
1577            "steps": [ { "request": { "method": "POST",
1578                           "url": format!("{}/xml/xmlgw.phtml", server.uri()) },
1579                         "capture": { "sess_id": { "from": "xml",
1580                             "path": "response.operation.result.data.api.sessionid" } } } ],
1581            "apply": [ { "into": "header", "name": "X-Session", "value": "${sess_id}" } ]
1582        });
1583        let p = FlowProvider::from_config(&cfg).unwrap();
1584        let c = p.credential().await.unwrap();
1585        assert!(matches!(&c, Credential::Header { value, .. } if value == "XYZ-SESSION"));
1586    }
1587
1588    #[tokio::test]
1589    async fn capture_from_xml_miss_is_an_error() {
1590        let server = MockServer::start().await;
1591        Mock::given(method("POST"))
1592            .and(path("/x"))
1593            .respond_with(ResponseTemplate::new(200).set_body_string("<r><a>1</a></r>"))
1594            .mount(&server)
1595            .await;
1596        let cfg = json!({
1597            "steps": [ { "request": { "method": "POST", "url": format!("{}/x", server.uri()) },
1598                         "capture": { "s": { "from": "xml", "path": "r.missing" } } } ],
1599            "apply": [ { "into": "header", "name": "X", "value": "${s}" } ]
1600        });
1601        let p = FlowProvider::from_config(&cfg).unwrap();
1602        assert!(p.credential().await.is_err());
1603    }
1604}