use time::Duration;
use url::Url;
use crate::Error;
use crate::account::{Email, LockoutPolicy};
use crate::flow::step_up::StepUpFailures;
use crate::http::CookieName;
use crate::limits::RateLimits;
use crate::password::PasswordParams;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
origin: Url,
rp_id: String,
rp_name: String,
owner_email: Email,
owner_name: String,
cookie_name: CookieName,
device_cookie_name: CookieName,
setup_url: Url,
sign_in_link_url: Url,
invite_url: Url,
sessions_url: Url,
durations: Durations,
lockout: LockoutPolicy,
step_up_failures: StepUpFailures,
rate_limits: RateLimits,
password: PasswordParams,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Durations {
device_ttl: Duration,
session_ttl: Duration,
pending_ttl: Duration,
setup_ttl: Duration,
link_ttl: Duration,
invite_ttl: Duration,
resend_cooldown: Duration,
step_up_window: Duration,
}
impl Default for Durations {
fn default() -> Self {
Self {
device_ttl: Duration::days(400),
session_ttl: Duration::days(30),
pending_ttl: Duration::minutes(10),
setup_ttl: Duration::hours(1),
link_ttl: Duration::minutes(15),
invite_ttl: Duration::days(3),
resend_cooldown: Duration::minutes(1),
step_up_window: Duration::minutes(5),
}
}
}
#[derive(Debug, Clone)]
#[must_use = "call build() to get a Config"]
pub struct ConfigBuilder {
owner_email: String,
origin: String,
rp_id: Option<String>,
rp_name: Option<String>,
owner_name: Option<String>,
cookie_name: String,
device_cookie_name: String,
setup_path: String,
sign_in_link_path: String,
invite_path: String,
sessions_path: String,
durations: Durations,
lockout: LockoutPolicy,
step_up_failures: StepUpFailures,
rate_limits: RateLimits,
password: PasswordParams,
}
macro_rules! durations {
($($(#[$doc:meta])* $name:ident),* $(,)?) => {
impl ConfigBuilder {
$(
$(#[$doc])*
pub fn $name(mut self, value: Duration) -> Self {
self.durations.$name = value;
self
}
)*
}
impl Config {
$(
$(#[$doc])*
#[must_use]
pub fn $name(&self) -> Duration {
self.durations.$name
}
)*
}
};
}
durations!(
device_ttl,
session_ttl,
pending_ttl,
setup_ttl,
link_ttl,
invite_ttl,
resend_cooldown,
step_up_window,
);
impl ConfigBuilder {
pub fn rp_id(mut self, rp_id: &str) -> Self {
self.rp_id = Some(rp_id.to_owned());
self
}
pub fn rp_name(mut self, rp_name: &str) -> Self {
self.rp_name = Some(rp_name.to_owned());
self
}
pub fn owner_name(mut self, name: &str) -> Self {
self.owner_name = Some(name.to_owned());
self
}
pub fn cookie_name(mut self, name: &str) -> Self {
name.clone_into(&mut self.cookie_name);
self
}
pub fn device_cookie_name(mut self, name: &str) -> Self {
name.clone_into(&mut self.device_cookie_name);
self
}
pub fn setup_path(mut self, path: &str) -> Self {
path.clone_into(&mut self.setup_path);
self
}
pub fn sign_in_link_path(mut self, path: &str) -> Self {
path.clone_into(&mut self.sign_in_link_path);
self
}
pub fn invite_path(mut self, path: &str) -> Self {
path.clone_into(&mut self.invite_path);
self
}
pub fn sessions_path(mut self, path: &str) -> Self {
path.clone_into(&mut self.sessions_path);
self
}
pub fn lockout(mut self, lockout: LockoutPolicy) -> Self {
self.lockout = lockout;
self
}
pub fn step_up_failures(mut self, policy: StepUpFailures) -> Self {
self.step_up_failures = policy;
self
}
pub fn rate_limits(mut self, rate_limits: RateLimits) -> Self {
self.rate_limits = rate_limits;
self
}
pub fn password(mut self, params: PasswordParams) -> Self {
self.password = params;
self
}
pub fn build(self) -> Result<Config, Error> {
let origin = Url::parse(&self.origin).map_err(|_| Error::Config("origin is not a URL"))?;
let host = origin
.host_str()
.ok_or(Error::Config("origin has no host"))?
.to_owned();
let local = host == "localhost";
if origin.scheme() != "https" && !local {
return Err(Error::Config("origin must be https"));
}
let owner_email =
Email::parse(&self.owner_email).map_err(|_| Error::Config("owner email"))?;
let cookie_name = CookieName::new(&self.cookie_name)?;
let device_cookie_name = CookieName::new(&self.device_cookie_name)?;
if cookie_name == device_cookie_name {
return Err(Error::Config(
"device cookie name must differ from the cookie name",
));
}
let join = |path: &str| {
origin
.join(path)
.map_err(|_| Error::Config("a path does not join onto the origin"))
};
if self.step_up_failures == (StepUpFailures::PerSession { limit: 0 }) {
return Err(Error::Config(
"the step-up failure limit must be at least 1",
));
}
let rp_id = self.rp_id.unwrap_or(host);
Ok(Config {
setup_url: join(&self.setup_path)?,
sign_in_link_url: join(&self.sign_in_link_path)?,
invite_url: join(&self.invite_path)?,
sessions_url: join(&self.sessions_path)?,
rp_name: self.rp_name.unwrap_or_else(|| rp_id.clone()),
rp_id,
owner_name: self
.owner_name
.unwrap_or_else(|| owner_email.as_str().to_owned()),
owner_email,
cookie_name,
device_cookie_name,
origin,
durations: self.durations,
lockout: self.lockout,
step_up_failures: self.step_up_failures,
rate_limits: self.rate_limits,
password: self.password,
})
}
}
impl Config {
pub fn builder(owner_email: &str, origin: &str) -> ConfigBuilder {
ConfigBuilder {
owner_email: owner_email.to_owned(),
origin: origin.to_owned(),
rp_id: None,
rp_name: None,
owner_name: None,
cookie_name: "__Host-latchkey".to_owned(),
device_cookie_name: "__Host-latchkey_device".to_owned(),
setup_path: "/setup".to_owned(),
sign_in_link_path: "/sign-in/link".to_owned(),
invite_path: "/invite".to_owned(),
sessions_path: "/settings/sessions".to_owned(),
durations: Durations::default(),
lockout: LockoutPolicy::default(),
step_up_failures: StepUpFailures::default(),
rate_limits: RateLimits::default(),
password: PasswordParams::default(),
}
}
#[must_use]
pub fn origin(&self) -> &Url {
&self.origin
}
#[must_use]
pub fn origin_string(&self) -> String {
self.origin.origin().ascii_serialization()
}
#[must_use]
pub fn rp_id(&self) -> &str {
&self.rp_id
}
#[must_use]
pub fn rp_name(&self) -> &str {
&self.rp_name
}
#[must_use]
pub fn owner_email(&self) -> &Email {
&self.owner_email
}
#[must_use]
pub fn owner_name(&self) -> &str {
&self.owner_name
}
#[must_use]
pub fn cookie_name(&self) -> &CookieName {
&self.cookie_name
}
#[must_use]
pub fn device_cookie_name(&self) -> &CookieName {
&self.device_cookie_name
}
#[must_use]
pub fn setup_url(&self) -> &Url {
&self.setup_url
}
#[must_use]
pub fn sign_in_link_url(&self) -> &Url {
&self.sign_in_link_url
}
#[must_use]
pub fn invite_url(&self) -> &Url {
&self.invite_url
}
#[must_use]
pub fn sessions_url(&self) -> &Url {
&self.sessions_url
}
#[must_use]
pub fn lockout(&self) -> &LockoutPolicy {
&self.lockout
}
#[must_use]
pub fn step_up_failures(&self) -> StepUpFailures {
self.step_up_failures
}
#[must_use]
pub fn rate_limits(&self) -> &RateLimits {
&self.rate_limits
}
#[must_use]
pub fn password(&self) -> PasswordParams {
self.password
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_latchkey_0_2() -> Result<(), Error> {
let config = Config::builder("o@example.com", "https://home.example.com").build()?;
assert_eq!(config.cookie_name().as_str(), "__Host-latchkey");
assert_eq!(
config.device_cookie_name().as_str(),
"__Host-latchkey_device"
);
assert_eq!(config.session_ttl(), Duration::days(30));
assert_eq!(config.step_up_window(), Duration::minutes(5));
assert_eq!(config.link_ttl(), Duration::minutes(15));
assert_eq!(
config.invite_url().as_str(),
"https://home.example.com/invite"
);
assert_eq!(config.owner_name(), "o@example.com");
assert_eq!(config.rp_name(), "home.example.com");
assert_eq!(config.step_up_failures(), StepUpFailures::AccountLockout);
Ok(())
}
#[test]
fn rejects_bad_cookie_names_and_plain_http() {
let builder = || Config::builder("o@example.com", "https://home.example.com");
assert!(builder().cookie_name("session").build().is_err());
assert!(
builder()
.device_cookie_name("__Host-latchkey")
.build()
.is_err()
);
assert!(
Config::builder("o@example.com", "http://home.example.com")
.build()
.is_err()
);
assert!(
Config::builder("o@example.com", "http://localhost:3000")
.build()
.is_ok()
);
assert!(
Config::builder("not an email", "https://h.example")
.build()
.is_err()
);
assert!(
Config::builder("o@example.com", "not a url")
.build()
.is_err()
);
assert_eq!(
builder()
.step_up_failures(StepUpFailures::PerSession { limit: 0 })
.build()
.err(),
Some(Error::Config(
"the step-up failure limit must be at least 1"
))
);
assert!(
builder()
.step_up_failures(StepUpFailures::PerSession { limit: 5 })
.build()
.is_ok()
);
}
}