use std::collections::BTreeMap;
use reqwest::header::{CONTENT_TYPE, COOKIE, SET_COOKIE};
use vti_common::vault::{
CookieJarEntry, PasswordLoginConfig, PasswordLoginFormat, SameSite, TotpSeed,
};
use crate::error::AppError;
const POST_TIMEOUT_SECS: u64 = 15;
#[derive(Debug, thiserror::Error)]
pub enum PasswordPostError {
#[error("login_url invalid: {0}")]
InvalidLoginUrl(String),
#[error("totp generation not supported in this maintainer build: {0}")]
TotpNotImplemented(String),
#[error("HTTP request to {url} failed: {source}")]
Transport {
url: String,
#[source]
source: reqwest::Error,
},
#[error("login rejected with HTTP {status}")]
NonSuccessStatus { status: u16 },
#[error("response parse failure: {0}")]
ResponseParse(String),
}
impl From<PasswordPostError> for AppError {
fn from(value: PasswordPostError) -> Self {
match value {
PasswordPostError::NonSuccessStatus { status } if (400..500).contains(&status) => {
AppError::Validation(format!("vault/proxy-login: credential_rejected ({status})"))
}
PasswordPostError::NonSuccessStatus { status } => {
AppError::Internal(format!("vault/proxy-login: target_unreachable ({status})"))
}
PasswordPostError::Transport { url, source } => AppError::Internal(format!(
"vault/proxy-login: target_unreachable {url}: {source}"
)),
PasswordPostError::InvalidLoginUrl(msg) => {
AppError::Validation(format!("vault/proxy-login: invalid login_url — {msg}"))
}
PasswordPostError::TotpNotImplemented(msg) => {
AppError::Internal(format!("vault/proxy-login: {msg}"))
}
PasswordPostError::ResponseParse(msg) => {
AppError::Internal(format!("vault/proxy-login: response parse — {msg}"))
}
}
}
}
pub fn validate_login_url(login_url: &str) -> Result<url::Url, PasswordPostError> {
let url = url::Url::parse(login_url)
.map_err(|e| PasswordPostError::InvalidLoginUrl(format!("not a URL: {e}")))?;
match url.scheme() {
"https" => Ok(url),
"http" => {
let host = url
.host_str()
.unwrap_or("")
.trim_start_matches('[')
.trim_end_matches(']');
let is_loopback = host == "localhost"
|| host == "::1"
|| host.starts_with("127.")
|| host == "0.0.0.0";
if is_loopback {
Ok(url)
} else {
Err(PasswordPostError::InvalidLoginUrl(format!(
"http:// is permitted only for loopback hosts; got {host}"
)))
}
}
other => Err(PasswordPostError::InvalidLoginUrl(format!(
"unsupported scheme {other}"
))),
}
}
pub fn build_request_body(
config: &PasswordLoginConfig,
username: Option<&str>,
password: &str,
totp_code: Option<&str>,
) -> Result<(&'static str, String), PasswordPostError> {
let mut fields: BTreeMap<String, String> = BTreeMap::new();
if let Some(u) = username {
fields.insert(config.effective_username_field().to_string(), u.to_string());
}
fields.insert(
config.effective_password_field().to_string(),
password.to_string(),
);
if let Some(field) = config.totp_field.as_deref() {
match totp_code {
Some(code) => {
fields.insert(field.to_string(), code.to_string());
}
None => {
return Err(PasswordPostError::TotpNotImplemented(
"loginConfig.totpField is set but no TOTP seed is available or TOTP \
generation is not yet implemented in this maintainer build"
.into(),
));
}
}
}
if let Some(extra) = config.extra_fields.as_ref() {
for (k, v) in extra {
fields.insert(k.clone(), v.clone());
}
}
match config.format {
PasswordLoginFormat::Json => {
let body = serde_json::to_string(&fields).map_err(|e| {
PasswordPostError::ResponseParse(format!("serialise JSON body: {e}"))
})?;
Ok(("application/json", body))
}
PasswordLoginFormat::FormUrlencoded => {
let mut buf = String::new();
for (k, v) in &fields {
if !buf.is_empty() {
buf.push('&');
}
buf.push_str(&urlencoding::encode(k));
buf.push('=');
buf.push_str(&urlencoding::encode(v));
}
Ok(("application/x-www-form-urlencoded", buf))
}
}
}
pub fn parse_set_cookie(header: &str) -> Option<CookieJarEntry> {
let mut parts = header.split(';').map(str::trim);
let nv = parts.next()?;
let (name, value) = nv.split_once('=')?;
let name = name.trim().to_string();
let value = value.trim().to_string();
if name.is_empty() {
return None;
}
let mut domain: Option<String> = None;
let mut path: Option<String> = None;
let mut expires: Option<String> = None;
let mut secure = false;
let mut http_only = false;
let mut same_site: Option<SameSite> = None;
for attr in parts {
if attr.is_empty() {
continue;
}
let (k, v) = match attr.split_once('=') {
Some((k, v)) => (k.trim(), Some(v.trim())),
None => (attr.trim(), None),
};
let kl = k.to_ascii_lowercase();
match (kl.as_str(), v) {
("domain", Some(v)) => domain = Some(v.trim_start_matches('.').to_string()),
("path", Some(v)) => path = Some(v.to_string()),
("expires", Some(v)) => expires = Some(v.to_string()),
("max-age", Some(_)) => {} ("secure", _) => secure = true,
("httponly", _) => http_only = true,
("samesite", Some(v)) => {
same_site = match v.to_ascii_lowercase().as_str() {
"strict" => Some(SameSite::Strict),
"lax" => Some(SameSite::Lax),
"none" => Some(SameSite::None),
_ => None,
};
}
_ => {}
}
}
Some(CookieJarEntry {
name,
value,
domain: domain.unwrap_or_default(),
path: path.unwrap_or_else(|| "/".to_string()),
expires,
secure: if secure { Some(true) } else { None },
http_only: if http_only { Some(true) } else { None },
same_site,
})
}
pub fn cookie_domain_matches(host: &str, cookie_domain: &str) -> bool {
if cookie_domain.is_empty() {
return true; }
let h = host.trim_start_matches('.').to_ascii_lowercase();
let cd = cookie_domain.trim_start_matches('.').to_ascii_lowercase();
h == cd || h.ends_with(&format!(".{cd}"))
}
pub async fn run_password_post(
config: &PasswordLoginConfig,
username: Option<&str>,
password: &str,
totp: Option<&TotpSeed>,
) -> Result<Vec<CookieJarEntry>, PasswordPostError> {
let url = validate_login_url(&config.login_url)?;
let host = url
.host_str()
.ok_or_else(|| PasswordPostError::InvalidLoginUrl("URL has no host".into()))?
.to_string();
let totp_code: Option<&str> = match (config.totp_field.as_deref(), totp) {
(None, _) => None,
(Some(_), None) => {
return Err(PasswordPostError::TotpNotImplemented(
"config.totpField set but entry has no totp seed".into(),
));
}
(Some(_), Some(_seed)) => {
return Err(PasswordPostError::TotpNotImplemented(
"TOTP code generation lands in a follow-up patch — use entries without \
loginConfig.totpField for M2B.5"
.into(),
));
}
};
let (content_type, body) = build_request_body(config, username, password, totp_code)?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(POST_TIMEOUT_SECS))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| PasswordPostError::Transport {
url: url.to_string(),
source: e,
})?;
let resp = client
.post(url.clone())
.header(CONTENT_TYPE, content_type)
.body(body)
.send()
.await
.map_err(|e| PasswordPostError::Transport {
url: url.to_string(),
source: e,
})?;
let status = resp.status().as_u16();
let success = config.effective_success_status();
if !success.contains(&status) {
return Err(PasswordPostError::NonSuccessStatus { status });
}
let mut cookies = Vec::new();
for header_value in resp.headers().get_all(SET_COOKIE).iter() {
let raw = header_value
.to_str()
.map_err(|e| PasswordPostError::ResponseParse(format!("Set-Cookie not utf-8: {e}")))?;
let Some(mut cookie) = parse_set_cookie(raw) else {
continue;
};
if !cookie.domain.is_empty() && !cookie_domain_matches(&host, &cookie.domain) {
tracing::warn!(
login_host = %host,
cookie_domain = %cookie.domain,
cookie_name = %cookie.name,
"discarding Set-Cookie whose Domain doesn't match login URL host"
);
continue;
}
if cookie.domain.is_empty() {
cookie.domain = host.clone();
}
cookies.push(cookie);
}
Ok(cookies)
}
const _: Option<&reqwest::header::HeaderName> = Some(&COOKIE);
#[cfg(test)]
mod tests {
use super::*;
fn cfg_min(url: &str) -> PasswordLoginConfig {
PasswordLoginConfig {
login_url: url.to_string(),
format: PasswordLoginFormat::Json,
username_field: None,
password_field: None,
totp_field: None,
extra_fields: None,
success_status: None,
}
}
#[test]
fn validate_login_url_accepts_https() {
assert!(validate_login_url("https://example.com/login").is_ok());
}
#[test]
fn validate_login_url_accepts_http_to_loopback() {
for url in [
"http://localhost:3000/login",
"http://127.0.0.1/api/login",
"http://[::1]:8080/login",
] {
assert!(
validate_login_url(url).is_ok(),
"loopback http should be accepted: {url}"
);
}
}
#[test]
fn validate_login_url_rejects_http_to_non_loopback() {
let err = validate_login_url("http://example.com/login").unwrap_err();
assert!(
matches!(err, PasswordPostError::InvalidLoginUrl(_)),
"got {err:?}"
);
}
#[test]
fn validate_login_url_rejects_unsupported_scheme() {
assert!(matches!(
validate_login_url("ftp://example.com/login"),
Err(PasswordPostError::InvalidLoginUrl(_))
));
}
#[test]
fn build_request_body_json_default_fields() {
let (ct, body) = build_request_body(
&cfg_min("https://example.com/login"),
Some("alice"),
"hunter2",
None,
)
.unwrap();
assert_eq!(ct, "application/json");
assert_eq!(body, r#"{"password":"hunter2","username":"alice"}"#);
}
#[test]
fn build_request_body_form_urlencoded_with_extras() {
let mut config = cfg_min("https://example.com/login");
config.format = PasswordLoginFormat::FormUrlencoded;
config.username_field = Some("email".into());
let mut extras = BTreeMap::new();
extras.insert("grantType".to_string(), "password".to_string());
config.extra_fields = Some(extras);
let (ct, body) = build_request_body(&config, Some("a@b.com"), "p ass!", None).unwrap();
assert_eq!(ct, "application/x-www-form-urlencoded");
assert_eq!(
body,
"email=a%40b.com&grantType=password&password=p%20ass%21"
);
}
#[test]
fn build_request_body_omits_username_when_none() {
let (_ct, body) =
build_request_body(&cfg_min("https://example.com/login"), None, "secret", None)
.unwrap();
assert_eq!(body, r#"{"password":"secret"}"#);
}
#[test]
fn build_request_body_errors_when_totp_field_set_but_no_code() {
let mut config = cfg_min("https://example.com/login");
config.totp_field = Some("otp".into());
let err = build_request_body(&config, Some("a"), "p", None).unwrap_err();
assert!(matches!(err, PasswordPostError::TotpNotImplemented(_)));
}
#[test]
fn parse_set_cookie_minimal() {
let c = parse_set_cookie("session=abc123").unwrap();
assert_eq!(c.name, "session");
assert_eq!(c.value, "abc123");
assert_eq!(c.path, "/"); assert!(c.domain.is_empty()); assert_eq!(c.secure, None);
assert_eq!(c.http_only, None);
}
#[test]
fn parse_set_cookie_full() {
let c = parse_set_cookie(
"session=xyz; Domain=.example.com; Path=/api; Secure; HttpOnly; SameSite=Lax; Expires=Wed, 09 Jun 2027 10:18:14 GMT",
)
.unwrap();
assert_eq!(c.name, "session");
assert_eq!(c.value, "xyz");
assert_eq!(c.domain, "example.com"); assert_eq!(c.path, "/api");
assert_eq!(c.secure, Some(true));
assert_eq!(c.http_only, Some(true));
assert_eq!(c.same_site, Some(SameSite::Lax));
assert_eq!(c.expires.as_deref(), Some("Wed, 09 Jun 2027 10:18:14 GMT"));
}
#[test]
fn parse_set_cookie_rejects_empty_name() {
assert!(parse_set_cookie("=abc; Path=/").is_none());
}
#[test]
fn cookie_domain_matches_canonical_cases() {
assert!(cookie_domain_matches("api.example.com", "api.example.com"));
assert!(cookie_domain_matches("api.example.com", "example.com"));
assert!(!cookie_domain_matches("api.example.com", "evil.com"));
assert!(!cookie_domain_matches("api.example.com", "example.org"));
assert!(cookie_domain_matches("api.example.com", ".example.com"));
assert!(cookie_domain_matches("API.example.COM", "example.com"));
assert!(cookie_domain_matches("anything.example", ""));
}
mod http {
use super::*;
use wiremock::matchers::{body_string_contains, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_cfg(login_url: String) -> PasswordLoginConfig {
PasswordLoginConfig {
login_url,
format: PasswordLoginFormat::Json,
username_field: None,
password_field: None,
totp_field: None,
extra_fields: None,
success_status: None,
}
}
#[tokio::test]
async fn success_2xx_captures_cookies_and_fills_host_only_domain() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/login"))
.and(header("content-type", "application/json"))
.and(body_string_contains("\"username\":\"alice\""))
.and(body_string_contains("\"password\":\"hunter2\""))
.respond_with(
ResponseTemplate::new(200)
.append_header("set-cookie", "session=abc; Path=/; HttpOnly")
.append_header("set-cookie", "csrf=xyz; Path=/; Secure"),
)
.mount(&server)
.await;
let cfg = make_cfg(format!("{}/api/login", server.uri()));
let cookies = run_password_post(&cfg, Some("alice"), "hunter2", None)
.await
.expect("password POST should succeed");
assert_eq!(cookies.len(), 2);
let host = url::Url::parse(&cfg.login_url)
.unwrap()
.host_str()
.unwrap()
.to_string();
for c in &cookies {
assert_eq!(c.domain, host, "host-only cookie's domain should be filled");
}
assert!(cookies.iter().any(|c| c.name == "session"));
assert!(cookies.iter().any(|c| c.name == "csrf"));
}
#[tokio::test]
async fn http_4xx_maps_to_credential_rejected() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let cfg = make_cfg(format!("{}/login", server.uri()));
let err = run_password_post(&cfg, Some("a"), "b", None)
.await
.unwrap_err();
assert!(
matches!(err, PasswordPostError::NonSuccessStatus { status: 401 }),
"got {err:?}"
);
}
#[tokio::test]
async fn http_5xx_maps_to_target_unreachable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let cfg = make_cfg(format!("{}/login", server.uri()));
let err = run_password_post(&cfg, Some("a"), "b", None)
.await
.unwrap_err();
assert!(matches!(
err,
PasswordPostError::NonSuccessStatus { status: 503 }
));
}
#[tokio::test]
async fn form_urlencoded_body_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/login"))
.and(header("content-type", "application/x-www-form-urlencoded"))
.and(body_string_contains("username=alice"))
.and(body_string_contains("password=hunter2"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let mut cfg = make_cfg(format!("{}/api/login", server.uri()));
cfg.format = PasswordLoginFormat::FormUrlencoded;
let cookies = run_password_post(&cfg, Some("alice"), "hunter2", None)
.await
.expect("form-urlencoded POST should succeed");
assert_eq!(cookies.len(), 0, "no Set-Cookie on this response");
}
#[tokio::test]
async fn custom_success_status_treats_302_as_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(
ResponseTemplate::new(302)
.append_header("set-cookie", "session=ok; Path=/")
.append_header("location", "/dashboard"),
)
.mount(&server)
.await;
let mut cfg = make_cfg(format!("{}/login", server.uri()));
cfg.success_status = Some(vec![302]);
let cookies = run_password_post(&cfg, Some("a"), "b", None)
.await
.expect("302 should be treated as success per config");
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name, "session");
}
#[tokio::test]
async fn cookie_with_unrelated_domain_is_discarded() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(
ResponseTemplate::new(200)
.append_header("set-cookie", "kept=a; Path=/")
.append_header("set-cookie", "evil=b; Domain=evil.com; Path=/"),
)
.mount(&server)
.await;
let cfg = make_cfg(format!("{}/login", server.uri()));
let cookies = run_password_post(&cfg, Some("a"), "b", None).await.unwrap();
assert_eq!(cookies.len(), 1, "evil-domain cookie must be discarded");
assert_eq!(cookies[0].name, "kept");
}
}
}