1use 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
63const APP_LOGIN_PATH: &str = "/data/app/login";
65const IDP_PATH_PREFIX: &str = "/idp/";
71const OIDC_AUTH_PATH_SUFFIX: &str = "/oidc/auth";
72const APP_SESSION_PATH: &str = "/data/app/session";
74const TRIAL_PATH: &str = "/data/api/v1/trial";
76const SESSION_COOKIE_PREFIX: &str = "webui-sid-";
79
80#[derive(Debug, Clone)]
82pub struct GatewaySession {
83 pub cookie_name: String,
85 pub cookie_value: String,
87 pub csrf_token: String,
90}
91
92impl GatewaySession {
93 fn cookie_header(&self) -> String {
95 format!("{}={}", self.cookie_name, self.cookie_value)
96 }
97}
98
99#[derive(Debug, Deserialize)]
102struct SessionInfo {
103 #[serde(rename = "csrfToken", default)]
104 csrf_token: String,
105}
106
107#[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#[derive(Debug, Deserialize)]
120struct SubmitAnswer {
121 #[serde(default)]
122 success: bool,
123 #[serde(default)]
124 token: String,
125}
126
127pub struct IdpLoginFlow {
131 base: url::Url,
132 client: reqwest::Client,
133 cookies: Vec<(String, String)>,
136}
137
138impl IdpLoginFlow {
139 pub fn new(base_url: &str) -> Result<Self, CoreError> {
142 super::install_crypto_provider();
143 let client = reqwest::Client::builder()
144 .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 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 self.cookies.retain(|(prior, _)| *prior != name);
182 self.cookies.push((name, cookie_value));
183 }
184 }
185 }
186 }
187
188 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 fn flow_error(step: &str, detail: String) -> CoreError {
200 CoreError::Internal(format!("gateway login flow failed at {step}: {detail}"))
201 }
202
203 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 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 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 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 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
321trait 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
336fn 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
359pub async fn login(
363 flow: IdpLoginFlow,
364 username: &str,
365 password: &Secret,
366) -> Result<(IdpLoginFlow, GatewaySession), CoreError> {
367 let mut flow = flow;
368
369 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 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 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 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 return Err(CoreError::Auth {
423 status: 401,
424 endpoint: Some(submit_basic_path),
425 });
426 }
427 let token2 = submit.token;
428
429 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 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 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 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
529pub 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
575fn 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
584impl IdpLoginFlow {
597 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 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 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 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}