use crate::error::ConfigReason;
use time::Duration;
use url::Url;
use crate::Error;
use crate::account::{Email, LockoutPolicy, PasswordOnly};
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,
ceremony_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,
password_only: PasswordOnly,
rate_limits: RateLimits,
password: PasswordParams,
insecure_localhost_cookies: bool,
}
#[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,
ceremony_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,
password_only: PasswordOnly,
rate_limits: RateLimits,
password: PasswordParams,
insecure_localhost_cookies: bool,
}
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 ceremony_cookie_name(mut self, name: &str) -> Self {
name.clone_into(&mut self.ceremony_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 password_only(mut self, policy: PasswordOnly) -> Self {
self.password_only = policy;
self
}
pub fn danger_insecure_localhost_cookies(mut self) -> Self {
self.insecure_localhost_cookies = true;
self
}
pub fn rate_limits(mut self, rate_limits: RateLimits) -> Self {
self.rate_limits = rate_limits;
self
}
pub fn password_params(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(ConfigReason::OriginNotUrl))?;
let host = origin
.host_str()
.ok_or(Error::Config(ConfigReason::OriginHasNoHost))?
.to_owned();
let local = matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]");
let plain_local = origin.scheme() == "http" && local;
if origin.scheme() != "https" && !plain_local {
return Err(Error::Config(ConfigReason::OriginNotHttps));
}
if self.insecure_localhost_cookies && !plain_local {
return Err(Error::Config(ConfigReason::InsecureCookiesNotLocalhost));
}
let owner_email =
Email::parse(&self.owner_email).map_err(|_| Error::Config(ConfigReason::OwnerEmail))?;
let cookie = |name: &str| {
if self.insecure_localhost_cookies {
CookieName::insecure_localhost(name)
} else {
CookieName::new(name)
}
};
let cookie_name = cookie(&self.cookie_name)?;
let device_cookie_name = cookie(&self.device_cookie_name)?;
let ceremony_cookie_name = cookie(&self.ceremony_cookie_name)?;
if cookie_name == device_cookie_name
|| cookie_name == ceremony_cookie_name
|| device_cookie_name == ceremony_cookie_name
{
return Err(Error::Config(ConfigReason::CookieNamesEqual));
}
let join = |path: &str| {
origin
.join(path)
.map_err(|_| Error::Config(ConfigReason::PathDoesNotJoin))
};
if self.step_up_failures == (StepUpFailures::PerSession { limit: 0 }) {
return Err(Error::Config(ConfigReason::StepUpLimitZero));
}
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,
ceremony_cookie_name,
origin,
durations: self.durations,
lockout: self.lockout,
step_up_failures: self.step_up_failures,
password_only: self.password_only,
rate_limits: self.rate_limits,
password: self.password,
insecure_localhost_cookies: self.insecure_localhost_cookies,
})
}
}
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(),
ceremony_cookie_name: "__Host-latchkey_ceremony".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(),
password_only: PasswordOnly::default(),
rate_limits: RateLimits::default(),
password: PasswordParams::default(),
insecure_localhost_cookies: false,
}
}
#[must_use]
pub fn origin(&self) -> &str {
self.origin.as_str()
}
#[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 insecure_localhost_cookies(&self) -> bool {
self.insecure_localhost_cookies
}
#[must_use]
pub fn ceremony_cookie_name(&self) -> &CookieName {
&self.ceremony_cookie_name
}
#[must_use]
pub fn setup_url(&self) -> &str {
self.setup_url.as_str()
}
pub(crate) fn setup_target(&self) -> &Url {
&self.setup_url
}
#[must_use]
pub fn sign_in_link_url(&self) -> &str {
self.sign_in_link_url.as_str()
}
pub(crate) fn sign_in_link_target(&self) -> &Url {
&self.sign_in_link_url
}
#[must_use]
pub fn invite_url(&self) -> &str {
self.invite_url.as_str()
}
pub(crate) fn invite_target(&self) -> &Url {
&self.invite_url
}
#[must_use]
pub fn sessions_url(&self) -> &str {
self.sessions_url.as_str()
}
#[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 password_only(&self) -> PasswordOnly {
self.password_only
}
#[must_use]
pub fn rate_limits(&self) -> &RateLimits {
&self.rate_limits
}
#[must_use]
pub fn password_params(&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.ceremony_cookie_name().as_str(),
"__Host-latchkey_ceremony"
);
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(), "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);
assert_eq!(config.password_only(), PasswordOnly::Refused);
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!(
builder()
.ceremony_cookie_name("__Host-latchkey_device")
.build()
.is_err()
);
assert!(builder().ceremony_cookie_name("ceremony").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(ConfigReason::StepUpLimitZero))
);
assert!(
builder()
.step_up_failures(StepUpFailures::PerSession { limit: 5 })
.build()
.is_ok()
);
}
}
#[cfg(test)]
mod insecure_localhost_tests {
use super::*;
fn dev(origin: &str) -> Result<Config, Error> {
Config::builder("o@example.com", origin)
.danger_insecure_localhost_cookies()
.build()
}
#[test]
fn insecure_cookies_only_for_plain_http_on_this_machine() -> Result<(), Error> {
for origin in [
"http://localhost",
"http://localhost:3000",
"http://127.0.0.1:8080",
"http://[::1]:8080",
] {
let config = dev(origin)?;
assert!(config.insecure_localhost_cookies(), "{origin}");
assert_eq!(config.cookie_name().as_str(), "latchkey");
assert_eq!(config.device_cookie_name().as_str(), "latchkey_device");
assert_eq!(config.ceremony_cookie_name().as_str(), "latchkey_ceremony");
assert!(!config.cookie_name().is_secure());
}
let refused = Some(Error::Config(ConfigReason::InsecureCookiesNotLocalhost));
for origin in [
"https://home.example.com",
"https://localhost:3000",
"https://127.0.0.1",
] {
assert_eq!(dev(origin).err(), refused, "{origin}");
}
for origin in [
"http://home.example.com",
"http://localhost.example.com",
"http://127.0.0.2",
"http://10.0.0.1",
] {
assert_eq!(
dev(origin).err(),
Some(Error::Config(ConfigReason::OriginNotHttps)),
"{origin}"
);
}
Ok(())
}
#[test]
fn by_default_cookies_are_host_only_and_secure_even_on_localhost() -> Result<(), Error> {
let config = Config::builder("o@example.com", "http://127.0.0.1:3000").build()?;
assert!(!config.insecure_localhost_cookies());
assert_eq!(config.cookie_name().as_str(), "__Host-latchkey");
assert!(config.cookie_name().is_secure());
Ok(())
}
#[test]
fn insecure_names_still_differ_and_stay_valid() {
let builder = || {
Config::builder("o@example.com", "http://localhost:3000")
.danger_insecure_localhost_cookies()
};
assert!(builder().device_cookie_name("latchkey").build().is_err());
assert!(builder().cookie_name("__Host-").build().is_err());
assert_eq!(
builder()
.cookie_name("__Host-app")
.build()
.map(|config| config.cookie_name().as_str().to_owned()),
Ok("app".to_owned())
);
}
}