Skip to main content

ignition_core/client/
idp.rs

1//! Native OIDC login + CSRF flow (04-03, tier 1 of the trial-reset
2//! ladder) — the internal IdP's challenge dance, live-probed during
3//! 04-RESEARCH and **live-verified END-TO-END on 8.3.3 during this
4//! plan's spike**: login → session → CSRF → `POST /data/api/v1/trial`
5//! flipped `expired:true → false` (`trialSecondsLeft 0 → 7199`).
6//!
7//! ## The flow (all steps live-observed; the research's 10-step map,
8//! with the two LOW-confidence deliverables now resolved live)
9//!
10//! 1. `GET /data/app/login` → 302 into `/idp/<name>/oidc/auth?…` —
11//!    the IdP NAME is derived from this redirect (gateways rename the
12//!    internal IdP; `default` is only the common case, `temp` was
13//!    live-observed on 8.3.x)
14//!    (+ `idp-relay-*` cookie)
15//! 2. `GET /idp/<name>/oidc/auth?…` → 302 to
16//!    `/idp/<name>/authn/login?…&token=<T0>` (+ `idp-sid-<name>-*`
17//!    cookie)
18//! 3. `POST /idp/<name>/authn/next-challenge` `{"token":T0}` →
19//!    `{"complete":false,"nextChallenge":[…],"token":<T1>}` — **the
20//!    token ROTATES on every call; thread it forward or the next call
21//!    400s in Jetty HTML** (research Pitfall 2)
22//! 4. `POST /idp/<name>/authn/submit-challenge/basic`
23//!    `{"token":T1,"rememberMe":false,"challenge":{username,password}}`
24//!    → `{"success":bool,"token":<T2>}`; `success:false` = rejected
25//!    credentials (live-observed on 8.3.6 with a wrong password)
26//! 5. `POST next-challenge {"token":T2}` → `{"complete":true,…,
27//!    "token":<T3>}`
28//! 6. `GET /idp/<name>/oidc/auth?<orig params>&token=<T3>` → 302 to
29//!    `/data/federate/callback/internal?code&state`
30//! 7. `GET /data/federate/callback/internal?…` → 302 `/app` +
31//!    **`Set-Cookie: webui-sid-<gatewayId>=…`** (the session cookie —
32//!    name RESOLVED LIVE; `Path=/; HttpOnly; SameSite=Strict`)
33//! 8. `GET /data/app/session` (session cookie) →
34//!    `{"userPayload":{…},"csrfToken":"…"}` (field RESOLVED LIVE)
35//! 9. `POST /data/api/v1/trial` (session cookie + `X-CSRF-Token`
36//!    header) → 200 = the fresh [`TrialWire`]
37//! 10. read-back: `GET /data/api/v1/trial` → `expired:false` (the
38//!     action layer owns the flip check — mutations read back)
39//!
40//! ## Design rules (research anti-patterns, honored)
41//!
42//! - The flow NEVER touches the locked client pipeline: a DEDICATED
43//!   flow-local `reqwest::Client` with `redirect(Policy::none())`
44//!   consumes each 302 by hand (Location header → next GET).
45//! - NO cookie store (the `cookies` feature stays OUT): the ~4 known
46//!   Set-Cookies are captured into a `Vec<(name, value)>` and replayed
47//!   verbatim — a fixed sequence, not arbitrary browsing.
48//! - Non-JSON 4xx from the IdP endpoints (consumed-token replay →
49//!   Jetty HTML 400) surfaces as a flow failure with the HTML `<title>`
50//!   extracted (the classify-style sniff, flow-local edition).
51//! - Passwords ride [`Secret`] end-to-end; the only exposure is the
52//!   one JSON-body construction site (the redaction discipline).
53
54use std::time::Duration;
55
56use serde::Deserialize;
57use serde_json::json;
58
59use crate::client::trial::TrialWire;
60use crate::config::Secret;
61use crate::error::CoreError;
62
63/// Login entry point — 302s into the IdP OIDC flow.
64const APP_LOGIN_PATH: &str = "/data/app/login";
65/// The fixed shape of step 1's redirect: `/idp/<name>/oidc/auth?…`.
66/// The `<name>` segment is DERIVED per gateway — the internal IdP is
67/// not always called `default` (live-observed `temp` on 8.3.x), so
68/// every IdP URL in the flow is built from the name the gateway
69/// itself hands out.
70const IDP_PATH_PREFIX: &str = "/idp/";
71const OIDC_AUTH_PATH_SUFFIX: &str = "/oidc/auth";
72/// The session/CSRF endpoint (step 8).
73const APP_SESSION_PATH: &str = "/data/app/session";
74/// The trial reset target (step 9).
75const TRIAL_PATH: &str = "/data/api/v1/trial";
76/// The session cookie's name prefix (the suffix is the gateway id —
77/// captured generically from Set-Cookie, live-resolved).
78const SESSION_COOKIE_PREFIX: &str = "webui-sid-";
79
80/// The authenticated gateway session the flow yields.
81#[derive(Debug, Clone)]
82pub struct GatewaySession {
83    /// The session cookie's name (`webui-sid-<gatewayId>`).
84    pub cookie_name: String,
85    /// The session cookie's value.
86    pub cookie_value: String,
87    /// The CSRF token (step 8's `csrfToken` field) — rides the
88    /// `X-CSRF-Token` header on the reset POST.
89    pub csrf_token: String,
90}
91
92impl GatewaySession {
93    /// The `Cookie:` header value for this session.
94    fn cookie_header(&self) -> String {
95        format!("{}={}", self.cookie_name, self.cookie_value)
96    }
97}
98
99/// Step 8's body — only `csrfToken` is consumed; the user payload
100/// round-trips as passthrough.
101#[derive(Debug, Deserialize)]
102struct SessionInfo {
103    #[serde(rename = "csrfToken", default)]
104    csrf_token: String,
105}
106
107/// Step 3/5's body — the rotating token + completeness.
108#[derive(Debug, Deserialize)]
109struct ChallengeAnswer {
110    #[serde(default)]
111    complete: bool,
112    #[serde(rename = "nextChallenge", default)]
113    next_challenge: Vec<serde_json::Value>,
114    #[serde(default)]
115    token: String,
116}
117
118/// Step 4's body.
119#[derive(Debug, Deserialize)]
120struct SubmitAnswer {
121    #[serde(default)]
122    success: bool,
123    #[serde(default)]
124    token: String,
125}
126
127/// One flow-local HTTP client for the whole login dance. Consumed by
128/// [`login`] / [`trial_reset_via_session`]; never merged with the
129/// locked [`crate::client::ReqwestGatewayApi`] pipeline.
130pub struct IdpLoginFlow {
131    base: url::Url,
132    client: reqwest::Client,
133    /// Every cookie the flow has captured, in capture order — replayed
134    /// verbatim (the fixed ~4-cookie sequence; NO cookie store).
135    cookies: Vec<(String, String)>,
136}
137
138impl IdpLoginFlow {
139    /// Build the flow against a rig's base URL (e.g.
140    /// `http://localhost:9088`).
141    pub fn new(base_url: &str) -> Result<Self, CoreError> {
142        super::install_crypto_provider();
143        let client = reqwest::Client::builder()
144            // The locked client's rule, flow-local edition: consume
145            // every 302 BY HAND (the flow's steps ARE the redirects).
146            .redirect(reqwest::redirect::Policy::none())
147            .connect_timeout(Duration::from_secs(10))
148            .timeout(Duration::from_secs(30))
149            .build()
150            .map_err(|err| CoreError::Internal(format!("cannot build login client: {err}")))?;
151        Ok(Self {
152            base: url::Url::parse(base_url)
153                .map_err(|err| CoreError::Internal(format!("invalid rig URL: {err}")))?,
154            client,
155            cookies: Vec::new(),
156        })
157    }
158
159    fn url_for(&self, path_and_query: &str) -> url::Url {
160        self.base
161            .join(path_and_query)
162            .expect("base joins an absolute path")
163    }
164
165    /// Capture every `Set-Cookie` on the response (name=value only —
166    /// attributes dropped; the replay is manual).
167    fn capture_cookies(&mut self, response: &reqwest::Response) {
168        for value in response.headers().get_all(reqwest::header::SET_COOKIE) {
169            if let Ok(cookie) = value.to_str()
170                && let Some((name, cookie_value)) = cookie.split_once('=')
171            {
172                let name = name.trim().to_string();
173                let cookie_value = cookie_value
174                    .split(';')
175                    .next()
176                    .unwrap_or(cookie_value)
177                    .trim()
178                    .to_string();
179                if !name.is_empty() && !cookie_value.is_empty() {
180                    // A re-set cookie replaces its prior value.
181                    self.cookies.retain(|(prior, _)| *prior != name);
182                    self.cookies.push((name, cookie_value));
183                }
184            }
185        }
186    }
187
188    /// The `Cookie:` header for everything captured so far.
189    fn cookie_header(&self) -> String {
190        self.cookies
191            .iter()
192            .map(|(name, value)| format!("{name}={value}"))
193            .collect::<Vec<_>>()
194            .join("; ")
195    }
196
197    /// A flow-local transport+shape failure: the message names the
198    /// step so agents see exactly where the dance broke.
199    fn flow_error(step: &str, detail: String) -> CoreError {
200        CoreError::Internal(format!("gateway login flow failed at {step}: {detail}"))
201    }
202
203    /// Extract `<title>Error NNN</title>` from a Jetty HTML error page
204    /// (the consumed-token replay shape — research Pitfall 2), else
205    /// truncate the body.
206    fn html_title_or_excerpt(body: &str) -> String {
207        if let Some(start) = body.find("<title>")
208            && let Some(end) = body[start + 7..].find("</title>")
209        {
210            return body[start + 7..start + 7 + end].to_string();
211        }
212        let excerpt: String = body.chars().take(120).collect();
213        excerpt.replace(['\n', '\r'], " ")
214    }
215
216    /// GET `path_and_query` with the captured cookies; expect a 302
217    /// and return its Location (path + query — the next hop).
218    async fn follow_redirect(
219        &mut self,
220        step: &str,
221        path_and_query: &str,
222    ) -> Result<String, CoreError> {
223        let url = self.url_for(path_and_query);
224        let mut request = self.client.get(url.clone());
225        if !self.cookies.is_empty() {
226            request = request.header(reqwest::header::COOKIE, self.cookie_header());
227        }
228        let response = request.send().await.map_err(|err| CoreError::Network {
229            url: url.to_string(),
230            source: Some(err),
231            observation: None,
232        })?;
233        self.capture_cookies(&response);
234        match response.status().as_u16() {
235            302 | 303 => {
236                let location = response
237                    .headers()
238                    .get(reqwest::header::LOCATION)
239                    .and_then(|value| value.to_str().ok())
240                    .map(str::to_string);
241                let location = location.ok_or_else(|| {
242                    Self::flow_error(step, "redirect carried no Location header".into())
243                })?;
244                // The fragment is client-side-only decoration: this flow
245                // only ever talks to the server, and a stray `#…` would
246                // taint token extraction (step 2's query_param) or land
247                // AFTER step 6's appended token — dropping the token out
248                // of the request query entirely. Strip it at the one
249                // choke point every hop passes through.
250                Ok(match location.split_once('#') {
251                    Some((head, _)) => head.to_string(),
252                    None => location,
253                })
254            }
255            status => {
256                let body = response.text().await.unwrap_or_default();
257                Err(Self::flow_error(
258                    step,
259                    format!(
260                        "expected a redirect, got HTTP {status} ({})",
261                        Self::html_title_or_excerpt(&body)
262                    ),
263                ))
264            }
265        }
266    }
267
268    /// POST `path` with a JSON body + captured cookies; expect 200 JSON
269    /// (the challenge endpoints' contract). Non-2xx or non-JSON → flow
270    /// failure with the HTML title sniff.
271    async fn post_json_flow(
272        &self,
273        step: &str,
274        path: &str,
275        body: &serde_json::Value,
276    ) -> Result<serde_json::Value, CoreError> {
277        let url = self.url_for(path);
278        let mut request = self
279            .client
280            .post(url.clone())
281            .header(reqwest::header::ACCEPT, "application/json");
282        if !self.cookies.is_empty() {
283            request = request.header(reqwest::header::COOKIE, self.cookie_header());
284        }
285        let response = request
286            .json(body)
287            .send()
288            .await
289            .map_err(|err| CoreError::Network {
290                url: url.to_string(),
291                source: Some(err),
292                observation: None,
293            })?;
294        let status = response.status().as_u16();
295        let text = response.text().await.unwrap_or_default();
296        if !(200..300).contains(&status) {
297            // 401/403 from the challenge endpoints = auth-class; other
298            // 4xx (the Jetty-HTML token-replay 400) = flow failure
299            // with the title extracted.
300            if status == 401 || status == 403 {
301                return Err(CoreError::Auth {
302                    status,
303                    endpoint: Some(path.to_string()),
304                });
305            }
306            return Err(Self::flow_error(
307                step,
308                format!("HTTP {status} ({})", Self::html_title_or_excerpt(&text)),
309            ));
310        }
311        serde_json::from_str(&text).map_err(|err| {
312            Self::flow_error(
313                step,
314                format!("non-JSON answer ({})", Self::html_title_or_excerpt(&text)),
315            )
316            .tap_detail(err)
317        })
318    }
319}
320
321/// Small helper to append the underlying parse error to a flow failure
322/// without changing its class (kept local + trivial).
323trait TapDetail {
324    fn tap_detail(self, err: serde_json::Error) -> CoreError;
325}
326
327impl TapDetail for CoreError {
328    fn tap_detail(self, err: serde_json::Error) -> CoreError {
329        match self {
330            CoreError::Internal(message) => CoreError::Internal(format!("{message}: {err}")),
331            other => other,
332        }
333    }
334}
335
336/// Step 1's redirect must have the shape `/idp/<name>/oidc/auth?…` —
337/// the IdP name is DERIVED from the redirect itself (the gateway is
338/// authoritative; hardcoding `default` breaks renamed-IdP gateways).
339/// Returns the derived name; a shape mismatch fails with the observed
340/// target named so a genuine non-IdP redirect stays loud and legible.
341fn parse_oidc_start(location: &str) -> Result<&str, CoreError> {
342    let path = location.split('?').next().unwrap_or(location);
343    let name = path
344        .strip_prefix(IDP_PATH_PREFIX)
345        .and_then(|rest| rest.strip_suffix(OIDC_AUTH_PATH_SUFFIX))
346        .filter(|name| !name.is_empty() && !name.contains('/'));
347    match name {
348        Some(name) => Ok(name),
349        None => Err(IdpLoginFlow::flow_error(
350            "step 1",
351            format!(
352                "unexpected redirect target {location:?} (not the gateway IdP OIDC \
353                 endpoint — expected /idp/<name>/oidc/auth)"
354            ),
355        )),
356    }
357}
358
359/// Run the full login dance (steps 1–8) and yield the gateway session.
360/// `password` exposure happens at exactly ONE site: the step-4 JSON
361/// body construction (the redaction discipline).
362pub async fn login(
363    flow: IdpLoginFlow,
364    username: &str,
365    password: &Secret,
366) -> Result<(IdpLoginFlow, GatewaySession), CoreError> {
367    let mut flow = flow;
368
369    // 1. Entry: /data/app/login → the OIDC authorization URL. The IdP
370    //    name comes out of the redirect itself.
371    let oidc_start = flow
372        .follow_redirect("step 1 (GET /data/app/login)", APP_LOGIN_PATH)
373        .await?;
374    let idp_name = parse_oidc_start(&oidc_start)?;
375    let next_challenge_path = format!("/idp/{idp_name}/authn/next-challenge");
376    let submit_basic_path = format!("/idp/{idp_name}/authn/submit-challenge/basic");
377
378    // 2. OIDC auth → the login challenge page URL carrying T0.
379    let login_url = flow
380        .follow_redirect("step 2 (GET oidc/auth)", &oidc_start)
381        .await?;
382    let token0 = query_param(&login_url, "token").ok_or_else(|| {
383        IdpLoginFlow::flow_error("step 2", "the authn/login redirect carried no token".into())
384    })?;
385
386    // 3. next-challenge {token: T0} → T1 (TOKEN ROTATES — thread forward).
387    let answer: ChallengeAnswer = serde_json::from_value(
388        flow.post_json_flow(
389            "step 3 (next-challenge)",
390            &next_challenge_path,
391            &json!({ "token": token0 }),
392        )
393        .await?,
394    )
395    .map_err(|err| IdpLoginFlow::flow_error("step 3", format!("answer shape: {err}")))?;
396    if answer.complete {
397        return Err(IdpLoginFlow::flow_error(
398            "step 3",
399            "flow already complete before credentials were offered".into(),
400        ));
401    }
402    let token1 = answer.token;
403
404    // 4. submit-challenge/basic — the ONLY password exposure site.
405    let submit: SubmitAnswer = serde_json::from_value(
406        flow.post_json_flow(
407            "step 4 (submit-challenge/basic)",
408            &submit_basic_path,
409            &json!({
410                "token": token1,
411                "rememberMe": false,
412                "challenge": { "username": username, "password": password.expose() }
413            }),
414        )
415        .await?,
416    )
417    .map_err(|err| IdpLoginFlow::flow_error("step 4", format!("answer shape: {err}")))?;
418    if !submit.success {
419        // Live-observed shape on 8.3.6: 200 {"success":false,"token":…}.
420        // Auth class + slug are right; the variant's token-flavored hint
421        // is the accepted trade-off (documented at the flow's module).
422        return Err(CoreError::Auth {
423            status: 401,
424            endpoint: Some(submit_basic_path),
425        });
426    }
427    let token2 = submit.token;
428
429    // 5. next-challenge {token: T2} → complete + T3.
430    let answer: ChallengeAnswer = serde_json::from_value(
431        flow.post_json_flow(
432            "step 5 (next-challenge)",
433            &next_challenge_path,
434            &json!({ "token": token2 }),
435        )
436        .await?,
437    )
438    .map_err(|err| IdpLoginFlow::flow_error("step 5", format!("answer shape: {err}")))?;
439    if !answer.complete {
440        let kinds: Vec<String> = answer
441            .next_challenge
442            .iter()
443            .filter_map(|challenge| challenge.get("type").and_then(|t| t.as_str()))
444            .map(str::to_string)
445            .collect();
446        return Err(IdpLoginFlow::flow_error(
447            "step 5",
448            format!(
449                "the IdP presented another challenge beyond basic auth \
450                 ({kinds:?}) — headless login does not continue past it"
451            ),
452        ));
453    }
454    let token3 = answer.token;
455
456    // 6. oidc/auth with the ORIGINAL params + token=T3 → the federate
457    //    callback URL. (Step 1's Location is replayed VERBATIM with the
458    //    token appended — the IdP name, path and query all come from the
459    //    gateway, live-verified shape.)
460    let step6_url = if oidc_start.contains('?') {
461        format!("{oidc_start}&token={token3}")
462    } else {
463        format!("{oidc_start}?token={token3}")
464    };
465    let callback = flow
466        .follow_redirect("step 6 (GET oidc/auth + token)", &step6_url)
467        .await?;
468
469    // 7. The federate callback → the webui-sid-* session cookie.
470    flow.follow_redirect("step 7 (GET federate callback)", &callback)
471        .await?;
472    let (session_name, session_value) = flow
473        .cookies
474        .iter()
475        .find(|(name, _)| name.starts_with(SESSION_COOKIE_PREFIX))
476        .cloned()
477        .ok_or_else(|| {
478            IdpLoginFlow::flow_error(
479                "step 7",
480                format!("no {SESSION_COOKIE_PREFIX}* session cookie was set"),
481            )
482        })?;
483
484    // 8. /data/app/session → the CSRF token.
485    let session_url = flow.url_for(APP_SESSION_PATH);
486    let mut request = flow.client.get(session_url.clone());
487    if !flow.cookies.is_empty() {
488        request = request.header(reqwest::header::COOKIE, flow.cookie_header());
489    }
490    let response = request.send().await.map_err(|err| CoreError::Network {
491        url: session_url.to_string(),
492        source: Some(err),
493        observation: None,
494    })?;
495    let status = response.status().as_u16();
496    let text = response.text().await.unwrap_or_default();
497    if status == 401 || status == 403 {
498        return Err(CoreError::Auth {
499            status,
500            endpoint: Some(APP_SESSION_PATH.to_string()),
501        });
502    }
503    if !(200..300).contains(&status) {
504        return Err(IdpLoginFlow::flow_error(
505            "step 8",
506            format!("HTTP {status} fetching the session CSRF token"),
507        ));
508    }
509    let info: SessionInfo = serde_json::from_str(&text).map_err(|err| {
510        IdpLoginFlow::flow_error("step 8", format!("session answer shape: {err}"))
511    })?;
512    if info.csrf_token.is_empty() {
513        return Err(IdpLoginFlow::flow_error(
514            "step 8",
515            "the session answer carried no csrfToken".into(),
516        ));
517    }
518
519    Ok((
520        flow,
521        GatewaySession {
522            cookie_name: session_name,
523            cookie_value: session_value,
524            csrf_token: info.csrf_token,
525        },
526    ))
527}
528
529/// Step 9: the reset POST with the session cookie + CSRF header, on
530/// the flow-local path (never the locked pipeline). The 2xx body IS
531/// the fresh [`TrialWire`] (live-observed on 8.3.3: expired true →
532/// false, 7199s).
533pub async fn trial_reset_via_session(
534    flow: &IdpLoginFlow,
535    session: &GatewaySession,
536) -> Result<TrialWire, CoreError> {
537    let url = flow.url_for(TRIAL_PATH);
538    let response = flow
539        .client
540        .post(url.clone())
541        .header(reqwest::header::ACCEPT, "application/json")
542        .header(reqwest::header::COOKIE, session.cookie_header())
543        .header("X-CSRF-Token", &session.csrf_token)
544        .send()
545        .await
546        .map_err(|err| CoreError::Network {
547            url: url.to_string(),
548            source: Some(err),
549            observation: None,
550        })?;
551    let status = response.status().as_u16();
552    let text = response.text().await.unwrap_or_default();
553    if status == 401 || status == 403 {
554        return Err(CoreError::Auth {
555            status,
556            endpoint: Some(TRIAL_PATH.to_string()),
557        });
558    }
559    if !(200..300).contains(&status) {
560        return Err(IdpLoginFlow::flow_error(
561            "step 9 (POST trial)",
562            format!(
563                "HTTP {status} ({})",
564                IdpLoginFlow::html_title_or_excerpt(&text)
565            ),
566        ));
567    }
568    serde_json::from_str(&text).map_err(|err| {
569        CoreError::Internal(format!(
570            "trial reset response did not match the trial shape: {err}"
571        ))
572    })
573}
574
575/// Pull one query parameter out of a path?query string.
576fn query_param(path_and_query: &str, name: &str) -> Option<String> {
577    let query = path_and_query.split_once('?')?.1;
578    query.split('&').find_map(|pair| {
579        let (key, value) = pair.split_once('=')?;
580        (key == name).then(|| value.to_string())
581    })
582}
583
584// --- The session-tier generic helpers (ADOPT-01) -----------------------
585//
586// The adopt capability (client/adopt.rs) needs GET/POST/PUT against
587// /data routes on the SAME session+CSRF footing as the trial reset,
588// but lives outside this module (wire models + tests of its own). The
589// three helpers below are the minimal pub(crate) surface: session
590// cookie + X-CSRF-Token + JSON in/out, the trial-reset error
591// classification verbatim (401/403 auth-class; other non-2xx flow
592// failures with the Jetty title sniff; non-JSON shape failures).
593// Response-body verdicts (success:false and friends) belong to the
594// CALLER — these helpers only move JSON.
595
596impl IdpLoginFlow {
597    /// GET `path?pairs` on the session tier → JSON.
598    pub(crate) async fn session_get_json(
599        &self,
600        session: &GatewaySession,
601        path: &str,
602        pairs: &[(&str, &str)],
603    ) -> Result<serde_json::Value, CoreError> {
604        let mut url = self.url_for(path);
605        url.query_pairs_mut().extend_pairs(pairs.iter().copied());
606        let request = self
607            .client
608            .get(url.clone())
609            .header(reqwest::header::ACCEPT, "application/json")
610            .header(reqwest::header::COOKIE, session.cookie_header());
611        let response = request.send().await.map_err(|err| CoreError::Network {
612            url: url.to_string(),
613            source: Some(err),
614            observation: None,
615        })?;
616        self.session_finish(url.as_str(), response).await
617    }
618
619    /// POST `path` with a JSON body on the session tier → JSON.
620    pub(crate) async fn session_post_json(
621        &self,
622        session: &GatewaySession,
623        path: &str,
624        body: &serde_json::Value,
625    ) -> Result<serde_json::Value, CoreError> {
626        self.session_send(reqwest::Method::POST, session, path, Some(body))
627            .await
628    }
629
630    /// PUT `path` with a JSON body on the session tier → JSON.
631    pub(crate) async fn session_put_json(
632        &self,
633        session: &GatewaySession,
634        path: &str,
635        body: &serde_json::Value,
636    ) -> Result<serde_json::Value, CoreError> {
637        self.session_send(reqwest::Method::PUT, session, path, Some(body))
638            .await
639    }
640
641    async fn session_send(
642        &self,
643        method: reqwest::Method,
644        session: &GatewaySession,
645        path: &str,
646        body: Option<&serde_json::Value>,
647    ) -> Result<serde_json::Value, CoreError> {
648        let url = self.url_for(path);
649        let mut request = self
650            .client
651            .request(method, url.clone())
652            .header(reqwest::header::ACCEPT, "application/json")
653            .header(reqwest::header::COOKIE, session.cookie_header())
654            .header("X-CSRF-Token", &session.csrf_token);
655        if let Some(body) = body {
656            request = request.json(body);
657        }
658        let response = request.send().await.map_err(|err| CoreError::Network {
659            url: url.to_string(),
660            source: Some(err),
661            observation: None,
662        })?;
663        self.session_finish(url.as_str(), response).await
664    }
665
666    /// The shared verdict: 401/403 auth-class; other non-2xx flow
667    /// failure (title sniff); 2xx must be JSON.
668    async fn session_finish(
669        &self,
670        path: &str,
671        response: reqwest::Response,
672    ) -> Result<serde_json::Value, CoreError> {
673        let status = response.status().as_u16();
674        let text = response.text().await.unwrap_or_default();
675        if status == 401 || status == 403 {
676            return Err(CoreError::Auth {
677                status,
678                endpoint: Some(path.to_string()),
679            });
680        }
681        if !(200..300).contains(&status) {
682            return Err(IdpLoginFlow::flow_error(
683                "session request",
684                format!(
685                    "HTTP {status} ({})",
686                    IdpLoginFlow::html_title_or_excerpt(&text)
687                ),
688            ));
689        }
690        serde_json::from_str(&text).map_err(|err| {
691            CoreError::Internal(format!("session response from {path} was not JSON ({err})"))
692        })
693    }
694}