use crate::net::http::{self, Url};
use serde::Deserialize;
use std::time::Duration;
pub(super) fn issuer_matches(doc_issuer: &str, base_url: &str) -> bool {
doc_issuer
.trim_end_matches('/')
.eq_ignore_ascii_case(base_url.trim_end_matches('/'))
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ResourceMeta {
#[serde(default)]
#[allow(dead_code)]
pub access_mode: Option<String>,
#[serde(default)]
pub content_digest: bool,
}
pub fn fetch(endpoint: &str, timeout: Duration) -> Option<ResourceMeta> {
let base = Url::parse(endpoint).ok()?;
let well_known = Url {
scheme: base.scheme.clone(),
host: base.host.clone(),
port: base.port,
path: "/.well-known/aauth-resource.json".to_string(),
};
let mut stream = connect(&well_known, timeout).ok()?;
let resp = http::send(
stream.as_mut(),
&well_known.host_header(),
"GET",
&well_known.path,
&[("Accept", "application/json")],
&[],
)
.ok()?;
if !resp.is_success() {
return None;
}
serde_json::from_slice(&resp.body).ok()
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ProviderMeta {
#[serde(default)]
pub issuer: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub name: Option<String>,
}
pub fn fetch_agent_provider(
base_url: &str,
timeout: Duration,
) -> Result<Option<ProviderMeta>, String> {
let Ok(base) = Url::parse(base_url) else {
return Ok(None);
};
let well_known = Url {
scheme: base.scheme.clone(),
host: base.host.clone(),
port: base.port,
path: "/.well-known/aauth-agent.json".to_string(),
};
let Ok(mut stream) = connect(&well_known, timeout) else {
return Ok(None);
};
let Ok(resp) = http::send(
stream.as_mut(),
&well_known.host_header(),
"GET",
&well_known.path,
&[("Accept", "application/json")],
&[],
) else {
return Ok(None);
};
if !resp.is_success() {
return Ok(None);
}
let Ok(meta) = serde_json::from_slice::<ProviderMeta>(&resp.body) else {
return Ok(None);
};
if let Some(iss) = meta.issuer.as_deref()
&& !issuer_matches(iss, base_url)
{
return Err(format!(
"aauth: provider metadata issuer {iss:?} does not match configured provider {base_url:?} (possible host-poisoning)"
));
}
Ok(Some(meta))
}
fn connect(url: &Url, timeout: Duration) -> Result<Box<dyn http::Stream>, String> {
let tcp = http::connect_tcp(&url.host, url.port, timeout).map_err(|e| e.to_string())?;
if url.is_tls() {
#[cfg(feature = "tls")]
{
let s = crate::net::tls::connect(tcp, &url.host, None).map_err(|e| e.to_string())?;
return Ok(Box::new(s));
}
#[cfg(not(feature = "tls"))]
{
return Err("tls off".into());
}
}
Ok(Box::new(tcp))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
#[test]
fn issuer_match_is_origin_exact_modulo_trailing_slash_and_case() {
assert!(issuer_matches("https://ap.example", "https://ap.example"));
assert!(issuer_matches("https://ap.example/", "https://ap.example")); assert!(issuer_matches("https://AP.example", "https://ap.example")); assert!(!issuer_matches(
"https://evil.example",
"https://ap.example"
));
assert!(!issuer_matches("http://ap.example", "https://ap.example"));
assert!(!issuer_matches(
"https://ap.example:8443",
"https://ap.example"
));
}
fn serve_once(status: u16, body: impl Fn(&str) -> String) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let origin = format!("http://127.0.0.1:{port}");
let doc = body(&origin);
std::thread::spawn(move || {
if let Ok((mut s, _)) = listener.accept() {
let mut buf = [0u8; 2048];
let _ = s.read(&mut buf);
let resp = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{doc}",
doc.len()
);
let _ = s.write_all(resp.as_bytes());
let _ = s.flush();
}
});
origin
}
#[test]
fn matching_issuer_is_accepted() {
let origin = serve_once(200, |o| format!(r#"{{"issuer":"{o}","name":"AP"}}"#));
let meta = fetch_agent_provider(&origin, Duration::from_secs(2)).unwrap();
assert_eq!(meta.unwrap().issuer.as_deref(), Some(origin.as_str()));
}
#[test]
fn mismatched_issuer_is_rejected() {
let origin = serve_once(200, |_| r#"{"issuer":"https://evil.example"}"#.to_string());
let err = fetch_agent_provider(&origin, Duration::from_secs(2)).unwrap_err();
assert!(err.contains("host-poisoning"), "{err}");
}
#[test]
fn absent_document_is_best_effort_none() {
let origin = serve_once(404, |_| r#"{"error":"not found"}"#.to_string());
assert!(
fetch_agent_provider(&origin, Duration::from_secs(2))
.unwrap()
.is_none()
);
}
}