use crate::error::Error;
use crate::goals::CodedError;
use crate::hash::random_id;
use crate::http::{FetchInit, Response, SearchParams, Url};
use crate::js::{self, Object, Value};
use crate::oauth::s256;
use crate::re::{js_re, test};
use crate::runlight::Runlight;
use crate::{arr, obj};
const PENDING_MS: i64 = 15 * 60_000;
const TIMEOUT_MS: u64 = 10_000;
const MAX_BYTES: usize = 64 * 1024;
pub type ConnectError = CodedError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConnectFailure {
Connect(ConnectError),
Runlight(Error),
}
impl From<Error> for ConnectFailure {
fn from(e: Error) -> ConnectFailure {
ConnectFailure::Runlight(e)
}
}
impl From<crate::store::DbError> for ConnectFailure {
fn from(e: crate::store::DbError) -> ConnectFailure {
ConnectFailure::Runlight(Error::Db(e))
}
}
impl std::fmt::Display for ConnectFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConnectFailure::Connect(e) => f.write_str(&e.message),
ConnectFailure::Runlight(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for ConnectFailure {}
fn refuse(message: impl Into<String>, code: &str, params: &[(&str, &str)]) -> ConnectFailure {
ConnectFailure::Connect(CodedError::new(message, code, params))
}
fn invalid_url() -> ConnectFailure {
ConnectFailure::Runlight(Error::Other("TypeError: Invalid URL".into()))
}
fn host_of(url: &str) -> Result<String, ConnectFailure> {
Url::parse(url).map(|u| u.host()).ok_or_else(invalid_url)
}
pub fn install_url(value: Option<&Value>, local: bool) -> Result<String, ConnectError> {
let url = crate::re::replace_all(js_re!(r"/+$"), js::trim(&js::str_or_empty(value)), "");
if !crate::safefetch::install_address(&url, local) || !Url::can_parse(&url) {
return Err(CodedError::new("Enter the install's address, like https://example.com/runlight", "url", &[]));
}
Ok(url)
}
fn json_of(answer: &Response) -> Option<Value> {
if answer.body.len() > MAX_BYTES {
return None;
}
answer.json_body().ok()
}
fn capped(mut init: FetchInit) -> FetchInit {
init.max_bytes = Some(MAX_BYTES + 1);
init.truncate = true;
init
}
fn pending_from(value: &str, now: i64) -> Option<Value> {
let pending = js::parse(value).ok()?;
if !matches!(pending, Value::Object(_) | Value::Array(_)) {
return None;
}
match pending.get("expires") {
Some(Value::Number(expires)) if *expires >= now as f64 => Some(pending),
_ => None,
}
}
async fn clear_expired(runlight: &Runlight) -> Result<(), ConnectFailure> {
let store = runlight.store();
for (key, value) in store.settings_starting_with("connect:").await? {
if pending_from(&value, runlight.now()).is_none() {
store.set_setting(&key, None).await?;
}
}
Ok(())
}
pub async fn start_connect(
runlight: &Runlight,
input: Option<&Value>,
back: &str,
site: &str,
) -> Result<String, ConnectFailure> {
let url = install_url(input, runlight.local_installs()).map_err(ConnectFailure::Connect)?;
let unreachable = |url: &str| -> ConnectFailure {
match host_of(url) {
Ok(host) => refuse(format!("Could not reach {url}"), "unreachable", &[("host", &host)]),
Err(e) => e,
}
};
let Ok(answer) = runlight
.fetch_install(
&format!("{url}/.well-known/oauth-authorization-server"),
capped(FetchInit::default().timeout(TIMEOUT_MS)),
)
.await
else {
return Err(unreachable(&url));
};
let meta = if answer.ok() { json_of(&answer) } else { None }.unwrap_or(Value::Null);
let (Some(authorization), Some(token), Some(registration)) = (
meta.get("authorization_endpoint").filter(|v| js::truthy(v)),
meta.get("token_endpoint").filter(|v| js::truthy(v)),
meta.get("registration_endpoint").filter(|v| js::truthy(v)),
) else {
return Err(refuse(format!("{url} did not answer like a Runlight install"), "not_runlight", &[("url", &url)]));
};
let origin = Url::parse(&url).map(|u| u.origin());
let own = |endpoint: &Value| -> bool {
let Some(origin) = &origin else { return false };
Url::parse(&js::js_string(endpoint)).is_some_and(|u| &u.origin() == origin)
};
if ![authorization, token, registration].into_iter().all(own) {
return Err(refuse(format!("{url} named endpoints on another address"), "endpoints", &[("url", &url)]));
}
let manage = matches!(meta.get("scopes_supported"), Some(Value::Array(scopes)) if scopes.iter().any(|s| s.as_str() == Some("manage")));
if !manage {
return Err(refuse(
format!("{url} runs an older Runlight. Update it, or connect it with an API token from its Settings."),
"old",
&[("url", &url)],
));
}
let back_host = host_of(back)?;
let body = obj! { "client_name" => format!("Runlight at {back_host}"), "redirect_uris" => arr![back] };
let Ok(registered) = runlight
.fetch_install(
&js::js_string(registration),
capped(
FetchInit::method("POST")
.header("content-type", "application/json")
.body(body.to_json())
.timeout(TIMEOUT_MS),
),
)
.await
else {
return Err(unreachable(&url));
};
let client = json_of(®istered).unwrap_or(Value::Null);
let client_id = client.get("client_id").filter(|v| js::truthy(v)).cloned();
let Some(client_id) = client_id.filter(|_| registered.ok()) else {
let reason = match client.get("error_description").filter(|v| js::truthy(v)) {
Some(d) => format!("{}.", js::head16(&js::js_string(d), 200)),
None if registered.status == 400 => "This server's address must use https.".to_string(),
None => format!("It answered {}.", registered.status),
};
return Err(refuse(
format!("{url} would not let this server connect. {reason}"),
"register",
&[("url", &url), ("reason", &reason)],
));
};
clear_expired(runlight).await?;
let state = random_id(16);
let verifier = format!("{}{}", random_id(32), random_id(32));
let pending = obj! {
"url" => url.clone(),
"client" => client_id.clone(),
"verifier" => verifier.clone(),
"redirect" => back,
"token" => token.clone(),
"expires" => runlight.now() + PENDING_MS,
};
runlight.store().set_setting(&format!("connect:{state}"), Some(&pending.to_json())).await?;
let mut to = Url::parse(&js::js_string(authorization)).ok_or_else(invalid_url)?;
let client_text = js::js_string(&client_id);
let mut params = vec![
("response_type", "code".to_string()),
("client_id", client_text),
("redirect_uri", back.to_string()),
("code_challenge", s256(&verifier)),
("code_challenge_method", "S256".to_string()),
("scope", "manage".to_string()),
("state", state),
];
if !site.is_empty() {
params.push(("site", site.to_string()));
}
to.set_search(&SearchParams::from_pairs(params).to_string());
Ok(to.href())
}
pub async fn finish_connect(runlight: &Runlight, params: &SearchParams) -> Result<String, ConnectFailure> {
let store = runlight.store();
let state = params.get("state").unwrap_or("");
let key = format!("connect:{state}");
let stored = if test(js_re!(r"^[a-f0-9]{32}$"), state) {
store.take_setting(&key).await?.filter(|s| !s.is_empty())
} else {
None
};
let Some(pending) = stored.and_then(|text| pending_from(&text, runlight.now())) else {
return Err(refuse("That connection took too long or was already used. Start again.", "expired", &[]));
};
if params.get("error") == Some("access_denied") {
return Err(refuse("The connection was not allowed.", "denied", &[]));
}
if let Some(error) = params.get("error").filter(|e| !e.is_empty()) {
let message = params.get("error_description").unwrap_or(error);
return Err(refuse(message, "refused", &[]));
}
let text = |k: &str| js::js_string(pending.at(k));
let form = SearchParams::from_pairs([
("grant_type", "authorization_code".to_string()),
("code", params.get("code").unwrap_or("").to_string()),
("client_id", text("client")),
("redirect_uri", text("redirect")),
("code_verifier", text("verifier")),
]);
let answer = runlight
.fetch_install(
&text("token"),
capped(
FetchInit::method("POST")
.header("content-type", "application/x-www-form-urlencoded")
.body(form.to_string())
.timeout(TIMEOUT_MS),
),
)
.await
.ok();
let granted = answer.filter(Response::ok).and_then(|a| json_of(&a)).unwrap_or(Value::Null);
let Some(access_token) = granted.get("access_token").filter(|v| js::truthy(v)) else {
let host = host_of(&text("url"))?;
return Err(refuse(format!("{host} did not give this server a token. Start again."), "token", &[]));
};
let mut remote = Object::new();
remote.set("url", pending.at("url").clone());
remote.set("token", access_token.clone());
if let Some(site) = granted.get("site") {
remote.set("site", site.clone());
}
let site = runlight.add_site(&obj! { "remote" => remote }).await?;
Ok(site.id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_addresses_are_https_or_local_when_allowed() {
let url = |s: &str| install_url(Some(&Value::from(s)), false);
assert_eq!(url(" https://example.com/runlight// ").unwrap(), "https://example.com/runlight");
assert_eq!(url("http://localhost:3000").unwrap_err().code, "url");
assert_eq!(install_url(Some(&Value::from("http://localhost:3000")), true).unwrap(), "http://localhost:3000");
assert_eq!(install_url(Some(&Value::from("http://10.0.0.1")), true).unwrap_err().code, "url");
assert_eq!(url("javascript:alert(1)").unwrap_err().code, "url");
assert_eq!(url("http://example.com").unwrap_err().code, "url");
assert_eq!(install_url(None, false).unwrap_err().code, "url");
}
}