use std::time::Duration;
use serde::Deserialize;
use serde_json::json;
use crate::client::trial::TrialWire;
use crate::config::Secret;
use crate::error::CoreError;
const APP_LOGIN_PATH: &str = "/data/app/login";
const OIDC_AUTH_PREFIX: &str = "/idp/default/oidc/auth";
const NEXT_CHALLENGE_PATH: &str = "/idp/default/authn/next-challenge";
const SUBMIT_BASIC_PATH: &str = "/idp/default/authn/submit-challenge/basic";
const APP_SESSION_PATH: &str = "/data/app/session";
const TRIAL_PATH: &str = "/data/api/v1/trial";
const SESSION_COOKIE_PREFIX: &str = "webui-sid-";
#[derive(Debug, Clone)]
pub struct GatewaySession {
pub cookie_name: String,
pub cookie_value: String,
pub csrf_token: String,
}
impl GatewaySession {
fn cookie_header(&self) -> String {
format!("{}={}", self.cookie_name, self.cookie_value)
}
}
#[derive(Debug, Deserialize)]
struct SessionInfo {
#[serde(rename = "csrfToken", default)]
csrf_token: String,
}
#[derive(Debug, Deserialize)]
struct ChallengeAnswer {
#[serde(default)]
complete: bool,
#[serde(rename = "nextChallenge", default)]
next_challenge: Vec<serde_json::Value>,
#[serde(default)]
token: String,
}
#[derive(Debug, Deserialize)]
struct SubmitAnswer {
#[serde(default)]
success: bool,
#[serde(default)]
token: String,
}
pub struct IdpLoginFlow {
base: url::Url,
client: reqwest::Client,
cookies: Vec<(String, String)>,
}
impl IdpLoginFlow {
pub fn new(base_url: &str) -> Result<Self, CoreError> {
super::install_crypto_provider();
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.map_err(|err| CoreError::Internal(format!("cannot build login client: {err}")))?;
Ok(Self {
base: url::Url::parse(base_url)
.map_err(|err| CoreError::Internal(format!("invalid rig URL: {err}")))?,
client,
cookies: Vec::new(),
})
}
fn url_for(&self, path_and_query: &str) -> url::Url {
self.base
.join(path_and_query)
.expect("base joins an absolute path")
}
fn capture_cookies(&mut self, response: &reqwest::Response) {
for value in response.headers().get_all(reqwest::header::SET_COOKIE) {
if let Ok(cookie) = value.to_str()
&& let Some((name, cookie_value)) = cookie.split_once('=')
{
let name = name.trim().to_string();
let cookie_value = cookie_value
.split(';')
.next()
.unwrap_or(cookie_value)
.trim()
.to_string();
if !name.is_empty() && !cookie_value.is_empty() {
self.cookies.retain(|(prior, _)| *prior != name);
self.cookies.push((name, cookie_value));
}
}
}
}
fn cookie_header(&self) -> String {
self.cookies
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join("; ")
}
fn flow_error(step: &str, detail: String) -> CoreError {
CoreError::Internal(format!("gateway login flow failed at {step}: {detail}"))
}
fn html_title_or_excerpt(body: &str) -> String {
if let Some(start) = body.find("<title>")
&& let Some(end) = body[start + 7..].find("</title>")
{
return body[start + 7..start + 7 + end].to_string();
}
let excerpt: String = body.chars().take(120).collect();
excerpt.replace(['\n', '\r'], " ")
}
async fn follow_redirect(
&mut self,
step: &str,
path_and_query: &str,
) -> Result<String, CoreError> {
let url = self.url_for(path_and_query);
let mut request = self.client.get(url.clone());
if !self.cookies.is_empty() {
request = request.header(reqwest::header::COOKIE, self.cookie_header());
}
let response = request.send().await.map_err(|err| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
})?;
self.capture_cookies(&response);
match response.status().as_u16() {
302 | 303 => {
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
location.ok_or_else(|| {
Self::flow_error(step, "redirect carried no Location header".into())
})
}
status => {
let body = response.text().await.unwrap_or_default();
Err(Self::flow_error(
step,
format!(
"expected a redirect, got HTTP {status} ({})",
Self::html_title_or_excerpt(&body)
),
))
}
}
}
async fn post_json_flow(
&self,
step: &str,
path: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, CoreError> {
let url = self.url_for(path);
let mut request = self
.client
.post(url.clone())
.header(reqwest::header::ACCEPT, "application/json");
if !self.cookies.is_empty() {
request = request.header(reqwest::header::COOKIE, self.cookie_header());
}
let response = request
.json(body)
.send()
.await
.map_err(|err| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
})?;
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
if !(200..300).contains(&status) {
if status == 401 || status == 403 {
return Err(CoreError::Auth {
status,
endpoint: Some(path.to_string()),
});
}
return Err(Self::flow_error(
step,
format!("HTTP {status} ({})", Self::html_title_or_excerpt(&text)),
));
}
serde_json::from_str(&text).map_err(|err| {
Self::flow_error(
step,
format!("non-JSON answer ({})", Self::html_title_or_excerpt(&text)),
)
.tap_detail(err)
})
}
}
trait TapDetail {
fn tap_detail(self, err: serde_json::Error) -> CoreError;
}
impl TapDetail for CoreError {
fn tap_detail(self, err: serde_json::Error) -> CoreError {
match self {
CoreError::Internal(message) => CoreError::Internal(format!("{message}: {err}")),
other => other,
}
}
}
pub async fn login(
flow: IdpLoginFlow,
username: &str,
password: &Secret,
) -> Result<(IdpLoginFlow, GatewaySession), CoreError> {
let mut flow = flow;
let oidc_start = flow
.follow_redirect("step 1 (GET /data/app/login)", APP_LOGIN_PATH)
.await?;
if !oidc_start.starts_with(OIDC_AUTH_PREFIX) {
return Err(IdpLoginFlow::flow_error(
"step 1",
format!("unexpected redirect target {oidc_start:?} (not the internal IdP)"),
));
}
let login_url = flow
.follow_redirect("step 2 (GET oidc/auth)", &oidc_start)
.await?;
let token0 = query_param(&login_url, "token").ok_or_else(|| {
IdpLoginFlow::flow_error("step 2", "the authn/login redirect carried no token".into())
})?;
let answer: ChallengeAnswer = serde_json::from_value(
flow.post_json_flow(
"step 3 (next-challenge)",
NEXT_CHALLENGE_PATH,
&json!({ "token": token0 }),
)
.await?,
)
.map_err(|err| IdpLoginFlow::flow_error("step 3", format!("answer shape: {err}")))?;
if answer.complete {
return Err(IdpLoginFlow::flow_error(
"step 3",
"flow already complete before credentials were offered".into(),
));
}
let token1 = answer.token;
let submit: SubmitAnswer = serde_json::from_value(
flow.post_json_flow(
"step 4 (submit-challenge/basic)",
SUBMIT_BASIC_PATH,
&json!({
"token": token1,
"rememberMe": false,
"challenge": { "username": username, "password": password.expose() }
}),
)
.await?,
)
.map_err(|err| IdpLoginFlow::flow_error("step 4", format!("answer shape: {err}")))?;
if !submit.success {
return Err(CoreError::Auth {
status: 401,
endpoint: Some(SUBMIT_BASIC_PATH.to_string()),
});
}
let token2 = submit.token;
let answer: ChallengeAnswer = serde_json::from_value(
flow.post_json_flow(
"step 5 (next-challenge)",
NEXT_CHALLENGE_PATH,
&json!({ "token": token2 }),
)
.await?,
)
.map_err(|err| IdpLoginFlow::flow_error("step 5", format!("answer shape: {err}")))?;
if !answer.complete {
let kinds: Vec<String> = answer
.next_challenge
.iter()
.filter_map(|challenge| challenge.get("type").and_then(|t| t.as_str()))
.map(str::to_string)
.collect();
return Err(IdpLoginFlow::flow_error(
"step 5",
format!(
"the IdP presented another challenge beyond basic auth \
({kinds:?}) — headless login does not continue past it"
),
));
}
let token3 = answer.token;
let oidc_query = oidc_start
.split_once('?')
.map(|(_, query)| query.to_string())
.unwrap_or_default();
let callback = flow
.follow_redirect(
"step 6 (GET oidc/auth + token)",
&format!("{OIDC_AUTH_PREFIX}?{oidc_query}&token={token3}"),
)
.await?;
flow.follow_redirect("step 7 (GET federate callback)", &callback)
.await?;
let (session_name, session_value) = flow
.cookies
.iter()
.find(|(name, _)| name.starts_with(SESSION_COOKIE_PREFIX))
.cloned()
.ok_or_else(|| {
IdpLoginFlow::flow_error(
"step 7",
format!("no {SESSION_COOKIE_PREFIX}* session cookie was set"),
)
})?;
let session_url = flow.url_for(APP_SESSION_PATH);
let mut request = flow.client.get(session_url.clone());
if !flow.cookies.is_empty() {
request = request.header(reqwest::header::COOKIE, flow.cookie_header());
}
let response = request.send().await.map_err(|err| CoreError::Network {
url: session_url.to_string(),
source: Some(err),
observation: None,
})?;
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(CoreError::Auth {
status,
endpoint: Some(APP_SESSION_PATH.to_string()),
});
}
if !(200..300).contains(&status) {
return Err(IdpLoginFlow::flow_error(
"step 8",
format!("HTTP {status} fetching the session CSRF token"),
));
}
let info: SessionInfo = serde_json::from_str(&text).map_err(|err| {
IdpLoginFlow::flow_error("step 8", format!("session answer shape: {err}"))
})?;
if info.csrf_token.is_empty() {
return Err(IdpLoginFlow::flow_error(
"step 8",
"the session answer carried no csrfToken".into(),
));
}
Ok((
flow,
GatewaySession {
cookie_name: session_name,
cookie_value: session_value,
csrf_token: info.csrf_token,
},
))
}
pub async fn trial_reset_via_session(
flow: &IdpLoginFlow,
session: &GatewaySession,
) -> Result<TrialWire, CoreError> {
let url = flow.url_for(TRIAL_PATH);
let response = flow
.client
.post(url.clone())
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::COOKIE, session.cookie_header())
.header("X-CSRF-Token", &session.csrf_token)
.send()
.await
.map_err(|err| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
})?;
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(CoreError::Auth {
status,
endpoint: Some(TRIAL_PATH.to_string()),
});
}
if !(200..300).contains(&status) {
return Err(IdpLoginFlow::flow_error(
"step 9 (POST trial)",
format!(
"HTTP {status} ({})",
IdpLoginFlow::html_title_or_excerpt(&text)
),
));
}
serde_json::from_str(&text).map_err(|err| {
CoreError::Internal(format!(
"trial reset response did not match the trial shape: {err}"
))
})
}
fn query_param(path_and_query: &str, name: &str) -> Option<String> {
let query = path_and_query.split_once('?')?.1;
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == name).then(|| value.to_string())
})
}
impl IdpLoginFlow {
pub(crate) async fn session_get_json(
&self,
session: &GatewaySession,
path: &str,
pairs: &[(&str, &str)],
) -> Result<serde_json::Value, CoreError> {
let mut url = self.url_for(path);
url.query_pairs_mut().extend_pairs(pairs.iter().copied());
let request = self
.client
.get(url.clone())
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::COOKIE, session.cookie_header());
let response = request.send().await.map_err(|err| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
})?;
self.session_finish(url.as_str(), response).await
}
pub(crate) async fn session_post_json(
&self,
session: &GatewaySession,
path: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, CoreError> {
self.session_send(reqwest::Method::POST, session, path, Some(body))
.await
}
pub(crate) async fn session_put_json(
&self,
session: &GatewaySession,
path: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, CoreError> {
self.session_send(reqwest::Method::PUT, session, path, Some(body))
.await
}
async fn session_send(
&self,
method: reqwest::Method,
session: &GatewaySession,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<serde_json::Value, CoreError> {
let url = self.url_for(path);
let mut request = self
.client
.request(method, url.clone())
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::COOKIE, session.cookie_header())
.header("X-CSRF-Token", &session.csrf_token);
if let Some(body) = body {
request = request.json(body);
}
let response = request.send().await.map_err(|err| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
})?;
self.session_finish(url.as_str(), response).await
}
async fn session_finish(
&self,
path: &str,
response: reqwest::Response,
) -> Result<serde_json::Value, CoreError> {
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(CoreError::Auth {
status,
endpoint: Some(path.to_string()),
});
}
if !(200..300).contains(&status) {
return Err(IdpLoginFlow::flow_error(
"session request",
format!(
"HTTP {status} ({})",
IdpLoginFlow::html_title_or_excerpt(&text)
),
));
}
serde_json::from_str(&text).map_err(|err| {
CoreError::Internal(format!("session response from {path} was not JSON ({err})"))
})
}
}