1use 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
60const APP_LOGIN_PATH: &str = "/data/app/login";
62const OIDC_AUTH_PREFIX: &str = "/idp/default/oidc/auth";
65const NEXT_CHALLENGE_PATH: &str = "/idp/default/authn/next-challenge";
67const SUBMIT_BASIC_PATH: &str = "/idp/default/authn/submit-challenge/basic";
68const APP_SESSION_PATH: &str = "/data/app/session";
70const TRIAL_PATH: &str = "/data/api/v1/trial";
72const SESSION_COOKIE_PREFIX: &str = "webui-sid-";
75
76#[derive(Debug, Clone)]
78pub struct GatewaySession {
79 pub cookie_name: String,
81 pub cookie_value: String,
83 pub csrf_token: String,
86}
87
88impl GatewaySession {
89 fn cookie_header(&self) -> String {
91 format!("{}={}", self.cookie_name, self.cookie_value)
92 }
93}
94
95#[derive(Debug, Deserialize)]
98struct SessionInfo {
99 #[serde(rename = "csrfToken", default)]
100 csrf_token: String,
101}
102
103#[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#[derive(Debug, Deserialize)]
116struct SubmitAnswer {
117 #[serde(default)]
118 success: bool,
119 #[serde(default)]
120 token: String,
121}
122
123pub struct IdpLoginFlow {
127 base: url::Url,
128 client: reqwest::Client,
129 cookies: Vec<(String, String)>,
132}
133
134impl IdpLoginFlow {
135 pub fn new(base_url: &str) -> Result<Self, CoreError> {
138 super::install_crypto_provider();
139 let client = reqwest::Client::builder()
140 .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 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 self.cookies.retain(|(prior, _)| *prior != name);
178 self.cookies.push((name, cookie_value));
179 }
180 }
181 }
182 }
183
184 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 fn flow_error(step: &str, detail: String) -> CoreError {
196 CoreError::Internal(format!("gateway login flow failed at {step}: {detail}"))
197 }
198
199 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 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 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 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
307trait 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
322pub async fn login(
326 flow: IdpLoginFlow,
327 username: &str,
328 password: &Secret,
329) -> Result<(IdpLoginFlow, GatewaySession), CoreError> {
330 let mut flow = flow;
331
332 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 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 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 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 return Err(CoreError::Auth {
388 status: 401,
389 endpoint: Some(SUBMIT_BASIC_PATH.to_string()),
390 });
391 }
392 let token2 = submit.token;
393
394 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 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 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 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
495pub 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
541fn 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
550impl IdpLoginFlow {
563 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 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 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 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}