use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use cookies::{CachingJar, CookieCacheError};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT};
use reqwest::redirect::Policy;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::session::Session;
use crate::{DEFAULT_TIMEOUT, FEDORA_USER_AGENT};
mod cookies;
pub const FEDORA_OPENID_API: &str = "https://id.fedoraproject.org/api/v1/";
pub const FEDORA_OPENID_STG_API: &str = "https://id.stg.fedoraproject.org/api/v1/";
#[derive(Debug, thiserror::Error)]
pub enum OpenIDClientError {
#[error("Failed to contact OpenID provider: {error}")]
Request {
#[from]
error: reqwest::Error,
},
#[error("Failed to parse redirection URL: {error}")]
UrlParsing {
#[from]
error: url::ParseError,
},
#[error("{error}")]
Redirection {
error: String,
},
#[error("Failed to authenticate with OpenID service: {error}")]
Authentication {
error: String,
},
#[error("Failed to deserialize JSON returned by OpenID endpoint: {error}")]
Deserialization {
#[from]
error: serde_json::error::Error,
},
#[error("Authentication failed, possibly due to wrong username / password.")]
Login,
}
#[derive(Debug, Deserialize)]
struct OpenIDResponse {
success: bool,
response: OpenIDParameters,
}
#[derive(Debug, Deserialize, Serialize)]
struct OpenIDParameters {
#[serde(rename = "openid.assoc_handle")]
assoc_handle: String,
#[serde(rename = "openid.cla.signed_cla")]
cla_signed_cla: String,
#[serde(rename = "openid.claimed_id")]
claimed_id: String,
#[serde(rename = "openid.identity")]
identity: String,
#[serde(rename = "openid.lp.is_member")]
lp_is_member: String,
#[serde(rename = "openid.mode")]
mode: String,
#[serde(rename = "openid.ns")]
ns: String,
#[serde(rename = "openid.ns.cla")]
ns_cla: String,
#[serde(rename = "openid.ns.lp")]
ns_lp: String,
#[serde(rename = "openid.ns.sreg")]
ns_sreg: String,
#[serde(rename = "openid.op_endpoint")]
op_endpoint: String,
#[serde(rename = "openid.response_nonce")]
response_nonce: String,
#[serde(rename = "openid.return_to")]
return_to: String,
#[serde(rename = "openid.sig")]
sig: String,
#[serde(rename = "openid.signed")]
signed: String,
#[serde(rename = "openid.sreg.email")]
sreg_email: String,
#[serde(rename = "openid.sreg.nickname")]
sreg_nickname: String,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug)]
pub struct OpenIDSessionBuilder<'a> {
login_url: Url,
auth_url: Url,
timeout: Option<Duration>,
user_agent: Option<&'a str>,
}
#[derive(Debug)]
pub enum OpenIDSessionKind {
Default,
Staging,
Custom {
auth_url: Url,
},
}
impl<'a> OpenIDSessionBuilder<'a> {
pub fn new(login_url: Url, kind: OpenIDSessionKind) -> Self {
use OpenIDSessionKind::*;
let auth_url = match kind {
Default => Url::parse(FEDORA_OPENID_API).expect("Failed to parse a hardcoded URL."),
Staging => Url::parse(FEDORA_OPENID_STG_API).expect("Failed to parse a hardcoded URL."),
Custom { auth_url } => {
log::warn!("Authenticating with nonstandard OpenID provider URL: {}", auth_url);
auth_url
},
};
OpenIDSessionBuilder {
login_url,
auth_url,
timeout: None,
user_agent: None,
}
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn user_agent(mut self, user_agent: &'a str) -> Self {
self.user_agent = Some(user_agent);
self
}
pub fn build(self) -> OpenIDSessionLogin {
let timeout = match self.timeout {
Some(timeout) => timeout,
None => DEFAULT_TIMEOUT,
};
let user_agent = match self.user_agent {
Some(user_agent) => user_agent,
None => FEDORA_USER_AGENT,
};
let mut default_headers = HeaderMap::new();
default_headers.append(
USER_AGENT,
HeaderValue::from_str(user_agent).expect("Failed to parse hardcoded HTTP headers."),
);
default_headers.append(ACCEPT, HeaderValue::from_static("application/json"));
let (jar, fresh): (CachingJar, bool) = match CachingJar::read_from_disk() {
Ok(jar) => {
let fresh = jar
.store
.read()
.expect("Poisoned lock!")
.iter_unexpired()
.any(|cookie| cookie.domain.matches(&self.login_url));
if fresh {
log::debug!("Session cookie(s) are fresh, no re-authentication necessary.");
} else {
log::info!("Session cookie(s) have expired, re-authentication necessary.");
}
(jar, fresh)
},
Err(error) => {
if let CookieCacheError::DoesNotExist = error {
log::info!("Creating new cookie cache.");
} else {
log::info!("Failed to load cached cookies: {}", error);
}
(CachingJar::empty(), false)
},
};
OpenIDSessionLogin {
login_url: self.login_url,
auth_url: self.auth_url,
headers: default_headers,
timeout,
jar,
fresh,
}
}
}
#[derive(Debug)]
pub struct OpenIDSessionLogin {
login_url: Url,
auth_url: Url,
headers: HeaderMap,
timeout: Duration,
jar: CachingJar,
fresh: bool,
}
impl OpenIDSessionLogin {
pub async fn login(self, username: &str, password: &str) -> Result<Session, OpenIDClientError> {
let jar = Arc::new(self.jar);
if self.fresh {
if let Err(error) = jar.write_to_disk() {
log::error!("Failed to write cached cookies: {}", error);
}
let client: Client = Client::builder()
.default_headers(self.headers)
.cookie_store(true)
.cookie_provider(jar)
.timeout(self.timeout)
.build()
.expect("Failed to initialize the network stack.");
return Ok(Session { client });
}
let client: Client = Client::builder()
.default_headers(self.headers.clone())
.cookie_store(true)
.cookie_provider(jar.clone())
.timeout(self.timeout)
.redirect(Policy::none())
.build()
.expect("Failed to initialize the network stack.");
let mut url = self.login_url;
let mut state: HashMap<Cow<str>, Cow<str>> = HashMap::new();
loop {
let response = client.get(url.clone()).send().await?;
let status = response.status();
for (key, value) in url.query_pairs() {
state.insert(Cow::Owned(key.to_string()), Cow::Owned(value.to_string()));
}
if status.is_redirection() {
let header: &HeaderValue = match response.headers().get("location") {
Some(value) => value,
None => {
return Err(OpenIDClientError::Redirection {
error: String::from("No redirect URL provided in HTTP redirect headers."),
});
},
};
let string = match header.to_str() {
Ok(string) => string,
Err(_) => {
return Err(OpenIDClientError::Redirection {
error: String::from("Failed to decode redirect URL."),
});
},
};
url = Url::parse(string)?;
} else {
break;
}
}
state.insert(Cow::Borrowed("username"), Cow::Borrowed(username));
state.insert(Cow::Borrowed("password"), Cow::Borrowed(password));
state.insert(
Cow::Borrowed("auth_module"),
Cow::Borrowed("fedoauth.auth.fas.Auth_FAS"),
);
state.insert(Cow::Borrowed("auth_flow"), Cow::Borrowed("fedora"));
state
.entry(Cow::Borrowed("openid.mode"))
.or_insert_with(|| Cow::Borrowed("checkid_setup"));
let response = client.post(self.auth_url).form(&state).send().await.map_err(|error| {
OpenIDClientError::Authentication {
error: error.to_string(),
}
})?;
let string = response.text().await?;
let openid_auth: OpenIDResponse = serde_json::from_str(&string).map_err(|_| OpenIDClientError::Login)?;
if !openid_auth.success {
return Err(OpenIDClientError::Authentication {
error: String::from("OpenID endpoint returned an error code."),
});
}
let return_url = Url::parse(&openid_auth.response.return_to)?;
let response = client
.post(return_url)
.form(&openid_auth.response)
.send()
.await
.map_err(|error| OpenIDClientError::Request { error })?;
if !response.status().is_success() && !response.status().is_redirection() {
return Err(OpenIDClientError::Authentication {
error: String::from("Failed to complete authentication with the original site."),
});
};
if let Err(error) = jar.write_to_disk() {
log::error!("Failed to write cookie jar to disk: {}", error);
}
let client: Client = Client::builder()
.default_headers(self.headers)
.cookie_store(true)
.cookie_provider(jar)
.timeout(self.timeout)
.build()
.expect("Failed to initialize the network stack.");
Ok(Session { client })
}
}