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